fix(scanner): printing disambiguation + Layer-1 500 #39

Merged
varutasu merged 1 commit from fix/scanner-printing-disambiguation into main 2026-05-27 14:10:47 -04:00
3 changed files with 120 additions and 37 deletions
Showing only changes of commit 2d4600e2be - Show all commits

View file

@ -16,6 +16,9 @@ export default function CameraScanner({ onCardScanned, onError }) {
const [trackedCards, setTrackedCards] = useState([]); // Array of tracked card objects const [trackedCards, setTrackedCards] = useState([]); // Array of tracked card objects
const trackedCardsRef = useRef([]); const trackedCardsRef = useRef([]);
const nextCardIdRef = useRef(1); const nextCardIdRef = useRef(1);
const visionCooldownUntilRef = useRef(0);
const activeVerificationRef = useRef(0);
const lastErrorAtRef = useRef(0);
// Mana symbol settings // Mana symbol settings
const [manaSymbolSettings, setManaSymbolSettings] = useState({ useSVG: false }); const [manaSymbolSettings, setManaSymbolSettings] = useState({ useSVG: false });
@ -278,7 +281,7 @@ export default function CameraScanner({ onCardScanned, onError }) {
const processIdentifyResponse = async (cardTracker, imageData, result) => { const processIdentifyResponse = async (cardTracker, imageData, result) => {
if (!result.isCard) { if (!result.isCard) {
cardTracker.status = 'negative'; cardTracker.status = 'negative';
onError?.(result.reason || 'No trading card detected'); reportScannerError(result.reason || 'No trading card detected');
return; return;
} }
@ -310,17 +313,29 @@ export default function CameraScanner({ onCardScanned, onError }) {
if (result.needsReview || result.needsUserInput) { if (result.needsReview || result.needsUserInput) {
cardTracker.status = 'negative'; cardTracker.status = 'negative';
onError?.(result.message || 'Could not identify card — saved for review or retry.'); reportScannerError(result.message || 'Could not identify card — saved for review or retry.');
return; return;
} }
cardTracker.status = 'negative'; cardTracker.status = 'negative';
onError?.('Could not identify card from scan.'); reportScannerError('Could not identify card from scan.');
};
const reportScannerError = (message) => {
const now = Date.now();
if (now - lastErrorAtRef.current < 4000) return;
lastErrorAtRef.current = now;
onError?.(message);
}; };
// Server-side card identification // Server-side card identification
const verifyCardShape = async (cardTracker) => { const verifyCardShape = async (cardTracker) => {
if (!videoRef.current || !canvasRef.current || cardTracker.status !== 'detecting') return; if (!videoRef.current || !canvasRef.current || cardTracker.status !== 'detecting') return;
if (disambiguation) return;
if (activeVerificationRef.current >= 1) return;
activeVerificationRef.current += 1;
cardTracker.status = 'verifying';
try { try {
cardTracker.scanAttempts++; cardTracker.scanAttempts++;
@ -378,7 +393,12 @@ export default function CameraScanner({ onCardScanned, onError }) {
console.warn('Layer-1 OCR path failed, escalating to vision:', l1Error); console.warn('Layer-1 OCR path failed, escalating to vision:', l1Error);
} }
// Layer 2: vision via AI Gateway // Layer 2: vision via AI Gateway (skip while rate-limited)
if (Date.now() < visionCooldownUntilRef.current) {
cardTracker.status = 'detecting';
return;
}
const response = await fetch('/api/scan/identify', { const response = await fetch('/api/scan/identify', {
method: 'POST', method: 'POST',
headers: authHeaders, headers: authHeaders,
@ -386,7 +406,8 @@ export default function CameraScanner({ onCardScanned, onError }) {
}); });
if (response.status === 429) { if (response.status === 429) {
onError?.('Too many scan attempts. Please wait a moment and try again.'); visionCooldownUntilRef.current = Date.now() + 60_000;
reportScannerError('Too many scan attempts. Please wait a moment and try again.');
cardTracker.status = 'negative'; cardTracker.status = 'negative';
return; return;
} }
@ -402,7 +423,9 @@ export default function CameraScanner({ onCardScanned, onError }) {
} catch (error) { } catch (error) {
console.error(`Error verifying card ${cardTracker.id}:`, error); console.error(`Error verifying card ${cardTracker.id}:`, error);
cardTracker.status = 'negative'; cardTracker.status = 'negative';
onError?.(error.message || 'Scan failed'); reportScannerError(error.message || 'Scan failed');
} finally {
activeVerificationRef.current = Math.max(0, activeVerificationRef.current - 1);
} }
}; };
@ -423,13 +446,12 @@ export default function CameraScanner({ onCardScanned, onError }) {
trackingIntervalRef.current = setInterval(() => { trackingIntervalRef.current = setInterval(() => {
const cardsToVerify = trackedCardsRef.current.filter(card => const cardsToVerify = trackedCardsRef.current.filter(card =>
card.status === 'detecting' && card.status === 'detecting' &&
card.stableCount >= 4 && // Reduced back to 4 for better responsiveness card.stableCount >= 6 &&
card.scanAttempts < 2 && // Allow 2 attempts again card.scanAttempts < 1 &&
Date.now() - card.firstSeen > 1500 // Reduced to 1.5 seconds Date.now() - card.firstSeen > 2500
); );
// Verify up to 2 cards simultaneously to allow multi-card scanning const cardsToProcess = cardsToVerify.slice(0, 1);
const cardsToProcess = cardsToVerify.slice(0, 2);
cardsToProcess.forEach(card => { cardsToProcess.forEach(card => {
verifyCardShape(card); verifyCardShape(card);
}); });

View file

@ -104,30 +104,49 @@ export async function matchCardInCatalog({
SELECT * FROM cards SELECT * FROM cards
WHERE LOWER(name) = LOWER(${trimmedName}) WHERE LOWER(name) = LOWER(${trimmedName})
AND (LOWER(set_name) = LOWER(${set}) OR LOWER(set_code) = LOWER(${setCode || set})) AND (LOWER(set_name) = LOWER(${set}) OR LOWER(set_code) = LOWER(${setCode || set}))
LIMIT 1 LIMIT 5
` `
: await sql` : await sql`
SELECT * FROM cards SELECT * FROM cards
WHERE LOWER(name) = LOWER(${trimmedName}) WHERE LOWER(name) = LOWER(${trimmedName})
AND LOWER(set_code) = LOWER(${setCode}) AND LOWER(set_code) = LOWER(${setCode})
LIMIT 1 LIMIT 5
`; `;
if (setResult.rows.length > 0) { if (setResult.rows.length === 1) {
existingCard = setResult.rows[0]; existingCard = setResult.rows[0];
} else if (setResult.rows.length > 1) {
return {
type: 'disambiguation',
card: null,
matches: setResult.rows.map(mapCardRow),
needsUserSelection: true,
message: `Found ${setResult.rows.length} matches for "${trimmedName}" in that set. Select the correct printing.`,
};
} }
} }
if (!existingCard) { if (!existingCard) {
const nameResult = await sql` const nameResult = game
SELECT * FROM cards ? await sql`
WHERE LOWER(name) = LOWER(${trimmedName}) SELECT * FROM cards
ORDER BY WHERE LOWER(name) = LOWER(${trimmedName})
CASE WHEN game = ${game || 'UNKNOWN'} THEN 1 ELSE 2 END, ORDER BY CASE WHEN game = ${game} THEN 0 ELSE 1 END, set_name, card_number
created_at DESC `
LIMIT 1 : await sql`
`; SELECT * FROM cards
if (nameResult.rows.length > 0) { WHERE LOWER(name) = LOWER(${trimmedName})
ORDER BY set_name, card_number
`;
if (nameResult.rows.length === 1) {
existingCard = nameResult.rows[0]; existingCard = nameResult.rows[0];
} else if (nameResult.rows.length > 1) {
return {
type: 'disambiguation',
card: null,
matches: nameResult.rows.slice(0, 8).map(mapCardRow),
needsUserSelection: true,
message: `Found ${nameResult.rows.length} printings of "${trimmedName}". Select the correct card.`,
};
} }
} }
@ -169,6 +188,26 @@ export async function matchCardInCatalog({
} }
if (existingCard) { if (existingCard) {
const siblingsResult = await sql`
SELECT * FROM cards
WHERE LOWER(name) = LOWER(${trimmedName})
ORDER BY set_name, card_number
`;
if (siblingsResult.rows.length > 1) {
const ordered = [
existingCard,
...siblingsResult.rows.filter((row) => row.id !== existingCard.id),
];
return {
type: 'disambiguation',
card: null,
matches: ordered.slice(0, 8).map(mapCardRow),
needsUserSelection: true,
message: `Found ${siblingsResult.rows.length} printings of "${trimmedName}". Confirm the correct one.`,
};
}
return { return {
type: 'matched', type: 'matched',
card: existingCard, card: existingCard,

View file

@ -36,7 +36,6 @@ export function extractNameCandidate(ocrText) {
return ocrText.replace(/\s+/g, ' ').trim(); return ocrText.replace(/\s+/g, ' ').trim();
} }
// Prefer the first substantial line (card titles are printed at the top).
const scored = lines.slice(0, 5).map((line, index) => ({ const scored = lines.slice(0, 5).map((line, index) => ({
line, line,
score: line.length - index * 2, score: line.length - index * 2,
@ -45,6 +44,26 @@ export function extractNameCandidate(ocrText) {
return scored[0].line; return scored[0].line;
} }
async function querySimilarCards(query, game) {
if (game) {
return sql`
SELECT *, similarity(name, ${query}) AS sim
FROM cards
WHERE similarity(name, ${query}) > ${DISAMBIGUATION_THRESHOLD - 0.05}
ORDER BY sim DESC, CASE WHEN game = ${game} THEN 0 ELSE 1 END, LENGTH(name)
LIMIT 8
`;
}
return sql`
SELECT *, similarity(name, ${query}) AS sim
FROM cards
WHERE similarity(name, ${query}) > ${DISAMBIGUATION_THRESHOLD - 0.05}
ORDER BY sim DESC, LENGTH(name)
LIMIT 8
`;
}
/** /**
* Fuzzy match OCR text against cards.name using pg_trgm similarity. * Fuzzy match OCR text against cards.name using pg_trgm similarity.
*/ */
@ -60,19 +79,7 @@ export async function matchTextInCatalog({ ocrText, game = null, ocrConfidence =
}; };
} }
const result = await sql` const result = await querySimilarCards(query, game || null);
SELECT
*,
similarity(name, ${query}) AS sim
FROM cards
WHERE similarity(name, ${query}) > ${DISAMBIGUATION_THRESHOLD - 0.05}
ORDER BY
sim DESC,
CASE WHEN ${game} IS NOT NULL AND game = ${game} THEN 0 ELSE 1 END,
LENGTH(name)
LIMIT 8
`;
const candidates = result.rows.filter((row) => row.sim >= DISAMBIGUATION_THRESHOLD); const candidates = result.rows.filter((row) => row.sim >= DISAMBIGUATION_THRESHOLD);
if (candidates.length === 0) { if (candidates.length === 0) {
@ -84,6 +91,21 @@ export async function matchTextInCatalog({ ocrText, game = null, ocrConfidence =
}; };
} }
const normalizedQuery = query.toLowerCase();
const exactNameMatches = candidates.filter(
(row) => row.name?.toLowerCase() === normalizedQuery
);
// Same card name, multiple printings — always ask the user.
if (exactNameMatches.length > 1) {
return {
type: 'disambiguation',
matches: exactNameMatches.slice(0, 8).map(mapCardRow),
query,
message: `Found ${exactNameMatches.length} printings of "${query}". Select the correct one.`,
};
}
const top = candidates[0]; const top = candidates[0];
const runnerUp = candidates[1]; const runnerUp = candidates[1];
const clearWinner = const clearWinner =