Skip to content

Commit 7d955c5

Browse files
committed
fix: scan
1 parent f76c597 commit 7d955c5

3 files changed

Lines changed: 212 additions & 68 deletions

File tree

web/app/composables/useCardAutoScan.ts

Lines changed: 113 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -45,9 +45,18 @@ const BURST_COUNT = 2
4545
/** Gap between burst shots so focus / exposure has a chance to settle. */
4646
const BURST_INTERVAL_MS = 130
4747
/** Lerp weight (new vs previous) when tracking the displayed quad. */
48-
const SMOOTH_LERP = 0.55
48+
const SMOOTH_LERP = 0.5
4949
/** Drift above this fraction of the long edge ⇒ new scene, snap instead of lerp. */
5050
const SCENE_CHANGE_FRAC = 0.22
51+
/** Two consecutive detections within this drift = confirmed (kills flicker). */
52+
const VOTE_DRIFT_FRAC = 0.12
53+
/** Empty detections we wait through before the overlay disappears (≈ 400 ms). */
54+
const MISS_LINGER_TICKS = 4
55+
/**
56+
* Pokémon card ratio — we force the *displayed* rect to this so the overlay
57+
* always reads as a card even when the underlying detection has slight noise.
58+
*/
59+
const CARD_AR = 1.397
5160

5261
interface BurstShot {
5362
buf: ArrayBuffer
@@ -85,6 +94,8 @@ export function useCardAutoScan(opts: UseCardAutoScanOptions) {
8594
let capturing = false
8695
let lastCorners: Pt[] | null = null
8796
let displayedCorners: Pt[] | null = null
97+
let pendingCorners: Pt[] | null = null
98+
let missTicks = 0
8899
let steadyTicks = 0
89100
let lostTicks = 0
90101
let lastCaptureAt = 0
@@ -163,6 +174,60 @@ export function useCardAutoScan(opts: UseCardAutoScanOptions) {
163174
})) as Pt[]
164175
}
165176

177+
/**
178+
* Force the displayed rectangle to the Pokémon card aspect ratio (88/63),
179+
* keeping the detected center + rotation. Detections under perspective are
180+
* always trapezoids with small noise; rendering a forced-ratio rotated
181+
* rectangle reads as "the card" instead of "some quad" — same trick the
182+
* competitor uses for that locked-on look.
183+
*
184+
* @param q - Smoothed corners (any quadrilateral) in video-intrinsic px.
185+
* @returns Card-AR rectangle corners at the same center, rotation and scale.
186+
*/
187+
function forceCardShape(q: Pt[]): Pt[] {
188+
const cx = (q[0]!.x + q[1]!.x + q[2]!.x + q[3]!.x) / 4
189+
const cy = (q[0]!.y + q[1]!.y + q[2]!.y + q[3]!.y) / 4
190+
const topDx = q[1]!.x - q[0]!.x
191+
const topDy = q[1]!.y - q[0]!.y
192+
const botDx = q[2]!.x - q[3]!.x
193+
const botDy = q[2]!.y - q[3]!.y
194+
const avgW = (Math.hypot(topDx, topDy) + Math.hypot(botDx, botDy)) / 2
195+
const avgH =
196+
(Math.hypot(q[3]!.x - q[0]!.x, q[3]!.y - q[0]!.y) + Math.hypot(q[2]!.x - q[1]!.x, q[2]!.y - q[1]!.y)) / 2
197+
let w: number
198+
let h: number
199+
if (avgH >= avgW) {
200+
w = avgW
201+
h = avgW * CARD_AR
202+
} else {
203+
h = avgH
204+
w = avgH * CARD_AR
205+
}
206+
const angle = Math.atan2((topDy + botDy) / 2, (topDx + botDx) / 2)
207+
const cos = Math.cos(angle)
208+
const sin = Math.sin(angle)
209+
const dx = w / 2
210+
const dy = h / 2
211+
const rot = (px: number, py: number): Pt => ({
212+
x: cx + px * cos - py * sin,
213+
y: cy + px * sin + py * cos,
214+
})
215+
return [rot(-dx, -dy), rot(dx, -dy), rot(dx, dy), rot(-dx, dy)]
216+
}
217+
218+
/**
219+
* Average two equal-length quads corner-by-corner.
220+
* @param a - First quad.
221+
* @param b - Second quad.
222+
* @returns A quad whose corners are the midpoints of `a` and `b`.
223+
*/
224+
function averageQuad(a: Pt[], b: Pt[]): Pt[] {
225+
return [0, 1, 2, 3].map((i) => ({
226+
x: (a[i]!.x + b[i]!.x) / 2,
227+
y: (a[i]!.y + b[i]!.y) / 2,
228+
})) as Pt[]
229+
}
230+
166231
/** Start a burst: send the first warp request; subsequent ones are scheduled. */
167232
function beginBurst(corners: Pt[]): void {
168233
burstCorners = corners
@@ -245,33 +310,62 @@ export function useCardAutoScan(opts: UseCardAutoScanOptions) {
245310
}
246311

247312
/**
248-
* Apply the steady-then-capture state machine to a fresh detection.
313+
* Apply voting + miss-linger + the steady-then-capture state machine to a
314+
* fresh detection. Voting (two consecutive consistent detections) and a
315+
* short linger on empty frames stop the overlay from flickering / morphing
316+
* on every spurious frame — only confirmed cards reach the display.
317+
*
249318
* @param corners - Ordered card corners in video-intrinsic px, or `null`.
250319
*/
251320
function onDetection(corners: Pt[] | null): void {
252321
const el = video.value
253-
if (!corners || !el) {
254-
lastCorners = null
255-
displayedCorners = null
256-
quad.value = null
257-
steadyTicks = 0
258-
if (phase.value === 'cooldown') {
259-
lostTicks += 1
260-
if (lostTicks >= LOST_TICKS_REARM) {
322+
if (!el) {
323+
return
324+
}
325+
const longEdge = Math.max(el.videoWidth, el.videoHeight) || 1080
326+
327+
// No card this tick — linger before clearing so a single bad frame doesn't
328+
// hide the overlay we are tracking.
329+
if (!corners) {
330+
pendingCorners = null
331+
missTicks += 1
332+
if (missTicks >= MISS_LINGER_TICKS) {
333+
lastCorners = null
334+
displayedCorners = null
335+
quad.value = null
336+
steadyTicks = 0
337+
if (phase.value === 'cooldown') {
338+
lostTicks += 1
339+
if (lostTicks >= LOST_TICKS_REARM) {
340+
phase.value = 'watching'
341+
}
342+
} else if (phase.value !== 'idle' && !capturing) {
261343
phase.value = 'watching'
262344
}
263-
} else if (phase.value !== 'idle' && !capturing) {
264-
phase.value = 'watching'
265345
}
266346
return
267347
}
348+
missTicks = 0
268349

269-
const longEdge = Math.max(el.videoWidth, el.videoHeight) || 1080
350+
// Voting: wait for two consecutive detections within VOTE_DRIFT_FRAC of
351+
// each other before trusting the result. Hand-shake / texture flicker
352+
// produces wildly varying quads and gets filtered here.
353+
if (!pendingCorners) {
354+
pendingCorners = corners
355+
return
356+
}
357+
if (cornerDrift(corners, pendingCorners) > longEdge * VOTE_DRIFT_FRAC) {
358+
pendingCorners = corners
359+
return
360+
}
361+
const confirmed = averageQuad(pendingCorners, corners)
362+
pendingCorners = corners
270363

271-
// Temporal smoothing for the overlay rectangle (UI only — the steady check
272-
// below still uses raw detections so a hand-held card converges quickly).
273-
displayedCorners = smoothCorners(displayedCorners, corners, longEdge)
274-
quad.value = [displayedCorners[0]!, displayedCorners[1]!, displayedCorners[2]!, displayedCorners[3]!]
364+
// Smooth toward the confirmed quad, then force a card-AR rectangle on top
365+
// — the displayed rectangle reads as "the card" instead of "some shape".
366+
displayedCorners = smoothCorners(displayedCorners, confirmed, longEdge)
367+
const shaped = forceCardShape(displayedCorners)
368+
quad.value = [shaped[0]!, shaped[1]!, shaped[2]!, shaped[3]!]
275369
lostTicks = 0
276370

277371
if (phase.value === 'cooldown' || capturing) {
@@ -395,6 +489,8 @@ export function useCardAutoScan(opts: UseCardAutoScanOptions) {
395489
capturing = false
396490
lastCorners = null
397491
displayedCorners = null
492+
pendingCorners = null
493+
missTicks = 0
398494
burstCorners = null
399495
burstShots = []
400496
burstAwaiting = 0

web/app/pages/collection/scan.vue

Lines changed: 53 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -313,15 +313,21 @@
313313
:style="webcamPreviewStyle"
314314
/>
315315
<svg
316-
v-if="cardQuadPoints && videoIntrinsicW && videoIntrinsicH"
316+
v-if="cardOverlay && videoIntrinsicW && videoIntrinsicH"
317317
class="pointer-events-none absolute inset-0 h-full w-full transition-transform duration-150 ease-out"
318318
:style="webcamPreviewStyle"
319319
:viewBox="`0 0 ${videoIntrinsicW} ${videoIntrinsicH}`"
320320
preserveAspectRatio="xMidYMid slice"
321321
>
322-
<polygon
323-
:points="cardQuadPoints"
324-
fill="rgba(249,115,22,0.12)"
322+
<rect
323+
:x="cardOverlay.cx - cardOverlay.w / 2"
324+
:y="cardOverlay.cy - cardOverlay.h / 2"
325+
:width="cardOverlay.w"
326+
:height="cardOverlay.h"
327+
:rx="Math.min(cardOverlay.w, cardOverlay.h) * 0.045"
328+
:ry="Math.min(cardOverlay.w, cardOverlay.h) * 0.045"
329+
:transform="`rotate(${cardOverlay.angleDeg} ${cardOverlay.cx} ${cardOverlay.cy})`"
330+
fill="rgba(249,115,22,0.10)"
325331
stroke="#f97316"
326332
stroke-width="6"
327333
stroke-linejoin="round"
@@ -1408,12 +1414,21 @@ function playBeep(): void {
14081414
}
14091415
}
14101416
1411-
/** Upload the deskewed card the detector produced, then chime on success. */
1417+
/**
1418+
* Upload the deskewed card the detector produced. The success chime is
1419+
* triggered later, when the `added` event arrives and the info pop-up appears
1420+
* — that's the moment the user actually wants to hear confirmation.
1421+
*/
14121422
async function onDetectorCapture(file: File): Promise<void> {
14131423
uploading.value = true
1424+
// A new card is being processed — drop the previous overlay right away so
1425+
// the user sees we've moved on. The new card's overlay will appear when the
1426+
// `added` event lands and the `latestAdded` watcher plays the chime.
1427+
if (latestAdded.value) {
1428+
dismissedAddedId.value = latestAdded.value.event_id
1429+
}
14141430
try {
14151431
await uploadPhoto(file, SCAN_LANGUAGE, undefined, WEBCAM_UPLOAD_COMPRESS)
1416-
playBeep()
14171432
} catch (err) {
14181433
toast.add({ title: 'Envoi impossible', description: apiErrorMessage(err), color: 'error' })
14191434
} finally {
@@ -1436,13 +1451,32 @@ const {
14361451
onCapture: onDetectorCapture,
14371452
})
14381453
1439-
/** `quad` (video-intrinsic px) → SVG points string; the SVG viewBox matches. */
1440-
const cardQuadPoints = computed<string | null>(() => {
1454+
/**
1455+
* The detector forces the quad to the card aspect ratio, so the 4 points form
1456+
* a true rotated rectangle. Decompose them into {center, w, h, angle} so we
1457+
* can draw a rounded `<rect>` — matches the competitor's clean look.
1458+
*/
1459+
const cardOverlay = computed<{
1460+
cx: number
1461+
cy: number
1462+
w: number
1463+
h: number
1464+
angleDeg: number
1465+
} | null>(() => {
14411466
const q = cardQuad.value
14421467
if (!q) {
14431468
return null
14441469
}
1445-
return q.map((p) => `${p.x},${p.y}`).join(' ')
1470+
const cx = (q[0].x + q[1].x + q[2].x + q[3].x) / 4
1471+
const cy = (q[0].y + q[1].y + q[2].y + q[3].y) / 4
1472+
const topDx = q[1].x - q[0].x
1473+
const topDy = q[1].y - q[0].y
1474+
const botDx = q[2].x - q[3].x
1475+
const botDy = q[2].y - q[3].y
1476+
const w = (Math.hypot(topDx, topDy) + Math.hypot(botDx, botDy)) / 2
1477+
const h = (Math.hypot(q[3].x - q[0].x, q[3].y - q[0].y) + Math.hypot(q[2].x - q[1].x, q[2].y - q[1].y)) / 2
1478+
const angleDeg = (Math.atan2((topDy + botDy) / 2, (topDx + botDx) / 2) * 180) / Math.PI
1479+
return { cx, cy, w, h, angleDeg }
14461480
})
14471481
14481482
/** Most recent successfully-added card, for the bottom info overlay. */
@@ -1455,6 +1489,16 @@ const latestAdded = computed(() => {
14551489
return ev
14561490
})
14571491
1492+
// Chime when the card is *identified* (info pop-up appears) — not on capture
1493+
// — so the user gets a clear audible "ça y est, on l'a" matching the visual.
1494+
let lastBeepedAddedId: string | null = null
1495+
watch(latestAdded, (v) => {
1496+
if (v && v.event_id !== lastBeepedAddedId) {
1497+
lastBeepedAddedId = v.event_id
1498+
playBeep()
1499+
}
1500+
})
1501+
14581502
const autoScanStatus = computed<{ label: string; color: 'primary' | 'success' | 'neutral' }>(() => {
14591503
if (detectorError.value) {
14601504
return { label: 'Moteur de scan indisponible', color: 'neutral' }

0 commit comments

Comments
 (0)