Skip to content

Commit fd2ea40

Browse files
committed
fix: scan
1 parent 514e0ba commit fd2ea40

8 files changed

Lines changed: 559 additions & 126 deletions

File tree

api/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -183,6 +183,7 @@ On Windows, `main.py` already calls `ensure_proactor_event_loop()`; the loop fro
183183

184184
- Swagger UI: http://127.0.0.1:8000/docs
185185
- Health: `GET /health`
186+
- Scan stream: `GET /scan-stream/health`, `GET /scan-stream/recent`, `POST /scan-stream/photo`
186187
- Article images are stored in **Supabase Storage** (public URLs in DB).
187188

188189
### Local desktop worker (Tauri only)

api/routes/scan_stream_route.py

Lines changed: 28 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -132,16 +132,13 @@ def list_recent_scans(
132132
return {"items": events}
133133

134134

135-
@router.websocket("/ws/scan-stream")
136-
async def scan_stream_socket(
137-
ws: WebSocket,
138-
db: Annotated[Session, Depends(get_db)],
139-
) -> None:
140-
"""
141-
Authenticated WebSocket. The browser cannot send custom headers on
142-
``WebSocket``, so the JWT travels in ``?token=<jwt>``. We reuse
143-
:func:`core.deps.get_bearer_or_query_token` via the dependency chain.
144-
"""
135+
@router.get("/scan-stream/health")
136+
def scan_stream_health() -> dict[str, Any]:
137+
"""Lightweight probe — confirms the scan-stream router is deployed."""
138+
return {"ok": True, "websocket_paths": ["/ws/scan-stream", "/scan-stream/ws"]}
139+
140+
141+
async def _scan_stream_socket_impl(ws: WebSocket, db: Session) -> None:
145142
try:
146143
user = get_current_user_from_token_str(
147144
raw_token=ws.query_params.get("token", ""),
@@ -157,8 +154,6 @@ async def scan_stream_socket(
157154
try:
158155
for past in backlog:
159156
await ws.send_json(past)
160-
# Keep the socket open; we never expect inbound frames but a tiny
161-
# receive loop terminates cleanly on client disconnect / page reload.
162157
while True:
163158
await ws.receive_text()
164159
except WebSocketDisconnect:
@@ -167,3 +162,24 @@ async def scan_stream_socket(
167162
logger.exception("scan-stream socket crashed (user=%s)", user.id)
168163
finally:
169164
await hub.disconnect(user.id, ws)
165+
166+
167+
@router.websocket("/ws/scan-stream")
168+
async def scan_stream_socket(
169+
ws: WebSocket,
170+
db: Annotated[Session, Depends(get_db)],
171+
) -> None:
172+
"""
173+
Authenticated WebSocket. The browser cannot send custom headers on
174+
``WebSocket``, so the JWT travels in ``?token=<jwt>``.
175+
"""
176+
await _scan_stream_socket_impl(ws, db)
177+
178+
179+
@router.websocket("/scan-stream/ws")
180+
async def scan_stream_socket_alias(
181+
ws: WebSocket,
182+
db: Annotated[Session, Depends(get_db)],
183+
) -> None:
184+
"""Alias for nginx configs that only proxy ``/scan-stream/*``."""
185+
await _scan_stream_socket_impl(ws, db)

web/app/composables/useArticles.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -259,6 +259,8 @@ export function useArticles() {
259259

260260
/**
261261
* POST worker local — retire l’annonce Vinted (fiche article).
262+
*
263+
* @returns Worker ack with optional Vinted status.
262264
*/
263265
async function removeVintedListing(id: number) {
264266
if (!import.meta.client || !isDesktopApp.value || !$vintedLocal) {
@@ -270,6 +272,8 @@ export function useArticles() {
270272

271273
/**
272274
* POST `/articles/:id/remove-ebay-listing` — retire l’annonce eBay (API).
275+
*
276+
* @returns Updated article after eBay delist.
273277
*/
274278
async function removeEbayListing(id: number) {
275279
const { data } = await $api.post<Article>(`/articles/${id}/remove-ebay-listing`)

web/app/composables/useCardAutoScan.ts

Lines changed: 135 additions & 71 deletions
Original file line numberDiff line numberDiff line change
@@ -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 }

web/app/composables/useCardmarketOrdersSync.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ export function useCardmarketOrdersSync() {
4545
*
4646
* @param onPayload - Called for every JSON event received.
4747
* @param openTimeoutMs - Maximum wait for `open`.
48+
* @returns Open socket, or `null` when URL/auth is missing or open times out.
4849
*/
4950
async function openProgressSocket(
5051
onPayload: (ev: OrdersSyncEvent) => void,

0 commit comments

Comments
 (0)