scry/lib/recognition/recognitionService.ts
Chris Kruining b4e4ff73ec
.
2026-02-09 16:35:08 +01:00

369 lines
9.5 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";
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<RecognitionOptions> = {
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<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);
// 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<RecognitionOptions>
): 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;
},
};
}