Skip to content

Commit ab7fc93

Browse files
MagicalTuxclaude
andcommitted
fix(ean): consensus floor on edge decode to reject phantom barcodes
The per-scanline width-ratio EAN/UPC decoder is intentionally permissive so it can recover blurred/curved codes, so a stray scanline over plain text or artwork occasionally satisfies a checksum by chance — most often as UPC-E (8 digits are cheap to fake). scan() previously reported the top-voted value unconditionally, so a real photo of packaging text hallucinated a UPC-E. Require the winning value to clear an absolute consensus floor (3 scanlines) and out-poll the runner-up 2:1; genuine barcodes are crossed by dozens of agreeing lines and pass easily. Verified on the real capture: the curved EAN-13 still decodes (36 agreeing lines), while four non-barcode regions of the same frame (text, hand, wall) now correctly report nothing. Adds testdata/real_scene_text.png + a must-not-decode regression and examples/eanvotedebug.rs (per-scanline vote tally) for tuning. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent a9c5eb1 commit ab7fc93

5 files changed

Lines changed: 93 additions & 8 deletions

File tree

‎Cargo.toml‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,3 +58,7 @@ required-features = ["cli"]
5858
[[example]]
5959
name = "scan1ddebug"
6060
required-features = ["cli"]
61+
62+
[[example]]
63+
name = "eanvotedebug"
64+
required-features = ["cli"]

‎examples/eanvotedebug.rs‎

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
// Diagnostic: per-scanline EAN/UPC edge-decode vote tally (to tune the consensus floor).
2+
use anyd::codes::ean::decode_edges;
3+
use anyd::scan1d::{ScanOptions, scan_edges};
4+
5+
fn main() {
6+
let path = std::env::args().nth(1).expect("usage: eanvotedebug <png>");
7+
let rgba = oxideav_png::decode_png_to_rgba(&std::fs::read(&path).unwrap()).unwrap();
8+
let (w, h) = (rgba.width as usize, rgba.height as usize);
9+
let luma: Vec<u8> = rgba
10+
.data
11+
.chunks_exact(4)
12+
.map(|p| ((p[0] as u32 * 299 + p[1] as u32 * 587 + p[2] as u32 * 114) / 1000) as u8)
13+
.collect();
14+
let frame = anyd::GrayFrame::new(&luma, w, h).unwrap();
15+
16+
let opts = ScanOptions::default();
17+
let lines = scan_edges(&frame, &opts);
18+
let mut tally: Vec<(String, usize)> = Vec::new();
19+
let mut decoded = 0usize;
20+
for c in &lines {
21+
if let Some(sym) = decode_edges(&c.edges) {
22+
decoded += 1;
23+
let key = format!("{:?}|{}", sym.symbology, sym.text().unwrap_or_default());
24+
if let Some(e) = tally.iter_mut().find(|(k, _)| *k == key) {
25+
e.1 += 1;
26+
} else {
27+
tally.push((key, 1));
28+
}
29+
}
30+
}
31+
tally.sort_by_key(|(_, n)| std::cmp::Reverse(*n));
32+
println!(
33+
"frame {w}x{h}: {} scanlines, {decoded} decoded, {} distinct values",
34+
lines.len(),
35+
tally.len()
36+
);
37+
for (k, n) in &tally {
38+
println!(" {n:4} {k}");
39+
}
40+
}

‎src/codes/ean/edge.rs‎

Lines changed: 21 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -223,11 +223,21 @@ where
223223
(0..=max_off).step_by(2).find_map(|o| f(&w[o..o + len]))
224224
}
225225

226+
/// Minimum scanlines that must agree on a value before it is reported. A genuine
227+
/// barcode is crossed by dozens of clean scanlines; unstructured texture (printed text,
228+
/// artwork) yields only a stray line or two that happen to slip past a checksum — most
229+
/// dangerously as UPC-E, whose eight digits are cheap to satisfy by chance.
230+
const MIN_CONSENSUS_VOTES: usize = 3;
231+
/// The winning value must additionally out-poll the runner-up by this factor, so a
232+
/// scanline set split between two readings is treated as unresolved rather than guessed.
233+
const DOMINANCE: usize = 2;
234+
226235
/// Robustly read an EAN/UPC symbol from a frame by decoding every fine scanline
227-
/// ([`scan_edges`]) and taking the value that the most scanlines agree on. Consensus
228-
/// across scanlines is what makes a curved, blurred capture — where each individual line
229-
/// corrupts different thin features — decode reliably, and it also suppresses the rare
230-
/// false positive a single noisy line might slip past the checksum.
236+
/// ([`scan_edges`]) and taking the value the most scanlines agree on — provided that
237+
/// value clears a consensus floor and clearly out-polls any competitor. Consensus across
238+
/// scanlines is what makes a curved, blurred capture — where each individual line
239+
/// corrupts different thin features — decode reliably; the floor and dominance test are
240+
/// what stop a lone checksum-passing misread on non-barcode texture from being reported.
231241
pub fn scan(frame: &GrayFrame<'_>, opts: &ScanOptions) -> Option<Symbol> {
232242
let mut tally: Vec<(String, usize, Symbol)> = Vec::new();
233243
for cand in scan_edges(frame, opts) {
@@ -243,8 +253,11 @@ pub fn scan(frame: &GrayFrame<'_>, opts: &ScanOptions) -> Option<Symbol> {
243253
tally.push((key, 1, sym));
244254
}
245255
}
246-
tally
247-
.into_iter()
248-
.max_by_key(|(_, n, _)| *n)
249-
.map(|(_, _, s)| s)
256+
tally.sort_by_key(|(_, n, _)| core::cmp::Reverse(*n));
257+
let top = tally.first().map(|(_, n, _)| *n).unwrap_or(0);
258+
let runner_up = tally.get(1).map(|(_, n, _)| *n).unwrap_or(0);
259+
if top < MIN_CONSENSUS_VOTES || top < DOMINANCE * runner_up {
260+
return None;
261+
}
262+
tally.into_iter().next().map(|(_, _, s)| s)
250263
}

‎testdata/real_scene_text.png‎

96.1 KB
Loading

‎tests/real_world.rs‎

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -422,3 +422,31 @@ fn real_scene_curved_ean_edge_decode() {
422422
);
423423
assert_eq!(sym.symbology, anyd::Symbology::Ean13);
424424
}
425+
426+
/// A region of the same capture holding only printed text and artwork — no barcode.
427+
/// The edge decoder is deliberately permissive per scanline (it must recover blurred
428+
/// codes), so individual lines occasionally satisfy a checksum by chance, most often as
429+
/// UPC-E. The cross-scanline consensus floor in [`anyd::codes::ean::scan`] must reject
430+
/// those: a live camera pointed at packaging would otherwise flash phantom barcodes over
431+
/// plain text. This pins that guard against the real texture that first exposed it.
432+
#[cfg(feature = "cli")]
433+
#[test]
434+
fn real_scene_text_is_not_a_barcode() {
435+
use anyd::GrayFrame;
436+
use anyd::scan1d::ScanOptions;
437+
438+
let bytes = std::fs::read("testdata/real_scene_text.png").expect("read fixture");
439+
let rgba = oxideav_png::decode_png_to_rgba(&bytes).expect("decode PNG");
440+
let (w, h) = (rgba.width as usize, rgba.height as usize);
441+
let luma: Vec<u8> = rgba
442+
.data
443+
.chunks_exact(4)
444+
.map(|p| ((p[0] as u32 * 299 + p[1] as u32 * 587 + p[2] as u32 * 114) / 1000) as u8)
445+
.collect();
446+
let frame = GrayFrame::new(&luma, w, h).expect("valid frame");
447+
448+
assert!(
449+
anyd::codes::ean::scan(&frame, &ScanOptions::default()).is_none(),
450+
"consensus floor must reject stray checksum-passing misreads on plain text"
451+
);
452+
}

0 commit comments

Comments
 (0)