Add quad corner refinement and homography warp for rectified identify crops; wire warped capture through identifyTrackedCardCapture with validation and axis-aligned fallback; add detection frame a11y labels. Co-authored-by: Cursor <cursoragent@cursor.com>
335 lines
8.8 KiB
JavaScript
335 lines
8.8 KiB
JavaScript
import { isValidCardQuad, warpCardCaptureFromVideo } from './scanner-card-warp.js';
|
|
|
|
/** 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,
|
|
};
|
|
}
|
|
|
|
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 fetchVisionIdentify(imageData, authHeaders) {
|
|
const response = await fetch('/api/scan/identify', {
|
|
method: 'POST',
|
|
headers: authHeaders,
|
|
body: JSON.stringify({ imageData }),
|
|
});
|
|
|
|
if (response.status === 429) {
|
|
return { ok: false, rateLimited: true };
|
|
}
|
|
|
|
if (!response.ok) {
|
|
const errBody = await response.json().catch(() => ({}));
|
|
throw new Error(errBody.error || `Scan identify failed: ${response.status}`);
|
|
}
|
|
|
|
const result = await response.json();
|
|
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 { recognizeCardFields } = await import('./ocr-worker.js');
|
|
const ocr = await recognizeCardFields(imageData);
|
|
|
|
if (ocr.nameText.length < 3) {
|
|
return { handled: false };
|
|
}
|
|
|
|
const l1 = await fetchIdentifyByText({
|
|
ocrText: ocr.nameText,
|
|
ocrConfidence: ocr.nameConfidence,
|
|
cardNumber: ocr.cardNumber || undefined,
|
|
authHeaders,
|
|
});
|
|
|
|
if (!l1.ok || l1.result.escalate) {
|
|
return { handled: false };
|
|
}
|
|
|
|
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 imageData = captureCardRegionFromVideo(
|
|
video,
|
|
canvas,
|
|
cardTracker.bounds,
|
|
CAPTURE_MARGIN_PX,
|
|
cardTracker.corners
|
|
);
|
|
|
|
try {
|
|
const l1 = await tryLayer1TextIdentify(imageData, authHeaders);
|
|
if (l1.handled) {
|
|
return { imageData, ...l1 };
|
|
}
|
|
} catch (l1Error) {
|
|
console.warn('Layer-1 OCR path failed, escalating to vision:', l1Error);
|
|
}
|
|
|
|
if (Date.now() < visionCooldownUntilMs) {
|
|
return { imageData, retry: true };
|
|
}
|
|
|
|
const vision = await fetchVisionIdentify(imageData, authHeaders);
|
|
if (vision.rateLimited) {
|
|
return { imageData, rateLimited: true };
|
|
}
|
|
|
|
return {
|
|
imageData,
|
|
handled: true,
|
|
outcome: resolveIdentifyOutcome(vision.result),
|
|
};
|
|
}
|