Files
odysseus/static/js/editor/perspective-warp.js
T
pewdiepie-archdaemonandClaude Opus 5.5 5fac693c1c feat(photo-editor): Ajax Image Editor client (Photoshop-parity tools)
Photoshop-parity editor client: layers, selection/masking, brushes,
smart filters, LUT import, Quick Edit, mobile gestures, workspaces,
batch operations, and the service-worker versioning (v978->v1006).

Source: vendor/photocraft (ad1cff44) photo-editor client files, merged
onto upstream/dev (a8c147b2). Conflict resolution documented in
pr_work/A_conflicts.md; editor files taken from vendor/photocraft
(upstream's editor is an earlier snapshot of the same feature).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 11:57:35 +00:00

112 lines
4.0 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Perspective (keystone) warp via a projective homography.
//
// Pure array maths so it can run in the browser and in Node tests. The
// destination is described by four normalized corners (TL, TR, BR, BL) as
// fractions of the output size; source pixels are sampled by inverse mapping.
export function solveSquareToQuad(corners) {
const [p0, p1, p2, p3] = corners; // TL, TR, BR, BL
const dx1 = p1.x - p2.x;
const dx2 = p3.x - p2.x;
const dx3 = p0.x - p1.x + p2.x - p3.x;
const dy1 = p1.y - p2.y;
const dy2 = p3.y - p2.y;
const dy3 = p0.y - p1.y + p2.y - p3.y;
if (dx3 === 0 && dy3 === 0) {
// Affine.
return [p1.x - p0.x, p2.x - p1.x, p0.x, p1.y - p0.y, p2.y - p1.y, p0.y, 0, 0, 1];
}
const den = dx1 * dy2 - dx2 * dy1;
const g = (dx3 * dy2 - dx2 * dy3) / den;
const h = (dx1 * dy3 - dx3 * dy1) / den;
const a = p1.x - p0.x + g * p1.x;
const b = p3.x - p0.x + h * p3.x;
const c = p0.x;
const d = p1.y - p0.y + g * p1.y;
const e = p3.y - p0.y + h * p3.y;
const f = p0.y;
return [a, b, c, d, e, f, g, h, 1];
}
function invert3x3(m) {
const [a, b, c, d, e, f, g, h, i] = m;
const A = e * i - f * h;
const B = -(d * i - f * g);
const C = d * h - e * g;
const D = -(b * i - c * h);
const E = a * i - c * g;
const F = -(a * h - b * g);
const G = b * f - c * e;
const H = -(a * f - c * d);
const I = a * e - b * d;
const det = a * A + b * B + c * C;
if (!det) return null;
return [A / det, D / det, G / det, B / det, E / det, H / det, C / det, F / det, I / det];
}
/** Convert a `{ perspectiveX, perspectiveY }` amount pair (each −100..100) into
* destination corners for a unit square, keystoned about the centre. */
export function keystoneCorners(perspectiveX = 0, perspectiveY = 0) {
const topScale = Math.max(0.05, 1 - perspectiveX / 200); // >0 narrows the top
const sideScale = Math.max(0.05, 1 - perspectiveY / 200); // >0 narrows the sides
const insetX = (1 - topScale) / 2;
const insetY = (1 - sideScale) / 2;
return [
{ x: insetX, y: insetY }, // TL
{ x: 1 - insetX, y: insetY }, // TR
{ x: 1, y: 1 - insetY }, // BR
{ x: 0, y: 1 - insetY }, // BL
];
}
/** Warp an RGBA buffer (`Uint8ClampedArray`, `Uint8Array` or plain array) with
* an inverse-mapped homography. Returns a fresh `Uint8ClampedArray`. */
export function warpRgba(src, srcW, srcH, corners, outW, outH) {
const out = new Uint8ClampedArray(outW * outH * 4);
const H = solveSquareToQuad(corners);
const Hinv = invert3x3(H);
if (!Hinv) return out;
const [a, b, c, d, e, f, g, h] = Hinv;
for (let y = 0; y < outH; y++) {
const v = outH > 1 ? y / (outH - 1) : 0;
for (let x = 0; x < outW; x++) {
const u = outW > 1 ? x / (outW - 1) : 0;
const w = g * u + h * v + 1;
if (!w) continue;
const s = (a * u + b * v + c) / w;
const t = (d * u + e * v + f) / w;
if (s < 0 || s > 1 || t < 0 || t > 1) continue;
const sx = Math.min(srcW - 1, Math.max(0, Math.round(s * (srcW - 1))));
const sy = Math.min(srcH - 1, Math.max(0, Math.round(t * (srcH - 1))));
const si = (sy * srcW + sx) * 4;
const di = (y * outW + x) * 4;
out[di] = src[si];
out[di + 1] = src[si + 1];
out[di + 2] = src[si + 2];
out[di + 3] = src[si + 3];
}
}
return out;
}
/** Browser helper: return a new canvas holding the warped source canvas. */
export function perspectiveWarpCanvas(sourceCanvas, corners, outW, outH) {
const srcW = sourceCanvas.width;
const srcH = sourceCanvas.height;
const sctx = sourceCanvas.getContext('2d');
const src = sctx.getImageData(0, 0, srcW, srcH).data;
const warped = warpRgba(src, srcW, srcH, corners, outW, outH);
const canvas = document.createElement('canvas');
canvas.width = outW;
canvas.height = outH;
const ctx = canvas.getContext('2d');
const image = ctx.createImageData(outW, outH);
image.data.set(warped);
ctx.putImageData(image, 0, 0);
return canvas;
}
export function isPerspectiveActive(perspectiveX = 0, perspectiveY = 0) {
return Math.abs(perspectiveX) > 0.01 || Math.abs(perspectiveY) > 0.01;
}