Skip to content

Commit 1d4709e

Browse files
MagicalTuxclaude
andcommitted
perf: keep the live demo's main thread cheap; bridge linear region seams
Main-thread work per 33ms tick was the real frame-rate cost: a full-res 1080p getImageData plus a 2M-pixel JS RGBA->luma conversion — run twice on worker-dispatch ticks — plus per-tick canvas reallocation (scratch and overlay resized every frame, clearing their backing stores). Demo (web/index.html): - locate now runs on a GPU-downscaled half-res grab: getImageData + luma conversion cost 4x less; the wasm locate export compensates by choosing downscale 1 for small frames, so the reduced image it analyses (and detection quality) is unchanged - the 2D worker reuses that same half-res luma by transfer (the JS box-average downscale pass is gone entirely) - the crop worker gets the full-res RGBA by zero-copy transfer and converts only the crop pixels to luma off-thread; the full-res grab now happens only on dispatch (~8/s), never on the steady tick - dropped the JS >50%-of-frame candidate filter — it was throwing away close-up codes; the locator's finder-aware size cap replaces it - fallback centre-band crop when the locator reports nothing, so a code that defeats the texture pass mid-motion-blur still gets read attempts - canvases (locate, scratch, overlay) only resize when dimensions change Library: - linear-label flood fill bridges a one-tile gap: bars/spaces wider than a tile have no interior transitions, and the inactive seam shattered one barcode into fragments (which the new aspect gate then rejected as implausibly narrow); matrix labels stay 8-connected so 2D codes do not glue onto neighbouring scene texture - release profile: fat LTO + one codegen unit (per-frame pipeline + wasm) Bench: synthetic false-positive rate 6.9% -> 0.0%, recall 100%, 1080p locate ~12% faster. Verified end-to-end against the wasm build (locate -> crop -> decode reads the EAN; decode2d stays clean on a 2D-free frame). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 7d77356 commit 1d4709e

4 files changed

Lines changed: 89 additions & 70 deletions

File tree

Cargo.toml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,13 @@ wasm = []
2525
[dependencies]
2626
oxideav-png = { version = "0.1.8", optional = true, default-features = false }
2727

28+
# The library ships inside a per-frame video pipeline (and as the wasm demo, where the
29+
# browser cannot recover codegen quality after the fact) — trade compile time for the
30+
# fastest code the backend can emit.
31+
[profile.release]
32+
lto = "fat"
33+
codegen-units = 1
34+
2835
[[bin]]
2936
name = "anyd"
3037
path = "src/bin/anyd.rs"

src/detect/tiles.rs

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -176,7 +176,13 @@ pub(crate) fn regions(
176176
})
177177
.collect();
178178

179-
// 8-connected flood fill that only merges tiles sharing the start tile's label.
179+
// Flood fill that only merges tiles sharing the start tile's label. For *linear*
180+
// labels expansion reaches Chebyshev distance 2, bridging a one-tile gap: a bar or
181+
// space wider than a tile (3–4-module runs at a coarse module scale) has no
182+
// transitions inside it, and the resulting inactive seam would otherwise shatter
183+
// one barcode into fragments that read as implausibly narrow. Matrix tiles stay
184+
// 8-connected — a 2D code is transition-dense throughout, and bridging would only
185+
// glue it to nearby scene texture.
180186
let mut visited = vec![false; cols * rows];
181187
let mut stack: Vec<(usize, usize)> = Vec::new();
182188
let mut out = Vec::new();
@@ -190,6 +196,7 @@ pub(crate) fn regions(
190196
}
191197
visited[start] = true;
192198
stack.push((sx, sy));
199+
let reach = if seed == Label::Matrix { 1 } else { 2 };
193200

194201
let mut min_tx = sx;
195202
let mut max_tx = sx;
@@ -204,10 +211,10 @@ pub(crate) fn regions(
204211
min_ty = min_ty.min(cy);
205212
max_ty = max_ty.max(cy);
206213

207-
let x0 = cx.saturating_sub(1);
208-
let x1 = (cx + 1).min(cols - 1);
209-
let y0 = cy.saturating_sub(1);
210-
let y1 = (cy + 1).min(rows - 1);
214+
let x0 = cx.saturating_sub(reach);
215+
let x1 = (cx + reach).min(cols - 1);
216+
let y0 = cy.saturating_sub(reach);
217+
let y1 = (cy + reach).min(rows - 1);
211218
for ny in y0..=y1 {
212219
for nx in x0..=x1 {
213220
let nidx = ny * cols + nx;

src/wasm.rs

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -191,7 +191,15 @@ pub unsafe extern "C" fn locate(w: usize, h: usize, luma_ptr: *const u8) -> u64
191191
Ok(f) => f,
192192
Err(_) => return export(b"[]".to_vec()),
193193
};
194-
let candidates = crate::detect::locate(&frame, &crate::detect::LocateOptions::default());
194+
// The locator's default downscale=2 is tuned for a full-resolution camera frame.
195+
// The demo feeds an already GPU-downscaled half-res grab (cheaper to extract on the
196+
// main thread), so pick the factor from the actual size: keep the reduced working
197+
// image near the same ~500×500+ scale either way.
198+
let opts = crate::detect::LocateOptions {
199+
downscale: if w.min(h) >= 800 { 2 } else { 1 },
200+
..Default::default()
201+
};
202+
let candidates = crate::detect::locate(&frame, &opts);
195203

196204
let mut json = String::from("[");
197205
for (i, c) in candidates.iter().enumerate() {

web/index.html

Lines changed: 61 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -172,15 +172,14 @@ <h2>Decode an image</h2>
172172
luma[i] = (data[j] * 299 + data[j + 1] * 587 + data[j + 2] * 114) / 1000 | 0;
173173
return { w, h, luma };
174174
}
175-
function callJson(fn, imageData) {
176-
const { w, h, luma } = lumaFrom(imageData);
175+
function callJson(fn, w, h, luma) {
177176
const ptr = writeBytes(luma);
178177
const packed = fn(w, h, ptr);
179178
wasm.dealloc(ptr, luma.length);
180179
return JSON.parse(new TextDecoder().decode(takeBytes(packed)) || '[]');
181180
}
182-
const decode = (img) => callJson(wasm.decode, img);
183-
const locate = (img) => callJson(wasm.locate, img);
181+
const decode = (img) => { const { w, h, luma } = lumaFrom(img); return callJson(wasm.decode, w, h, luma); };
182+
const locateLuma = (w, h, luma) => callJson(wasm.locate, w, h, luma);
184183

185184
// ---- encode UI ----
186185
const encCanvas = document.getElementById('enc-canvas');
@@ -430,8 +429,10 @@ <h2>Decode an image</h2>
430429

431430
// Decoding runs in a background worker (its own wasm instance) so the heavy sampler
432431
// can never block the UI thread — the live overlay stays smooth even when a scan is
433-
// slow. The main thread only sends a frame when the worker is free. It reuses the
434-
// cache-busted WASM_URL defined above so the worker loads the same fresh build.
432+
// slow. The main thread only sends a frame when the worker is free, transferring the
433+
// raw RGBA (zero-copy); the worker converts *only the crop pixels* to luminance, so
434+
// the main thread never pays for a full-frame conversion. It reuses the cache-busted
435+
// WASM_URL defined above so the worker loads the same fresh build.
435436
const workerSrc = `
436437
let w; const mem = () => new Uint8Array(w.memory.buffer);
437438
const ready = fetch(${JSON.stringify(WASM_URL)}).then(r => WebAssembly.instantiateStreaming(r, {})).then(x => { w = x.instance.exports; });
@@ -440,14 +441,19 @@ <h2>Decode an image</h2>
440441
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)) || '[]'); }
441442
self.onmessage = async (e) => {
442443
await ready;
443-
const { width, height, luma, crops } = e.data;
444+
const { width, height, rgba, crops } = e.data;
445+
const data = new Uint8Array(rgba);
444446
const seen = new Set(), found = [];
445447
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 }); } };
446448
for (const b of crops.slice(0, 6)) {
447449
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));
448450
const cw = x1 - x0, ch = y1 - y0; if (cw < 12 || ch < 12) continue;
449451
const crop = new Uint8Array(cw * ch);
450-
for (let y = 0; y < ch; y++) { const s = (y0 + y) * width + x0; crop.set(luma.subarray(s, s + cw), y * cw); }
452+
for (let y = 0; y < ch; y++) {
453+
let s = ((y0 + y) * width + x0) * 4;
454+
const d = y * cw;
455+
for (let x = 0; x < cw; x++, s += 4) crop[d + x] = (data[s] * 299 + data[s + 1] * 587 + data[s + 2] * 114) / 1000 | 0;
456+
}
451457
for (const r of decLuma(cw, ch, crop)) add(r, b);
452458
}
453459
self.postMessage(found);
@@ -462,10 +468,11 @@ <h2>Decode an image</h2>
462468

463469
// A second, parallel worker reads 2D codes (QR / Data Matrix / PDF417) directly off the
464470
// whole frame — they self-localize, so they do not need the coarse locator, which fixes
465-
// the case where a QR is in view but the locator never boxes it. It runs on a
466-
// half-resolution copy (fast, and big enough for a held-up code) and hands back each
467-
// code's box in full-frame pixels. Kept separate so its heavier full-frame pass never
468-
// stalls the crop worker's snappy 1D decoding.
471+
// the case where a QR is in view but the locator never boxes it. It reuses the locate
472+
// pass's half-resolution luma (transferred, zero extra conversion — fast, and big
473+
// enough for a held-up code) and hands back each code's box in full-frame pixels. Kept
474+
// separate so its heavier full-frame pass never stalls the crop worker's snappy 1D
475+
// decoding.
469476
const worker2dSrc = `
470477
let w; const mem = () => new Uint8Array(w.memory.buffer);
471478
const ready = fetch(${JSON.stringify(WASM_URL)}).then(r => WebAssembly.instantiateStreaming(r, {})).then(x => { w = x.instance.exports; });
@@ -486,33 +493,10 @@ <h2>Decode an image</h2>
486493
const worker2d = new Worker(URL.createObjectURL(new Blob([worker2dSrc], { type: 'text/javascript' })));
487494
worker2d.onmessage = (e) => { worker2dBusy = false; mergeDecoded(e.data, performance.now()); };
488495

489-
// Box-average a luma frame down 2x — a cheap, safe downscale for the full-frame 2D pass.
490-
function downscale2(luma, w, h) {
491-
const w2 = w >> 1, h2 = h >> 1, out = new Uint8Array(w2 * h2);
492-
for (let y = 0; y < h2; y++) {
493-
const s0 = (2 * y) * w, s1 = (2 * y + 1) * w, d = y * w2;
494-
for (let x = 0; x < w2; x++) { const a = 2 * x; out[d + x] = (luma[s0 + a] + luma[s0 + a + 1] + luma[s1 + a] + luma[s1 + a + 1]) >> 2; }
495-
}
496-
return { luma: out, w: w2, h: h2 };
497-
}
498-
499496
function bbox(corners) {
500497
const xs = corners.map(p => p[0]), ys = corners.map(p => p[1]);
501498
return { x0: Math.min(...xs), y0: Math.min(...ys), x1: Math.max(...xs), y1: Math.max(...ys) };
502499
}
503-
// Copy a padded sub-rectangle of an ImageData (for decoding a located region at full res).
504-
function cropRGBA(img, x0, y0, x1, y1) {
505-
x0 = Math.max(0, x0 | 0); y0 = Math.max(0, y0 | 0);
506-
x1 = Math.min(img.width, Math.ceil(x1)); y1 = Math.min(img.height, Math.ceil(y1));
507-
const cw = x1 - x0, ch = y1 - y0;
508-
if (cw < 12 || ch < 12) return null;
509-
const out = new ImageData(cw, ch);
510-
for (let y = 0; y < ch; y++) {
511-
const s = ((y0 + y) * img.width + x0) * 4;
512-
out.data.set(img.data.subarray(s, s + cw * 4), y * cw * 4);
513-
}
514-
return out;
515-
}
516500
// ---- futuristic target-lock HUD ----
517501
const HUD = { lock: '#5be9d4', acquire: '#f5a623', chip: '#ffc24b', text: '#eafffb', ink: 'rgba(6,13,17,0.80)' };
518502
const REDUCED = matchMedia('(prefers-reduced-motion: reduce)').matches;
@@ -680,28 +664,34 @@ <h2>Decode an image</h2>
680664
};
681665

682666
let liveCands = [], acquiring = [], liveScale = [1, 1], lastLocate = 0, lastMs = 0;
667+
const locCanvas = document.createElement('canvas');
683668
function loop(ts) {
684669
if (!running) { return; }
685670
requestAnimationFrame(loop);
686671
const vw = video.videoWidth, vh = video.videoHeight;
687672
if (!vw) return;
688673
const pw = Math.min(vw, 1920), ph = Math.round(pw * vh / vw);
689674

690-
// Grab + locate at ~30 fps (getImageData at 1080p is the heavy main-thread step);
691-
// the HUD still renders every frame below, so the overlay stays smooth.
675+
// Grab + locate at ~30 fps on a GPU-downscaled half-res frame: getImageData and the
676+
// RGBA→luma conversion — the two heavy main-thread steps — cost 4× less than at full
677+
// resolution, and the wasm locator compensates by skipping its own downscale on the
678+
// smaller input, so the reduced image it analyses (and detection quality) is
679+
// unchanged. The full-resolution grab happens only when the crop worker is actually
680+
// dispatched (~8×/s), never on the steady locate tick.
692681
if (ts - lastLocate > 33) {
693682
lastLocate = ts;
694-
scratch.width = pw; scratch.height = ph;
695-
const sctx = scratch.getContext('2d', { willReadFrequently: true });
696-
sctx.drawImage(video, 0, 0, pw, ph);
697-
const img = sctx.getImageData(0, 0, pw, ph);
698-
const frameArea = pw * ph;
683+
const lw = pw >> 1, lh = ph >> 1, sc = pw / lw;
684+
if (locCanvas.width !== lw) locCanvas.width = lw;
685+
if (locCanvas.height !== lh) locCanvas.height = lh;
686+
const lctx = locCanvas.getContext('2d', { willReadFrequently: true });
687+
lctx.drawImage(video, 0, 0, lw, lh);
699688
const t0 = performance.now();
700-
// Drop background-sized boxes: a code is a bounded object, not half the scene.
701-
liveCands = locate(img).filter(c => {
702-
const b = bbox(c.corners);
703-
return (b.x1 - b.x0) * (b.y1 - b.y0) < 0.5 * frameArea;
704-
});
689+
const { luma } = lumaFrom(lctx.getImageData(0, 0, lw, lh));
690+
// Corners come back in half-res pixels; lift them to processed (full-res) space so
691+
// tracks, crops and the HUD all share one coordinate system. No area filter here:
692+
// the locator itself caps scene-sized blobs, and — unlike the old JS filter — it
693+
// knows a finder-backed box is a close-up code, not background.
694+
liveCands = locateLuma(lw, lh, luma).map(c => ({ family: c.family, corners: c.corners.map(p => [p[0] * sc, p[1] * sc]) }));
705695
lastMs = performance.now() - t0;
706696
liveScale = [pw, ph];
707697
updateCandTracks(liveCands, ts);
@@ -719,23 +709,27 @@ <h2>Decode an image</h2>
719709
}
720710
// Decode off-thread on two parallel workers, each dispatched only when free so a slow
721711
// scan throttles itself and never stalls the UI: the crop worker (1D + located
722-
// regions, every ~120ms) and the full-frame 2D worker (self-localizing QR/2D on a
723-
// half-res copy, every ~400ms). Both read the one luma we build here; the 2D downscale
724-
// happens before the crop worker transfers the buffer away.
725-
const wantCrop = !workerBusy && ts - lastSent > 120;
712+
// regions, every ~120ms, fed the full-res RGBA by transfer) and the full-frame 2D
713+
// worker (self-localizing QR/2D, every ~400ms, fed this tick's half-res luma by
714+
// transfer). Order matters: the 2D dispatch reuses `luma` before it is detached.
726715
const want2d = !worker2dBusy && ts - last2d > 400;
727-
if (wantCrop || want2d) {
728-
const { luma } = lumaFrom(img);
729-
if (want2d) {
730-
last2d = ts; worker2dBusy = true;
731-
const d = downscale2(luma, pw, ph);
732-
worker2d.postMessage({ width: d.w, height: d.h, luma: d.luma, scale: 2 }, [d.luma.buffer]);
733-
}
734-
if (wantCrop) {
735-
lastSent = ts; workerBusy = true;
736-
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 }; });
737-
decodeWorker.postMessage({ width: pw, height: ph, luma, crops }, [luma.buffer]);
738-
}
716+
if (want2d) {
717+
last2d = ts; worker2dBusy = true;
718+
worker2d.postMessage({ width: lw, height: lh, luma, scale: sc }, [luma.buffer]);
719+
}
720+
const wantCrop = !workerBusy && ts - lastSent > 120;
721+
if (wantCrop) {
722+
lastSent = ts; workerBusy = true;
723+
if (scratch.width !== pw) scratch.width = pw;
724+
if (scratch.height !== ph) scratch.height = ph;
725+
const sctx = scratch.getContext('2d', { willReadFrequently: true });
726+
sctx.drawImage(video, 0, 0, pw, ph);
727+
const img = sctx.getImageData(0, 0, pw, ph);
728+
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 }; });
729+
// No candidate this tick (e.g. motion blur wiped the texture pass) — still try
730+
// the centre band, where a user aiming at a code will have put it.
731+
if (!crops.length) crops.push({ x0: 0, y0: ph * 0.2, x1: pw, y1: ph * 0.8 });
732+
decodeWorker.postMessage({ width: pw, height: ph, rgba: img.data.buffer, crops }, [img.data.buffer]);
739733
}
740734
}
741735

@@ -753,7 +747,10 @@ <h2>Decode an image</h2>
753747
lastDecoded.length ? lastDecoded.map(r => `${r.symbology}: ${r.text}`).join('\n') : '';
754748

755749
// HUD every frame (cheap: canvas only) for a smooth reticle/scan animation.
756-
overlay.width = video.clientWidth; overlay.height = video.clientHeight;
750+
// Resizing a canvas reallocates its backing store — only do it when the layout
751+
// actually changed.
752+
if (overlay.width !== video.clientWidth) overlay.width = video.clientWidth;
753+
if (overlay.height !== video.clientHeight) overlay.height = video.clientHeight;
757754
const octx = overlay.getContext('2d');
758755
octx.clearRect(0, 0, overlay.width, overlay.height);
759756
renderHUD(octx, overlay.width, overlay.height, acquiring, lastDecoded, overlay.width / liveScale[0], overlay.height / liveScale[1], ts);

0 commit comments

Comments
 (0)