@@ -22,30 +22,32 @@ export interface UseCardAutoScanOptions {
2222 onCapture : ( ) => void | Promise < void >
2323}
2424
25- /** ~4.5 samples / second — responsive without burning the main thread. */
26- const SAMPLE_MS = 220
27- /** Long edge of the downscaled analysis buffer (grayscale, ~card ratio). */
28- const SAMPLE_W = 80
29- const SAMPLE_H = 112
30- /** Mean abs frame delta (0–255) that counts as "something moved". */
31- const MOTION_DIFF = 8
32- /** Frame delta below which the scene is considered "held still". */
33- const STILL_DIFF = 2.6
34- /** Consecutive still samples required before firing (~0.66 s). */
35- const STILL_HITS = 3
36- /** Min grayscale stddev so a blank background never triggers a capture. */
37- const CONTENT_STDDEV_MIN = 10
38- /** Floor between two captures, guards against micro-jitter double shots. */
39- const MIN_COOLDOWN_MS = 1200
25+ /** ~8 samples / second when `requestVideoFrameCallback` is available. */
26+ const SAMPLE_MS = 125
27+ const SAMPLE_W = 96
28+ const SAMPLE_H = 134
29+ /** Center crop ratio — ignore desk edges that add false motion. */
30+ const CROP_RATIO = 0.72
31+ /**
32+ * EMA of frame delta above this ⇒ "moving". High on purpose: phone AE flicker
33+ * often sits at 12–25 even on a tripod.
34+ */
35+ const STILL_EMA_MAX = 28
36+ const STILL_EMA_ALPHA = 0.35
37+ /** EMA must stay below threshold for this many ticks before capture. */
38+ const STILL_TICKS_REQUIRED = 5
39+ /** Min contrast in the center crop (empty desk ≈ 3–5, card ≈ 12+). */
40+ const CONTENT_STDDEV_MIN = 5
41+ /** Ignore re-shooting the same card until the scene changes this much. */
42+ const SCENE_CHANGE_DIFF = 4
43+ const MIN_COOLDOWN_MS = 1100
44+ /** After camera ready, force one capture if nothing fired (tripod / card already in frame). */
45+ const FORCE_CAPTURE_AFTER_MS = 2800
4046
4147/**
42- * Touch-free "cash register" capture: watch the live camera, and the instant a
43- * card is placed and held steady, fire `onCapture` once. The next shot only
44- * arms after the scene changes again (card removed / swapped), so a single
45- * card is never scanned twice.
46- *
47- * Pure frame-difference heuristics (no ML, no extra deps): a capture needs a
48- * motion spike (card arriving) followed by a run of still, content-rich frames.
48+ * Touch-free "cash register" capture: sample the live camera, detect a card-like
49+ * center region, and fire `onCapture` when motion settles (EMA) or after a short
50+ * timeout if the card was already in frame.
4951 *
5052 * @param opts - Preview element, enable flag, busy flag and capture callback.
5153 * @returns Reactive `phase` for UI feedback.
@@ -55,21 +57,25 @@ export function useCardAutoScan(opts: UseCardAutoScanOptions) {
5557
5658 const phase : Ref < AutoScanPhase > = ref ( 'idle' )
5759
58- let timer : ReturnType < typeof setInterval > | null = null
5960 let canvas : HTMLCanvasElement | null = null
6061 let ctx : CanvasRenderingContext2D | null = null
6162 let prevGray : Float32Array | null = null
62- let stillRun = 0
63- let sawMotion = false
63+ let lastShotGray : Float32Array | null = null
64+ let motionEma = 99
65+ let stillTicks = 0
6466 let lastCaptureAt = 0
67+ let readySince = 0
68+ let rafId : number | null = null
69+ let intervalId : ReturnType < typeof setInterval > | null = null
6570
6671 /**
67- * Grayscale (luma) + stddev of the current downscaled frame.
68- * @returns Sample, or `null` when the video isn't drawable yet.
72+ * Downsample the central crop of the current video frame for motion detection.
73+ *
74+ * @returns Grayscale sample and luminance stddev, or `null` if the video is not ready.
6975 */
7076 function sampleFrame ( ) : { gray : Float32Array ; stddev : number } | null {
7177 const el = video . value
72- if ( ! el || ! el . videoWidth || ! el . videoHeight ) {
78+ if ( ! el || el . readyState < 2 || ! el . videoWidth || ! el . videoHeight ) {
7379 return null
7480 }
7581 if ( ! canvas ) {
@@ -81,7 +87,15 @@ export function useCardAutoScan(opts: UseCardAutoScanOptions) {
8187 if ( ! ctx ) {
8288 return null
8389 }
84- ctx . drawImage ( el , 0 , 0 , SAMPLE_W , SAMPLE_H )
90+
91+ const vw = el . videoWidth
92+ const vh = el . videoHeight
93+ const cw = vw * CROP_RATIO
94+ const ch = vh * CROP_RATIO
95+ const sx = ( vw - cw ) / 2
96+ const sy = ( vh - ch ) / 2
97+
98+ ctx . drawImage ( el , sx , sy , cw , ch , 0 , 0 , SAMPLE_W , SAMPLE_H )
8599 const { data } = ctx . getImageData ( 0 , 0 , SAMPLE_W , SAMPLE_H )
86100 const n = SAMPLE_W * SAMPLE_H
87101 const gray = new Float32Array ( n )
@@ -101,10 +115,9 @@ export function useCardAutoScan(opts: UseCardAutoScanOptions) {
101115 }
102116
103117 /**
104- * Mean absolute luma delta between two equal-length frames.
105- * @param a - Current frame luma buffer.
106- * @param b - Previous frame luma buffer.
107- * @returns Average per-pixel absolute difference (0–255).
118+ * Mean absolute difference between two grayscale frames (0 = identical).
119+ *
120+ * @returns Average per-pixel luminance delta.
108121 */
109122 function frameDelta ( a : Float32Array , b : Float32Array ) : number {
110123 let acc = 0
@@ -119,8 +132,23 @@ export function useCardAutoScan(opts: UseCardAutoScanOptions) {
119132 */
120133 function resetCycle ( ) : void {
121134 prevGray = null
122- stillRun = 0
123- sawMotion = false
135+ motionEma = 99
136+ stillTicks = 0
137+ readySince = Date . now ( )
138+ }
139+
140+ /**
141+ *
142+ */
143+ function tryCapture ( gray : Float32Array ) : void {
144+ lastCaptureAt = Date . now ( )
145+ lastShotGray = gray . slice ( )
146+ stillTicks = 0
147+ motionEma = 99
148+ phase . value = 'captured'
149+ void Promise . resolve ( onCapture ( ) ) . finally ( ( ) => {
150+ phase . value = 'cooldown'
151+ } )
124152 }
125153
126154 /**
@@ -132,76 +160,102 @@ export function useCardAutoScan(opts: UseCardAutoScanOptions) {
132160 }
133161 const sample = sampleFrame ( )
134162 if ( ! sample ) {
163+ phase . value = 'watching'
135164 return
136165 }
137166 const { gray, stddev } = sample
167+
168+ if ( stddev < CONTENT_STDDEV_MIN ) {
169+ stillTicks = 0
170+ motionEma = 99
171+ phase . value = 'watching'
172+ prevGray = gray
173+ return
174+ }
175+
138176 const prev = prevGray
139177 prevGray = gray
140- if ( ! prev ) {
141- return
178+ if ( prev ) {
179+ const delta = frameDelta ( gray , prev )
180+ motionEma = STILL_EMA_ALPHA * delta + ( 1 - STILL_EMA_ALPHA ) * motionEma
142181 }
143- const delta = frameDelta ( gray , prev )
144182
145- if ( phase . value === 'cooldown' ) {
146- // Wait for the card to leave / be swapped before arming again.
147- if ( delta >= MOTION_DIFF && Date . now ( ) - lastCaptureAt >= MIN_COOLDOWN_MS ) {
148- resetCycle ( )
149- phase . value = 'watching'
150- }
183+ const moving = motionEma > STILL_EMA_MAX
184+ if ( moving ) {
185+ stillTicks = 0
186+ phase . value = 'watching'
151187 return
152188 }
189+ stillTicks += 1
153190
154- if ( delta >= MOTION_DIFF ) {
155- sawMotion = true
156- stillRun = 0
157- phase . value = 'settling'
191+ if ( lastShotGray && frameDelta ( gray , lastShotGray ) < SCENE_CHANGE_DIFF ) {
192+ phase . value = 'cooldown'
158193 return
159194 }
160195
161- if ( ! sawMotion ) {
162- phase . value = 'watching'
196+ const settled = stillTicks >= STILL_TICKS_REQUIRED
197+ const forceByTime = readySince > 0 && Date . now ( ) - readySince >= FORCE_CAPTURE_AFTER_MS && ! lastShotGray
198+ const canShoot = ! busy . value && Date . now ( ) - lastCaptureAt >= MIN_COOLDOWN_MS
199+
200+ if ( canShoot && ( settled || forceByTime ) ) {
201+ tryCapture ( gray )
163202 return
164203 }
165204
166- // A card arrived and the frame is now calm — accumulate still samples.
167- if ( delta <= STILL_DIFF && stddev >= CONTENT_STDDEV_MIN ) {
168- stillRun += 1
169- phase . value = 'settling'
170- if ( stillRun >= STILL_HITS && ! busy . value && Date . now ( ) - lastCaptureAt >= MIN_COOLDOWN_MS ) {
171- lastCaptureAt = Date . now ( )
172- stillRun = 0
173- sawMotion = false
174- phase . value = 'captured'
175- void Promise . resolve ( onCapture ( ) ) . finally ( ( ) => {
176- phase . value = 'cooldown'
177- } )
205+ phase . value = 'settling'
206+ }
207+
208+ /**
209+ *
210+ */
211+ function scheduleLoop ( ) : void {
212+ const el = video . value
213+ if ( el && typeof el . requestVideoFrameCallback === 'function' ) {
214+ const onFrame = ( ) : void => {
215+ if ( ! enabled . value ) {
216+ return
217+ }
218+ tick ( )
219+ rafId = el . requestVideoFrameCallback ( onFrame )
178220 }
221+ rafId = el . requestVideoFrameCallback ( onFrame )
179222 return
180223 }
181- stillRun = 0
224+ intervalId = setInterval ( tick , SAMPLE_MS )
182225 }
183226
184227 /**
185228 *
186229 */
187- function start ( ) : void {
188- if ( timer !== null ) {
189- return
230+ function stopLoop ( ) : void {
231+ const el = video . value
232+ if ( rafId !== null && el && typeof el . cancelVideoFrameCallback === 'function' ) {
233+ el . cancelVideoFrameCallback ( rafId )
234+ }
235+ rafId = null
236+ if ( intervalId !== null ) {
237+ clearInterval ( intervalId )
238+ intervalId = null
190239 }
240+ }
241+
242+ /**
243+ *
244+ */
245+ function start ( ) : void {
246+ stopLoop ( )
191247 resetCycle ( )
192248 lastCaptureAt = 0
249+ lastShotGray = null
193250 phase . value = 'watching'
194- timer = setInterval ( tick , SAMPLE_MS )
251+ scheduleLoop ( )
195252 }
196253
197254 /**
198255 *
199256 */
200257 function stop ( ) : void {
201- if ( timer !== null ) {
202- clearInterval ( timer )
203- timer = null
204- }
258+ stopLoop ( )
205259 canvas = null
206260 ctx = null
207261 resetCycle ( )
@@ -220,6 +274,16 @@ export function useCardAutoScan(opts: UseCardAutoScanOptions) {
220274 { immediate : true } ,
221275 )
222276
277+ watch (
278+ ( ) => video . value ,
279+ ( ) => {
280+ if ( enabled . value ) {
281+ stop ( )
282+ start ( )
283+ }
284+ } ,
285+ )
286+
223287 onBeforeUnmount ( stop )
224288
225289 return { phase }
0 commit comments