@@ -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 be passed in front of the camera
7+ * - `watching` : waiting for a card to enter the frame
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
@@ -28,34 +28,30 @@ const SAMPLE_W = 96
2828const SAMPLE_H = 134
2929/** Center crop ratio — ignore desk edges that add false motion. */
3030const CROP_RATIO = 0.72
31- /**
32- * EMA of frame delta below this ⇒ "still". High on purpose: phone AE flicker
33- * often sits at 12–25 even on a tripod.
34- */
3531const STILL_EMA_MAX = 28
3632const 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
43- /** EMA must stay below threshold for this many ticks before capture. */
44- const STILL_TICKS_REQUIRED = 6
45- /** Min contrast in the center crop (empty desk ≈ 3–5, card ≈ 12+). */
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
33+ /** Brief motion when passing a card in front of the lens. */
34+ const MOTION_ENTER_EMA_MIN = 32
35+ const MOTION_ENTER_TICKS_REQUIRED = 2
36+ /** Stable frames before capture (~0,6–0,8 s). */
37+ const STILL_TICKS_REQUIRED = 5
38+ /** Empty desk ≈ 3–5 ; card in frame ≈ 10+. */
39+ const CONTENT_STDDEV_MIN = 7
40+ /** Stddev jump vs empty-scene baseline ⇒ card just entered (tripod / no hand wave). */
41+ const CONTENT_APPEAR_DELTA = 2.2
42+ /** Stable frames with a card visible but no big motion (phone on a stand). */
43+ const TRIPOD_STILL_TICKS = 7
44+ const SCENE_LEAVE_DIFF = 9
45+ const EMPTY_LEAVE_TICKS = 3
46+ const MIN_COOLDOWN_MS = 2000
47+ const MIN_REARM_MS = 2200
48+ const BASELINE_STDDEV_ALPHA = 0.12
5349
5450type ArmState = 'wait_pass' | 'settling' | 'wait_removal'
5551
5652/**
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 .
53+ * Touch-free capture: fires when a card enters the frame (motion pass, contrast
54+ * jump, or held still on a stand) then stays sharp , then waits until removal .
5955 *
6056 * @param opts - Preview element, enable flag, busy flag and capture callback.
6157 * @returns Reactive `phase` for UI feedback.
@@ -70,11 +66,12 @@ export function useCardAutoScan(opts: UseCardAutoScanOptions) {
7066 let prevGray : Float32Array | null = null
7167 let lastShotGray : Float32Array | null = null
7268 let motionEma = 0
69+ let baselineStddev = 4
7370 let stillTicks = 0
71+ let tripodStillTicks = 0
7472 let motionEnterTicks = 0
7573 let emptyLeaveTicks = 0
7674 let lastCaptureAt = 0
77- let armedAt = 0
7875 let armState : ArmState = 'wait_pass'
7976 let rafId : number | null = null
8077 let intervalId : ReturnType < typeof setInterval > | null = null
@@ -143,10 +140,10 @@ export function useCardAutoScan(opts: UseCardAutoScanOptions) {
143140 prevGray = null
144141 motionEma = 0
145142 stillTicks = 0
143+ tripodStillTicks = 0
146144 motionEnterTicks = 0
147145 emptyLeaveTicks = 0
148146 armState = 'wait_pass'
149- armedAt = Date . now ( )
150147 phase . value = 'watching'
151148 }
152149
@@ -155,6 +152,7 @@ export function useCardAutoScan(opts: UseCardAutoScanOptions) {
155152 lastCaptureAt = Date . now ( )
156153 lastShotGray = gray . slice ( )
157154 stillTicks = 0
155+ tripodStillTicks = 0
158156 motionEnterTicks = 0
159157 motionEma = 0
160158 armState = 'wait_removal'
@@ -182,7 +180,7 @@ export function useCardAutoScan(opts: UseCardAutoScanOptions) {
182180 return frameDelta ( gray , lastShotGray ) >= SCENE_LEAVE_DIFF
183181 }
184182
185- /** One detector frame — never uploads unless a pass + settle happened . */
183+ /** One detector frame — never uploads unless a card entered and settled . */
186184 function tick ( ) : void {
187185 if ( ! enabled . value ) {
188186 return
@@ -217,27 +215,44 @@ export function useCardAutoScan(opts: UseCardAutoScanOptions) {
217215 const hasContent = stddev >= CONTENT_STDDEV_MIN
218216 const moving = motionEma > STILL_EMA_MAX
219217
218+ if ( ! hasContent ) {
219+ baselineStddev = BASELINE_STDDEV_ALPHA * stddev + ( 1 - BASELINE_STDDEV_ALPHA ) * baselineStddev
220+ }
221+
220222 if ( armState === 'wait_pass' ) {
221223 phase . value = 'watching'
222224 if ( ! hasContent ) {
223225 motionEnterTicks = 0
224- stillTicks = 0
226+ tripodStillTicks = 0
225227 return
226228 }
229+
227230 if ( motionEma >= MOTION_ENTER_EMA_MIN ) {
228231 motionEnterTicks += 1
229232 } else {
230233 motionEnterTicks = 0
231234 }
232- if ( motionEnterTicks >= MOTION_ENTER_TICKS_REQUIRED ) {
235+
236+ if ( ! moving ) {
237+ tripodStillTicks += 1
238+ } else {
239+ tripodStillTicks = 0
240+ }
241+
242+ const contentAppeared = stddev - baselineStddev >= CONTENT_APPEAR_DELTA
243+ const motionPass = motionEnterTicks >= MOTION_ENTER_TICKS_REQUIRED
244+ const tripodReady = tripodStillTicks >= TRIPOD_STILL_TICKS
245+
246+ if ( contentAppeared || motionPass || tripodReady ) {
233247 armState = 'settling'
234248 stillTicks = 0
235249 motionEnterTicks = 0
250+ tripodStillTicks = 0
236251 }
237252 return
238253 }
239254
240- // settling — card was passed in , wait until it stops moving
255+ // settling — card is in frame , wait until it stops moving
241256 if ( ! hasContent || moving ) {
242257 stillTicks = 0
243258 phase . value = moving ? 'watching' : 'watching'
@@ -251,7 +266,7 @@ export function useCardAutoScan(opts: UseCardAutoScanOptions) {
251266 phase . value = 'settling'
252267
253268 const settled = stillTicks >= STILL_TICKS_REQUIRED
254- const canShoot = settled && Date . now ( ) - lastCaptureAt >= MIN_COOLDOWN_MS && Date . now ( ) - armedAt >= 400
269+ const canShoot = settled && Date . now ( ) - lastCaptureAt >= MIN_COOLDOWN_MS
255270
256271 if ( canShoot ) {
257272 tryCapture ( gray )
@@ -293,6 +308,7 @@ export function useCardAutoScan(opts: UseCardAutoScanOptions) {
293308 stopLoop ( )
294309 lastCaptureAt = 0
295310 lastShotGray = null
311+ baselineStddev = 4
296312 resetForNextPass ( )
297313 scheduleLoop ( )
298314 }
0 commit comments