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>
313 lines
8 KiB
TypeScript
313 lines
8 KiB
TypeScript
/**
|
|
* Card recognition service that orchestrates the full pipeline.
|
|
*
|
|
* Pipeline:
|
|
* 1. Card detection (optional) - find card boundaries
|
|
* 2. Perspective correction - warp to rectangle
|
|
* 3. CLAHE preprocessing - normalize lighting
|
|
* 4. Resize to 32x32
|
|
* 5. Compute perceptual hash
|
|
* 6. Match against database via Hamming distance
|
|
*/
|
|
|
|
import { detectCard, CardDetectionResult } from "./cardDetector";
|
|
import { warpPerspective } from "./perspectiveCorrection";
|
|
import { applyCLAHE } from "./clahe";
|
|
import {
|
|
computeColorHash,
|
|
hammingDistance,
|
|
MATCH_THRESHOLD,
|
|
HASH_BITS,
|
|
} from "./perceptualHash";
|
|
import { resizeImage, rotateImage } from "./imageUtils";
|
|
|
|
export interface RecognitionOptions {
|
|
/** Enable card detection and perspective correction. */
|
|
enableCardDetection?: boolean;
|
|
/** Enable rotation matching (try 0°, 90°, 180°, 270°). */
|
|
enableRotationMatching?: boolean;
|
|
/** Minimum confidence to accept a match (0-1). */
|
|
minConfidence?: number;
|
|
/** Maximum Hamming distance to accept a match. */
|
|
matchThreshold?: number;
|
|
}
|
|
|
|
export interface CardMatch {
|
|
/** Card ID from database. */
|
|
cardId: string;
|
|
/** Match confidence (0-1). */
|
|
confidence: number;
|
|
/** Hamming distance between hashes. */
|
|
distance: number;
|
|
/** Rotation used for match (0, 90, 180, or 270). */
|
|
rotation: number;
|
|
}
|
|
|
|
export interface RecognitionResult {
|
|
success: boolean;
|
|
match?: CardMatch;
|
|
cardDetection?: CardDetectionResult;
|
|
error?: string;
|
|
processingTimeMs: number;
|
|
}
|
|
|
|
export interface CardHashEntry {
|
|
id: string;
|
|
name: string;
|
|
setCode: string;
|
|
collectorNumber?: string;
|
|
imageUri?: string;
|
|
hash: Uint8Array;
|
|
}
|
|
|
|
const DEFAULT_OPTIONS: Required<RecognitionOptions> = {
|
|
enableCardDetection: true,
|
|
enableRotationMatching: true,
|
|
minConfidence: 0.85,
|
|
matchThreshold: MATCH_THRESHOLD,
|
|
};
|
|
|
|
/**
|
|
* Calculate confidence from Hamming distance.
|
|
*/
|
|
export function calculateConfidence(distance: number, totalBits: number = HASH_BITS): number {
|
|
return 1 - distance / totalBits;
|
|
}
|
|
|
|
/**
|
|
* Recognize a card from RGBA pixel data.
|
|
*
|
|
* @param pixels - RGBA pixel data
|
|
* @param width - Image width
|
|
* @param height - Image height
|
|
* @param cardHashes - Array of card hashes to match against
|
|
* @param options - Recognition options
|
|
* @returns Recognition result with best match
|
|
*/
|
|
export function recognizeCard(
|
|
pixels: Uint8Array | Uint8ClampedArray,
|
|
width: number,
|
|
height: number,
|
|
cardHashes: CardHashEntry[],
|
|
options: RecognitionOptions = {}
|
|
): RecognitionResult {
|
|
const startTime = performance.now();
|
|
const opts = { ...DEFAULT_OPTIONS, ...options };
|
|
|
|
try {
|
|
if (cardHashes.length === 0) {
|
|
return {
|
|
success: false,
|
|
error: "No cards in database",
|
|
processingTimeMs: performance.now() - startTime,
|
|
};
|
|
}
|
|
|
|
let cardPixels = pixels;
|
|
let cardWidth = width;
|
|
let cardHeight = height;
|
|
let detection: CardDetectionResult | undefined;
|
|
|
|
// Step 1: Detect and extract card (if enabled)
|
|
if (opts.enableCardDetection) {
|
|
detection = detectCard(pixels, width, height);
|
|
|
|
if (detection.found) {
|
|
const warped = warpPerspective(
|
|
pixels,
|
|
width,
|
|
height,
|
|
detection.corners
|
|
);
|
|
cardPixels = warped.pixels;
|
|
cardWidth = warped.width;
|
|
cardHeight = warped.height;
|
|
}
|
|
}
|
|
|
|
// Step 2: Find best match (with or without rotation)
|
|
const match = opts.enableRotationMatching
|
|
? findBestMatchWithRotations(cardPixels, cardWidth, cardHeight, cardHashes, opts)
|
|
: findBestMatchSingle(cardPixels, cardWidth, cardHeight, cardHashes, opts);
|
|
|
|
const processingTimeMs = performance.now() - startTime;
|
|
|
|
if (!match) {
|
|
return {
|
|
success: false,
|
|
cardDetection: detection,
|
|
error: "No match found",
|
|
processingTimeMs,
|
|
};
|
|
}
|
|
|
|
return {
|
|
success: true,
|
|
match,
|
|
cardDetection: detection,
|
|
processingTimeMs,
|
|
};
|
|
} catch (error) {
|
|
return {
|
|
success: false,
|
|
error: error instanceof Error ? error.message : "Unknown error",
|
|
processingTimeMs: performance.now() - startTime,
|
|
};
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Compute hash for an image with full preprocessing pipeline.
|
|
*/
|
|
export function computeImageHash(
|
|
pixels: Uint8Array | Uint8ClampedArray,
|
|
width: number,
|
|
height: number
|
|
): Uint8Array {
|
|
// Apply CLAHE
|
|
const clahePixels = applyCLAHE(pixels, width, height);
|
|
|
|
// Resize to 32x32
|
|
const resized = resizeImage(clahePixels, width, height, 32, 32);
|
|
|
|
// Compute hash
|
|
return computeColorHash(resized);
|
|
}
|
|
|
|
/**
|
|
* Find best match trying all 4 rotations.
|
|
*/
|
|
function findBestMatchWithRotations(
|
|
pixels: Uint8Array | Uint8ClampedArray,
|
|
width: number,
|
|
height: number,
|
|
cardHashes: CardHashEntry[],
|
|
opts: Required<RecognitionOptions>
|
|
): CardMatch | null {
|
|
let bestMatch: CardMatch | null = null;
|
|
|
|
const rotations: Array<0 | 90 | 180 | 270> = [0, 90, 180, 270];
|
|
|
|
for (const rotation of rotations) {
|
|
const { pixels: rotatedPixels, width: rotatedWidth, height: rotatedHeight } =
|
|
rotation === 0
|
|
? { pixels, width, height }
|
|
: rotateImage(pixels, width, height, rotation);
|
|
|
|
// Apply CLAHE
|
|
const clahePixels = applyCLAHE(rotatedPixels, rotatedWidth, rotatedHeight);
|
|
|
|
// Resize to 32x32
|
|
const resized = resizeImage(clahePixels, rotatedWidth, rotatedHeight, 32, 32);
|
|
|
|
// Compute hash
|
|
const queryHash = computeColorHash(resized);
|
|
|
|
// Find best match for this rotation
|
|
for (const card of cardHashes) {
|
|
if (!card.hash || card.hash.length !== queryHash.length) continue;
|
|
|
|
const distance = hammingDistance(queryHash, card.hash);
|
|
const confidence = calculateConfidence(distance);
|
|
|
|
if (distance <= opts.matchThreshold && confidence >= opts.minConfidence) {
|
|
if (!bestMatch || distance < bestMatch.distance) {
|
|
bestMatch = {
|
|
cardId: card.id,
|
|
confidence,
|
|
distance,
|
|
rotation,
|
|
};
|
|
}
|
|
|
|
// Early exit on perfect match
|
|
if (distance === 0) {
|
|
return bestMatch;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return bestMatch;
|
|
}
|
|
|
|
/**
|
|
* Find best match without rotation.
|
|
*/
|
|
function findBestMatchSingle(
|
|
pixels: Uint8Array | Uint8ClampedArray,
|
|
width: number,
|
|
height: number,
|
|
cardHashes: CardHashEntry[],
|
|
opts: Required<RecognitionOptions>
|
|
): CardMatch | null {
|
|
// Apply CLAHE
|
|
const clahePixels = applyCLAHE(pixels, width, height);
|
|
|
|
// Resize to 32x32
|
|
const resized = resizeImage(clahePixels, width, height, 32, 32);
|
|
|
|
// Compute hash
|
|
const queryHash = computeColorHash(resized);
|
|
|
|
let bestMatch: CardMatch | null = null;
|
|
|
|
for (const card of cardHashes) {
|
|
if (!card.hash || card.hash.length !== queryHash.length) continue;
|
|
|
|
const distance = hammingDistance(queryHash, card.hash);
|
|
const confidence = calculateConfidence(distance);
|
|
|
|
if (distance <= opts.matchThreshold && confidence >= opts.minConfidence) {
|
|
if (!bestMatch || distance < bestMatch.distance) {
|
|
bestMatch = {
|
|
cardId: card.id,
|
|
confidence,
|
|
distance,
|
|
rotation: 0,
|
|
};
|
|
}
|
|
|
|
if (distance === 0) {
|
|
return bestMatch;
|
|
}
|
|
}
|
|
}
|
|
|
|
return bestMatch;
|
|
}
|
|
|
|
/**
|
|
* Create a recognition service instance with cached card hashes.
|
|
*/
|
|
export function createRecognitionService(cardHashes: CardHashEntry[]) {
|
|
let cachedHashes = cardHashes;
|
|
|
|
return {
|
|
/**
|
|
* Recognize a card from RGBA pixel data.
|
|
*/
|
|
recognize(
|
|
pixels: Uint8Array | Uint8ClampedArray,
|
|
width: number,
|
|
height: number,
|
|
options?: RecognitionOptions
|
|
): RecognitionResult {
|
|
return recognizeCard(pixels, width, height, cachedHashes, options);
|
|
},
|
|
|
|
/**
|
|
* Update the cached card hashes.
|
|
*/
|
|
updateHashes(hashes: CardHashEntry[]) {
|
|
cachedHashes = hashes;
|
|
},
|
|
|
|
/**
|
|
* Get the number of cached hashes.
|
|
*/
|
|
getHashCount(): number {
|
|
return cachedHashes.length;
|
|
},
|
|
};
|
|
}
|