Skip to content

Commit a9c5eb1

Browse files
MagicalTuxclaude
andcommitted
Merge impl/real-1d-robustness: edge-width-ratio EAN/UPC decode for real captures
Adds a sub-pixel edge-width decode path (src/codes/ean/edge.rs) that reads EAN/UPC digits from per-scanline run-width ratios (per-digit local normalization, parity recovery, guard-by-ratio, checksum-validated, voted across scanlines) instead of the hard-quantized module grid. Recovers thin guard features that blur merges on small, curved, low-contrast captures. scan1d gains a peak-based fine edge extractor + scan_edges(); quantized path unchanged for other symbologies. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2 parents 609b50f + 1049b8f commit a9c5eb1

6 files changed

Lines changed: 555 additions & 4 deletions

File tree

‎src/bin/anyd.rs‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -437,7 +437,13 @@ fn cmd_decode(args: &[String]) -> Result<(), String> {
437437
}
438438

439439
// 1D front-end: scan lines, then try the checksummed linear decoders.
440-
let candidates = anyd::scan1d::scan_lines(&frame, &anyd::scan1d::ScanOptions::default());
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);
441447
let linear: Vec<Box<dyn Decode>> = vec![
442448
Box::new(anyd::codes::code128::Code128Decoder::new()),
443449
Box::new(anyd::codes::ean::EanDecoder::new()),

‎src/codes/ean/edge.rs‎

Lines changed: 250 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,250 @@
1+
//! EAN/UPC decoding from per-scanline sub-pixel edge widths (the industry-standard
2+
//! way real scanners read the retail family off a camera frame).
3+
//!
4+
//! Where [`decode`](super::decode) consumes a clean, hard-quantized [`LinearPattern`]
5+
//! bool grid, this path consumes the raw run *widths* (in pixels) of one image scanline
6+
//! — as produced by [`crate::scan1d::scan_edges`] — and classifies each digit from its
7+
//! four element widths normalized to that digit's own local 7-module width. Measuring
8+
//! the module scale per digit tolerates the slowly-varying pitch a curved surface
9+
//! imposes, and reading widths (not a global integer grid) recovers the single-module
10+
//! guards and elements that hard quantization merges under optical blur.
11+
//!
12+
//! The recovered digits, variant and check digit are assembled into exactly the
13+
//! [`Symbol`] the structural decoder would produce for the same code, so both paths are
14+
//! interchangeable and the lossless re-encode invariant holds.
15+
16+
use super::tables::{EAN13_FIRST_PARITY, check_digit, l_widths, upce_expand, upce_from_parity};
17+
use super::{EanMeta, EanVariant};
18+
use crate::image::GrayFrame;
19+
use crate::scan1d::{ScanOptions, scan_edges};
20+
use crate::segment::Segment;
21+
use crate::symbol::{Symbol, SymbolMeta};
22+
use crate::symbology::Symbology;
23+
24+
/// Maximum acceptable per-digit width-match score (sum of squared module-count
25+
/// deviations over the four elements). Clean digits score ~0; this rejects noise.
26+
const MAX_DIGIT_SCORE: f32 = 1.4;
27+
/// Maximum relative deviation of a guard element from the guard's mean element width.
28+
const GUARD_TOL: f32 = 0.55;
29+
30+
/// Decode every EAN/UPC main variant from one scanline's run widths, trying both scan
31+
/// directions. Returns the assembled [`Symbol`] for the first variant that validates
32+
/// (guards, parity and check digit), or `None`.
33+
pub fn decode_edges(edges: &[f32]) -> Option<Symbol> {
34+
if edges.len() < 3 {
35+
return None;
36+
}
37+
let forward: Vec<f32> = edges.windows(2).map(|w| w[1] - w[0]).collect();
38+
let mut reversed = forward.clone();
39+
reversed.reverse();
40+
for widths in [&forward, &reversed] {
41+
if let Some(sym) = decode_widths(widths) {
42+
return Some(sym);
43+
}
44+
}
45+
None
46+
}
47+
48+
/// Try each main variant against a run-width sequence that starts with a bar.
49+
fn decode_widths(w: &[f32]) -> Option<Symbol> {
50+
let (variant, digits) = [try_ean13(w), try_ean8(w), try_upce(w)]
51+
.into_iter()
52+
.flatten()
53+
.next()?;
54+
Some(Symbol::new(
55+
symbology_of(variant),
56+
vec![Segment::numeric(digits)],
57+
SymbolMeta::Ean(EanMeta {
58+
variant,
59+
addon: None,
60+
}),
61+
))
62+
}
63+
64+
fn symbology_of(variant: EanVariant) -> Symbology {
65+
match variant {
66+
EanVariant::Ean13 => Symbology::Ean13,
67+
EanVariant::Ean8 => Symbology::Ean8,
68+
EanVariant::UpcA => Symbology::UpcA,
69+
EanVariant::UpcE => Symbology::UpcE,
70+
EanVariant::Ean2 => Symbology::Ean2,
71+
EanVariant::Ean5 => Symbology::Ean5,
72+
}
73+
}
74+
75+
fn to_ascii(vals: &[u8]) -> Vec<u8> {
76+
vals.iter().map(|&v| b'0' + v).collect()
77+
}
78+
79+
/// Whether `n` guard elements are all within tolerance of their common mean width.
80+
fn guard_ok(elems: &[f32]) -> bool {
81+
let mean = elems.iter().sum::<f32>() / elems.len() as f32;
82+
if mean <= 0.0 {
83+
return false;
84+
}
85+
elems.iter().all(|&e| (e - mean).abs() <= GUARD_TOL * mean)
86+
}
87+
88+
/// Classify a 4-element digit from its widths. `left` selects the left half (L/G, parity
89+
/// recovered) versus the right half (R). Returns `(digit, is_even, score)`; even is
90+
/// always `false` for right digits.
91+
fn classify(w: &[f32], left: bool) -> Option<(u8, bool, f32)> {
92+
let total: f32 = w.iter().sum();
93+
if total <= 0.0 {
94+
return None;
95+
}
96+
let unit = total / 7.0;
97+
let norm = [w[0] / unit, w[1] / unit, w[2] / unit, w[3] / unit];
98+
let score_against = |pat: [u8; 4]| -> f32 {
99+
(0..4)
100+
.map(|i| {
101+
let d = norm[i] - pat[i] as f32;
102+
d * d
103+
})
104+
.sum()
105+
};
106+
let mut best: Option<(u8, bool, f32)> = None;
107+
for d in 0..10u8 {
108+
let l = l_widths(d);
109+
// R widths equal L widths; G widths are L reversed. Left digits try L and G;
110+
// right digits try only R.
111+
let cands: &[(bool, [u8; 4])] = if left {
112+
&[(false, l), (true, [l[3], l[2], l[1], l[0]])]
113+
} else {
114+
&[(false, l)]
115+
};
116+
for &(even, pat) in cands {
117+
let s = score_against(pat);
118+
if best.is_none_or(|(_, _, bs)| s < bs) {
119+
best = Some((d, even, s));
120+
}
121+
}
122+
}
123+
best.filter(|&(_, _, s)| s <= MAX_DIGIT_SCORE)
124+
}
125+
126+
/// EAN-13 / UPC-A: 59 runs (start `101`, six left digits, centre `01010`, six right
127+
/// digits, end `101`). Searches a small start offset for the guard.
128+
fn try_ean13(w: &[f32]) -> Option<(EanVariant, Vec<u8>)> {
129+
at_offsets(w, 59, |r| {
130+
if !guard_ok(&r[0..3]) || !guard_ok(&r[27..32]) || !guard_ok(&r[56..59]) {
131+
return None;
132+
}
133+
let mut left = [0u8; 6];
134+
let mut parity = [false; 6];
135+
for i in 0..6 {
136+
let (d, even, _) = classify(&r[3 + i * 4..7 + i * 4], true)?;
137+
left[i] = d;
138+
parity[i] = even;
139+
}
140+
let first = (0..10u8).find(|&f| EAN13_FIRST_PARITY[f as usize] == parity)?;
141+
let mut right = [0u8; 6];
142+
for i in 0..6 {
143+
let (d, _, _) = classify(&r[32 + i * 4..36 + i * 4], false)?;
144+
right[i] = d;
145+
}
146+
let mut vals = Vec::with_capacity(13);
147+
vals.push(first);
148+
vals.extend_from_slice(&left);
149+
vals.extend_from_slice(&right);
150+
if check_digit(&vals[..12]) != vals[12] {
151+
return None;
152+
}
153+
if first == 0 {
154+
Some((EanVariant::UpcA, to_ascii(&vals[1..])))
155+
} else {
156+
Some((EanVariant::Ean13, to_ascii(&vals)))
157+
}
158+
})
159+
}
160+
161+
/// EAN-8: 43 runs (start `101`, four left L digits, centre `01010`, four right digits,
162+
/// end `101`).
163+
fn try_ean8(w: &[f32]) -> Option<(EanVariant, Vec<u8>)> {
164+
at_offsets(w, 43, |r| {
165+
if !guard_ok(&r[0..3]) || !guard_ok(&r[19..24]) || !guard_ok(&r[40..43]) {
166+
return None;
167+
}
168+
let mut vals = [0u8; 8];
169+
for i in 0..4 {
170+
let (d, even, _) = classify(&r[3 + i * 4..7 + i * 4], true)?;
171+
if even {
172+
return None; // EAN-8 left digits are all odd (L).
173+
}
174+
vals[i] = d;
175+
}
176+
for i in 0..4 {
177+
let (d, _, _) = classify(&r[24 + i * 4..28 + i * 4], false)?;
178+
vals[4 + i] = d;
179+
}
180+
if check_digit(&vals[..7]) != vals[7] {
181+
return None;
182+
}
183+
Some((EanVariant::Ean8, to_ascii(&vals)))
184+
})
185+
}
186+
187+
/// UPC-E: 33 runs (start `101`, six L/G digits carrying number-system+check parity, end
188+
/// guard `010101`).
189+
fn try_upce(w: &[f32]) -> Option<(EanVariant, Vec<u8>)> {
190+
at_offsets(w, 33, |r| {
191+
if !guard_ok(&r[0..3]) || !guard_ok(&r[27..33]) {
192+
return None;
193+
}
194+
let mut payload = [0u8; 6];
195+
let mut parity = [false; 6];
196+
for i in 0..6 {
197+
let (d, even, _) = classify(&r[3 + i * 4..7 + i * 4], true)?;
198+
payload[i] = d;
199+
parity[i] = even;
200+
}
201+
let (ns, check) = upce_from_parity(&parity)?;
202+
if check_digit(&upce_expand(ns, &payload)) != check {
203+
return None;
204+
}
205+
let mut vals = Vec::with_capacity(8);
206+
vals.push(ns);
207+
vals.extend_from_slice(&payload);
208+
vals.push(check);
209+
Some((EanVariant::UpcE, to_ascii(&vals)))
210+
})
211+
}
212+
213+
/// Try `f` on `w[o..o+len]` for a few even start offsets (skipping spurious leading
214+
/// runs) and return its first success.
215+
fn at_offsets<F>(w: &[f32], len: usize, f: F) -> Option<(EanVariant, Vec<u8>)>
216+
where
217+
F: Fn(&[f32]) -> Option<(EanVariant, Vec<u8>)>,
218+
{
219+
if w.len() < len {
220+
return None;
221+
}
222+
let max_off = (w.len() - len).min(6);
223+
(0..=max_off).step_by(2).find_map(|o| f(&w[o..o + len]))
224+
}
225+
226+
/// 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.
231+
pub fn scan(frame: &GrayFrame<'_>, opts: &ScanOptions) -> Option<Symbol> {
232+
let mut tally: Vec<(String, usize, Symbol)> = Vec::new();
233+
for cand in scan_edges(frame, opts) {
234+
let Some(sym) = decode_edges(&cand.edges) else {
235+
continue;
236+
};
237+
let key = format!("{:?}|{}", sym.symbology, sym.text().unwrap_or_default());
238+
if let Some(entry) = tally.iter_mut().find(|(k, _, _)| *k == key) {
239+
entry.1 += 1;
240+
} else {
241+
let mut sym = sym;
242+
sym.location.get_or_insert(cand.location);
243+
tally.push((key, 1, sym));
244+
}
245+
}
246+
tally
247+
.into_iter()
248+
.max_by_key(|(_, n, _)| *n)
249+
.map(|(_, _, s)| s)
250+
}

‎src/codes/ean/mod.rs‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,10 @@
66
//! - `tables` — L/G/R element patterns, parity tables, guards, check digits and
77
//! the UPC-E ⟷ UPC-A expansion.
88
//! - `encode` — [`Symbol`] → [`LinearPattern`].
9-
//! - `decode` — [`LinearPattern`] → [`Symbol`].
9+
//! - `decode` — [`LinearPattern`] → [`Symbol`] (structural, from a clean module grid).
10+
//! - `edge` — [`Symbol`] from per-scanline sub-pixel run *widths* ([`scan`],
11+
//! [`decode_edges`]), the robust path for curved, blurred camera captures where hard
12+
//! quantization merges the single-module guards and elements.
1013
//!
1114
//! ## Lossless representation
1215
//!
@@ -39,10 +42,12 @@
3942
//! [`Symbology::Ean5`]: crate::Symbology::Ean5
4043
4144
mod decode;
45+
mod edge;
4246
mod encode;
4347
mod tables;
4448

4549
pub use decode::EanDecoder;
50+
pub use edge::{decode_edges, scan};
4651
pub use encode::EanEncoder;
4752

4853
/// Which member of the EAN/UPC family a [`crate::Symbol`] represents.

‎src/codes/ean/tables.rs‎

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,31 @@ pub fn match_right(m: &[bool]) -> Option<u8> {
7676
(0..10u8).find(|&d| m == r_code(d))
7777
}
7878

79+
/// Run-length the four elements of a 7-module EAN/UPC digit code (always exactly four
80+
/// runs), as module counts. Used by the edge/width-ratio decoder.
81+
fn widths7(code: [bool; 7]) -> [u8; 4] {
82+
let mut out = [0u8; 4];
83+
let mut idx = 0;
84+
let mut cur = code[0];
85+
for &b in &code {
86+
if b == cur {
87+
out[idx] += 1;
88+
} else {
89+
idx += 1;
90+
out[idx] = 1;
91+
cur = b;
92+
}
93+
}
94+
out
95+
}
96+
97+
/// Element widths (module counts, summing to 7) of the L-code for digit `d`, ordered
98+
/// space,bar,space,bar. The G-code widths are these reversed and the R-code widths are
99+
/// identical (complementing a bit string preserves its run lengths).
100+
pub fn l_widths(d: u8) -> [u8; 4] {
101+
widths7(l_code(d))
102+
}
103+
79104
// ---- Guard and delineator patterns (`true` = bar) --------------------------
80105

81106
/// Normal start/end guard: `101`.

0 commit comments

Comments
 (0)