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>
This commit is contained in:
parent
56499d5af9
commit
83ab4df537
138 changed files with 19136 additions and 7681 deletions
313
lib/recognition/recognitionService.ts
Normal file
313
lib/recognition/recognitionService.ts
Normal file
|
|
@ -0,0 +1,313 @@
|
|||
/**
|
||||
* 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;
|
||||
},
|
||||
};
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue