@@ -32,18 +32,19 @@ export interface UseCardAutoScanOptions {
3232const FRAME_MS = 100
3333/** Long edge of the downscaled frame sent for detection (more = sharper quad). */
3434const PROC_EDGE = 640
35- /** Steady tolerance as a fraction of the frame's long edge. */
36- const STEADY_FRAC = 0.014
37- /** Consecutive steady detections before firing (~0.4 s @ 100 ms cadence). */
38- const STEADY_TICKS = 4
3935/** "No card" detections before re-arming after a shot (card removed). */
4036const LOST_TICKS_REARM = 3
4137/** Floor between two captures (anti double-shot). */
4238const MIN_COOLDOWN_MS = 1500
43- /** How many shots we take in a burst — we keep the sharpest for OCR. */
44- const BURST_COUNT = 2
45- /** Gap between burst shots so focus / exposure has a chance to settle. */
46- const BURST_INTERVAL_MS = 130
39+ /**
40+ * How many shots we take in a burst — we keep the sharpest for OCR. Pikacheck-
41+ * style: a slightly larger burst over a longer window so the user can pivot
42+ * the card during capture (revealing a glary corner, exposing missing text…)
43+ * and the sharpest / clearest angle still wins.
44+ */
45+ const BURST_COUNT = 3
46+ /** Gap between burst shots — wide enough for hand movement to expose new info. */
47+ const BURST_INTERVAL_MS = 280
4748/** Lerp weight (new vs previous) when tracking the displayed quad. */
4849const SMOOTH_LERP = 0.5
4950/** Drift above this fraction of the long edge ⇒ new scene, snap instead of lerp. */
@@ -96,7 +97,6 @@ export function useCardAutoScan(opts: UseCardAutoScanOptions) {
9697 let displayedCorners : Pt [ ] | null = null
9798 let pendingCorners : Pt [ ] | null = null
9899 let missTicks = 0
99- let steadyTicks = 0
100100 let lostTicks = 0
101101 let lastCaptureAt = 0
102102
@@ -241,25 +241,28 @@ export function useCardAutoScan(opts: UseCardAutoScanOptions) {
241241
242242 /** Send the next warp request from the burst (or finalise if we've collected enough). */
243243 function requestWarp ( ) : void {
244- if ( ! worker || ! burstCorners ) {
244+ if ( ! worker ) {
245245 finaliseBurst ( )
246246 return
247247 }
248248 if ( burstShots . length >= burstAwaiting ) {
249249 finaliseBurst ( )
250250 return
251251 }
252+ // Use the freshest detected corners so a pivot/move during the burst still
253+ // yields a correctly cropped card. Fall back to the trigger corners if the
254+ // detector hasn't returned a new quad yet.
255+ const corners = lastCorners ?? burstCorners
252256 const full = grabFrame ( 0 )
253- if ( ! full ) {
254- // Skip this shot but keep trying — maybe the next one lands.
257+ if ( ! corners || ! full ) {
255258 if ( burstShots . length + 1 < burstAwaiting ) {
256259 burstTimer = setTimeout ( requestWarp , BURST_INTERVAL_MS )
257260 } else {
258261 finaliseBurst ( )
259262 }
260263 return
261264 }
262- worker . postMessage ( { t : 'warp' , buf : full . buf , w : full . w , h : full . h , corners : burstCorners } , [ full . buf ] )
265+ worker . postMessage ( { t : 'warp' , buf : full . buf , w : full . w , h : full . h , corners } , [ full . buf ] )
263266 }
264267
265268 /** Pick the sharpest shot from the burst, encode JPEG and hand it to `onCapture`. */
@@ -333,7 +336,6 @@ export function useCardAutoScan(opts: UseCardAutoScanOptions) {
333336 lastCorners = null
334337 displayedCorners = null
335338 quad . value = null
336- steadyTicks = 0
337339 if ( phase . value === 'cooldown' ) {
338340 lostTicks += 1
339341 if ( lostTicks >= LOST_TICKS_REARM ) {
@@ -368,28 +370,31 @@ export function useCardAutoScan(opts: UseCardAutoScanOptions) {
368370 quad . value = [ shaped [ 0 ] ! , shaped [ 1 ] ! , shaped [ 2 ] ! , shaped [ 3 ] ! ]
369371 lostTicks = 0
370372
373+ lastCorners = corners
374+
371375 if ( phase . value === 'cooldown' || capturing ) {
372376 return
373377 }
374378
375- if ( lastCorners && cornerDrift ( corners , lastCorners ) < longEdge * STEADY_FRAC ) {
376- steadyTicks += 1
377- } else {
378- steadyTicks = 0
379- }
380- lastCorners = corners
381-
382- if ( steadyTicks < STEADY_TICKS || busy . value || Date . now ( ) - lastCaptureAt < MIN_COOLDOWN_MS ) {
383- phase . value = 'settling'
379+ // No "hold steady" wait — Pikacheck-style. As soon as a card is confirmed
380+ // (vote passed) and we're past the cooldown, we shoot a burst. The user is
381+ // free to pivot/move the card during the burst; each shot uses the freshest
382+ // corners and the sharpest wins.
383+ if ( busy . value || Date . now ( ) - lastCaptureAt < MIN_COOLDOWN_MS ) {
384+ phase . value = 'watching'
384385 return
385386 }
386387
387388 beginBurst ( corners )
388389 }
389390
390- /** Grab one downscaled frame and hand it to the worker for detection. */
391+ /**
392+ * Grab one downscaled frame and hand it to the worker for detection. Note we
393+ * intentionally keep running during a capture burst so `lastCorners` stays
394+ * fresh — each burst shot uses the latest detection, supporting movement.
395+ */
391396 function pumpFrame ( ) : void {
392- if ( ! worker || ! enabled . value || detectInFlight || capturing || busy . value ) {
397+ if ( ! worker || ! enabled . value || detectInFlight || busy . value ) {
393398 return
394399 }
395400 const el = video . value
0 commit comments