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