2026-08-14 21:31:45 -04:00
|
|
|
import {
|
|
|
|
|
boundsFromCorners,
|
|
|
|
|
isValidCardQuad,
|
|
|
|
|
orderQuadCorners,
|
|
|
|
|
} from './scanner-card-warp.js';
|
|
|
|
|
|
2026-06-02 13:52:40 -04:00
|
|
|
/** 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;
|
|
|
|
|
|
2026-06-02 16:44:56 -04:00
|
|
|
/** 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. */
|
2026-08-14 21:21:20 -04:00
|
|
|
export const MIN_STABLE_COUNT_FOR_VERIFY = 3;
|
2026-06-02 16:44:56 -04:00
|
|
|
|
|
|
|
|
/** Minimum time (ms) a card must be tracked before verification. */
|
2026-08-14 21:21:20 -04:00
|
|
|
export const MIN_FIRST_SEEN_MS_FOR_VERIFY = 800;
|
2026-06-02 16:44:56 -04:00
|
|
|
|
|
|
|
|
/** 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
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-02 13:52:40 -04:00
|
|
|
/** 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,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-14 21:31:45 -04:00
|
|
|
/** 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 };
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-02 13:52:40 -04:00
|
|
|
/** 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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-14 21:31:45 -04:00
|
|
|
function buildGrayscaleAndEdges(imageData, width, height) {
|
2026-06-02 13:52:40 -04:00
|
|
|
const data = imageData.data;
|
2026-08-14 21:31:45 -04:00
|
|
|
const grayscale = new Uint8Array(width * height);
|
|
|
|
|
const edges = new Uint8Array(width * height);
|
2026-06-02 13:52:40 -04:00
|
|
|
|
2026-08-14 21:31:45 -04:00
|
|
|
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]);
|
2026-06-02 13:52:40 -04:00
|
|
|
}
|
|
|
|
|
|
2026-08-14 21:31:45 -04:00
|
|
|
for (let y = 1; y < height - 1; y++) {
|
|
|
|
|
for (let x = 1; x < width - 1; x++) {
|
|
|
|
|
const idx = y * width + x;
|
2026-06-02 13:52:40 -04:00
|
|
|
|
|
|
|
|
const gx =
|
|
|
|
|
-grayscale[idx - 1] +
|
|
|
|
|
grayscale[idx + 1] +
|
2026-08-14 21:31:45 -04:00
|
|
|
-2 * grayscale[idx - 1 + width] +
|
|
|
|
|
2 * grayscale[idx + 1 + width] +
|
|
|
|
|
-grayscale[idx - 1 + 2 * width] +
|
|
|
|
|
grayscale[idx + 1 + 2 * width];
|
2026-06-02 13:52:40 -04:00
|
|
|
|
|
|
|
|
const gy =
|
2026-08-14 21:31:45 -04:00
|
|
|
-grayscale[idx - width] -
|
2026-06-02 13:52:40 -04:00
|
|
|
2 * grayscale[idx] -
|
2026-08-14 21:31:45 -04:00
|
|
|
grayscale[idx + width] +
|
|
|
|
|
grayscale[idx - width + 2 * width] +
|
|
|
|
|
2 * grayscale[idx + 2 * width] +
|
|
|
|
|
grayscale[idx + width + 2 * width];
|
2026-06-02 13:52:40 -04:00
|
|
|
|
|
|
|
|
const magnitude = Math.sqrt(gx * gx + gy * gy);
|
2026-08-14 21:31:45 -04:00
|
|
|
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,
|
|
|
|
|
};
|
2026-06-02 13:52:40 -04:00
|
|
|
}
|
2026-08-14 21:31:45 -04:00
|
|
|
|
|
|
|
|
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;
|
2026-06-02 13:52:40 -04:00
|
|
|
}
|
|
|
|
|
|
2026-08-14 21:31:45 -04:00
|
|
|
return edgeDensity * 100 + perimeterRatio * 60 + edgeCount / 10;
|
|
|
|
|
}
|
2026-06-02 13:52:40 -04:00
|
|
|
|
2026-08-14 21:31:45 -04:00
|
|
|
function findCardCandidates(edges, width, height) {
|
2026-06-02 13:52:40 -04:00
|
|
|
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);
|
2026-08-14 21:31:45 -04:00
|
|
|
const candidates = [];
|
2026-06-02 13:52:40 -04:00
|
|
|
|
2026-08-14 21:31:45 -04:00
|
|
|
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) {
|
2026-06-02 13:52:40 -04:00
|
|
|
const aspectRatio = w / h;
|
|
|
|
|
if (aspectRatio < 0.63 || aspectRatio > 0.77) continue;
|
|
|
|
|
|
2026-08-14 21:31:45 -04:00
|
|
|
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,
|
|
|
|
|
});
|
2026-06-02 13:52:40 -04:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-14 21:31:45 -04:00
|
|
|
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,
|
|
|
|
|
};
|
|
|
|
|
});
|
2026-06-02 13:52:40 -04:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 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) {
|
2026-08-14 21:31:45 -04:00
|
|
|
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 }));
|
|
|
|
|
}
|
2026-06-02 13:52:40 -04:00
|
|
|
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++,
|
2026-08-14 21:31:45 -04:00
|
|
|
bounds: {
|
|
|
|
|
x: shape.x,
|
|
|
|
|
y: shape.y,
|
|
|
|
|
width: shape.width,
|
|
|
|
|
height: shape.height,
|
|
|
|
|
},
|
|
|
|
|
corners: shape.corners?.length === 4 ? shape.corners.map((corner) => ({ ...corner })) : null,
|
2026-06-02 13:52:40 -04:00
|
|
|
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 };
|
|
|
|
|
}
|