Skip to content

Commit 91f9179

Browse files
committed
fix: scan
1 parent fd2ea40 commit 91f9179

2 files changed

Lines changed: 108 additions & 61 deletions

File tree

web/app/composables/useCardAutoScan.ts

Lines changed: 103 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -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. */
3030
const 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
*/
3535
const STILL_EMA_MAX = 28
3636
const 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

web/app/pages/collection/scan.vue

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,9 @@
4040
v-if="liveCameraSupported && webcamActive && !prefersFullscreenCamera"
4141
v-model="autoScan"
4242
label="Scan auto (caisse)"
43-
:description="autoScan ? 'Capture dès que la carte est immobile' : 'Capture manuelle uniquement'"
43+
:description="
44+
autoScan ? 'Capture quand vous passez une carte puis la tenez immobile' : 'Capture manuelle uniquement'
45+
"
4446
/>
4547
<div v-else />
4648

@@ -938,7 +940,7 @@ function stopWebcam(): void {
938940
}
939941
940942
async function captureFromWebcam(): Promise<void> {
941-
if (!webcamReady.value || !videoEl.value) {
943+
if (uploading.value || !webcamReady.value || !videoEl.value) {
942944
return
943945
}
944946
const video = videoEl.value
@@ -1173,7 +1175,7 @@ const autoScanStatus = computed<{ label: string; color: 'primary' | 'success' |
11731175
}
11741176
switch (autoScanPhase.value) {
11751177
case 'watching':
1176-
return { label: 'Présentez une carte', color: 'neutral' }
1178+
return { label: 'Passez une carte devant la caméra', color: 'neutral' }
11771179
case 'settling':
11781180
return { label: 'Lecture… tenez la carte immobile', color: 'primary' }
11791181
case 'captured':

0 commit comments

Comments
 (0)