deckhearth/lib/scanner-card-warp.js
varutasu c52891a6b2
feat(scanner): perspective warp card detection (Phase 2) (#158)
Add quad corner refinement and homography warp for rectified identify
crops; wire warped capture through identifyTrackedCardCapture with
validation and axis-aligned fallback; add detection frame a11y labels.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-14 20:31:45 -05:00

271 lines
7.7 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.

/** Standard trading-card aspect ratio (width / height). */
export const CARD_ASPECT_RATIO = 5 / 7;
/** Order four corners as top-left, top-right, bottom-right, bottom-left. */
export function orderQuadCorners(points) {
if (!points || points.length !== 4) {
throw new Error('orderQuadCorners expects exactly four points');
}
const sortedByY = [...points].sort((a, b) => a.y - b.y);
const top = sortedByY.slice(0, 2).sort((a, b) => a.x - b.x);
const bottom = sortedByY.slice(2, 4).sort((a, b) => a.x - b.x);
return [top[0], top[1], bottom[1], bottom[0]];
}
/** Axis-aligned bounds enclosing a quad. */
export function boundsFromCorners(corners) {
const xs = corners.map((point) => point.x);
const ys = corners.map((point) => point.y);
const minX = Math.min(...xs);
const minY = Math.min(...ys);
const maxX = Math.max(...xs);
const maxY = Math.max(...ys);
return {
x: minX,
y: minY,
width: maxX - minX,
height: maxY - minY,
};
}
/** True when corners form a convex quad with plausible card aspect ratio. */
export function isValidCardQuad(corners, { minAspect = 0.6, maxAspect = 0.8 } = {}) {
if (!corners || corners.length !== 4) return false;
const ordered = orderQuadCorners(corners);
const bounds = boundsFromCorners(ordered);
if (bounds.width < 20 || bounds.height < 28) return false;
const aspect = bounds.width / bounds.height;
if (aspect < minAspect || aspect > maxAspect) return false;
const area = polygonArea(ordered);
const boundsArea = bounds.width * bounds.height;
if (area / boundsArea < 0.55) return false;
return isConvexQuad(ordered);
}
function polygonArea(points) {
let sum = 0;
for (let i = 0; i < points.length; i++) {
const current = points[i];
const next = points[(i + 1) % points.length];
sum += current.x * next.y - next.x * current.y;
}
return Math.abs(sum) / 2;
}
function isConvexQuad(points) {
let sign = 0;
for (let i = 0; i < 4; i++) {
const a = points[i];
const b = points[(i + 1) % 4];
const c = points[(i + 2) % 4];
const cross = (b.x - a.x) * (c.y - b.y) - (b.y - a.y) * (c.x - b.x);
if (cross === 0) continue;
const currentSign = cross > 0 ? 1 : -1;
if (sign === 0) {
sign = currentSign;
} else if (sign !== currentSign) {
return false;
}
}
return sign !== 0;
}
/** Solve 8×8 homography mapping src quad → axis-aligned dst rectangle. */
export function computeHomography(srcCorners, dstWidth, dstHeight) {
const src = orderQuadCorners(srcCorners);
const dst = [
{ x: 0, y: 0 },
{ x: dstWidth, y: 0 },
{ x: dstWidth, y: dstHeight },
{ x: 0, y: dstHeight },
];
const rows = [];
for (let i = 0; i < 4; i++) {
const { x, y } = src[i];
const { x: u, y: v } = dst[i];
rows.push([x, y, 1, 0, 0, 0, -u * x, -u * y, u]);
rows.push([0, 0, 0, x, y, 1, -v * x, -v * y, v]);
}
const h = solveLinearSystem(rows);
return [
[h[0], h[1], h[2]],
[h[3], h[4], h[5]],
[h[6], h[7], 1],
];
}
function solveLinearSystem(rows) {
const matrix = rows.map((row) => row.slice());
const size = 8;
for (let col = 0; col < size; col++) {
let pivotRow = col;
for (let row = col + 1; row < size; row++) {
if (Math.abs(matrix[row][col]) > Math.abs(matrix[pivotRow][col])) {
pivotRow = row;
}
}
if (Math.abs(matrix[pivotRow][col]) < 1e-9) {
throw new Error('Homography system is singular');
}
[matrix[col], matrix[pivotRow]] = [matrix[pivotRow], matrix[col]];
const pivot = matrix[col][col];
for (let j = col; j <= size; j++) {
matrix[col][j] /= pivot;
}
for (let row = 0; row < size; row++) {
if (row === col) continue;
const factor = matrix[row][col];
for (let j = col; j <= size; j++) {
matrix[row][j] -= factor * matrix[col][j];
}
}
}
return matrix.map((row) => row[size]);
}
function applyHomographyInverse(matrix, x, y) {
const denom = matrix[2][0] * x + matrix[2][1] * y + matrix[2][2];
const srcX = (matrix[0][0] * x + matrix[0][1] * y + matrix[0][2]) / denom;
const srcY = (matrix[1][0] * x + matrix[1][1] * y + matrix[1][2]) / denom;
return { x: srcX, y: srcY };
}
function invert3x3(matrix) {
const [
[a, b, c],
[d, e, f],
[g, h, i],
] = matrix;
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 (Math.abs(det) < 1e-9) {
throw new Error('Homography matrix is not invertible');
}
const invDet = 1 / det;
return [
[A * invDet, D * invDet, G * invDet],
[B * invDet, E * invDet, H * invDet],
[C * invDet, F * invDet, I * invDet],
];
}
function sampleBilinear(data, width, height, x, y) {
const clampedX = Math.max(0, Math.min(width - 1, x));
const clampedY = Math.max(0, Math.min(height - 1, y));
const x0 = Math.floor(clampedX);
const y0 = Math.floor(clampedY);
const x1 = Math.min(x0 + 1, width - 1);
const y1 = Math.min(y0 + 1, height - 1);
const tx = clampedX - x0;
const ty = clampedY - y0;
const idx = (row, col) => (row * width + col) * 4;
const sample = (row, col) => {
const base = idx(row, col);
return [data[base], data[base + 1], data[base + 2], data[base + 3]];
};
const c00 = sample(y0, x0);
const c10 = sample(y0, x1);
const c01 = sample(y1, x0);
const c11 = sample(y1, x1);
const out = [0, 0, 0, 255];
for (let channel = 0; channel < 3; channel++) {
const top = c00[channel] * (1 - tx) + c10[channel] * tx;
const bottom = c01[channel] * (1 - tx) + c11[channel] * tx;
out[channel] = Math.round(top * (1 - ty) + bottom * ty);
}
return out;
}
/**
* Perspective-correct a card region from the live video frame.
* Returns a JPEG data URL sized to the card aspect ratio.
*/
export function warpCardCaptureFromVideo(
video,
canvas,
corners,
{ jpegQuality = 0.92, maxWidth = 480 } = {}
) {
if (!video || !canvas || !corners || corners.length !== 4) {
throw new Error('warpCardCaptureFromVideo requires video, canvas, and four corners');
}
const ordered = orderQuadCorners(corners);
const bounds = boundsFromCorners(ordered);
const outputHeight = Math.max(1, Math.round(maxWidth / CARD_ASPECT_RATIO));
const outputWidth = maxWidth;
const homography = computeHomography(ordered, outputWidth, outputHeight);
const inverse = invert3x3(homography);
const sourceCanvas = document.createElement('canvas');
sourceCanvas.width = video.videoWidth;
sourceCanvas.height = video.videoHeight;
const sourceCtx = sourceCanvas.getContext('2d');
sourceCtx.drawImage(video, 0, 0);
const sourceData = sourceCtx.getImageData(0, 0, sourceCanvas.width, sourceCanvas.height).data;
canvas.width = outputWidth;
canvas.height = outputHeight;
const ctx = canvas.getContext('2d');
const output = ctx.createImageData(outputWidth, outputHeight);
for (let y = 0; y < outputHeight; y++) {
for (let x = 0; x < outputWidth; x++) {
const mapped = applyHomographyInverse(inverse, x, y);
if (
mapped.x < bounds.x - 5 ||
mapped.y < bounds.y - 5 ||
mapped.x > bounds.x + bounds.width + 5 ||
mapped.y > bounds.y + bounds.height + 5
) {
continue;
}
const rgba = sampleBilinear(
sourceData,
sourceCanvas.width,
sourceCanvas.height,
mapped.x,
mapped.y
);
const outIdx = (y * outputWidth + x) * 4;
output.data[outIdx] = rgba[0];
output.data[outIdx + 1] = rgba[1];
output.data[outIdx + 2] = rgba[2];
output.data[outIdx + 3] = 255;
}
}
ctx.putImageData(output, 0, 0);
return canvas.toDataURL('image/jpeg', jpegQuality);
}