@@ -4,7 +4,7 @@ import type { Ref } from 'vue'
44 * Auto-scan state, surfaced to the UI so the user knows what the camera is
55 * doing without any button:
66 * - `idle` : detector stopped (camera off / disabled)
7- * - `watching` : waiting for a card to enter the frame
7+ * - `watching` : waiting for a card to be passed in front of the camera
88 * - `settling` : a card moved in, waiting for it to be held still
99 * - `captured` : a stable card was just shot and sent
1010 * - `cooldown` : waiting for the card to leave before arming the next one
@@ -29,25 +29,33 @@ const SAMPLE_H = 134
2929/** Center crop ratio — ignore desk edges that add false motion. */
3030const CROP_RATIO = 0.72
3131/**
32- * EMA of frame delta above this ⇒ "moving ". High on purpose: phone AE flicker
32+ * EMA of frame delta below this ⇒ "still ". High on purpose: phone AE flicker
3333 * often sits at 12–25 even on a tripod.
3434 */
3535const STILL_EMA_MAX = 28
3636const STILL_EMA_ALPHA = 0.35
37+ /**
38+ * EMA must exceed this briefly to count as "a card was passed in".
39+ * Higher than `STILL_EMA_MAX` so idle desk / AE noise does not arm a capture.
40+ */
41+ const MOTION_ENTER_EMA_MIN = 38
42+ const MOTION_ENTER_TICKS_REQUIRED = 3
3743/** EMA must stay below threshold for this many ticks before capture. */
38- const STILL_TICKS_REQUIRED = 5
44+ const STILL_TICKS_REQUIRED = 6
3945/** 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
46+ const CONTENT_STDDEV_MIN = 8
47+ /** Scene must change this much after capture before we accept the next card. */
48+ const SCENE_LEAVE_DIFF = 11
49+ /** Empty-frame ticks required to consider the card removed (low contrast). */
50+ const EMPTY_LEAVE_TICKS = 4
51+ const MIN_COOLDOWN_MS = 2200
52+ const MIN_REARM_MS = 2800
53+
54+ type ArmState = 'wait_pass' | 'settling' | 'wait_removal'
4655
4756/**
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.
57+ * Touch-free "cash register" capture: only fires when the user *passes* a card
58+ * (motion burst → still → capture), then waits until the card leaves the frame.
5159 *
5260 * @param opts - Preview element, enable flag, busy flag and capture callback.
5361 * @returns Reactive `phase` for UI feedback.
@@ -61,10 +69,13 @@ export function useCardAutoScan(opts: UseCardAutoScanOptions) {
6169 let ctx : CanvasRenderingContext2D | null = null
6270 let prevGray : Float32Array | null = null
6371 let lastShotGray : Float32Array | null = null
64- let motionEma = 99
72+ let motionEma = 0
6573 let stillTicks = 0
74+ let motionEnterTicks = 0
75+ let emptyLeaveTicks = 0
6676 let lastCaptureAt = 0
67- let readySince = 0
77+ let armedAt = 0
78+ let armState : ArmState = 'wait_pass'
6879 let rafId : number | null = null
6980 let intervalId : ReturnType < typeof setInterval > | null = null
7081
@@ -127,33 +138,51 @@ export function useCardAutoScan(opts: UseCardAutoScanOptions) {
127138 return acc / a . length
128139 }
129140
130- /**
131- *
132- */
133- function resetCycle ( ) : void {
141+ /** Reset motion counters when (re)arming for the next card pass. */
142+ function resetForNextPass ( ) : void {
134143 prevGray = null
135- motionEma = 99
144+ motionEma = 0
136145 stillTicks = 0
137- readySince = Date . now ( )
146+ motionEnterTicks = 0
147+ emptyLeaveTicks = 0
148+ armState = 'wait_pass'
149+ armedAt = Date . now ( )
150+ phase . value = 'watching'
138151 }
139152
140- /**
141- *
142- */
153+ /** Fire one capture and enter removal-wait state. */
143154 function tryCapture ( gray : Float32Array ) : void {
144155 lastCaptureAt = Date . now ( )
145156 lastShotGray = gray . slice ( )
146157 stillTicks = 0
147- motionEma = 99
158+ motionEnterTicks = 0
159+ motionEma = 0
160+ armState = 'wait_removal'
161+ emptyLeaveTicks = 0
148162 phase . value = 'captured'
149163 void Promise . resolve ( onCapture ( ) ) . finally ( ( ) => {
150164 phase . value = 'cooldown'
151165 } )
152166 }
153167
154168 /**
169+ * True when the captured card is no longer in frame (safe to scan the next one).
155170 *
171+ * @returns Whether the scene cleared enough to arm the next pass.
156172 */
173+ function cardHasLeftFrame ( gray : Float32Array , stddev : number ) : boolean {
174+ if ( stddev < CONTENT_STDDEV_MIN ) {
175+ emptyLeaveTicks += 1
176+ return emptyLeaveTicks >= EMPTY_LEAVE_TICKS
177+ }
178+ emptyLeaveTicks = 0
179+ if ( ! lastShotGray ) {
180+ return false
181+ }
182+ return frameDelta ( gray , lastShotGray ) >= SCENE_LEAVE_DIFF
183+ }
184+
185+ /** One detector frame — never uploads unless a pass + settle happened. */
157186 function tick ( ) : void {
158187 if ( ! enabled . value ) {
159188 return
@@ -165,49 +194,71 @@ export function useCardAutoScan(opts: UseCardAutoScanOptions) {
165194 }
166195 const { gray, stddev } = sample
167196
168- if ( stddev < CONTENT_STDDEV_MIN ) {
169- stillTicks = 0
170- motionEma = 99
171- phase . value = 'watching'
172- prevGray = gray
173- return
174- }
175-
176197 const prev = prevGray
177198 prevGray = gray
178199 if ( prev ) {
179200 const delta = frameDelta ( gray , prev )
180201 motionEma = STILL_EMA_ALPHA * delta + ( 1 - STILL_EMA_ALPHA ) * motionEma
181202 }
182203
183- const moving = motionEma > STILL_EMA_MAX
184- if ( moving ) {
185- stillTicks = 0
186- phase . value = 'watching'
204+ if ( busy . value ) {
187205 return
188206 }
189- stillTicks += 1
190207
191- if ( lastShotGray && frameDelta ( gray , lastShotGray ) < SCENE_CHANGE_DIFF ) {
208+ if ( armState === 'wait_removal' ) {
192209 phase . value = 'cooldown'
210+ const rearmDelayOk = Date . now ( ) - lastCaptureAt >= MIN_REARM_MS
211+ if ( rearmDelayOk && cardHasLeftFrame ( gray , stddev ) ) {
212+ resetForNextPass ( )
213+ }
193214 return
194215 }
195216
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
217+ const hasContent = stddev >= CONTENT_STDDEV_MIN
218+ const moving = motionEma > STILL_EMA_MAX
199219
200- if ( canShoot && ( settled || forceByTime ) ) {
201- tryCapture ( gray )
220+ if ( armState === 'wait_pass' ) {
221+ phase . value = 'watching'
222+ if ( ! hasContent ) {
223+ motionEnterTicks = 0
224+ stillTicks = 0
225+ return
226+ }
227+ if ( motionEma >= MOTION_ENTER_EMA_MIN ) {
228+ motionEnterTicks += 1
229+ } else {
230+ motionEnterTicks = 0
231+ }
232+ if ( motionEnterTicks >= MOTION_ENTER_TICKS_REQUIRED ) {
233+ armState = 'settling'
234+ stillTicks = 0
235+ motionEnterTicks = 0
236+ }
202237 return
203238 }
204239
240+ // settling — card was passed in, wait until it stops moving
241+ if ( ! hasContent || moving ) {
242+ stillTicks = 0
243+ phase . value = moving ? 'watching' : 'watching'
244+ if ( ! hasContent ) {
245+ armState = 'wait_pass'
246+ }
247+ return
248+ }
249+
250+ stillTicks += 1
205251 phase . value = 'settling'
252+
253+ const settled = stillTicks >= STILL_TICKS_REQUIRED
254+ const canShoot = settled && Date . now ( ) - lastCaptureAt >= MIN_COOLDOWN_MS && Date . now ( ) - armedAt >= 400
255+
256+ if ( canShoot ) {
257+ tryCapture ( gray )
258+ }
206259 }
207260
208- /**
209- *
210- */
261+ /** Start sampling frames from the video element. */
211262 function scheduleLoop ( ) : void {
212263 const el = video . value
213264 if ( el && typeof el . requestVideoFrameCallback === 'function' ) {
@@ -224,9 +275,7 @@ export function useCardAutoScan(opts: UseCardAutoScanOptions) {
224275 intervalId = setInterval ( tick , SAMPLE_MS )
225276 }
226277
227- /**
228- *
229- */
278+ /** Stop the frame sampling loop. */
230279 function stopLoop ( ) : void {
231280 const el = video . value
232281 if ( rafId !== null && el && typeof el . cancelVideoFrameCallback === 'function' ) {
@@ -239,26 +288,22 @@ export function useCardAutoScan(opts: UseCardAutoScanOptions) {
239288 }
240289 }
241290
242- /**
243- *
244- */
291+ /** Start the detector loop. */
245292 function start ( ) : void {
246293 stopLoop ( )
247- resetCycle ( )
248294 lastCaptureAt = 0
249295 lastShotGray = null
250- phase . value = 'watching'
296+ resetForNextPass ( )
251297 scheduleLoop ( )
252298 }
253299
254- /**
255- *
256- */
300+ /** Stop the detector and release canvas resources. */
257301 function stop ( ) : void {
258302 stopLoop ( )
259303 canvas = null
260304 ctx = null
261- resetCycle ( )
305+ lastShotGray = null
306+ armState = 'wait_pass'
262307 phase . value = 'idle'
263308 }
264309
0 commit comments