Allow consecutive scans without refresh by resetting trackers and counting vision rate limits once per card. Add a fixed card guide, widen detection bounds, and correct object-cover overlay math. Co-authored-by: Cursor <cursoragent@cursor.com>
224 lines
6.2 KiB
JavaScript
224 lines
6.2 KiB
JavaScript
import { describe, expect, it } from 'vitest';
|
|
import {
|
|
convertToVideoCoordinates,
|
|
MAX_SCAN_ATTEMPTS,
|
|
mergeDetectedShapesIntoTrackedCards,
|
|
NEGATIVE_RETRY_MS,
|
|
rectanglesOverlap,
|
|
refineCardCornersFromEdges,
|
|
selectCardsReadyForVerification,
|
|
TRACK_STALE_MS,
|
|
} from '../../lib/scanner-card-detection.js';
|
|
|
|
describe('rectanglesOverlap', () => {
|
|
it('returns true when rectangles mostly overlap', () => {
|
|
const a = { x: 0, y: 0, width: 100, height: 140 };
|
|
const b = { x: 10, y: 10, width: 100, height: 140 };
|
|
expect(rectanglesOverlap(a, b, 0.3)).toBe(true);
|
|
});
|
|
|
|
it('returns false when rectangles do not touch', () => {
|
|
const a = { x: 0, y: 0, width: 50, height: 70 };
|
|
const b = { x: 200, y: 200, width: 50, height: 70 };
|
|
expect(rectanglesOverlap(a, b)).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('convertToVideoCoordinates', () => {
|
|
it('scales detection canvas coords to video pixel space', () => {
|
|
const canvas = { width: 320, height: 240 };
|
|
const video = { videoWidth: 1280, videoHeight: 960 };
|
|
expect(convertToVideoCoordinates({ x: 10, y: 20, width: 100, height: 140 }, canvas, video)).toEqual({
|
|
x: 40,
|
|
y: 80,
|
|
width: 400,
|
|
height: 560,
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('mergeDetectedShapesIntoTrackedCards', () => {
|
|
it('creates a new tracked card for an unmatched shape', () => {
|
|
const now = 1_000_000;
|
|
const shape = {
|
|
x: 10,
|
|
y: 20,
|
|
width: 100,
|
|
height: 140,
|
|
score: 80,
|
|
corners: [
|
|
{ x: 10, y: 20 },
|
|
{ x: 110, y: 22 },
|
|
{ x: 108, y: 160 },
|
|
{ x: 12, y: 158 },
|
|
],
|
|
};
|
|
|
|
const { cards, nextCardId } = mergeDetectedShapesIntoTrackedCards([], [shape], 1, now);
|
|
|
|
expect(cards).toHaveLength(1);
|
|
expect(cards[0]).toMatchObject({
|
|
id: 1,
|
|
status: 'detecting',
|
|
stableCount: 1,
|
|
scanAttempts: 0,
|
|
bounds: { x: 10, y: 20, width: 100, height: 140 },
|
|
corners: shape.corners,
|
|
});
|
|
expect(nextCardId).toBe(2);
|
|
});
|
|
|
|
it('updates an overlapping tracked card instead of creating a duplicate', () => {
|
|
const now = 1_000_000;
|
|
const existing = {
|
|
id: 5,
|
|
bounds: { x: 10, y: 20, width: 100, height: 140 },
|
|
status: 'detecting',
|
|
firstSeen: now - 500,
|
|
lastSeen: now - 100,
|
|
stableCount: 3,
|
|
scanAttempts: 0,
|
|
};
|
|
const moved = { x: 12, y: 22, width: 100, height: 140, score: 82 };
|
|
|
|
const { cards, nextCardId } = mergeDetectedShapesIntoTrackedCards([existing], [moved], 6, now);
|
|
|
|
expect(cards).toHaveLength(1);
|
|
expect(cards[0].id).toBe(5);
|
|
expect(cards[0].stableCount).toBe(4);
|
|
expect(cards[0].lastSeen).toBe(now);
|
|
expect(nextCardId).toBe(6);
|
|
});
|
|
|
|
it('drops cards not seen within TRACK_STALE_MS', () => {
|
|
const now = 10_000;
|
|
const stale = {
|
|
id: 1,
|
|
bounds: { x: 0, y: 0, width: 50, height: 70 },
|
|
status: 'detecting',
|
|
firstSeen: now - TRACK_STALE_MS - 1,
|
|
lastSeen: now - TRACK_STALE_MS - 1,
|
|
stableCount: 2,
|
|
scanAttempts: 0,
|
|
};
|
|
|
|
const { cards } = mergeDetectedShapesIntoTrackedCards([stale], [], 2, now);
|
|
expect(cards).toHaveLength(0);
|
|
});
|
|
});
|
|
|
|
describe('selectCardsReadyForVerification', () => {
|
|
it('returns detecting cards that are stable long enough', () => {
|
|
const now = 10_000;
|
|
const ready = {
|
|
id: 1,
|
|
status: 'detecting',
|
|
stableCount: 3,
|
|
scanAttempts: 0,
|
|
firstSeen: now - 900,
|
|
};
|
|
const tooFresh = {
|
|
id: 2,
|
|
status: 'detecting',
|
|
stableCount: 3,
|
|
scanAttempts: 0,
|
|
firstSeen: now - 500,
|
|
};
|
|
|
|
expect(selectCardsReadyForVerification([ready, tooFresh], now)).toEqual([ready]);
|
|
});
|
|
|
|
it('excludes cards that exhausted verification attempts', () => {
|
|
const now = 10_000;
|
|
const retried = {
|
|
id: 1,
|
|
status: 'detecting',
|
|
stableCount: 8,
|
|
scanAttempts: MAX_SCAN_ATTEMPTS,
|
|
firstSeen: now - 5000,
|
|
};
|
|
|
|
expect(selectCardsReadyForVerification([retried], now)).toHaveLength(0);
|
|
});
|
|
|
|
it('allows retry after a failed attempt when under the attempt cap', () => {
|
|
const now = 10_000;
|
|
const retrying = {
|
|
id: 1,
|
|
status: 'detecting',
|
|
stableCount: 8,
|
|
scanAttempts: 1,
|
|
firstSeen: now - 5000,
|
|
};
|
|
|
|
expect(selectCardsReadyForVerification([retrying], now)).toHaveLength(1);
|
|
});
|
|
|
|
it('creates a new tracker when a finished card overlaps a fresh detection', () => {
|
|
const now = 1_000_000;
|
|
const finished = {
|
|
id: 1,
|
|
bounds: { x: 10, y: 20, width: 100, height: 140 },
|
|
status: 'scanned',
|
|
firstSeen: now - 2000,
|
|
lastSeen: now - 100,
|
|
stableCount: 5,
|
|
scanAttempts: 1,
|
|
};
|
|
const shape = { x: 12, y: 22, width: 100, height: 140, score: 80 };
|
|
|
|
const { cards, nextCardId } = mergeDetectedShapesIntoTrackedCards([finished], [shape], 2, now);
|
|
|
|
expect(cards).toHaveLength(2);
|
|
expect(cards.some((card) => card.id === 2 && card.status === 'detecting')).toBe(true);
|
|
expect(nextCardId).toBe(3);
|
|
});
|
|
|
|
it('resets negative trackers after the retry window', () => {
|
|
const now = 10_000;
|
|
const negative = {
|
|
id: 1,
|
|
bounds: { x: 10, y: 20, width: 100, height: 140 },
|
|
status: 'negative',
|
|
negativeAt: now - NEGATIVE_RETRY_MS - 1,
|
|
firstSeen: now - 8000,
|
|
lastSeen: now - 100,
|
|
stableCount: 2,
|
|
scanAttempts: 1,
|
|
};
|
|
const shape = { x: 10, y: 20, width: 100, height: 140, score: 80 };
|
|
|
|
const { cards } = mergeDetectedShapesIntoTrackedCards([negative], [shape], 2, now);
|
|
|
|
expect(cards[0].status).toBe('detecting');
|
|
expect(cards[0].scanAttempts).toBe(0);
|
|
});
|
|
});
|
|
|
|
describe('refineCardCornersFromEdges', () => {
|
|
it('picks strong edge pixels near each quadrant corner', () => {
|
|
const width = 120;
|
|
const height = 168;
|
|
const edges = new Uint8Array(width * height);
|
|
const mark = (x, y) => {
|
|
edges[y * width + x] = 255;
|
|
};
|
|
|
|
mark(8, 10);
|
|
mark(110, 12);
|
|
mark(108, 156);
|
|
mark(10, 154);
|
|
|
|
const corners = refineCardCornersFromEdges(edges, width, height, {
|
|
x: 5,
|
|
y: 8,
|
|
width: 110,
|
|
height: 150,
|
|
});
|
|
|
|
expect(corners[0]).toEqual({ x: 8, y: 10 });
|
|
expect(corners[1]).toEqual({ x: 110, y: 12 });
|
|
expect(corners[2]).toEqual({ x: 108, y: 156 });
|
|
expect(corners[3]).toEqual({ x: 10, y: 154 });
|
|
});
|
|
});
|