272 lines
7.7 KiB
JavaScript
272 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);
|
|||
|
|
}
|