/** * Perspective correction to transform a quadrilateral region into a rectangle. * Uses homography transformation with bilinear sampling. */ import { Point, sampleBilinear } from "./imageUtils"; /** Standard output size for corrected card images (63:88 aspect ratio). */ export const OUTPUT_WIDTH = 480; export const OUTPUT_HEIGHT = 670; /** * Apply perspective correction to extract and normalize a card from an image. * * @param pixels - Source RGBA pixel data * @param width - Source image width * @param height - Source image height * @param corners - Four corners in order: top-left, top-right, bottom-right, bottom-left * @param outputWidth - Width of output image * @param outputHeight - Height of output image * @returns Perspective-corrected RGBA pixel data */ export function warpPerspective( pixels: Uint8Array | Uint8ClampedArray, width: number, height: number, corners: Point[], outputWidth: number = OUTPUT_WIDTH, outputHeight: number = OUTPUT_HEIGHT ): { pixels: Uint8Array; width: number; height: number } { if (corners.length !== 4) { throw new Error("Exactly 4 corners required"); } // Determine if card is landscape (rotated 90°) const width1 = distance(corners[0], corners[1]); const height1 = distance(corners[1], corners[2]); let orderedCorners: Point[]; if (width1 > height1) { // Card is landscape - rotate corners to portrait orderedCorners = [corners[1], corners[2], corners[3], corners[0]]; } else { orderedCorners = corners; } // Compute the perspective transform matrix const matrix = computePerspectiveTransform(orderedCorners, outputWidth, outputHeight); const inverse = invertMatrix3x3(matrix); // Apply the transform const result = new Uint8Array(outputWidth * outputHeight * 4); for (let y = 0; y < outputHeight; y++) { for (let x = 0; x < outputWidth; x++) { // Apply inverse transform to find source coordinates const srcPoint = applyTransform(inverse, x, y); // Bilinear interpolation for smooth sampling const [r, g, b, a] = sampleBilinear(pixels, width, height, srcPoint.x, srcPoint.y); const idx = (y * outputWidth + x) * 4; result[idx] = r; result[idx + 1] = g; result[idx + 2] = b; result[idx + 3] = a; } } return { pixels: result, width: outputWidth, height: outputHeight }; } /** * Compute a perspective transform matrix from quad corners to rectangle. * Uses the Direct Linear Transform (DLT) algorithm. */ function computePerspectiveTransform( src: Point[], dstWidth: number, dstHeight: number ): number[] { // Destination corners (rectangle) const dst: Point[] = [ { x: 0, y: 0 }, { x: dstWidth - 1, y: 0 }, { x: dstWidth - 1, y: dstHeight - 1 }, { x: 0, y: dstHeight - 1 }, ]; // Build the 8x8 matrix for solving the homography const A: number[][] = []; const b: number[] = []; for (let i = 0; i < 4; i++) { const sx = src[i].x; const sy = src[i].y; const dx = dst[i].x; const dy = dst[i].y; A.push([sx, sy, 1, 0, 0, 0, -dx * sx, -dx * sy]); b.push(dx); A.push([0, 0, 0, sx, sy, 1, -dy * sx, -dy * sy]); b.push(dy); } // Solve using Gaussian elimination const h = solveLinearSystem(A, b); // Return 3x3 matrix as flat array [h11, h12, h13, h21, h22, h23, h31, h32, h33] return [h[0], h[1], h[2], h[3], h[4], h[5], h[6], h[7], 1]; } /** * Solve a linear system Ax = b using Gaussian elimination with partial pivoting. */ function solveLinearSystem(A: number[][], b: number[]): number[] { const n = b.length; // Create augmented matrix const augmented: number[][] = A.map((row, i) => [...row, b[i]]); // Forward elimination with partial pivoting for (let col = 0; col < n; col++) { // Find pivot let maxRow = col; for (let row = col + 1; row < n; row++) { if (Math.abs(augmented[row][col]) > Math.abs(augmented[maxRow][col])) { maxRow = row; } } // Swap rows [augmented[col], augmented[maxRow]] = [augmented[maxRow], augmented[col]]; // Eliminate for (let row = col + 1; row < n; row++) { if (Math.abs(augmented[col][col]) < 1e-10) continue; const factor = augmented[row][col] / augmented[col][col]; for (let j = col; j <= n; j++) { augmented[row][j] -= factor * augmented[col][j]; } } } // Back substitution const x = new Array(n).fill(0); for (let i = n - 1; i >= 0; i--) { x[i] = augmented[i][n]; for (let j = i + 1; j < n; j++) { x[i] -= augmented[i][j] * x[j]; } if (Math.abs(augmented[i][i]) > 1e-10) { x[i] /= augmented[i][i]; } } return x; } /** * Apply a 3x3 transform matrix to a point. */ function applyTransform(H: number[], x: number, y: number): Point { let w = H[6] * x + H[7] * y + H[8]; if (Math.abs(w) < 1e-10) w = 1e-10; return { x: (H[0] * x + H[1] * y + H[2]) / w, y: (H[3] * x + H[4] * y + H[5]) / w, }; } /** * Invert a 3x3 matrix. */ function invertMatrix3x3(m: number[]): number[] { const det = m[0] * (m[4] * m[8] - m[5] * m[7]) - m[1] * (m[3] * m[8] - m[5] * m[6]) + m[2] * (m[3] * m[7] - m[4] * m[6]); const invDet = Math.abs(det) < 1e-10 ? 1e10 : 1 / det; return [ (m[4] * m[8] - m[5] * m[7]) * invDet, (m[2] * m[7] - m[1] * m[8]) * invDet, (m[1] * m[5] - m[2] * m[4]) * invDet, (m[5] * m[6] - m[3] * m[8]) * invDet, (m[0] * m[8] - m[2] * m[6]) * invDet, (m[2] * m[3] - m[0] * m[5]) * invDet, (m[3] * m[7] - m[4] * m[6]) * invDet, (m[1] * m[6] - m[0] * m[7]) * invDet, (m[0] * m[4] - m[1] * m[3]) * invDet, ]; } /** * Calculate distance between two points. */ function distance(a: Point, b: Point): number { const dx = b.x - a.x; const dy = b.y - a.y; return Math.sqrt(dx * dx + dy * dy); }