Skip to content

Commit e9fc3d7

Browse files
committed
Fix camera freeze: decode in a Web Worker, throttle locate to 30fps
The hardened sampler (binarization ladder + TPS dewarp + oversampling) is far too heavy to run synchronously on a 1080p frame in the render loop — a busy no-code frame could block the main thread for seconds. Move decoding to a background worker (its own wasm instance, crop-decodes located regions off the UI thread), throttle the grab+locate to ~30fps, and render the HUD every frame. UI stays responsive regardless of scan cost.
1 parent 24fe1c1 commit e9fc3d7

1 file changed

Lines changed: 62 additions & 37 deletions

File tree

‎web/index.html‎

Lines changed: 62 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -300,7 +300,39 @@ <h2>Decode an image</h2>
300300
const video = document.getElementById('cam-video');
301301
const overlay = document.getElementById('cam-overlay');
302302
const scratch = document.createElement('canvas');
303-
let stream, running = false, lastDecode = 0, lastDecoded = [];
303+
let stream, running = false, lastSent = 0, lastDecoded = [], workerBusy = false;
304+
305+
// Decoding runs in a background worker (its own wasm instance) so the heavy sampler
306+
// can never block the UI thread — the live overlay stays smooth even when a scan is
307+
// slow. The main thread only sends a frame when the worker is free.
308+
const WASM_URL = new URL('anyd.wasm', location.href).href;
309+
const workerSrc = `
310+
let w; const mem = () => new Uint8Array(w.memory.buffer);
311+
const ready = fetch(${JSON.stringify(WASM_URL)}).then(r => WebAssembly.instantiateStreaming(r, {})).then(x => { w = x.instance.exports; });
312+
function wr(b){ const p = w.alloc(b.length); mem().set(b, p); return p; }
313+
function take(pk){ const ptr = Number(pk >> 32n), len = Number(pk & 0xffffffffn); const o = mem().slice(ptr, ptr + len); w.dealloc(ptr, len); return o; }
314+
function decLuma(cw, ch, luma){ const p = wr(luma); const pk = w.decode(cw, ch, p); w.dealloc(p, luma.length); return JSON.parse(new TextDecoder().decode(take(pk)) || '[]'); }
315+
self.onmessage = async (e) => {
316+
await ready;
317+
const { width, height, luma, crops } = e.data;
318+
const seen = new Set(), found = [];
319+
const add = (r, box) => { const k = r.symbology + '|' + r.text; if (!seen.has(k)) { seen.add(k); found.push({ symbology: r.symbology, text: r.text, box }); } };
320+
for (const b of crops.slice(0, 6)) {
321+
const x0 = Math.max(0, b.x0 | 0), y0 = Math.max(0, b.y0 | 0), x1 = Math.min(width, Math.ceil(b.x1)), y1 = Math.min(height, Math.ceil(b.y1));
322+
const cw = x1 - x0, ch = y1 - y0; if (cw < 12 || ch < 12) continue;
323+
const crop = new Uint8Array(cw * ch);
324+
for (let y = 0; y < ch; y++) { const s = (y0 + y) * width + x0; crop.set(luma.subarray(s, s + cw), y * cw); }
325+
for (const r of decLuma(cw, ch, crop)) add(r, b);
326+
}
327+
self.postMessage(found);
328+
};
329+
`;
330+
const decodeWorker = new Worker(URL.createObjectURL(new Blob([workerSrc], { type: 'text/javascript' })));
331+
decodeWorker.onmessage = (e) => {
332+
lastDecoded = e.data; workerBusy = false;
333+
document.getElementById('cam-result').textContent =
334+
lastDecoded.length ? lastDecoded.map(r => `${r.symbology}: ${r.text}`).join('\n') : '';
335+
};
304336

305337
function bbox(corners) {
306338
const xs = corners.map(p => p[0]), ys = corners.map(p => p[1]);
@@ -441,7 +473,7 @@ <h2>Decode an image</h2>
441473
document.getElementById('cam-btn').disabled = true;
442474
document.getElementById('cam-stop').disabled = false;
443475
document.getElementById('cam-capture').disabled = false;
444-
running = true; requestAnimationFrame(loop);
476+
running = true; workerBusy = false; lastSent = 0; requestAnimationFrame(loop);
445477
};
446478
document.getElementById('cam-stop').onclick = () => {
447479
running = false; stream && stream.getTracks().forEach(t => t.stop());
@@ -481,55 +513,48 @@ <h2>Decode an image</h2>
481513
`captured frame ${pw}×${ph} — ${cands.length} located, ${decoded.length} decoded (saved PNG + JSON)`;
482514
};
483515

516+
let liveCands = [], liveScale = [1, 1], lastLocate = 0, lastMs = 0;
484517
function loop(ts) {
485-
if (!running) return;
518+
if (!running) { return; }
519+
requestAnimationFrame(loop);
486520
const vw = video.videoWidth, vh = video.videoHeight;
487-
if (vw) {
488-
// Process at (capped) native resolution — small codes need the pixels: a QR that
489-
// is ~30px in a 480px frame is invisible to both the locator and the sampler.
490-
// 1080p passes through at full res (locate is ~1.8 ms there).
491-
const pw = Math.min(vw, 1920), ph = Math.round(pw * vh / vw);
521+
if (!vw) return;
522+
const pw = Math.min(vw, 1920), ph = Math.round(pw * vh / vw);
523+
524+
// Grab + locate at ~30 fps (getImageData at 1080p is the heavy main-thread step);
525+
// the HUD still renders every frame below, so the overlay stays smooth.
526+
if (ts - lastLocate > 33) {
527+
lastLocate = ts;
492528
scratch.width = pw; scratch.height = ph;
493529
const sctx = scratch.getContext('2d', { willReadFrequently: true });
494530
sctx.drawImage(video, 0, 0, pw, ph);
495531
const img = sctx.getImageData(0, 0, pw, ph);
496532
const frameArea = pw * ph;
497-
498533
const t0 = performance.now();
499534
// Drop background-sized boxes: a code is a bounded object, not half the scene.
500-
const cands = locate(img).filter(c => {
535+
liveCands = locate(img).filter(c => {
501536
const b = bbox(c.corners);
502537
return (b.x1 - b.x0) * (b.y1 - b.y0) < 0.5 * frameArea;
503538
});
504-
const ms = performance.now() - t0;
505-
506-
// Periodic decode: the two-stage pipeline — crop each located region and decode it
507-
// at full resolution (a small code fills its own crop), plus a whole-frame attempt.
508-
if (ts - lastDecode > 300) {
509-
lastDecode = ts;
510-
const seen = new Set(), found = [];
511-
const add = (r, box) => { const k = r.symbology + '|' + r.text; if (!seen.has(k)) { seen.add(k); found.push({ ...r, box }); } };
512-
decode(img).forEach(r => add(r, null));
513-
for (const c of cands) {
514-
if (found.length >= 8) break;
515-
const b = bbox(c.corners);
516-
const crop = cropRGBA(img, b.x0 - 16, b.y0 - 16, b.x1 + 16, b.y1 + 16);
517-
if (crop) decode(crop).forEach(r => add(r, b));
518-
}
519-
lastDecoded = found;
520-
document.getElementById('cam-result').textContent =
521-
found.length ? found.map(r => `${r.symbology}: ${r.text}`).join('\n') : '';
539+
lastMs = performance.now() - t0;
540+
liveScale = [pw, ph];
541+
// Decode off-thread: send a frame only when the worker is free, so a slow scan
542+
// throttles itself and can never stall the UI.
543+
if (!workerBusy && ts - lastSent > 120) {
544+
lastSent = ts; workerBusy = true;
545+
const { luma } = lumaFrom(img);
546+
const crops = liveCands.map(c => { const b = bbox(c.corners); return { x0: b.x0 - 16, y0: b.y0 - 16, x1: b.x1 + 16, y1: b.y1 + 16 }; });
547+
decodeWorker.postMessage({ width: pw, height: ph, luma, crops }, [luma.buffer]);
522548
}
523-
524-
overlay.width = video.clientWidth; overlay.height = video.clientHeight;
525-
const octx = overlay.getContext('2d');
526-
octx.clearRect(0, 0, overlay.width, overlay.height);
527-
renderHUD(octx, overlay.width, overlay.height, cands, lastDecoded, overlay.width / pw, overlay.height / ph, ts);
528-
529-
document.getElementById('cam-stat').innerHTML =
530-
`◈ tracking <b>${cands.length}</b> · locked <b>${lastDecoded.length}</b> · scan <b>${ms.toFixed(1)}ms</b>`;
531549
}
532-
requestAnimationFrame(loop);
550+
551+
// HUD every frame (cheap: canvas only) for a smooth reticle/scan animation.
552+
overlay.width = video.clientWidth; overlay.height = video.clientHeight;
553+
const octx = overlay.getContext('2d');
554+
octx.clearRect(0, 0, overlay.width, overlay.height);
555+
renderHUD(octx, overlay.width, overlay.height, liveCands, lastDecoded, overlay.width / liveScale[0], overlay.height / liveScale[1], ts);
556+
document.getElementById('cam-stat').innerHTML =
557+
`◈ tracking <b>${liveCands.length}</b> · locked <b>${lastDecoded.length}</b> · scan <b>${lastMs.toFixed(1)}ms</b>`;
533558
}
534559

535560
// ---- file decode ----

0 commit comments

Comments
 (0)