Skip to content

Commit 0a230ee

Browse files
MagicalTuxclaude
andcommitted
datamatrix: analyse the symbol under a mask of its own quad
The sampler located the symbol's corners and fitted its edges against every dark pixel in the L component's bounding box, so print beside the code, a label edge or the dark surface behind a rotated label attracted the fits and skewed the grid: in a scene it read a third of the symbols shown to it (and none amid print), though each decoded fine on a blank page. A first quad now comes from the L component's own pixels; the corner opposite the L vertex - the timing borders' meeting point, which is often not connected to the L - is completed from the other three; and the working mask is the binarization clipped to that quad, which brings back the unconnected modules and nothing else. The far corner's slack is tried tight (beats crowding print) then generous (beats perspective, where it is well off the parallelogram). liveeval: Data Matrix 20/60 -> 60/60; overall live 97.5%, 0 wrong reads. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
1 parent 4690e02 commit 0a230ee

3 files changed

Lines changed: 113 additions & 8 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1111

1212
- **Live detection.** On camera-like scenes the locate → crop → decode loop read about
1313
half the codes it was shown and reported dozens of values that were not there, at
14-
~1 s per decode pass. It now reads all of the 1D and 83% of the 2D codes on the same scenes
14+
~1 s per decode pass. It now reads all of the 1D and 95% of the 2D codes on the same scenes
1515
with no wrong values, in tens of milliseconds (`examples/liveeval.rs`,
1616
`tests/live_pipeline.rs`):
1717
- `scan1d` treated a whole scan line as one barcode and required both crop edges to
@@ -32,6 +32,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
3232
(its payload RS is far too weak for thousands of hypotheses per frame); it now
3333
requires real ring structure and the template byte. Aztec no longer "decodes" a
3434
blank patch (all-zero codewords are a valid RS codeword).
35+
- The Data Matrix sampler assumed the largest dark blob in the image was the symbol
36+
and let any ink near it pull the corner and edge fits; it read a third of the
37+
symbols it was shown in a scene, now all of them.
3538
- QR and App Clip scanning no longer spend hundreds of milliseconds on code-free
3639
frames.
3740
- `FrameDetector` matched `Hints` by exact fingerprint equality, which sensor noise

‎README.md‎

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -111,8 +111,9 @@ are driven by handing `scan1d::scan_spans` candidates to `scan1d::try_decode`. D
111111
`tests/live_pipeline.rs`, `tests/scan1d_pipeline.rs`, `tests/rotated1d.rs`.
112112

113113
³ Data Matrix ships an image sampler: Otsu binarization → the largest few dark
114-
components tried in turn (so a dark surface or print beside the code does not displace
115-
it) → solid-L finder + timing-edge line fitting → perspective corners → `imgproc` grid
114+
components tried in turn, each analysed under a mask clipped to its own quad (so a dark
115+
surface behind the label or print beside the code neither displaces the symbol nor
116+
attracts the corner and edge fits) → solid-L finder + timing-edge line fitting → perspective corners → `imgproc` grid
116117
sampling, with symbol size and grid chirality confirmed by Reed–Solomon. Robust to
117118
any-angle rotation, scale, blur/noise and (size-graded) tilt; envelope in
118119
`tests/datamatrix_image.rs`.
@@ -286,10 +287,10 @@ runs (`cargo run --release --example liveeval`):
286287
| | read rate | wrong values | decode time / frame |
287288
|---|---:|---:|---:|
288289
| 1D (EAN-13, Code 128, Code 39, ITF) | 100% (240/240) | 0 | ~50 ms crop batch |
289-
| 2D (QR, Aztec, PDF417, Data Matrix) | 83% | 0 | ~120 ms whole-frame pass |
290+
| 2D (QR, Data Matrix, Aztec, PDF417) | 95% (228/240) | 0 | ~120 ms whole-frame pass |
290291

291-
Data Matrix is the weak one (its sampler needs a clean surround); QR and Aztec are at
292-
97–100%. `tests/live_pipeline.rs` pins this behaviour in CI, including the things that
292+
QR and Data Matrix read 60/60, Aztec 58/60; PDF417 (50/60) is the weak one — its sampler
293+
is affine-only and wants ≥3 px modules. `tests/live_pipeline.rs` pins this behaviour in CI, including the things that
293294
must *not* read: text, half an EAN-13 (structurally a UPC-E), ITF fragments.
294295

295296
The demo page shows the intended live wiring: the main thread runs `locate` on a

‎src/codes/datamatrix/sample.rs‎

Lines changed: 103 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@ use crate::error::{Error, Result};
5555
use crate::geometry::{Location, Point, Quad};
5656
use crate::image::GrayFrame;
5757
use crate::imgproc::binary::BinaryImage;
58-
use crate::imgproc::components::{Connectivity, connected_components};
58+
use crate::imgproc::components::{Connectivity, connected_components, flood_region};
5959
use crate::imgproc::homography::Homography;
6060
use crate::imgproc::line::{Line, fit_line_least_squares};
6161
use crate::imgproc::sample::sample_bilinear;
@@ -280,6 +280,13 @@ fn corner_targets(geom: &Geometry, swap: bool) -> [Point; 4] {
280280

281281
/// Extract corners, the L orientation, a size estimate and the binarization threshold
282282
/// from a supplied binarization `bin` (and its global sampling `threshold`).
283+
/// How far beyond its parallelogram-completed position the symbol's far corner (where
284+
/// the timing borders meet) may lie and still be kept in the working mask, as growth
285+
/// factors about the centroid, tried in order. Tight first: it keeps print that crowds
286+
/// that corner out of the fit. Then generous: under perspective the true corner is well
287+
/// off the parallelogram, and clipping it costs the timing borders their meeting point.
288+
const FAR_CORNER_SLACK: [f32; 2] = [1.08, 1.35];
289+
283290
/// Dark components tried as the symbol, largest first.
284291
const MAX_COMPONENTS: usize = 6;
285292

@@ -309,7 +316,12 @@ fn extract_geometries(
309316
c.area >= 16 && w >= MIN_FRAME && h >= MIN_FRAME && w <= 4 * h && h <= 4 * w
310317
})
311318
.take(MAX_COMPONENTS)
312-
.filter_map(|c| extract_geometry(frame, bin, threshold, &c.bounds).ok())
319+
.collect::<Vec<_>>()
320+
.into_iter()
321+
.flat_map(|c| FAR_CORNER_SLACK.map(|slack| (c, slack)))
322+
.filter_map(|(c, slack)| {
323+
extract_geometry(frame, bin, threshold, &c.bounds, c.area, slack).ok()
324+
})
313325
.collect();
314326
if geoms.is_empty() {
315327
return Err(Error::undecodable("no dark region shaped like a symbol"));
@@ -323,13 +335,102 @@ fn extract_geometry(
323335
bin: &BinaryImage,
324336
threshold: u8,
325337
b: &crate::imgproc::components::BoundingBox,
338+
area: usize,
339+
far_slack: f32,
326340
) -> Result<Geometry> {
327341
let pad = 2usize;
328342
let x0 = b.min_x.saturating_sub(pad);
329343
let y0 = b.min_y.saturating_sub(pad);
330344
let x1 = (b.max_x + pad).min(bin.width() - 1);
331345
let y1 = (b.max_y + pad).min(bin.height() - 1);
332346

347+
// Everything below reasons about "the symbol's dark boundary", so ink that is not
348+
// the symbol's — print beside the code, the dark surface behind a label — must not
349+
// be allowed to attract the corner search or the edge-line fits, which otherwise
350+
// lock onto it and skew the grid. Two stages: a first quad from the L component's
351+
// *own* pixels (the box of a rotated symbol has other ink in its corners), then the
352+
// working mask is the binarization clipped to that quad, slightly grown — which
353+
// brings back the symbol's modules that are not connected to the L (timing dots,
354+
// interior islands) and nothing else.
355+
// The component's pixels: flood from a dark pixel on its top row. Another component
356+
// can share that row, so floods are tried until one has this component's area.
357+
let mut component = Vec::new();
358+
let mut x = b.min_x;
359+
while x <= b.max_x {
360+
if bin.get(x, b.min_y) {
361+
let pixels = flood_region(bin, (x, b.min_y), true);
362+
if pixels.len() == area {
363+
component = pixels;
364+
break;
365+
}
366+
// Skip the rest of this dark run: it floods to the same region.
367+
while x <= b.max_x && bin.get(x, b.min_y) {
368+
x += 1;
369+
}
370+
} else {
371+
x += 1;
372+
}
373+
}
374+
let mut own = BinaryImage::new(bin.width(), bin.height());
375+
for &(x, y) in &component {
376+
own.set(x, y, true);
377+
}
378+
let rough = find_corners(&own, x0, y0, x1, y1)?;
379+
let centre = centroid_of(&rough);
380+
// The L reaches three of the symbol's corners; the fourth — where the two timing
381+
// borders meet — may not be connected to it at all, leaving that corner of the
382+
// rough quad recessed (or somewhere inside the symbol). The L's vertex is the
383+
// corner whose two edges are solid; the corner opposite it is replaced by the
384+
// parallelogram completion of the other three when that lies farther out.
385+
let solid: [f32; 4] =
386+
core::array::from_fn(|i| edge_darkness(&own, rough[i], rough[(i + 1) % 4], centre));
387+
let vertex = (0..4)
388+
.max_by(|&i, &j| {
389+
(solid[(i + 3) % 4] + solid[i]).total_cmp(&(solid[(j + 3) % 4] + solid[j]))
390+
})
391+
.unwrap_or(0);
392+
let far = (vertex + 2) % 4;
393+
let mut hull = rough;
394+
let completed = Point::new(
395+
rough[(far + 3) % 4].x + rough[(far + 1) % 4].x - rough[vertex].x,
396+
rough[(far + 3) % 4].y + rough[(far + 1) % 4].y - rough[vertex].y,
397+
);
398+
if completed.distance(centre) > rough[far].distance(centre) {
399+
hull[far] = completed;
400+
}
401+
for (i, p) in hull.iter_mut().enumerate() {
402+
// Then grown a little about the centroid, for blur and the rough fit; the far
403+
// corner by `far_slack` (see [`FAR_CORNER_SLACK`]).
404+
let (dx, dy) = (p.x - centre.x, p.y - centre.y);
405+
let len = dx.hypot(dy).max(1.0);
406+
let k = if i == far { far_slack } else { 1.08 } + 2.0 / len;
407+
*p = Point::new(centre.x + dx * k, centre.y + dy * k);
408+
}
409+
let inside = |x: f32, y: f32| {
410+
// Same side of all four edges of the (convex, consistently wound) quad.
411+
let mut sign = 0.0f32;
412+
(0..4).all(|i| {
413+
let (a, c) = (hull[i], hull[(i + 1) % 4]);
414+
let cross = (c.x - a.x) * (y - a.y) - (c.y - a.y) * (x - a.x);
415+
if cross == 0.0 {
416+
return true;
417+
}
418+
if sign == 0.0 {
419+
sign = cross.signum();
420+
}
421+
cross.signum() == sign
422+
})
423+
};
424+
let mut window = BinaryImage::new(bin.width(), bin.height());
425+
for y in y0..=y1 {
426+
for x in x0..=x1 {
427+
if bin.get(x, y) && inside(x as f32, y as f32) {
428+
window.set(x, y, true);
429+
}
430+
}
431+
}
432+
let bin = &window;
433+
333434
let corners = find_corners(bin, x0, y0, x1, y1)?;
334435
let centroid = centroid_of(&corners);
335436

0 commit comments

Comments
 (0)