Skip to content

Commit 897a5b6

Browse files
MagicalTuxclaude
andcommitted
fix: live decode starved by runaway QR fallbacks on cluttered crops
Real-capture repro (anyd-capture-1 (6): Suntory bottle, EAN-13 4901777300521): the locator boxed the barcode perfectly and tracked it for 376 cycles, yet nothing ever decoded. The crop worker processed crops in order and scan_all spent 4.5 s NATIVE (10 s+ in wasm) on a 336x672 kanji ingredient panel before reaching the barcode crop, which decodes in 1.3 ms. Every dispatch starved the same way, so locks either never appeared or expired between reads. The hog was the QR fourth-corner refinement sweep: its grid radius grows with the (falsely estimated, often huge) module size of clutter triples, and it full-samples + RS-decodes every placement for up to four bogus hypotheses. Library: - refine_fourth_corner now walks placements nearest-first from the affine prediction (a genuine curved capture decodes within a step or two) under a global work budget counted in sampled modules, and caps the sweep radius; the busy text crop drops 4486 ms -> 208 ms native (~600 ms wasm) with every curved/real-world QR test still passing - new decode1d wasm export: 1D-only scan for crops the locator already classified as linear — 7 ms in wasm on the repro EAN crop Demo: - crop worker routes by candidate family (linear -> decode1d, else full decode) and runs linear crops first - results stream per crop instead of per batch, so a decoded barcode shows up immediately rather than after the slowest clutter crop - worker onerror clears the busy flag — a dead worker no longer silently disables decoding for the rest of the session - cropdecode diagnostic example (crop + per-stage timing + raw luma dump) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 1d4709e commit 897a5b6

5 files changed

Lines changed: 184 additions & 38 deletions

File tree

Cargo.toml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,10 @@ required-features = ["cli"]
6262
name = "locdebug"
6363
required-features = ["cli"]
6464

65+
[[example]]
66+
name = "cropdecode"
67+
required-features = ["cli"]
68+
6569
[[example]]
6670
name = "scan1ddebug"
6771
required-features = ["cli"]

examples/cropdecode.rs

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
// Diagnostic: reproduce the live demo's crop-worker decode on a captured frame.
2+
// Usage: cropdecode <png> [x0 y0 x1 y1] [--dump out.raw]
3+
// Box in pixels, padded 16px like the demo; --dump writes the crop as raw luma
4+
// (w and h printed) for feeding other harnesses, e.g. the wasm build under Node.
5+
use std::time::Instant;
6+
7+
fn main() {
8+
let mut raw_args: Vec<String> = std::env::args().skip(1).collect();
9+
let dump = raw_args
10+
.iter()
11+
.position(|a| a == "--dump")
12+
.map(|i| raw_args.remove(i + 1))
13+
.inspect(|_| {
14+
raw_args.retain(|a| a != "--dump");
15+
});
16+
let mut args = raw_args.into_iter();
17+
let path = args.next().expect("usage: cropdecode <png> [x0 y0 x1 y1]");
18+
let rgba = oxideav_png::decode_png_to_rgba(&std::fs::read(&path).unwrap()).unwrap();
19+
let (w, h) = (rgba.width as usize, rgba.height as usize);
20+
let luma: Vec<u8> = rgba
21+
.data
22+
.chunks_exact(4)
23+
.map(|p| ((p[0] as u32 * 299 + p[1] as u32 * 587 + p[2] as u32 * 114) / 1000) as u8)
24+
.collect();
25+
26+
let boxed: Vec<usize> = args.map(|a| a.parse().unwrap()).collect();
27+
let (x0, y0, x1, y1) = if boxed.len() == 4 {
28+
(
29+
boxed[0].saturating_sub(16),
30+
boxed[1].saturating_sub(16),
31+
(boxed[2] + 16).min(w),
32+
(boxed[3] + 16).min(h),
33+
)
34+
} else {
35+
(0, 0, w, h)
36+
};
37+
let (cw, ch) = (x1 - x0, y1 - y0);
38+
let mut crop = vec![0u8; cw * ch];
39+
for y in 0..ch {
40+
crop[y * cw..(y + 1) * cw]
41+
.copy_from_slice(&luma[(y0 + y) * w + x0..(y0 + y) * w + x1]);
42+
}
43+
let frame = anyd::GrayFrame::new(&crop, cw, ch).unwrap();
44+
if let Some(out) = dump {
45+
std::fs::write(&out, &crop).unwrap();
46+
println!("dumped {cw}x{ch} raw luma to {out}");
47+
}
48+
49+
let t = Instant::now();
50+
let found = anyd::pipeline::scan_all(&frame);
51+
println!(
52+
"scan_all on {cw}x{ch} crop ({x0},{y0})-({x1},{y1}): {} results in {:.1}ms",
53+
found.len(),
54+
t.elapsed().as_secs_f64() * 1000.0
55+
);
56+
for s in &found {
57+
println!(" {}: {:?}", s.symbology, s.text());
58+
}
59+
60+
// Break the 1D path down so a miss can be attributed.
61+
let t = Instant::now();
62+
let ean = anyd::codes::ean::scan(&frame, &anyd::scan1d::ScanOptions::default());
63+
println!(
64+
"ean::scan (width-ratio edge path): {:?} in {:.1}ms",
65+
ean.and_then(|s| s.text()),
66+
t.elapsed().as_secs_f64() * 1000.0
67+
);
68+
let t = Instant::now();
69+
let lines = anyd::scan1d::scan_lines(&frame, &anyd::scan1d::ScanOptions::default());
70+
println!(
71+
"scan1d::scan_lines: {} candidates in {:.1}ms (conf: {:?})",
72+
lines.len(),
73+
t.elapsed().as_secs_f64() * 1000.0,
74+
lines.iter().map(|c| c.confidence).collect::<Vec<_>>()
75+
);
76+
}

src/codes/qr/sample.rs

Lines changed: 53 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -157,18 +157,34 @@ pub fn scan(frame: &GrayFrame<'_>) -> Result<Symbol> {
157157
// parallelogram places wrongly and whose alignment pattern is too degraded to
158158
// detect: sweep that free corner and let Reed–Solomon accept the geometry that
159159
// reads. Reached only when every direct hypothesis failed, so clean renders never
160-
// pay for it.
160+
// pay for it. The shared budget bounds the sweep's worst case: on a genuinely
161+
// curved symbol the winning placement sits within a step or two of the affine
162+
// prediction (the sweep walks outward from it), while a cluttered no-QR crop —
163+
// whose false triples would otherwise sweep huge grids at large estimated module
164+
// sizes — exhausts the budget in bounded time (this stage alone cost ~4.5 s on a
165+
// busy 336×672 text crop before the cap; the whole scan is now ~100 ms there).
166+
let mut budget = REFINE_MODULE_BUDGET;
161167
for located in &pending {
162-
if let Some(sym) = refine_fourth_corner(frame, located, &integral, &decoder) {
168+
if let Some(sym) = refine_fourth_corner(frame, located, &integral, &decoder, &mut budget) {
163169
return Ok(sym);
164170
}
171+
if budget == 0 {
172+
break;
173+
}
165174
}
166175
Err(last)
167176
}
168177

169178
/// Located hypotheses (dim > 21) retained for the fourth-corner refinement fallback.
170179
const REFINE_HYPOTHESES: usize = 4;
171180

181+
/// Total work the fourth-corner sweep may spend across *all* retained hypotheses,
182+
/// counted in sampled grid modules (one candidate placement of a dim×dim symbol costs
183+
/// `dim²`). A genuine curved capture decodes within the first few placements around the
184+
/// affine prediction; this cap only bites on cluttered crops whose false triples would
185+
/// otherwise sweep hundreds of placements each. ≈350k modules ≲ 100 ms native.
186+
const REFINE_MODULE_BUDGET: usize = 350_000;
187+
172188
/// Per binarization pass, how many failed hypotheses may pay for the expensive non-planar
173189
/// (thin-plate-spline) dewarp. Candidates are tried best-score first, so a genuine
174190
/// curved symbol's correct triple is dewarped within this budget, while a cluttered frame
@@ -190,6 +206,7 @@ fn refine_fourth_corner(
190206
located: &Located,
191207
integral: &IntegralImage,
192208
decoder: &QrDecoder,
209+
budget: &mut usize,
193210
) -> Option<Symbol> {
194211
let [tl, tr, _, bl] = located.corners;
195212
let dim = located.dimension;
@@ -215,31 +232,40 @@ fn refine_fourth_corner(
215232
];
216233
let reach = ms * 3.0;
217234
let step = (ms * 0.4).clamp(0.75, 3.0);
218-
let n = (reach / step) as i32;
219-
for thr in &thresholds {
220-
for gy in -n..=n {
221-
for gx in -n..=n {
222-
let ax = ex + gx as f32 * step;
223-
let ay = ey + gy as f32 * step;
224-
let dst = [
225-
(tl.x as f64, tl.y as f64),
226-
(tr.x as f64, tr.y as f64),
227-
(ax as f64, ay as f64),
228-
(bl.x as f64, bl.y as f64),
229-
];
230-
let projection = Projection::quad_to_quad(src, dst);
231-
let trial = Located {
232-
projection,
233-
dimension: dim,
234-
threshold: located.threshold,
235-
local: located.local,
236-
corners: located.corners,
237-
module_size: located.module_size,
238-
};
239-
let matrix = trial.sample(frame, thr);
240-
if let Ok(sym) = decoder.decode_matrix(&matrix) {
241-
return Some(sym);
242-
}
235+
let n = ((reach / step) as i32).min(8);
236+
// Walk the grid nearest-first: on a genuine curved capture the true placement sits
237+
// within a step or two of the affine prediction, so it is found long before the
238+
// budget matters. Both thresholds are tried per placement, closest ones first.
239+
let mut offsets: Vec<(i32, i32)> = (-n..=n)
240+
.flat_map(|gy| (-n..=n).map(move |gx| (gx, gy)))
241+
.collect();
242+
offsets.sort_by_key(|&(gx, gy)| gx * gx + gy * gy);
243+
for (gx, gy) in offsets {
244+
let ax = ex + gx as f32 * step;
245+
let ay = ey + gy as f32 * step;
246+
let dst = [
247+
(tl.x as f64, tl.y as f64),
248+
(tr.x as f64, tr.y as f64),
249+
(ax as f64, ay as f64),
250+
(bl.x as f64, bl.y as f64),
251+
];
252+
let projection = Projection::quad_to_quad(src, dst);
253+
let trial = Located {
254+
projection,
255+
dimension: dim,
256+
threshold: located.threshold,
257+
local: located.local,
258+
corners: located.corners,
259+
module_size: located.module_size,
260+
};
261+
for thr in &thresholds {
262+
*budget = budget.saturating_sub(dim * dim);
263+
let matrix = trial.sample(frame, thr);
264+
if let Ok(sym) = decoder.decode_matrix(&matrix) {
265+
return Some(sym);
266+
}
267+
if *budget == 0 {
268+
return None;
243269
}
244270
}
245271
}

src/wasm.rs

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -102,8 +102,32 @@ pub unsafe extern "C" fn decode(w: usize, h: usize, luma_ptr: *const u8) -> u64
102102

103103
// Same shared decode entry point as the CLI (`crate::pipeline::scan_all`), so the
104104
// demo and the command line always agree on what a frame decodes to.
105-
let out = crate::pipeline::scan_all(&frame);
105+
export(symbols_json(&crate::pipeline::scan_all(&frame)))
106+
}
107+
108+
/// Decode only the **1D / linear** symbologies in a `w`×`h` luminance frame; returns
109+
/// the same JSON shape as `decode`.
110+
///
111+
/// This exists for the live pipeline: a crop the locator classified as *linear* should
112+
/// never pay for the 2D samplers — their finder/geometry fallbacks are the expensive
113+
/// part of a scan, and on a text-heavy crop they can dominate the whole dispatch while
114+
/// the 1D read itself takes a millisecond (see [`crate::pipeline::scan_1d`]).
115+
///
116+
/// # Safety
117+
/// `luma_ptr` must point to `w*h` readable bytes.
118+
#[unsafe(no_mangle)]
119+
pub unsafe extern "C" fn decode1d(w: usize, h: usize, luma_ptr: *const u8) -> u64 {
120+
let luma = unsafe { input(luma_ptr, w * h) };
121+
let frame = match GrayFrame::new(luma, w, h) {
122+
Ok(f) => f,
123+
Err(_) => return export(b"[]".to_vec()),
124+
};
125+
export(symbols_json(&crate::pipeline::scan_1d(&frame)))
126+
}
106127

128+
/// Serialize decoded symbols as the `[{"symbology":..,"text":..}]` JSON both decode
129+
/// entry points return.
130+
fn symbols_json(out: &[crate::Symbol]) -> Vec<u8> {
107131
let mut json = String::from("[");
108132
for (i, sym) in out.iter().enumerate() {
109133
if i > 0 {
@@ -116,7 +140,7 @@ pub unsafe extern "C" fn decode(w: usize, h: usize, luma_ptr: *const u8) -> u64
116140
json.push('}');
117141
}
118142
json.push(']');
119-
export(json.into_bytes())
143+
json.into_bytes()
120144
}
121145

122146
/// Decode only the **2D** codes (QR, Data Matrix, PDF417) in a `w`×`h` luminance frame,

web/index.html

Lines changed: 25 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -438,14 +438,17 @@ <h2>Decode an image</h2>
438438
const ready = fetch(${JSON.stringify(WASM_URL)}).then(r => WebAssembly.instantiateStreaming(r, {})).then(x => { w = x.instance.exports; });
439439
function wr(b){ const p = w.alloc(b.length); mem().set(b, p); return p; }
440440
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; }
441-
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)) || '[]'); }
441+
function decLuma(fn, cw, ch, luma){ const p = wr(luma); const pk = fn(cw, ch, p); w.dealloc(p, luma.length); return JSON.parse(new TextDecoder().decode(take(pk)) || '[]'); }
442442
self.onmessage = async (e) => {
443443
await ready;
444444
const { width, height, rgba, crops } = e.data;
445445
const data = new Uint8Array(rgba);
446446
const seen = new Set(), found = [];
447447
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 }); } };
448-
for (const b of crops.slice(0, 6)) {
448+
// Linear crops first: they decode in ~a millisecond, so a slow matrix crop later in
449+
// the batch can never delay the barcode read that motivated the dispatch.
450+
const batch = crops.slice(0, 6).sort((a, b) => (a.family === 'linear' ? 0 : 1) - (b.family === 'linear' ? 0 : 1));
451+
for (const b of batch) {
449452
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));
450453
const cw = x1 - x0, ch = y1 - y0; if (cw < 12 || ch < 12) continue;
451454
const crop = new Uint8Array(cw * ch);
@@ -454,17 +457,29 @@ <h2>Decode an image</h2>
454457
const d = y * cw;
455458
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;
456459
}
457-
for (const r of decLuma(cw, ch, crop)) add(r, b);
460+
// Route by the locator's family guess: a linear crop skips the 2D samplers, whose
461+
// geometry fallbacks dominate scan time on busy crops (1D reads are ~1ms).
462+
const fn = b.family === 'linear' ? w.decode1d : w.decode;
463+
found.length = 0;
464+
for (const r of decLuma(fn, cw, ch, crop)) add(r, b);
465+
// Stream each crop's hits immediately: a decoded barcode must not wait on a slow
466+
// matrix crop later in the batch (worst case ~600ms each on dense print).
467+
if (found.length) self.postMessage({ results: found.slice() });
458468
}
459-
self.postMessage(found);
469+
self.postMessage({ done: true });
460470
};
461471
`;
462472
const decodeWorker = new Worker(URL.createObjectURL(new Blob([workerSrc], { type: 'text/javascript' })));
463473
decodeWorker.onmessage = (e) => {
464-
workerBusy = false;
465-
// Merge this batch into the persistent tracks; expiry happens in the render loop.
466-
mergeDecoded(e.data, performance.now());
474+
const { results, done } = e.data;
475+
// Streamed per-crop results merge as they arrive; the dispatch slot only frees once
476+
// the whole batch is done.
477+
if (results) mergeDecoded(results, performance.now());
478+
if (done) workerBusy = false;
467479
};
480+
// A worker that dies (failed wasm fetch, OOM) must not wedge the dispatch flag shut —
481+
// that would silently disable decoding for the rest of the session.
482+
decodeWorker.onerror = () => { workerBusy = false; };
468483

469484
// A second, parallel worker reads 2D codes (QR / Data Matrix / PDF417) directly off the
470485
// whole frame — they self-localize, so they do not need the coarse locator, which fixes
@@ -492,6 +507,7 @@ <h2>Decode an image</h2>
492507
`;
493508
const worker2d = new Worker(URL.createObjectURL(new Blob([worker2dSrc], { type: 'text/javascript' })));
494509
worker2d.onmessage = (e) => { worker2dBusy = false; mergeDecoded(e.data, performance.now()); };
510+
worker2d.onerror = () => { worker2dBusy = false; };
495511

496512
function bbox(corners) {
497513
const xs = corners.map(p => p[0]), ys = corners.map(p => p[1]);
@@ -725,10 +741,10 @@ <h2>Decode an image</h2>
725741
const sctx = scratch.getContext('2d', { willReadFrequently: true });
726742
sctx.drawImage(video, 0, 0, pw, ph);
727743
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 }; });
744+
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, family: c.family }; });
729745
// No candidate this tick (e.g. motion blur wiped the texture pass) — still try
730746
// 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 });
747+
if (!crops.length) crops.push({ x0: 0, y0: ph * 0.2, x1: pw, y1: ph * 0.8, family: 'any' });
732748
decodeWorker.postMessage({ width: pw, height: ph, rgba: img.data.buffer, crops }, [img.data.buffer]);
733749
}
734750
}

0 commit comments

Comments
 (0)