deckhearth/lib/scanner-card-warp.js

272 lines
7.7 KiB
JavaScript
Raw Permalink Normal View History

/** 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);
}