/** Detection canvas dimensions (smaller than video for performance). */ export const DETECTION_CANVAS_WIDTH = 320; export const DETECTION_CANVAS_HEIGHT = 240; /** Milliseconds before a tracked card is dropped when not re-detected. */ export const TRACK_STALE_MS = 3000; /** Shape-detection polling interval in the camera scanner hook. */ export const SHAPE_DETECTION_INTERVAL_MS = 200; /** Verification polling interval in the camera scanner hook. */ export const VERIFICATION_INTERVAL_MS = 1000; /** Minimum stable frames before a tracked card is sent for identification. */ export const MIN_STABLE_COUNT_FOR_VERIFY = 3; /** Minimum time (ms) a card must be tracked before verification. */ export const MIN_FIRST_SEEN_MS_FOR_VERIFY = 800; /** Delay after camera start before detection loops begin. */ export const DETECTION_START_DELAY_MS = 1000; /** Tracked cards eligible for one-shot server verification. */ export function selectCardsReadyForVerification( trackedCards, now = Date.now(), { minStableCount = MIN_STABLE_COUNT_FOR_VERIFY, maxScanAttempts = 1, minFirstSeenMs = MIN_FIRST_SEEN_MS_FOR_VERIFY, } = {} ) { return trackedCards.filter( (card) => card.status === 'detecting' && card.stableCount >= minStableCount && card.scanAttempts < maxScanAttempts && now - card.firstSeen > minFirstSeenMs ); } /** Convert detection-canvas coordinates to video pixel coordinates. */ export function convertToVideoCoordinates(detection, canvas, video) { const scaleX = video.videoWidth / canvas.width; const scaleY = video.videoHeight / canvas.height; return { x: detection.x * scaleX, y: detection.y * scaleY, width: detection.width * scaleX, height: detection.height * scaleY, }; } /** True when overlap area exceeds `threshold` of the smaller rectangle. */ export function rectanglesOverlap(rect1, rect2, threshold = 0.3) { const x1 = Math.max(rect1.x, rect2.x); const y1 = Math.max(rect1.y, rect2.y); const x2 = Math.min(rect1.x + rect1.width, rect2.x + rect2.width); const y2 = Math.min(rect1.y + rect1.height, rect2.y + rect2.height); if (x2 <= x1 || y2 <= y1) return false; const overlapArea = (x2 - x1) * (y2 - y1); const rect1Area = rect1.width * rect1.height; const rect2Area = rect2.width * rect2.height; const smallerArea = Math.min(rect1Area, rect2Area); return overlapArea / smallerArea > threshold; } /** * Draw the current video frame on `detectionCanvas` and return up to 5 card-like * bounding boxes in video coordinates. */ export function detectCardShapesFromFrame(video, detectionCanvas) { if (!video || !detectionCanvas) return []; const ctx = detectionCanvas.getContext('2d'); detectionCanvas.width = DETECTION_CANVAS_WIDTH; detectionCanvas.height = DETECTION_CANVAS_HEIGHT; ctx.drawImage(video, 0, 0, detectionCanvas.width, detectionCanvas.height); const imageData = ctx.getImageData(0, 0, detectionCanvas.width, detectionCanvas.height); const data = imageData.data; const grayscale = []; const edges = []; for (let i = 0; i < data.length; i += 4) { const gray = Math.round(0.299 * data[i] + 0.587 * data[i + 1] + 0.114 * data[i + 2]); grayscale.push(gray); } for (let y = 1; y < detectionCanvas.height - 1; y++) { for (let x = 1; x < detectionCanvas.width - 1; x++) { const idx = y * detectionCanvas.width + x; const gx = -grayscale[idx - 1] + grayscale[idx + 1] + -2 * grayscale[idx - 1 + detectionCanvas.width] + 2 * grayscale[idx + 1 + detectionCanvas.width] + -grayscale[idx - 1 + 2 * detectionCanvas.width] + grayscale[idx + 1 + 2 * detectionCanvas.width]; const gy = -grayscale[idx - detectionCanvas.width] - 2 * grayscale[idx] - grayscale[idx + detectionCanvas.width] + grayscale[idx - detectionCanvas.width + 2 * detectionCanvas.width] + 2 * grayscale[idx + 2 * detectionCanvas.width] + grayscale[idx + detectionCanvas.width + 2 * detectionCanvas.width]; const magnitude = Math.sqrt(gx * gx + gy * gy); edges[idx] = magnitude > 100 ? 255 : 0; } } const cardShapes = []; const { width, height } = detectionCanvas; const minCardWidth = Math.floor(width * 0.2); const maxCardWidth = Math.floor(width * 0.6); const minCardHeight = Math.floor(height * 0.25); const maxCardHeight = Math.floor(height * 0.7); for (let y = 0; y < height - minCardHeight; y += 15) { for (let x = 0; x < width - minCardWidth; x += 15) { for (let w = minCardWidth; w <= maxCardWidth && x + w < width; w += 20) { for (let h = minCardHeight; h <= maxCardHeight && y + h < height; h += 20) { const aspectRatio = w / h; if (aspectRatio < 0.63 || aspectRatio > 0.77) continue; let edgeCount = 0; let totalPixels = 0; let perimeterEdges = 0; for (let sy = y; sy < y + h; sy += 4) { for (let sx = x; sx < x + w; sx += 4) { const idx = sy * width + sx; if (edges[idx] === 255) { edgeCount++; const isPerimeter = sx < x + w * 0.15 || sx > x + w * 0.85 || sy < y + h * 0.15 || sy > y + h * 0.85; if (isPerimeter) { perimeterEdges++; } } totalPixels++; } } const edgeDensity = edgeCount / totalPixels; const perimeterRatio = perimeterEdges / (edgeCount || 1); if (edgeDensity > 0.2 && edgeDensity < 0.6 && perimeterRatio > 0.4 && edgeCount > 80) { const score = edgeDensity * 100 + perimeterRatio * 60 + edgeCount / 10; if (score > 50) { const videoCoords = convertToVideoCoordinates( { x, y, width: w, height: h }, detectionCanvas, video ); cardShapes.push({ ...videoCoords, score, aspectRatio, edgeDensity, }); } } } } } } return cardShapes.sort((a, b) => b.score - a.score).slice(0, 5); } /** * Merge fresh shape detections into the tracked-card list. * Returns updated cards and the next card id counter. */ export function mergeDetectedShapesIntoTrackedCards( trackedCards, detectedShapes, nextCardId, now = Date.now() ) { const updatedCards = [...trackedCards]; for (let i = updatedCards.length - 1; i >= 0; i--) { if (now - updatedCards[i].lastSeen > TRACK_STALE_MS) { console.log( `🗑️ Removing stale tracked card ${updatedCards[i].id} (last seen ${Math.round((now - updatedCards[i].lastSeen) / 1000)}s ago)` ); updatedCards.splice(i, 1); } } let idCounter = nextCardId; detectedShapes.forEach((shape) => { let matchedCard = null; for (const card of updatedCards) { if (rectanglesOverlap(shape, card.bounds, 0.4)) { matchedCard = card; break; } } if (matchedCard) { matchedCard.bounds = { ...shape }; matchedCard.lastSeen = now; matchedCard.stableCount = Math.min(matchedCard.stableCount + 1, 10); const positionChange = Math.abs(matchedCard.bounds.x - shape.x) + Math.abs(matchedCard.bounds.y - shape.y); if (positionChange > 50 && matchedCard.status === 'negative') { console.log(`🔄 Card ${matchedCard.id} moved significantly, allowing re-scan`); matchedCard.status = 'detecting'; matchedCard.scanAttempts = 0; matchedCard.stableCount = 1; } } else { const newCard = { id: idCounter++, bounds: { ...shape }, status: 'detecting', firstSeen: now, lastSeen: now, stableCount: 1, scanAttempts: 0, }; updatedCards.push(newCard); console.log(`🎯 New card shape detected: ${newCard.id}`); } }); return { cards: updatedCards, nextCardId: idCounter }; }