/** * 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"; import { isDebugEnabled, saveDebugImage, drawCorners, resetDebugPrefix, } from "./debugSaver"; 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; /** Enable debug image saving at each pipeline step. */ debug?: boolean; } 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 = { enableCardDetection: true, enableRotationMatching: true, minConfidence: 0.85, matchThreshold: MATCH_THRESHOLD, debug: false, }; /** * 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 }; // Reset debug prefix for this recognition run if (opts.debug && isDebugEnabled()) { resetDebugPrefix(); } try { // Save input image if (opts.debug && isDebugEnabled()) { saveDebugImage("01_input", pixels, width, height); } 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); // Save detection visualization if (opts.debug && isDebugEnabled() && detection.found) { const detectionViz = drawCorners( pixels, width, height, detection.corners, [0, 255, 0] ); saveDebugImage("03_detection", detectionViz, width, height); } if (detection.found) { const warped = warpPerspective( pixels, width, height, detection.corners ); cardPixels = warped.pixels; cardWidth = warped.width; cardHeight = warped.height; // Save perspective corrected image if (opts.debug && isDebugEnabled()) { saveDebugImage("04_perspective", cardPixels, cardWidth, cardHeight); } } } // 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 ): 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); // Save CLAHE output (only for rotation 0 to avoid spam) if (opts.debug && isDebugEnabled() && rotation === 0) { saveDebugImage("05_clahe", clahePixels, rotatedWidth, rotatedHeight); } // Resize to 32x32 const resized = resizeImage(clahePixels, rotatedWidth, rotatedHeight, 32, 32); // Save final 32x32 (only for rotation 0) if (opts.debug && isDebugEnabled() && rotation === 0) { saveDebugImage("06_final_32x32", resized, 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 ): CardMatch | null { // Apply CLAHE const clahePixels = applyCLAHE(pixels, width, height); // Save CLAHE output if (opts.debug && isDebugEnabled()) { saveDebugImage("05_clahe", clahePixels, width, height); } // Resize to 32x32 const resized = resizeImage(clahePixels, width, height, 32, 32); // Save final 32x32 if (opts.debug && isDebugEnabled()) { saveDebugImage("06_final_32x32", resized, 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; }, }; }