scry/lib/recognition/cardDetector.ts
Chris Kruining 83ab4df537
Migrate from .NET MAUI to Expo + Convex
Complete rewrite of Scry using TypeScript stack:

- Expo/React Native with Expo Router (file-based routing)
- Convex backend (serverless functions + real-time database)
- Adaptive camera system (expo-camera in Expo Go, Vision Camera in production)
- React Native Skia + fast-opencv for image processing
- GDPR-compliant auth setup with Zitadel OIDC (pending configuration)

Key features:
- Card recognition pipeline ported to TypeScript
- Perceptual hashing (192-bit color pHash)
- CLAHE preprocessing for lighting normalization
- Local SQLite cache with Convex sync
- Collection management with offline support

Removes all .NET/MAUI code (src/, test/, tools/).

💘 Generated with Crush

Assisted-by: Claude Opus 4.5 via Crush <crush@charm.land>
2026-02-09 16:16:34 +01:00

492 lines
14 KiB
TypeScript

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