/** * Card detection using edge detection and contour analysis. * Detects card boundaries in images and returns the four corner points. * * This is a pure TypeScript implementation for when OpenCV is not available. */ import { Point, toGrayscale, gaussianBlur, distance, crossProduct, pointToLineDistance, } from "./imageUtils"; /** Standard MTG card aspect ratio (height / width). Cards are 63mm x 88mm. */ const CARD_ASPECT_RATIO = 88 / 63; // ~1.397 const ASPECT_RATIO_TOLERANCE = 0.25; const MIN_CARD_AREA_RATIO = 0.05; const MAX_CARD_AREA_RATIO = 0.98; export interface CardDetectionResult { found: boolean; corners: Point[]; confidence: number; debugMessage?: string; } /** * Detect a card in the image and return its corner points. */ export function detectCard( pixels: Uint8Array | Uint8ClampedArray, width: number, height: number ): CardDetectionResult { // Step 1: Convert to grayscale const grayscale = toGrayscale(pixels); // Step 2: Apply Gaussian blur to reduce noise const blurred = gaussianBlur(grayscale, width, height, 5); // Step 3: Apply Canny edge detection const edges = applyCannyEdgeDetection(blurred, width, height, 50, 150); // Step 4: Find contours const contours = findContours(edges, width, height); if (contours.length === 0) { return { found: false, corners: [], confidence: 0, debugMessage: "No contours found" }; } // Step 5: Find the best card-like quadrilateral const imageArea = width * height; const bestQuad = findBestCardQuadrilateral(contours, imageArea); if (!bestQuad) { return { found: false, corners: [], confidence: 0, debugMessage: "No card-like quadrilateral found" }; } // Step 6: Order corners consistently const orderedCorners = orderCorners(bestQuad); // Calculate confidence const confidence = calculateConfidence(orderedCorners, imageArea); return { found: true, corners: orderedCorners, confidence }; } /** * Apply Canny edge detection. */ function applyCannyEdgeDetection( gray: Uint8Array, width: number, height: number, lowThreshold: number, highThreshold: number ): Uint8Array { // Step 1: Compute gradients using Sobel operators const gradientX = new Float32Array(width * height); const gradientY = new Float32Array(width * height); const magnitude = new Float32Array(width * height); const direction = new Float32Array(width * height); // Sobel kernels const sobelX = [[-1, 0, 1], [-2, 0, 2], [-1, 0, 1]]; const sobelY = [[-1, -2, -1], [0, 0, 0], [1, 2, 1]]; for (let y = 1; y < height - 1; y++) { for (let x = 1; x < width - 1; x++) { let gx = 0, gy = 0; for (let ky = -1; ky <= 1; ky++) { for (let kx = -1; kx <= 1; kx++) { const pixel = gray[(y + ky) * width + (x + kx)]; gx += pixel * sobelX[ky + 1][kx + 1]; gy += pixel * sobelY[ky + 1][kx + 1]; } } const idx = y * width + x; gradientX[idx] = gx; gradientY[idx] = gy; magnitude[idx] = Math.sqrt(gx * gx + gy * gy); direction[idx] = Math.atan2(gy, gx); } } // Step 2: Non-maximum suppression const suppressed = new Float32Array(width * height); for (let y = 1; y < height - 1; y++) { for (let x = 1; x < width - 1; x++) { const idx = y * width + x; let angle = direction[idx] * 180 / Math.PI; if (angle < 0) angle += 180; let neighbor1: number, neighbor2: number; if (angle < 22.5 || angle >= 157.5) { neighbor1 = magnitude[y * width + (x - 1)]; neighbor2 = magnitude[y * width + (x + 1)]; } else if (angle >= 22.5 && angle < 67.5) { neighbor1 = magnitude[(y - 1) * width + (x + 1)]; neighbor2 = magnitude[(y + 1) * width + (x - 1)]; } else if (angle >= 67.5 && angle < 112.5) { neighbor1 = magnitude[(y - 1) * width + x]; neighbor2 = magnitude[(y + 1) * width + x]; } else { neighbor1 = magnitude[(y - 1) * width + (x - 1)]; neighbor2 = magnitude[(y + 1) * width + (x + 1)]; } if (magnitude[idx] >= neighbor1 && magnitude[idx] >= neighbor2) { suppressed[idx] = magnitude[idx]; } } } // Step 3: Double thresholding and edge tracking const result = new Uint8Array(width * height); const strong = new Uint8Array(width * height); const weak = new Uint8Array(width * height); for (let i = 0; i < width * height; i++) { if (suppressed[i] >= highThreshold) { strong[i] = 1; } else if (suppressed[i] >= lowThreshold) { weak[i] = 1; } } // Edge tracking by hysteresis for (let y = 1; y < height - 1; y++) { for (let x = 1; x < width - 1; x++) { const idx = y * width + x; if (strong[idx]) { result[idx] = 255; } else if (weak[idx]) { // Check if connected to strong edge outer: for (let dy = -1; dy <= 1; dy++) { for (let dx = -1; dx <= 1; dx++) { if (strong[(y + dy) * width + (x + dx)]) { result[idx] = 255; break outer; } } } } } } return result; } /** * Find contours in a binary edge image using flood fill. */ function findContours( edges: Uint8Array, width: number, height: number ): Point[][] { const visited = new Uint8Array(width * height); const contours: Point[][] = []; const dx = [-1, 0, 1, 1, 1, 0, -1, -1]; const dy = [-1, -1, -1, 0, 1, 1, 1, 0]; for (let y = 1; y < height - 1; y++) { for (let x = 1; x < width - 1; x++) { const idx = y * width + x; if (visited[idx]) continue; if (edges[idx] < 128) continue; // Trace contour using BFS const contour: Point[] = []; const queue: Array<{ x: number; y: number }> = [{ x, y }]; while (queue.length > 0 && contour.length < 10000) { const point = queue.shift()!; const pidx = point.y * width + point.x; if (point.x < 0 || point.x >= width || point.y < 0 || point.y >= height) continue; if (visited[pidx]) continue; if (edges[pidx] < 128) continue; visited[pidx] = 1; contour.push(point); for (let i = 0; i < 8; i++) { queue.push({ x: point.x + dx[i], y: point.y + dy[i] }); } } if (contour.length >= 4) { contours.push(contour); } } } return contours; } /** * Find the best quadrilateral that matches a card shape. */ function findBestCardQuadrilateral( contours: Point[][], imageArea: number ): Point[] | null { let bestQuad: Point[] | null = null; let bestScore = -Infinity; for (const contour of contours) { // Simplify contour using Douglas-Peucker const simplified = simplifyContour(contour, contour.length * 0.02); // Try to approximate as quadrilateral const quad = approximateQuadrilateral(simplified); if (!quad) continue; // Check if valid card shape const area = calculateQuadArea(quad); const areaRatio = area / imageArea; if (areaRatio < MIN_CARD_AREA_RATIO || areaRatio > MAX_CARD_AREA_RATIO) { continue; } // Check aspect ratio const aspectScore = calculateAspectRatioScore(quad); if (aspectScore < 0.5) continue; // Check convexity if (!isConvex(quad)) continue; // Score based on area and aspect ratio const score = areaRatio * aspectScore; if (score > bestScore) { bestScore = score; bestQuad = quad; } } return bestQuad; } /** * Simplify a contour using Douglas-Peucker algorithm. */ function simplifyContour(contour: Point[], epsilon: number): Point[] { if (contour.length < 3) return contour; const first = contour[0]; const last = contour[contour.length - 1]; let maxDist = 0; let maxIndex = 0; for (let i = 1; i < contour.length - 1; i++) { const dist = pointToLineDistance(contour[i], first, last); if (dist > maxDist) { maxDist = dist; maxIndex = i; } } if (maxDist > epsilon) { const left = simplifyContour(contour.slice(0, maxIndex + 1), epsilon); const right = simplifyContour(contour.slice(maxIndex), epsilon); return [...left.slice(0, -1), ...right]; } return [first, last]; } /** * Try to approximate a contour as a quadrilateral. */ function approximateQuadrilateral(contour: Point[]): Point[] | null { if (contour.length < 4) return null; if (contour.length === 4) return contour; // Find convex hull const hull = convexHull(contour); if (hull.length < 4) return null; if (hull.length === 4) return hull; // Find 4 extreme points return findExtremePoints(hull); } /** * Calculate convex hull using Graham scan. */ function convexHull(points: Point[]): Point[] { if (points.length < 3) return points; // Find bottom-most point const start = points.reduce((min, p) => p.y < min.y || (p.y === min.y && p.x < min.x) ? p : min ); // Sort by polar angle const sorted = points .filter(p => p !== start) .sort((a, b) => { const angleA = Math.atan2(a.y - start.y, a.x - start.x); const angleB = Math.atan2(b.y - start.y, b.x - start.x); if (angleA !== angleB) return angleA - angleB; return distance(a, start) - distance(b, start); }); const hull: Point[] = [start]; for (const point of sorted) { while (hull.length > 1 && crossProduct(hull[hull.length - 2], hull[hull.length - 1], point) <= 0) { hull.pop(); } hull.push(point); } return hull; } /** * Find 4 extreme points of a convex hull. */ function findExtremePoints(hull: Point[]): Point[] { if (hull.length <= 4) return hull; const minX = hull.reduce((min, p) => p.x < min.x ? p : min, hull[0]); const maxX = hull.reduce((max, p) => p.x > max.x ? p : max, hull[0]); const minY = hull.reduce((min, p) => p.y < min.y ? p : min, hull[0]); const maxY = hull.reduce((max, p) => p.y > max.y ? p : max, hull[0]); const extremes = new Set([minX, maxX, minY, maxY]); if (extremes.size === 4) { return Array.from(extremes); } // Fallback: take points at regular angular intervals const centerX = hull.reduce((s, p) => s + p.x, 0) / hull.length; const centerY = hull.reduce((s, p) => s + p.y, 0) / hull.length; const sorted = [...hull].sort((a, b) => Math.atan2(a.y - centerY, a.x - centerX) - Math.atan2(b.y - centerY, b.x - centerX) ); const step = Math.floor(sorted.length / 4); return [sorted[0], sorted[step], sorted[step * 2], sorted[step * 3]]; } /** * Calculate area of a quadrilateral using shoelace formula. */ function calculateQuadArea(quad: Point[]): number { let area = 0; for (let i = 0; i < 4; i++) { const j = (i + 1) % 4; area += quad[i].x * quad[j].y; area -= quad[j].x * quad[i].y; } return Math.abs(area) / 2; } /** * Calculate how well the aspect ratio matches a card. */ function calculateAspectRatioScore(quad: Point[]): number { const width1 = distance(quad[0], quad[1]); const width2 = distance(quad[2], quad[3]); const height1 = distance(quad[1], quad[2]); const height2 = distance(quad[3], quad[0]); const avgWidth = (width1 + width2) / 2; const avgHeight = (height1 + height2) / 2; // Ensure we get the right ratio regardless of orientation const aspectRatio = avgWidth > avgHeight ? avgWidth / avgHeight : avgHeight / avgWidth; const deviation = Math.abs(aspectRatio - CARD_ASPECT_RATIO) / CARD_ASPECT_RATIO; return Math.max(0, 1 - deviation / ASPECT_RATIO_TOLERANCE); } /** * Check if a quadrilateral is convex. */ function isConvex(quad: Point[]): boolean { let sign = 0; for (let i = 0; i < 4; i++) { const cross = crossProduct( quad[i], quad[(i + 1) % 4], quad[(i + 2) % 4] ); if (Math.abs(cross) < 0.0001) continue; const currentSign = cross > 0 ? 1 : -1; if (sign === 0) { sign = currentSign; } else if (sign !== currentSign) { return false; } } return true; } /** * Order corners consistently: top-left, top-right, bottom-right, bottom-left. */ function orderCorners(corners: Point[]): Point[] { const centerX = corners.reduce((s, c) => s + c.x, 0) / 4; const centerY = corners.reduce((s, c) => s + c.y, 0) / 4; const topLeft = corners.filter(c => c.x < centerX && c.y < centerY) .sort((a, b) => (a.x + a.y) - (b.x + b.y))[0]; const topRight = corners.filter(c => c.x >= centerX && c.y < centerY) .sort((a, b) => (a.y - a.x) - (b.y - b.x))[0]; const bottomRight = corners.filter(c => c.x >= centerX && c.y >= centerY) .sort((a, b) => (b.x + b.y) - (a.x + a.y))[0]; const bottomLeft = corners.filter(c => c.x < centerX && c.y >= centerY) .sort((a, b) => (b.y - b.x) - (a.y - a.x))[0]; // Handle edge cases by sorting by angle if (!topLeft || !topRight || !bottomRight || !bottomLeft) { const sorted = [...corners].sort((a, b) => Math.atan2(a.y - centerY, a.x - centerX) - Math.atan2(b.y - centerY, b.x - centerX) ); // Find index of point with minimum sum (top-left) let minSumIdx = 0; let minSum = Infinity; sorted.forEach((c, i) => { const sum = c.x + c.y; if (sum < minSum) { minSum = sum; minSumIdx = i; } }); return [...sorted.slice(minSumIdx), ...sorted.slice(0, minSumIdx)]; } return [topLeft, topRight, bottomRight, bottomLeft]; } /** * Calculate confidence of the detection. */ function calculateConfidence(corners: Point[], imageArea: number): number { const area = calculateQuadArea(corners); const areaScore = Math.min(area / imageArea / 0.5, 1); const aspectScore = calculateAspectRatioScore(corners); return areaScore * 0.4 + aspectScore * 0.6; }