deckhearth/lib/scanner-card-detection.js
Randall Stillwell 9439b12f01 refactor(scanner): extract camera lifecycle hook (Brief 4)
Move stream start/stop, detection intervals, and tracked-card polling
into lib/use-camera-scanner.js. CameraScanner keeps identification UI
and disambiguation wiring only.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-02 15:40:16 -05:00

251 lines
7.9 KiB
JavaScript

/** 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 = 6;
/** Minimum time (ms) a card must be tracked before verification. */
export const MIN_FIRST_SEEN_MS_FOR_VERIFY = 2500;
/** 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 };
}