deckhearth/lib/scanner-card-identify.js

442 lines
13 KiB
JavaScript
Raw Permalink Normal View History

import { isValidCardQuad, warpCardCaptureFromVideo } from './scanner-card-warp.js';
/** Debug mode flag — set via localStorage or window global */
function isDebugMode() {
if (typeof window === 'undefined') return false;
return (
window.__SCANNER_DEBUG === true ||
localStorage.getItem('SCANNER_DEBUG') === 'true'
);
}
function debugLog(emoji, message, data) {
if (!isDebugMode()) return;
const timestamp = new Date().toISOString().split('T')[1].slice(0, 12);
if (data !== undefined) {
console.log(`[Scanner Debug ${timestamp}] ${emoji} ${message}`, data);
} else {
console.log(`[Scanner Debug ${timestamp}] ${emoji} ${message}`);
}
}
/** Default margin (px) around tracked bounds when cropping a card capture. */
export const CAPTURE_MARGIN_PX = 20;
/** JPEG quality for card crops sent to OCR / vision. */
export const OCR_CAPTURE_JPEG_QUALITY = 0.92;
/** Vision rate-limit backoff duration (ms). */
export const VISION_RATE_LIMIT_MS = 60_000;
export function getScanAuthHeaders() {
return {
'Content-Type': 'application/json',
Authorization: `Bearer ${localStorage.getItem('auth_token')}`,
};
}
export function candidateMatchesSetHint(candidate, setName, setCode) {
if (!setName && !setCode) return true;
const hint = (setName || setCode || '').toLowerCase();
const setNameLower = (candidate.set_name || '').toLowerCase();
const setCodeLower = (candidate.set_code || '').toLowerCase();
return setNameLower.includes(hint) || hint.includes(setNameLower) || setCodeLower === hint;
}
export function buildOcrMetaFromIdentifyResult(result) {
return {
confidence: result.ocr?.confidence ?? result.card?.ocr?.confidence,
rawText: result.ocr?.rawText ?? result.card?.ocr?.rawText,
abilities: result.card?.ocr?.abilities || [],
hp: result.card?.hp,
manaCost: result.card?.mana_cost,
cardName: result.ocr?.cardName,
query: result.ocr?.query,
};
}
export function buildScannedCardPayload(finalCard, { imageData, scanImageUrl, ocrMeta = {} }) {
return {
name: finalCard.name,
set: finalCard.set_name,
setCode: finalCard.set_code,
cardNumber: finalCard.card_number,
game: finalCard.game,
cardType: finalCard.card_type,
rarity: finalCard.rarity,
hp: finalCard.hp || ocrMeta.hp,
manaCost: finalCard.mana_cost || ocrMeta.manaCost,
abilities: ocrMeta.abilities || [],
ocrText: ocrMeta.rawText,
confidence: ocrMeta.confidence,
capturedImage: imageData,
scanImageUrl,
image_url: finalCard.image_url,
databaseId: finalCard.id,
isExisting: true,
};
}
/**
* Map a server identify response to a UI action the scanner component applies.
* @returns {{ type: string, cardStatus: string, [key: string]: unknown }}
*/
export function resolveIdentifyOutcome(result) {
if (!result.isCard) {
return {
type: 'error',
cardStatus: 'negative',
message: result.reason || 'No trading card detected',
};
}
const ocrMeta = buildOcrMetaFromIdentifyResult(result);
if (result.card) {
return { type: 'emit', cardStatus: 'confirmed', card: result.card, ocrMeta };
}
if (result.needsUserSelection && result.matches?.length) {
return {
type: 'disambiguation',
cardStatus: 'confirmed',
matches: result.matches,
ocrMeta,
message: result.message,
fromLayer1: result.layer === 1,
fromLayer0: result.layer === 0,
};
}
if (result.needsReview) {
return {
type: 'notice',
cardStatus: 'confirmed',
message: result.message || 'Scan saved for admin review.',
};
}
if (result.needsUserInput) {
return {
type: 'error',
cardStatus: 'negative',
message: result.message || 'Could not identify card — try again or submit for review.',
};
}
return {
type: 'error',
cardStatus: 'negative',
message: 'Could not identify card from scan.',
};
}
/**
* After a vision refine call during disambiguation, decide the next UI step.
*/
export function resolveDisambiguationRefineAction(result, { candidates }) {
if (result.needsReview) {
return { type: 'review_submitted', message: result.message };
}
if (result.card) {
return {
type: 'emit',
card: result.card,
ocrMeta: {
confidence: result.ocr?.confidence,
rawText: result.ocr?.rawText,
abilities: result.card?.ocr?.abilities || [],
},
};
}
const setHint = result.ocr?.setName || result.ocr?.setCode;
if (!result.matches?.length || !setHint) {
return { type: 'noop' };
}
const filtered = candidates.filter((candidate) =>
candidateMatchesSetHint(candidate, result.ocr.setName, result.ocr.setCode)
);
if (filtered.length === 0) {
return {
type: 'catalog_gap',
setHint,
submitPayload: {
name: result.ocr?.cardName || candidates[0]?.name,
candidateCardIds: candidates.map((c) => c.id),
},
hintMessage: `No "${setHint}" printing in our catalog. Tap "My card isn't listed" to save for admin review.`,
};
}
if (filtered.length === 1) {
return { type: 'pick', candidate: filtered[0] };
}
if (filtered.length > 1 && filtered.length < candidates.length) {
return {
type: 'narrow',
filtered,
visionHint: setHint,
message: `Narrowed to ${filtered.length} printings matching "${setHint}".`,
};
}
return { type: 'noop' };
}
/** Crop a tracked card region from the live video frame; returns a JPEG data URL. */
export function captureCardRegionFromVideo(
video,
canvas,
bounds,
margin = CAPTURE_MARGIN_PX,
corners = null
) {
if (corners?.length === 4 && typeof document !== 'undefined' && isValidCardQuad(corners)) {
try {
return warpCardCaptureFromVideo(video, canvas, corners, {
jpegQuality: OCR_CAPTURE_JPEG_QUALITY,
});
} catch (warpError) {
console.warn('Perspective warp failed, falling back to axis-aligned crop:', warpError);
}
}
const ctx = canvas.getContext('2d');
const { x, y, width, height } = bounds;
canvas.width = width + margin * 2;
canvas.height = height + margin * 2;
ctx.drawImage(
video,
Math.max(0, x - margin),
Math.max(0, y - margin),
width + margin * 2,
height + margin * 2,
0,
0,
canvas.width,
canvas.height
);
return canvas.toDataURL('image/jpeg', OCR_CAPTURE_JPEG_QUALITY);
}
export async function submitScanForReview({ imageData, name, candidateCardIds, authHeaders }) {
const response = await fetch('/api/scan/submit-for-review', {
method: 'POST',
headers: authHeaders,
body: JSON.stringify({ imageData, name, candidateCardIds }),
});
if (response.status === 429) {
return { ok: false, rateLimited: true };
}
if (!response.ok) {
const errBody = await response.json().catch(() => ({}));
throw new Error(errBody.error || 'Failed to submit scan for review');
}
const result = await response.json();
return { ok: true, message: result.message };
}
export async function fetchIdentifyByText({ ocrText, ocrConfidence, cardNumber, authHeaders }) {
const response = await fetch('/api/cards/identify-by-text', {
method: 'POST',
headers: authHeaders,
body: JSON.stringify({ ocrText, ocrConfidence, cardNumber }),
});
if (!response.ok) {
return { ok: false };
}
const result = await response.json();
return { ok: true, result };
}
export async function fetchIdentifyByImage(imageData, authHeaders, game) {
const response = await fetch('/api/scan/identify-by-image', {
method: 'POST',
headers: authHeaders,
body: JSON.stringify({ imageData, game }),
});
if (response.status === 429) {
return { ok: false, rateLimited: true };
}
if (!response.ok) {
return { ok: false };
}
const result = await response.json();
return { ok: true, result };
}
/**
* Layer 0: visual kNN against precomputed catalog embeddings.
*/
export async function tryLayer0VisualIdentify(imageData, authHeaders, game) {
const startMs = Date.now();
debugLog('🔍', 'Layer 0 (pgvector visual) started');
const l0 = await fetchIdentifyByImage(imageData, authHeaders, game);
const durationMs = Date.now() - startMs;
if (!l0.ok) {
debugLog('⚠️', `Layer 0 failed → ${durationMs}ms`, { rateLimited: l0.rateLimited });
return { handled: false };
}
if (l0.result.escalate) {
debugLog('⬆️', `Layer 0 escalating → ${durationMs}ms`, { reason: l0.result.escalateReason || 'low confidence' });
return { handled: false };
}
debugLog('✅', `Layer 0 resolved → ${durationMs}ms`, { card: l0.result.card?.name, matches: l0.result.matches?.length });
return {
handled: true,
outcome: resolveIdentifyOutcome(l0.result),
};
}
export async function fetchVisionIdentify(imageData, authHeaders) {
const startMs = Date.now();
debugLog('🤖', 'Layer 2 (Vision API) call started');
const response = await fetch('/api/scan/identify', {
method: 'POST',
headers: authHeaders,
body: JSON.stringify({ imageData }),
});
const durationMs = Date.now() - startMs;
if (response.status === 429) {
debugLog('🚫', `Vision API rate limited → ${durationMs}ms`);
return { ok: false, rateLimited: true };
}
if (!response.ok) {
const errBody = await response.json().catch(() => ({}));
debugLog('❌', `Vision API failed (${response.status}) → ${durationMs}ms`, { error: errBody.error });
throw new Error(errBody.error || `Scan identify failed: ${response.status}`);
}
const result = await response.json();
debugLog('✅', `Vision API success → ${durationMs}ms`, { card: result.card?.name, needsUserSelection: result.needsUserSelection });
return { ok: true, result };
}
/**
* Layer 1: local OCR name strip + pg_trgm catalog match.
* Returns { handled: true, outcome } when L1 resolves without escalation.
*/
export async function tryLayer1TextIdentify(imageData, authHeaders) {
const startMs = Date.now();
debugLog('📝', 'Layer 1 (Tesseract OCR + pg_trgm) started');
const { recognizeCardFields } = await import('./ocr-worker.js');
const ocrStartMs = Date.now();
const ocr = await recognizeCardFields(imageData);
const ocrDurationMs = Date.now() - ocrStartMs;
debugLog('🔤', `OCR completed → ${ocrDurationMs}ms`, { nameText: ocr.nameText, confidence: ocr.nameConfidence });
if (ocr.nameText.length < 3) {
debugLog('⚠️', `Layer 1 skipped (name too short: "${ocr.nameText}") → ${Date.now() - startMs}ms`);
return { handled: false };
}
const l1 = await fetchIdentifyByText({
ocrText: ocr.nameText,
ocrConfidence: ocr.nameConfidence,
cardNumber: ocr.cardNumber || undefined,
authHeaders,
});
const durationMs = Date.now() - startMs;
if (!l1.ok || l1.result.escalate) {
debugLog('⬆️', `Layer 1 escalating → ${durationMs}ms`, { escalate: l1.result?.escalate });
return { handled: false };
}
debugLog('✅', `Layer 1 resolved → ${durationMs}ms`, { card: l1.result.card?.name, matches: l1.result.matches?.length });
return {
handled: true,
outcome: resolveIdentifyOutcome(l1.result),
};
}
/**
* Run Layer 1 then Layer 2 identification for a tracked card capture.
*/
export async function identifyTrackedCardCapture({
video,
canvas,
cardTracker,
authHeaders,
visionCooldownUntilMs = 0,
}) {
const pipelineStartMs = Date.now();
debugLog('🎯', `Card verification started (tracker-${cardTracker.id})`);
const imageData = captureCardRegionFromVideo(
video,
canvas,
cardTracker.bounds,
CAPTURE_MARGIN_PX,
cardTracker.corners
);
try {
const l0 = await tryLayer0VisualIdentify(imageData, authHeaders);
if (l0.handled) {
debugLog('🎉', `Pipeline complete (L0) → ${Date.now() - pipelineStartMs}ms total`);
return { imageData, ...l0 };
}
} catch (l0Error) {
console.warn('Layer-0 visual path failed, falling back to text/vision:', l0Error);
debugLog('⚠️', 'Layer 0 exception', { error: l0Error.message });
}
try {
const l1 = await tryLayer1TextIdentify(imageData, authHeaders);
if (l1.handled) {
debugLog('🎉', `Pipeline complete (L1) → ${Date.now() - pipelineStartMs}ms total`);
return { imageData, ...l1 };
}
} catch (l1Error) {
console.warn('Layer-1 OCR path failed, escalating to vision:', l1Error);
debugLog('⚠️', 'Layer 1 exception', { error: l1Error.message });
}
if (Date.now() < visionCooldownUntilMs) {
const cooldownSecondsRemaining = Math.ceil((visionCooldownUntilMs - Date.now()) / 1000);
debugLog('⏸️', `Vision cooldown active — retry in ${cooldownSecondsRemaining}s`);
return { imageData, retry: true };
}
const vision = await fetchVisionIdentify(imageData, authHeaders);
if (vision.rateLimited) {
debugLog('🚫', `Pipeline halted (rate limited) → ${Date.now() - pipelineStartMs}ms total`);
return { imageData, rateLimited: true };
}
debugLog('🎉', `Pipeline complete (L2) → ${Date.now() - pipelineStartMs}ms total`);
return {
imageData,
handled: true,
outcome: resolveIdentifyOutcome(vision.result),
};
}