fix(scanner): printing disambiguation + Layer-1 500 #39
3 changed files with 120 additions and 37 deletions
|
|
@ -16,6 +16,9 @@ export default function CameraScanner({ onCardScanned, onError }) {
|
|||
const [trackedCards, setTrackedCards] = useState([]); // Array of tracked card objects
|
||||
const trackedCardsRef = useRef([]);
|
||||
const nextCardIdRef = useRef(1);
|
||||
const visionCooldownUntilRef = useRef(0);
|
||||
const activeVerificationRef = useRef(0);
|
||||
const lastErrorAtRef = useRef(0);
|
||||
|
||||
// Mana symbol settings
|
||||
const [manaSymbolSettings, setManaSymbolSettings] = useState({ useSVG: false });
|
||||
|
|
@ -278,7 +281,7 @@ export default function CameraScanner({ onCardScanned, onError }) {
|
|||
const processIdentifyResponse = async (cardTracker, imageData, result) => {
|
||||
if (!result.isCard) {
|
||||
cardTracker.status = 'negative';
|
||||
onError?.(result.reason || 'No trading card detected');
|
||||
reportScannerError(result.reason || 'No trading card detected');
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -310,17 +313,29 @@ export default function CameraScanner({ onCardScanned, onError }) {
|
|||
|
||||
if (result.needsReview || result.needsUserInput) {
|
||||
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;
|
||||
}
|
||||
|
||||
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
|
||||
const verifyCardShape = async (cardTracker) => {
|
||||
if (!videoRef.current || !canvasRef.current || cardTracker.status !== 'detecting') return;
|
||||
if (disambiguation) return;
|
||||
if (activeVerificationRef.current >= 1) return;
|
||||
|
||||
activeVerificationRef.current += 1;
|
||||
cardTracker.status = 'verifying';
|
||||
|
||||
try {
|
||||
cardTracker.scanAttempts++;
|
||||
|
|
@ -378,7 +393,12 @@ export default function CameraScanner({ onCardScanned, onError }) {
|
|||
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', {
|
||||
method: 'POST',
|
||||
headers: authHeaders,
|
||||
|
|
@ -386,7 +406,8 @@ export default function CameraScanner({ onCardScanned, onError }) {
|
|||
});
|
||||
|
||||
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';
|
||||
return;
|
||||
}
|
||||
|
|
@ -402,7 +423,9 @@ export default function CameraScanner({ onCardScanned, onError }) {
|
|||
} catch (error) {
|
||||
console.error(`Error verifying card ${cardTracker.id}:`, error);
|
||||
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(() => {
|
||||
const cardsToVerify = trackedCardsRef.current.filter(card =>
|
||||
card.status === 'detecting' &&
|
||||
card.stableCount >= 4 && // Reduced back to 4 for better responsiveness
|
||||
card.scanAttempts < 2 && // Allow 2 attempts again
|
||||
Date.now() - card.firstSeen > 1500 // Reduced to 1.5 seconds
|
||||
card.stableCount >= 6 &&
|
||||
card.scanAttempts < 1 &&
|
||||
Date.now() - card.firstSeen > 2500
|
||||
);
|
||||
|
||||
// Verify up to 2 cards simultaneously to allow multi-card scanning
|
||||
const cardsToProcess = cardsToVerify.slice(0, 2);
|
||||
const cardsToProcess = cardsToVerify.slice(0, 1);
|
||||
cardsToProcess.forEach(card => {
|
||||
verifyCardShape(card);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -104,30 +104,49 @@ export async function matchCardInCatalog({
|
|||
SELECT * FROM cards
|
||||
WHERE LOWER(name) = LOWER(${trimmedName})
|
||||
AND (LOWER(set_name) = LOWER(${set}) OR LOWER(set_code) = LOWER(${setCode || set}))
|
||||
LIMIT 1
|
||||
LIMIT 5
|
||||
`
|
||||
: await sql`
|
||||
SELECT * FROM cards
|
||||
WHERE LOWER(name) = LOWER(${trimmedName})
|
||||
AND LOWER(set_code) = LOWER(${setCode})
|
||||
LIMIT 1
|
||||
LIMIT 5
|
||||
`;
|
||||
if (setResult.rows.length > 0) {
|
||||
if (setResult.rows.length === 1) {
|
||||
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) {
|
||||
const nameResult = await sql`
|
||||
SELECT * FROM cards
|
||||
WHERE LOWER(name) = LOWER(${trimmedName})
|
||||
ORDER BY
|
||||
CASE WHEN game = ${game || 'UNKNOWN'} THEN 1 ELSE 2 END,
|
||||
created_at DESC
|
||||
LIMIT 1
|
||||
`;
|
||||
if (nameResult.rows.length > 0) {
|
||||
const nameResult = game
|
||||
? await sql`
|
||||
SELECT * FROM cards
|
||||
WHERE LOWER(name) = LOWER(${trimmedName})
|
||||
ORDER BY CASE WHEN game = ${game} THEN 0 ELSE 1 END, set_name, card_number
|
||||
`
|
||||
: await sql`
|
||||
SELECT * FROM cards
|
||||
WHERE LOWER(name) = LOWER(${trimmedName})
|
||||
ORDER BY set_name, card_number
|
||||
`;
|
||||
if (nameResult.rows.length === 1) {
|
||||
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) {
|
||||
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 {
|
||||
type: 'matched',
|
||||
card: existingCard,
|
||||
|
|
|
|||
|
|
@ -36,7 +36,6 @@ export function extractNameCandidate(ocrText) {
|
|||
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) => ({
|
||||
line,
|
||||
score: line.length - index * 2,
|
||||
|
|
@ -45,6 +44,26 @@ export function extractNameCandidate(ocrText) {
|
|||
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.
|
||||
*/
|
||||
|
|
@ -60,19 +79,7 @@ export async function matchTextInCatalog({ ocrText, game = null, ocrConfidence =
|
|||
};
|
||||
}
|
||||
|
||||
const result = await sql`
|
||||
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 result = await querySimilarCards(query, game || null);
|
||||
const candidates = result.rows.filter((row) => row.sim >= DISAMBIGUATION_THRESHOLD);
|
||||
|
||||
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 runnerUp = candidates[1];
|
||||
const clearWinner =
|
||||
|
|
|
|||
Loading…
Reference in a new issue