import { boundsFromCorners, isValidCardQuad, orderQuadCorners, } from './scanner-card-warp.js'; /** 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, }; } /** Convert a point from detection canvas space to video pixel space. */ export function convertPointToVideoCoordinates(point, canvas, video) { const scaleX = video.videoWidth / canvas.width; const scaleY = video.videoHeight / canvas.height; return { x: point.x * scaleX, y: point.y * 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; } function buildGrayscaleAndEdges(imageData, width, height) { const data = imageData.data; const grayscale = new Uint8Array(width * height); const edges = new Uint8Array(width * height); for (let i = 0, px = 0; i < data.length; i += 4, px++) { grayscale[px] = Math.round(0.299 * data[i] + 0.587 * data[i + 1] + 0.114 * data[i + 2]); } for (let y = 1; y < height - 1; y++) { for (let x = 1; x < width - 1; x++) { const idx = y * width + x; const gx = -grayscale[idx - 1] + grayscale[idx + 1] + -2 * grayscale[idx - 1 + width] + 2 * grayscale[idx + 1 + width] + -grayscale[idx - 1 + 2 * width] + grayscale[idx + 1 + 2 * width]; const gy = -grayscale[idx - width] - 2 * grayscale[idx] - grayscale[idx + width] + grayscale[idx - width + 2 * width] + 2 * grayscale[idx + 2 * width] + grayscale[idx + width + 2 * width]; const magnitude = Math.sqrt(gx * gx + gy * gy); edges[idx] = magnitude > 90 ? 255 : 0; } } return { grayscale, edges }; } /** * Refine a coarse card bbox into four corners using edge strength in each quadrant. * Exported for unit tests. */ export function refineCardCornersFromEdges(edges, width, height, bbox) { const { x, y, width: boxWidth, height: boxHeight } = bbox; const centerX = x + boxWidth / 2; const centerY = y + boxHeight / 2; const quadrants = [ { minX: x, maxX: centerX, minY: y, maxY: centerY }, { minX: centerX, maxX: x + boxWidth, minY: y, maxY: centerY }, { minX: centerX, maxX: x + boxWidth, minY: centerY, maxY: y + boxHeight }, { minX: x, maxX: centerX, minY: centerY, maxY: y + boxHeight }, ]; const corners = quadrants.map((quad) => { let best = null; let bestScore = -1; for (let py = Math.floor(quad.minY); py < quad.maxY; py++) { for (let px = Math.floor(quad.minX); px < quad.maxX; px++) { if (px < 0 || py < 0 || px >= width || py >= height) continue; const idx = py * width + px; if (edges[idx] !== 255) continue; const dx = px - centerX; const dy = py - centerY; const score = Math.hypot(dx, dy); if (score > bestScore) { bestScore = score; best = { x: px, y: py }; } } } if (!best) { return { x: (quad.minX + quad.maxX) / 2, y: (quad.minY + quad.maxY) / 2, }; } return best; }); return orderQuadCorners(corners); } function scoreCardCandidate(edges, width, height, bbox) { const { x, y, width: w, height: h } = bbox; let edgeCount = 0; let totalPixels = 0; let perimeterEdges = 0; for (let sy = y; sy < y + h; sy += 3) { for (let sx = x; sx < x + w; sx += 3) { if (sx < 0 || sy < 0 || sx >= width || sy >= height) continue; const idx = sy * width + sx; totalPixels++; if (edges[idx] !== 255) continue; 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++; } } const edgeDensity = edgeCount / (totalPixels || 1); const perimeterRatio = perimeterEdges / (edgeCount || 1); if (edgeDensity <= 0.18 || edgeDensity >= 0.65 || perimeterRatio <= 0.35 || edgeCount <= 60) { return null; } return edgeDensity * 100 + perimeterRatio * 60 + edgeCount / 10; } function findCardCandidates(edges, width, height) { 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); const candidates = []; for (let y = 0; y < height - minCardHeight; y += 12) { for (let x = 0; x < width - minCardWidth; x += 12) { for (let w = minCardWidth; w <= maxCardWidth && x + w < width; w += 16) { for (let h = minCardHeight; h <= maxCardHeight && y + h < height; h += 16) { const aspectRatio = w / h; if (aspectRatio < 0.63 || aspectRatio > 0.77) continue; const score = scoreCardCandidate(edges, width, height, { x, y, width: w, height: h }); if (score == null || score <= 50) continue; const corners = refineCardCornersFromEdges(edges, width, height, { x, y, width: w, height: h, }); const bounds = boundsFromCorners(corners); if (!isValidCardQuad(corners)) continue; candidates.push({ x: bounds.x, y: bounds.y, width: bounds.width, height: bounds.height, corners, score: score + (isValidCardQuad(corners) ? 20 : 0), aspectRatio, }); } } } } return candidates.sort((a, b) => b.score - a.score).slice(0, 5); } /** * Draw the current video frame on `detectionCanvas` and return up to 5 card-like * detections in video coordinates, including perspective corners when found. */ 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 { edges } = buildGrayscaleAndEdges( imageData, detectionCanvas.width, detectionCanvas.height ); const candidates = findCardCandidates(edges, detectionCanvas.width, detectionCanvas.height); return candidates.map((candidate) => { const videoBounds = convertToVideoCoordinates(candidate, detectionCanvas, video); const videoCorners = candidate.corners.map((corner) => convertPointToVideoCoordinates(corner, detectionCanvas, video) ); return { ...videoBounds, corners: videoCorners, score: candidate.score, aspectRatio: candidate.aspectRatio, }; }); } /** * 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 = { x: shape.x, y: shape.y, width: shape.width, height: shape.height, }; if (shape.corners?.length === 4) { matchedCard.corners = shape.corners.map((corner) => ({ ...corner })); } 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: { x: shape.x, y: shape.y, width: shape.width, height: shape.height, }, corners: shape.corners?.length === 4 ? shape.corners.map((corner) => ({ ...corner })) : null, 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 }; }