@@ -45,9 +45,18 @@ const BURST_COUNT = 2
4545/** Gap between burst shots so focus / exposure has a chance to settle. */
4646const 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. */
5050const 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
5261interface 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
0 commit comments