Skip to content

Commit d2641e1

Browse files
MagicalTuxclaude
andcommitted
fix(demo): unify decode path + cache-bust wasm so the demo actually decodes
Two compounding bugs made every prior fix invisible in the live demo: 1. Duplicated decode logic. The WASM decode export and the CLI each carried a hand-copied list of decoders that drifted: the CLI gained the new width-ratio EAN edge reader while the WASM export still ran only the old quantized 1D path, so the demo could never decode the curved/blurred 1D captures the CLI now reads. Consolidated both into one library entry point, anyd::pipeline::scan_all(frame) (2D samplers + EAN/UPC edge reader + quantized linear decoders, deduped). CLI and WASM now call it, so a front-end can never again ship a stale decoder list. 2. No cache-busting on the wasm fetch. GitHub Pages + the browser cache .wasm aggressively (and a blob worker's fetch ignores hard refresh), so visitors kept running a stale build no matter what was deployed. The page now loads anyd.wasm?v=<load-time>, shared by the main thread and the decode worker. Verified via scan_all on the exact crops the demo produces from real captures: the curved bottle EAN-13 (4901085663356) and a hand-held omron QR both decode. Adds testdata/real_scene_qr.png + real_scene_qr_via_scan_all pinning the shared path. Full suite / clippy / fmt / doc clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent ab7fc93 commit d2641e1

6 files changed

Lines changed: 120 additions & 86 deletions

File tree

‎src/bin/anyd.rs‎

Lines changed: 6 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ use anyd::output::Encoding;
2020
use anyd::render::render;
2121
use anyd::segment::Segment;
2222
use anyd::symbology::Symbology;
23-
use anyd::traits::{Decode, Encode};
23+
use anyd::traits::Encode;
2424

2525
fn main() -> ExitCode {
2626
let args: Vec<String> = std::env::args().skip(1).collect();
@@ -416,54 +416,15 @@ fn cmd_decode(args: &[String]) -> Result<(), String> {
416416
.collect();
417417
let frame = GrayFrame::new(&luma, w, h).map_err(|e| e.to_string())?;
418418

419-
let mut found: Vec<(String, Symbol)> = Vec::new();
420-
let mut seen = std::collections::HashSet::new();
421-
let mut record = |sym: Symbol| {
422-
let key = (sym.symbology, sym.text().unwrap_or_default());
423-
if seen.insert(key) {
424-
found.push((sym.symbology.to_string(), sym));
425-
}
426-
};
427-
428-
// 2D samplers.
429-
if let Ok(s) = anyd::codes::qr::scan(&frame) {
430-
record(s);
431-
}
432-
if let Ok(s) = anyd::codes::datamatrix::scan(&frame) {
433-
record(s);
434-
}
435-
if let Some(s) = anyd::codes::pdf417::scan(&frame) {
436-
record(s);
437-
}
438-
439-
// 1D front-end: scan lines, then try the checksummed linear decoders.
440-
let scan_opts = anyd::scan1d::ScanOptions::default();
441-
// EAN/UPC: the width-ratio edge path reads curved, blurred camera captures that the
442-
// quantized grid cannot, and votes across scanlines for a robust result.
443-
if let Some(s) = anyd::codes::ean::scan(&frame, &scan_opts) {
444-
record(s);
445-
}
446-
let candidates = anyd::scan1d::scan_lines(&frame, &scan_opts);
447-
let linear: Vec<Box<dyn Decode>> = vec![
448-
Box::new(anyd::codes::code128::Code128Decoder::new()),
449-
Box::new(anyd::codes::ean::EanDecoder::new()),
450-
Box::new(anyd::codes::code93::Code93Decoder::new()),
451-
Box::new(anyd::codes::code39::Code39Decoder::new()),
452-
Box::new(anyd::codes::itf::ItfDecoder::new()),
453-
Box::new(anyd::codes::codabar::CodabarDecoder::new()),
454-
];
455-
for cand in &candidates {
456-
for dec in &linear {
457-
if let Some(s) = anyd::scan1d::try_decode(cand, dec.as_ref()) {
458-
record(s);
459-
}
460-
}
461-
}
419+
// One shared decode entry point (see `anyd::pipeline::scan_all`) drives every
420+
// front-end, so the CLI and the WebAssembly demo can never disagree on what decodes.
421+
let found = anyd::pipeline::scan_all(&frame);
462422

463423
if found.is_empty() {
464424
return Err("no barcode found in image".into());
465425
}
466-
for (name, sym) in &found {
426+
for sym in &found {
427+
let name = sym.symbology.to_string();
467428
match sym.text() {
468429
Some(t) => println!("{name}: {t}"),
469430
None => println!("{name}: <binary> {}", hex(&sym.payload_bytes())),

‎src/pipeline.rs‎

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,3 +76,67 @@ impl Hints {
7676
self.previous.iter().find(|k| k.fingerprint == Some(fp))
7777
}
7878
}
79+
80+
/// Decode **every** symbology this library can read from a full (already-binarizable)
81+
/// luminance frame, returning one [`Symbol`] per distinct code found.
82+
///
83+
/// This is the single analysis entry point shared by every front-end — the `anyd` CLI,
84+
/// the WebAssembly `decode` export, and callers embedding the library — so they can never
85+
/// drift out of sync over which decoders run or in what order. It runs the 2D image
86+
/// samplers (QR, Data Matrix, PDF417) and the 1D pipeline (the width-ratio EAN/UPC edge
87+
/// reader that handles curved, blurred camera captures, plus the quantized `scan1d`
88+
/// front-end feeding the remaining checksummed linear decoders), de-duplicated by
89+
/// `(symbology, text)`. It never returns an error: a frame with no code yields an empty
90+
/// vec. Intended for a located crop or a whole still image; on a live stream, gate it
91+
/// behind [`crate::detect::locate`] and decode only the regions it returns.
92+
pub fn scan_all(frame: &crate::image::GrayFrame<'_>) -> Vec<Symbol> {
93+
use crate::traits::Decode;
94+
95+
let mut found: Vec<Symbol> = Vec::new();
96+
let mut push = |sym: Symbol| {
97+
let key = (sym.symbology, sym.text().unwrap_or_default());
98+
if !found
99+
.iter()
100+
.any(|s| (s.symbology, s.text().unwrap_or_default()) == key)
101+
{
102+
found.push(sym);
103+
}
104+
};
105+
106+
// 2D image samplers.
107+
if let Ok(s) = crate::codes::qr::scan(frame) {
108+
push(s);
109+
}
110+
if let Ok(s) = crate::codes::datamatrix::scan(frame) {
111+
push(s);
112+
}
113+
if let Some(s) = crate::codes::pdf417::scan(frame) {
114+
push(s);
115+
}
116+
117+
// 1D front-end. The EAN/UPC edge reader (width ratios, voted across scanlines) reads
118+
// curved/blurred captures the quantized grid cannot; the quantized `scan1d` path then
119+
// feeds the remaining checksummed linear decoders.
120+
let scan_opts = crate::scan1d::ScanOptions::default();
121+
if let Some(s) = crate::codes::ean::scan(frame, &scan_opts) {
122+
push(s);
123+
}
124+
let candidates = crate::scan1d::scan_lines(frame, &scan_opts);
125+
let linear: [Box<dyn Decode>; 6] = [
126+
Box::new(crate::codes::code128::Code128Decoder::new()),
127+
Box::new(crate::codes::ean::EanDecoder::new()),
128+
Box::new(crate::codes::code93::Code93Decoder::new()),
129+
Box::new(crate::codes::code39::Code39Decoder::new()),
130+
Box::new(crate::codes::itf::ItfDecoder::new()),
131+
Box::new(crate::codes::codabar::CodabarDecoder::new()),
132+
];
133+
for cand in &candidates {
134+
for dec in &linear {
135+
if let Some(s) = crate::scan1d::try_decode(cand, dec.as_ref()) {
136+
push(s);
137+
}
138+
}
139+
}
140+
141+
found
142+
}

‎src/wasm.rs‎

Lines changed: 7 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818
use crate::GrayFrame;
1919
use crate::output::Encoding;
2020
use crate::segment::Segment;
21-
use crate::traits::{Decode, Encode};
21+
use crate::traits::Encode;
2222

2323
/// Allocate `len` bytes in wasm memory and return the pointer (caller fills it).
2424
#[unsafe(no_mangle)]
@@ -100,50 +100,19 @@ pub unsafe extern "C" fn decode(w: usize, h: usize, luma_ptr: *const u8) -> u64
100100
Err(_) => return export(b"[]".to_vec()),
101101
};
102102

103-
let mut out: Vec<(String, String)> = Vec::new();
104-
let mut push = |sym: crate::Symbol| {
105-
let name = sym.symbology.to_string();
106-
let text = sym.text().unwrap_or_default();
107-
if !out.iter().any(|(n, t)| *n == name && *t == text) {
108-
out.push((name, text));
109-
}
110-
};
111-
112-
if let Ok(s) = crate::codes::qr::scan(&frame) {
113-
push(s);
114-
}
115-
if let Ok(s) = crate::codes::datamatrix::scan(&frame) {
116-
push(s);
117-
}
118-
if let Some(s) = crate::codes::pdf417::scan(&frame) {
119-
push(s);
120-
}
121-
let candidates = crate::scan1d::scan_lines(&frame, &crate::scan1d::ScanOptions::default());
122-
let linear: [Box<dyn Decode>; 6] = [
123-
Box::new(crate::codes::code128::Code128Decoder::new()),
124-
Box::new(crate::codes::ean::EanDecoder::new()),
125-
Box::new(crate::codes::code93::Code93Decoder::new()),
126-
Box::new(crate::codes::code39::Code39Decoder::new()),
127-
Box::new(crate::codes::itf::ItfDecoder::new()),
128-
Box::new(crate::codes::codabar::CodabarDecoder::new()),
129-
];
130-
for cand in &candidates {
131-
for dec in &linear {
132-
if let Some(s) = crate::scan1d::try_decode(cand, dec.as_ref()) {
133-
push(s);
134-
}
135-
}
136-
}
103+
// Same shared decode entry point as the CLI (`crate::pipeline::scan_all`), so the
104+
// demo and the command line always agree on what a frame decodes to.
105+
let out = crate::pipeline::scan_all(&frame);
137106

138107
let mut json = String::from("[");
139-
for (i, (name, text)) in out.iter().enumerate() {
108+
for (i, sym) in out.iter().enumerate() {
140109
if i > 0 {
141110
json.push(',');
142111
}
143112
json.push_str("{\"symbology\":");
144-
json_str(&mut json, name);
113+
json_str(&mut json, &sym.symbology.to_string());
145114
json.push_str(",\"text\":");
146-
json_str(&mut json, text);
115+
json_str(&mut json, &sym.text().unwrap_or_default());
147116
json.push('}');
148117
}
149118
json.push(']');

‎testdata/real_scene_qr.png‎

136 KB
Loading

‎tests/real_world.rs‎

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -450,3 +450,37 @@ fn real_scene_text_is_not_a_barcode() {
450450
"consensus floor must reject stray checksum-passing misreads on plain text"
451451
);
452452
}
453+
454+
/// The shared [`anyd::pipeline::scan_all`] entry point — the one both the CLI and the
455+
/// WebAssembly demo call — must read a real QR capture. `testdata/real_scene_qr.png` is
456+
/// the located crop of an omron package QR from a hand-held frame. This guards the
457+
/// consolidated decode path so a front-end can never again ship a decoder list that has
458+
/// drifted from what actually works (the demo previously ran a hand-copied list missing
459+
/// the current 1D reader).
460+
#[cfg(feature = "cli")]
461+
#[test]
462+
fn real_scene_qr_via_scan_all() {
463+
use anyd::GrayFrame;
464+
465+
let bytes = std::fs::read("testdata/real_scene_qr.png").expect("read fixture");
466+
let rgba = oxideav_png::decode_png_to_rgba(&bytes).expect("decode PNG");
467+
let (w, h) = (rgba.width as usize, rgba.height as usize);
468+
let luma: Vec<u8> = rgba
469+
.data
470+
.chunks_exact(4)
471+
.map(|p| ((p[0] as u32 * 299 + p[1] as u32 * 587 + p[2] as u32 * 114) / 1000) as u8)
472+
.collect();
473+
let frame = GrayFrame::new(&luma, w, h).expect("valid frame");
474+
475+
let found = anyd::pipeline::scan_all(&frame);
476+
let qr = found
477+
.iter()
478+
.find(|s| s.symbology == anyd::Symbology::QrCode)
479+
.expect("scan_all must read the QR capture");
480+
assert_eq!(
481+
qr.text().as_deref(),
482+
Some(
483+
"https://www.omronconnect.com/devices/?utm_source=qrcode&utm_medium=package&utm_campaign=mc-6800b"
484+
),
485+
);
486+
}

‎web/index.html‎

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -124,8 +124,14 @@ <h2>Decode an image</h2>
124124
let wasm;
125125
const mem = () => new Uint8Array(wasm.memory.buffer);
126126

127+
// Load the wasm with a per-load cache-buster. GitHub Pages and the browser cache
128+
// `.wasm` aggressively (and a blob worker's fetch ignores a hard refresh), so without
129+
// this a freshly deployed build would keep running the visitor's stale copy. The main
130+
// thread and the decode worker share this exact URL so they instantiate the same build.
131+
const WASM_URL = new URL('anyd.wasm?v=' + Date.now(), location.href).href;
132+
127133
async function init() {
128-
const res = await fetch('anyd.wasm');
134+
const res = await fetch(WASM_URL);
129135
const { instance } = await WebAssembly.instantiateStreaming(res, {});
130136
wasm = instance.exports;
131137
}
@@ -366,8 +372,8 @@ <h2>Decode an image</h2>
366372

367373
// Decoding runs in a background worker (its own wasm instance) so the heavy sampler
368374
// can never block the UI thread — the live overlay stays smooth even when a scan is
369-
// slow. The main thread only sends a frame when the worker is free.
370-
const WASM_URL = new URL('anyd.wasm', location.href).href;
375+
// slow. The main thread only sends a frame when the worker is free. It reuses the
376+
// cache-busted WASM_URL defined above so the worker loads the same fresh build.
371377
const workerSrc = `
372378
let w; const mem = () => new Uint8Array(w.memory.buffer);
373379
const ready = fetch(${JSON.stringify(WASM_URL)}).then(r => WebAssembly.instantiateStreaming(r, {})).then(x => { w = x.instance.exports; });

0 commit comments

Comments
 (0)