Skip to content

Commit 60a74b9

Browse files
MagicalTuxclaude
andcommitted
fix(detect): isolate 1D barcodes from busy scenes via per-tile labelling
The locator flood-filled every edge-dense tile into one region, then judged its family from the region's *average* h-vs-v edge balance. On a real photo (printed text + artwork around a barcode) the whole scene merged into a single frame-spanning blob whose averaged anisotropy read as a 2D matrix, so no linear box was ever emitted over the barcode — the live demo had nothing to crop and decode, even though the decoder reads the barcode fine when handed the crop. Label each tile by its OWN edge balance before clustering (horizontal-dominant / vertical-dominant / balanced) and flood-fill only same-label tiles. A barcode's directionally-coherent tiles now form their own component instead of dissolving into the isotropic text. Linear boxes are grown one tile out for the quiet zone; balanced regions thinner than 2 tiles in either axis (bar-end strips, text runs) are dropped so the matrix family stays meaningful. Verified end-to-end on testdata/real_scene_ean.png (native-res region of a real capture): locate() now yields a linear box over the EAN-13, and crop-decoding it reads 4901085663356. tests/real_world.rs::real_scene_locate_then_decode_ean pins the full locate -> crop -> decode loop the demo runs. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 426285b commit 60a74b9

4 files changed

Lines changed: 194 additions & 42 deletions

File tree

‎examples/locdebug.rs‎

Lines changed: 31 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,43 @@
11
// Diagnostic: what does detect::locate find on a real photo?
2-
use anyd::detect::{locate, LocateOptions};
2+
use anyd::detect::{LocateOptions, locate};
33
fn main() {
44
let path = std::env::args().nth(1).expect("usage: locdebug <png>");
55
let rgba = oxideav_png::decode_png_to_rgba(&std::fs::read(&path).unwrap()).unwrap();
66
let (w, h) = (rgba.width as usize, rgba.height as usize);
7-
let luma: Vec<u8> = rgba.data.chunks_exact(4)
8-
.map(|p| ((p[0] as u32 * 299 + p[1] as u32 * 587 + p[2] as u32 * 114) / 1000) as u8).collect();
7+
let luma: Vec<u8> = rgba
8+
.data
9+
.chunks_exact(4)
10+
.map(|p| ((p[0] as u32 * 299 + p[1] as u32 * 587 + p[2] as u32 * 114) / 1000) as u8)
11+
.collect();
912
let frame = anyd::GrayFrame::new(&luma, w, h).unwrap();
1013
let cands = locate(&frame, &LocateOptions::default());
1114
println!("frame {w}x{h}: {} candidates", cands.len());
1215
for c in &cands {
1316
let cr = c.location.outline.corners;
14-
let (xs, ys): (Vec<f32>, Vec<f32>) = (cr.iter().map(|p| p.x).collect(), cr.iter().map(|p| p.y).collect());
15-
let (x0, y0) = (xs.iter().cloned().fold(f32::MAX, f32::min), ys.iter().cloned().fold(f32::MAX, f32::min));
16-
let (x1, y1) = (xs.iter().cloned().fold(0.0, f32::max), ys.iter().cloned().fold(0.0, f32::max));
17-
let fam = c.symbology.map(|s| format!("{:?}", s.dimension())).unwrap_or("?".into());
18-
println!(" {fam:8} ({:.0},{:.0})-({:.0},{:.0}) {:.0}x{:.0}", x0, y0, x1, y1, x1 - x0, y1 - y0);
17+
let (xs, ys): (Vec<f32>, Vec<f32>) = (
18+
cr.iter().map(|p| p.x).collect(),
19+
cr.iter().map(|p| p.y).collect(),
20+
);
21+
let (x0, y0) = (
22+
xs.iter().cloned().fold(f32::MAX, f32::min),
23+
ys.iter().cloned().fold(f32::MAX, f32::min),
24+
);
25+
let (x1, y1) = (
26+
xs.iter().cloned().fold(0.0, f32::max),
27+
ys.iter().cloned().fold(0.0, f32::max),
28+
);
29+
let fam = c
30+
.symbology
31+
.map(|s| format!("{:?}", s.dimension()))
32+
.unwrap_or("?".into());
33+
println!(
34+
" {fam:8} ({:.0},{:.0})-({:.0},{:.0}) {:.0}x{:.0}",
35+
x0,
36+
y0,
37+
x1,
38+
y1,
39+
x1 - x0,
40+
y1 - y0
41+
);
1942
}
2043
}

‎src/detect/mod.rs‎

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,9 +18,12 @@
1818
//! 2. **Concentric finders** (`finder`): a run-length row scan with a vertical
1919
//! cross-check flags QR/Aztec `1:1:3:1:1` fiducials. These give a high-confidence
2020
//! matrix classification and a module-size estimate.
21-
//! 3. **Texture tiles** (`tiles`): tiles dense with dark/light edges are clustered
22-
//! into regions; the horizontal-vs-vertical edge balance separates 1D barcode
23-
//! regions from 2D matrix regions, catching every finder-less symbology.
21+
//! 3. **Texture tiles** (`tiles`): each dense-edged tile is first *labelled* by its own
22+
//! horizontal-vs-vertical edge balance — one-directional (a 1D barcode patch) or
23+
//! balanced (a 2D matrix) — and only same-label tiles are then clustered into regions.
24+
//! Labelling per tile before clustering is what lets a directional barcode be pulled
25+
//! out of the isotropic text and artwork it sits amongst, catching every finder-less
26+
//! symbology without the barcode dissolving into the surrounding scene.
2427
//! 4. **Merge + map back**: finder hits upgrade the region they fall in to a matrix
2528
//! guess with a real module size; region boxes are scaled back to full resolution and
2629
//! returned as [`Candidate`]s, ordered strongest-first and capped at

‎src/detect/tiles.rs‎

Lines changed: 83 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,19 @@
66
//!
77
//! The reduced dark mask is split into a grid of square tiles. For each tile we count
88
//! horizontal and vertical dark/light transitions in one pass; a tile whose transition
9-
//! density clears a threshold is "active". Active tiles are grouped by 8-connected
10-
//! flood fill into regions, and each region's horizontal-vs-vertical transition balance
11-
//! gives a coarse family guess: strongly one-directional edges read as a 1D barcode,
12-
//! balanced edges as a 2D matrix.
9+
//! density clears a threshold is "active". Each active tile is then *labelled* by its
10+
//! own edge balance before any clustering: a tile whose horizontal and vertical
11+
//! transition counts are strongly one-directional is a **linear** tile (and remembers
12+
//! whether horizontal or vertical edges dominate), while a balanced tile is a **matrix**
13+
//! tile. Flood fill then groups only tiles that share the *same* label into regions.
14+
//!
15+
//! Per-tile labelling before clustering is what lets a 1D barcode be pulled out of a
16+
//! busy scene. A barcode is a compact patch of tiles that all lean the same way
17+
//! (vertical bars ⇒ horizontal-edge-dominant); the printed text and artwork around it
18+
//! are edge-dense too, but *isotropic*, so they label as matrix and never merge into the
19+
//! barcode's component. Labelling the whole active blob at once — the old approach —
20+
//! averaged the barcode's strong anisotropy away against the surrounding text and lost
21+
//! the code entirely.
1322
1423
use super::grid::DownGrid;
1524

@@ -29,6 +38,21 @@ struct TileStats {
2938
area: Vec<u32>,
3039
}
3140

41+
/// Per-tile classification assigned before clustering. Flood fill only joins tiles that
42+
/// carry the same label, keeping a directional barcode patch out of the isotropic text
43+
/// and artwork it sits amongst.
44+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
45+
enum Label {
46+
/// Below the edge-density floor: ignored.
47+
Inactive,
48+
/// Active and horizontal-edge-dominant (upright bars).
49+
LinearH,
50+
/// Active and vertical-edge-dominant (bars rotated ~90°).
51+
LinearV,
52+
/// Active but balanced: a 2D matrix or plain scene texture.
53+
Matrix,
54+
}
55+
3256
/// The coarse layout family a region is guessed to belong to.
3357
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
3458
pub(crate) enum Family {
@@ -111,27 +135,47 @@ pub(crate) fn regions(
111135
let cols = stats.cols;
112136
let rows = stats.rows;
113137

114-
// Activity mask over the tile grid.
115-
let active: Vec<bool> = (0..cols * rows)
138+
// Per-tile label decided *before* clustering, so a tile's own edge balance — not the
139+
// average over a whole merged blob — chooses its family. Flood fill later joins only
140+
// tiles that carry the same label.
141+
let label: Vec<Label> = (0..cols * rows)
116142
.map(|i| {
117143
let a = stats.area[i];
118144
if a == 0 {
119-
return false;
145+
return Label::Inactive;
146+
}
147+
let h = stats.htrans[i];
148+
let v = stats.vtrans[i];
149+
let total = h + v;
150+
if total as f32 / (a as f32) < edge_density {
151+
return Label::Inactive;
152+
}
153+
// Anisotropy of this single tile. Vertical bars cross many row scans and few
154+
// column scans, so a barcode tile is strongly horizontal-dominant (and a
155+
// 90°-rotated one vertical-dominant); text and artwork are balanced.
156+
let anisotropy = (h as f32 - v as f32).abs() / total as f32;
157+
if anisotropy >= aniso {
158+
if h >= v {
159+
Label::LinearH
160+
} else {
161+
Label::LinearV
162+
}
163+
} else {
164+
Label::Matrix
120165
}
121-
let density = (stats.htrans[i] + stats.vtrans[i]) as f32 / a as f32;
122-
density >= edge_density
123166
})
124167
.collect();
125168

126-
// 8-connected flood fill over active tiles.
169+
// 8-connected flood fill that only merges tiles sharing the start tile's label.
127170
let mut visited = vec![false; cols * rows];
128171
let mut stack: Vec<(usize, usize)> = Vec::new();
129172
let mut out = Vec::new();
130173

131174
for sy in 0..rows {
132175
for sx in 0..cols {
133176
let start = sy * cols + sx;
134-
if !active[start] || visited[start] {
177+
let seed = label[start];
178+
if seed == Label::Inactive || visited[start] {
135179
continue;
136180
}
137181
visited[start] = true;
@@ -142,14 +186,9 @@ pub(crate) fn regions(
142186
let mut min_ty = sy;
143187
let mut max_ty = sy;
144188
let mut tiles = 0usize;
145-
let mut h_sum = 0u64;
146-
let mut v_sum = 0u64;
147189

148190
while let Some((cx, cy)) = stack.pop() {
149-
let idx = cy * cols + cx;
150191
tiles += 1;
151-
h_sum += u64::from(stats.htrans[idx]);
152-
v_sum += u64::from(stats.vtrans[idx]);
153192
min_tx = min_tx.min(cx);
154193
max_tx = max_tx.max(cx);
155194
min_ty = min_ty.min(cy);
@@ -162,7 +201,7 @@ pub(crate) fn regions(
162201
for ny in y0..=y1 {
163202
for nx in x0..=x1 {
164203
let nidx = ny * cols + nx;
165-
if active[nidx] && !visited[nidx] {
204+
if !visited[nidx] && label[nidx] == seed {
166205
visited[nidx] = true;
167206
stack.push((nx, ny));
168207
}
@@ -174,24 +213,37 @@ pub(crate) fn regions(
174213
continue;
175214
}
176215

177-
let total = (h_sum + v_sum) as f32;
178-
let anisotropy = if total > 0.0 {
179-
(h_sum as f32 - v_sum as f32).abs() / total
180-
} else {
181-
0.0
182-
};
183-
let family = if anisotropy >= aniso {
184-
Family::Linear
185-
} else {
186-
Family::Matrix
216+
let family = match seed {
217+
Label::LinearH | Label::LinearV => Family::Linear,
218+
_ => Family::Matrix,
187219
};
188220

221+
// A 2D matrix code always extends in both axes; a one-tile-thin strip of
222+
// balanced tiles is not one. These strips appear where a barcode's bars
223+
// terminate (the row of bar-ends adds vertical edges that cancel the
224+
// horizontal dominance) and as thin runs of text — reject them so the
225+
// matrix family stays meaningful. Linear codes are legitimately thin.
226+
if family == Family::Matrix && (max_tx - min_tx < 1 || max_ty - min_ty < 1) {
227+
continue;
228+
}
229+
189230
let t = stats.tile;
231+
let (mut x0, mut y0) = (min_tx * t, min_ty * t);
232+
let (mut x1, mut y1) = ((max_tx + 1) * t, (max_ty + 1) * t);
233+
// Barcodes read across their bars, so a linear box needs the quiet zone on
234+
// each side of the bars to survive downstream scanning. Grow it one tile out
235+
// (matrix codes carry their own quiet zone inside the finder search).
236+
if family == Family::Linear {
237+
x0 = x0.saturating_sub(t);
238+
y0 = y0.saturating_sub(t);
239+
x1 += t;
240+
y1 += t;
241+
}
190242
out.push(Region {
191-
x0: min_tx * t,
192-
y0: min_ty * t,
193-
x1: ((max_tx + 1) * t).min(grid.width),
194-
y1: ((max_ty + 1) * t).min(grid.height),
243+
x0,
244+
y0,
245+
x1: x1.min(grid.width),
246+
y1: y1.min(grid.height),
195247
family,
196248
});
197249
}

‎tests/real_world.rs‎

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -312,3 +312,77 @@ fn real_bottle_photo_front_end() {
312312
// dewarping improvement can light it up without editing this test.
313313
let _ = scan(&frame);
314314
}
315+
316+
/// End-to-end locate → crop → decode on a real captured photo of a 1D barcode, mirroring
317+
/// exactly what the live demo does per frame. `testdata/real_scene_ean.png` is a
318+
/// native-resolution region of a camera capture: a gold PET-bottle label with an EAN-13
319+
/// ("4901085663356") amid dense Japanese text and artwork.
320+
///
321+
/// This is the regression for the locator's per-tile labelling fix. Previously the whole
322+
/// edge-dense scene flood-filled into one frame-spanning matrix blob whose averaged
323+
/// anisotropy read as 2D, so the locator emitted **no** linear box on the barcode and the
324+
/// demo had nothing to crop-decode — even though the decoder reads the barcode fine when
325+
/// handed the right crop. Labelling each tile by its own edge balance before clustering
326+
/// keeps the barcode's directionally-coherent tiles out of the isotropic text, isolating
327+
/// it as a linear candidate.
328+
#[cfg(feature = "cli")]
329+
#[test]
330+
fn real_scene_locate_then_decode_ean() {
331+
use anyd::GrayFrame;
332+
use anyd::detect::{LocateOptions, locate};
333+
334+
let bytes = std::fs::read("testdata/real_scene_ean.png").expect("read fixture");
335+
let rgba = oxideav_png::decode_png_to_rgba(&bytes).expect("decode PNG");
336+
let (w, h) = (rgba.width as usize, rgba.height as usize);
337+
let luma: Vec<u8> = rgba
338+
.data
339+
.chunks_exact(4)
340+
.map(|p| ((p[0] as u32 * 299 + p[1] as u32 * 587 + p[2] as u32 * 114) / 1000) as u8)
341+
.collect();
342+
let frame = GrayFrame::new(&luma, w, h).expect("valid frame");
343+
344+
// Axis-aligned bounds of a candidate's outline, clamped to the frame.
345+
let bounds = |c: &anyd::pipeline::Candidate| {
346+
let cs = c.location.outline.corners;
347+
let x0 = cs.iter().map(|p| p.x).fold(f32::MAX, f32::min).max(0.0) as usize;
348+
let y0 = cs.iter().map(|p| p.y).fold(f32::MAX, f32::min).max(0.0) as usize;
349+
let x1 = (cs.iter().map(|p| p.x).fold(0.0, f32::max) as usize).min(w);
350+
let y1 = (cs.iter().map(|p| p.y).fold(0.0, f32::max) as usize).min(h);
351+
(x0, y0, x1, y1)
352+
};
353+
354+
// The barcode occupies roughly (250,205)-(790,405); its centre is ~(512,295).
355+
let (bx, by) = (512usize, 295usize);
356+
let cands = locate(&frame, &LocateOptions::default());
357+
let bar = cands
358+
.iter()
359+
.find(|c| {
360+
c.symbology.map(|s| s.dimension()) == Some(anyd::Dimension::Linear) && {
361+
let (x0, y0, x1, y1) = bounds(c);
362+
x0 <= bx && bx < x1 && y0 <= by && by < y1
363+
}
364+
})
365+
.expect("locate() must isolate the barcode as a linear candidate over its centre");
366+
367+
// Crop the located box and decode it end-to-end, exactly as the demo's worker does.
368+
let (x0, y0, x1, y1) = bounds(bar);
369+
let (cw, ch) = (x1 - x0, y1 - y0);
370+
let mut crop = vec![0u8; cw * ch];
371+
for y in 0..ch {
372+
for x in 0..cw {
373+
crop[y * cw + x] = frame.get_unchecked(x0 + x, y0 + y);
374+
}
375+
}
376+
let cframe = GrayFrame::new(&crop, cw, ch).expect("valid crop");
377+
378+
let lines = anyd::scan1d::scan_lines(&cframe, &anyd::scan1d::ScanOptions::default());
379+
let dec = anyd::codes::ean::EanDecoder::new();
380+
let text = lines
381+
.iter()
382+
.find_map(|cand| anyd::scan1d::try_decode(cand, &dec).and_then(|s| s.text()))
383+
.expect("the located region must decode as EAN-13");
384+
assert_eq!(
385+
text, "4901085663356",
386+
"wrong payload from the located barcode"
387+
);
388+
}

0 commit comments

Comments
 (0)