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>
492 lines
14 KiB
TypeScript
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;
|
|
}
|