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>
271 lines
7.7 KiB
JavaScript
271 lines
7.7 KiB
JavaScript
/** 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);
|
||
}
|