Skip to content

Commit b84cd42

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

7 files changed

Lines changed: 230 additions & 34 deletions

File tree

api/routes/scan_stream_route.py

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
File,
2424
Form,
2525
HTTPException,
26+
Query,
2627
UploadFile,
2728
WebSocket,
2829
status,
@@ -132,6 +133,47 @@ def list_recent_scans(
132133
return {"items": events}
133134

134135

136+
@router.delete("/scan-stream/events/{event_id}", status_code=status.HTTP_204_NO_CONTENT)
137+
def dismiss_scan_event(
138+
event_id: str,
139+
user: Annotated[User, Depends(get_current_user)],
140+
) -> None:
141+
"""Remove one scan event from the live backlog (does not delete collection rows)."""
142+
hub = get_scan_stream_hub()
143+
if not hub.dismiss_event(user.id, event_id.strip()):
144+
raise HTTPException(status_code=404, detail="Événement introuvable.")
145+
146+
147+
@router.delete("/scan-stream/events")
148+
def clear_scan_events(
149+
user: Annotated[User, Depends(get_current_user)],
150+
filter: Annotated[
151+
str,
152+
Query(description="failed | needs_review | problems (failed + needs_review)"),
153+
] = "problems",
154+
) -> dict[str, Any]:
155+
"""
156+
Bulk-remove scan events from the backlog.
157+
158+
Does not touch cards already inserted in the collection — only the scan feed.
159+
"""
160+
hub = get_scan_stream_hub()
161+
key = filter.strip().lower()
162+
if key in {"failed", "echec", "échec"}:
163+
statuses = {"failed"}
164+
elif key in {"needs_review", "review", "a_verifier", "à_vérifier"}:
165+
statuses = {"needs_review"}
166+
elif key in {"problems", "problem", "issues"}:
167+
statuses = {"failed", "needs_review"}
168+
else:
169+
raise HTTPException(
170+
status_code=400,
171+
detail="Filtre invalide. Utilisez problems, failed ou needs_review.",
172+
)
173+
removed = hub.clear_events(user.id, statuses=statuses)
174+
return {"removed": removed}
175+
176+
135177
@router.get("/scan-stream/health")
136178
def scan_stream_health() -> dict[str, Any]:
137179
"""Lightweight probe — confirms the scan-stream router is deployed."""

api/services/scan_stream_hub.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,41 @@ def history_snapshot(self, user_id: int, limit: int | None = None) -> list[dict[
9696
items = items[-limit:]
9797
return items
9898

99+
def dismiss_event(self, user_id: int, event_id: str) -> bool:
100+
"""Remove one event from the in-memory backlog (UI dismiss)."""
101+
buf = self._history.get(user_id)
102+
if not buf:
103+
return False
104+
kept = [item for item in buf if str(item.get("event_id")) != event_id]
105+
if len(kept) == len(buf):
106+
return False
107+
self._history[user_id] = deque(kept, maxlen=_HISTORY_PER_USER)
108+
return True
109+
110+
def clear_events(
111+
self,
112+
user_id: int,
113+
*,
114+
statuses: set[str] | None = None,
115+
) -> int:
116+
"""
117+
Drop events from the backlog, optionally filtered by terminal ``status``.
118+
119+
When ``statuses`` is ``None``, clears the entire history for the user.
120+
"""
121+
buf = self._history.get(user_id)
122+
if not buf:
123+
return 0
124+
if statuses is None:
125+
removed = len(buf)
126+
self._history.pop(user_id, None)
127+
return removed
128+
kept = [item for item in buf if str(item.get("status")) not in statuses]
129+
removed = len(buf) - len(kept)
130+
if removed:
131+
self._history[user_id] = deque(kept, maxlen=_HISTORY_PER_USER)
132+
return removed
133+
99134

100135
_HUB = ScanStreamHub()
101136

web/app/composables/useCardAutoScan.ts

Lines changed: 46 additions & 30 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 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
2828
const SAMPLE_H = 134
2929
/** Center crop ratio — ignore desk edges that add false motion. */
3030
const 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-
*/
3531
const STILL_EMA_MAX = 28
3632
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
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

5450
type 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
}

web/app/composables/useScanStream.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -410,6 +410,29 @@ export function useScanStream() {
410410
return data
411411
}
412412

413+
/**
414+
* Remove one event from the server backlog and the local list.
415+
*
416+
* @param eventId - Scan event id returned by upload / WebSocket.
417+
*/
418+
async function dismissEvent(eventId: string): Promise<void> {
419+
await $api.delete(`/scan-stream/events/${encodeURIComponent(eventId)}`)
420+
events.value = events.value.filter((e) => e.event_id !== eventId)
421+
}
422+
423+
/**
424+
* Bulk-remove failed and needs-review events from the feed.
425+
*
426+
* @returns Number of events removed on the server.
427+
*/
428+
async function clearProblemEvents(): Promise<number> {
429+
const { data } = await $api.delete<{ removed: number }>('/scan-stream/events', {
430+
params: { filter: 'problems' },
431+
})
432+
events.value = events.value.filter((e) => e.status !== 'failed' && e.status !== 'needs_review')
433+
return Number(data.removed) || 0
434+
}
435+
413436
return {
414437
events,
415438
connected,
@@ -420,5 +443,7 @@ export function useScanStream() {
420443
disconnect,
421444
refreshRecent,
422445
uploadPhoto,
446+
dismissEvent,
447+
clearProblemEvents,
423448
}
424449
}

web/app/layouts/default.vue

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -229,7 +229,7 @@ const links: ComputedRef<NavigationMenuItem[][]> = computed(() => {
229229
if (!isDesktopApp.value) {
230230
items.push({
231231
label: "Télécharger l'app",
232-
icon: 'i-lucide-download',
232+
icon: 'i-lucide-hard-drive-download',
233233
to: '/downloads',
234234
onSelect: () => {
235235
open.value = false

0 commit comments

Comments
 (0)