Skip to content

Commit d600d92

Browse files
MagicalTuxclaude
andcommitted
detect: noise- and shift-tolerant hint matching; docs
FrameDetector matched Hints by exact fingerprint equality over a tile-snapped box: a code that moved three pixels moved its box by a whole tile, flipping 4-9 of the 16 darkness bits, so on a camera stream nothing was ever reused. The fingerprint is now laid over the ink bounds (it follows the code, not the tiling) and compares whole-cell means; FrameDetector accepts a known symbol when the candidate overlaps its last location AND is within two fingerprint cells - position alone would hand a swapped-in code the old value. README and CHANGELOG describe the reworked live path, with measured numbers. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
1 parent 32f76bc commit d600d92

4 files changed

Lines changed: 312 additions & 74 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,72 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
### Fixed
11+
12+
- **Live detection.** On camera-like scenes the locate → crop → decode loop read about
13+
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 97.5% of 1D and 83% of 2D codes on the same scenes
15+
with no wrong values, in tens of milliseconds (`examples/liveeval.rs`,
16+
`tests/live_pipeline.rs`):
17+
- `scan1d` treated a whole scan line as one barcode and required both crop edges to
18+
be light; it now thresholds locally, cuts each line into quiet-zone-delimited spans,
19+
fits module width and bar/space bias jointly, and samples band-averaged profiles at
20+
any angle.
21+
- `pipeline::scan_1d` believed any single decode although half its decoders have no
22+
check character; readings now need cross-scanline consensus, quiet zones, a
23+
plausible length, and must not be a fragment of a longer reading.
24+
- The EAN/UPC width reader ignored quiet zones, reading a UPC-E out of the left half
25+
of any EAN-13 and out of other symbologies and text.
26+
- `detect::locate` only recognised barcodes within ~15° of the frame axes, was blinded
27+
by uneven lighting (global Otsu), let a single false finder hit turn a text blob into
28+
a top-ranked "QR" that swallowed the barcode inside it, and gave decoders no
29+
orientation. It is now gradient-orientation based with local binarization, and
30+
reports each linear region's reading axis and an oriented box.
31+
- The App Clip scanner decoded ordinary barcodes and text into fluent-looking URLs
32+
(its payload RS is far too weak for thousands of hypotheses per frame); it now
33+
requires real ring structure and the template byte. Aztec no longer "decodes" a
34+
blank patch (all-zero codewords are a valid RS codeword).
35+
- QR and App Clip scanning no longer spend hundreds of milliseconds on code-free
36+
frames.
37+
- `FrameDetector` matched `Hints` by exact fingerprint equality, which sensor noise
38+
and hand shake defeat on every frame; it now matches by position plus a tolerant
39+
fingerprint.
40+
- Web demo: a wasm trap or failed fetch left a decode worker "busy" forever, silently
41+
disabling decoding for the session; the crop batch was truncated before barcodes
42+
were prioritised, so busy scenes never decoded theirs.
43+
- Encoders now conform where they did not (output unreadable by other tools): Aztec
44+
orientation marks, Data Matrix 144×144 block interleave, Micro QR M1/M3 padding,
45+
Code 16K symbol value 106, one-track Pharmacode gap, Grid Matrix numeral/byte modes,
46+
DotCode corner-forced masks.
47+
- Decoders now read what other encoders produce: DataBar Expanded methods 3–14, PDF417
48+
byte shift 913, Code 128 / Code 16K / Codablock F FNC4, Code 49 numeric mode, Code 16K
49+
modes 5/6, rMQR ECI, Aztec latched punctuation and FLG(n), DotCode macros and error
50+
correction, Code 39 / Code 11 at 3:1 print ratio, Telepen cropped to its last bar.
51+
- Panics on hostile input: IMb, Mailmark, Han Xin, DataBar Expanded, DotCode and MaxiCode
52+
decoders; App Clip URLs with non-ASCII near the scheme; `MatrixBuf::new` overflow; CLI
53+
`--scale`, giant PNG headers, closed stdout.
54+
- Many decoders accepted malformed or non-canonical input (out-of-range numeric groups,
55+
aliased Kanji, empty payloads, over-wide elements, unvalidated termination bars, Micro
56+
QR M1 "error correction") that either mis-decoded or could not re-encode.
57+
- CLI: option parsing (flags swallowing data, unknown options ignored, surplus
58+
arguments), alpha-channel PNGs decoding as solid black.
59+
60+
### Added
61+
62+
- `pipeline::scan_1d_at`, `scan_stacked_at`, `scan_linear_at`: decode a located linear
63+
region along its known reading axis (1D readers, then the PDF417 family).
64+
- `scan1d::scan_spans`, `scan1d::refine_axis`, `ScanOptions::around`;
65+
`imgproc::orient::gradient_angle_peaks`; `codes::ean::decode_edges_within`;
66+
`detect::fingerprint_distance`.
67+
- `Location::rotation` and an oriented `outline` on linear `detect::locate` candidates.
68+
69+
### Changed
70+
71+
- `LocateOptions::anisotropy` is now a rotation-invariant gradient coherence (default
72+
`0.7`) and `edge_density` a fraction of edge pixels; `ScanOptions::angles_deg` accepts
73+
any angle.
74+
- One-track Pharmacode renders with the standard two-module gap (`max_modules` 78).
75+
1076
## [0.1.4](https://github.com/KarpelesLab/anydcode/compare/v0.1.3...v0.1.4) - 2026-09-15
1177

1278
### Added

‎README.md‎

Lines changed: 75 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -91,29 +91,38 @@ lighting and cylinder curvature; clean-render envelope in `tests/harness_image.r
9191
real-capture envelope (bottle and can labels down to ≈4.6 px/module) in
9292
`tests/real_world.rs`.
9393

94-
² 1D detection is the shared **`scan1d`** luminance front-end (edge detection →
95-
run-length → normalized `LinearPattern`), feeding each symbology's decoder. The
96-
end-to-end pixel→symbol path is verified for Code 128, Code 39 and EAN-13 in
97-
`tests/scan1d_pipeline.rs`; it applies to any standard bar/space linear code.
98-
Through `pipeline::scan_1d` the read is rotation-complete: mirrored patterns cover
99-
180°, and any other angle is handled by estimating the crop's dominant texture
100-
orientation and derotating before the rescan (`tests/rotated1d.rs`). The shared
101-
`pipeline::scan_1d` / `scan_all` entry points (and so the CLI and the demo) currently
102-
try the Code 128, EAN/UPC, Code 93, Code 39, ITF and Codabar decoders; the other
103-
linear decoders are driven by handing `scan1d::scan_lines` candidates to
104-
`scan1d::try_decode`. DataBar (finder-pattern based) and Pharmacode need dedicated
105-
samplers.
106-
107-
³ Data Matrix ships an image sampler: Otsu binarization → largest-component isolation
108-
→ solid-L finder + timing-edge line fitting → perspective corners → `imgproc` grid
94+
² 1D detection is the shared **`scan1d`** luminance front-end. Each scan line is a
95+
band-averaged profile (real pixels, no interpolation) thresholded against a sliding
96+
local mid-level, then **cut into spans** at every run too wide to be a symbol element —
97+
so a label edge, a dark surface beyond the label, print beside the code or a second
98+
code on the same line do not disturb the read — and each span is quantized on its own
99+
with a joint module-width / bar-vs-space-bias fit. Scan lines run at any angle, straight
100+
out of the frame. `pipeline::scan_1d` tries horizontal, then the crop's
101+
edge-orientation peaks, then vertical; `pipeline::scan_1d_at` takes a known reading
102+
axis (the locator reports one), refined by profile sharpness. Readings are accepted by
103+
**consensus**, not on a single decode: at least two scan lines must agree, the span must
104+
show real quiet zones, the payload must be plausibly long for its symbology (only
105+
Code 128, Code 93 and EAN/UPC carry a check character), and a reading that is a fragment
106+
of a longer, equally supported one is dropped. EAN/UPC additionally has a width-ratio
107+
reader for curved and blurred captures. The shared entry points currently try the
108+
Code 128, EAN/UPC, Code 93, Code 39, ITF and Codabar decoders; the other linear decoders
109+
are driven by handing `scan1d::scan_spans` candidates to `scan1d::try_decode`. DataBar
110+
(finder-pattern based) and Pharmacode need dedicated samplers. End-to-end coverage:
111+
`tests/live_pipeline.rs`, `tests/scan1d_pipeline.rs`, `tests/rotated1d.rs`.
112+
113+
³ 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
109116
sampling, with symbol size and grid chirality confirmed by Reed–Solomon. Robust to
110117
any-angle rotation, scale, blur/noise and (size-graded) tilt; envelope in
111118
`tests/datamatrix_image.rs`.
112119

113120
⁴ PDF417 (finder-less) is located by its start/stop guard columns → RANSAC edge fit →
114121
affine grid sampling → RS-corrected decode. Handles upright, rotation ≤±10°, scale,
115122
blur and noise; perspective keystoning is out of scope (no interior anchor). Envelope
116-
in `tests/pdf417_image.rs`.
123+
in `tests/pdf417_image.rs`. On the live path the locator reports a PDF417 as a linear
124+
region with its axis, and `pipeline::scan_linear_at` derotates the crop by it first, so
125+
located symbols read at any rotation.
117126

118127
⁶ Aztec, Micro QR and rMQR ship image samplers built on their native fiducials:
119128
Aztec's bullseye (equal-run cross-section scan → the enclosed light annulus floods
@@ -225,44 +234,70 @@ assert_eq!(encoder.encode(&decoded).unwrap(), encoding);
225234

226235
For a camera feed you rarely want to fully decode every frame. The `detect` module
227236
provides a fast, symbology-agnostic **locator** that returns the *position* of every
228-
code (a bounding quad + coarse family guess) in a single downscaled pass, **without
229-
decoding** — the cheap stage of the two-stage `Detect` → `Analyze` pipeline. It is
230-
recall-biased but structurally picky: concentric-finder detection backs matrix
231-
guesses, scene-sized merged blobs, text rows/columns masquerading as barcodes and
232-
non-code ink densities are gated out, overlapping duplicates of one physical code are
233-
suppressed, and a close-up code that defeats the texture pass is still reported from
234-
its finder hits alone:
237+
code (a quad + coarse family guess) in a single downscaled pass, **without decoding** —
238+
the cheap stage of the two-stage `Detect` → `Analyze` pipeline.
239+
240+
It box-averages the frame down, thresholds it against its *local* neighbourhood (so a
241+
lighting falloff, a shadow or a code on a dark product still binarizes), and sums each
242+
tile's gradients into a structure tensor. A tile whose edges all share one direction is
243+
a patch of bars — **at any rotation** — and such tiles cluster only with neighbours on
244+
the same axis, so a barcode separates from the print around it and comes back as a box
245+
*aligned with the code* plus its **reading axis** (`Location::rotation`). Incoherent
246+
edge-dense tiles are matrix/texture; concentric-finder hits (cross-checked on both axes
247+
and a diagonal) vouch for QR/Aztec. It is recall-biased but structurally picky: linear
248+
regions need a barcode's worth of unbroken bars filling their box, finderless matrix
249+
guesses need two well-separated tones at code-like ink coverage, scene-sized blobs and
250+
duplicates are dropped, and a close-up code that defeats the texture pass is still
251+
reported from its finders alone.
235252

236253
```rust
237254
use anyd::detect::{locate, LocateOptions};
238-
239-
let candidates = locate(&frame, &LocateOptions::default());
240-
for c in &candidates {
241-
// c.location — where the code is; c.symbology — family guess.
242-
// Hand off to the matching sampler/decoder only for new regions.
255+
use anyd::pipeline::{scan_all, scan_linear_at};
256+
257+
for c in locate(&frame, &LocateOptions::default()) {
258+
// c.location.outline — where; c.symbology — family guess; for a linear region
259+
// c.location.rotation — the axis to scan along. Crop the outline's bounds (plus a
260+
// margin) and decode just that:
261+
// linear → scan_linear_at(&crop, axis) (1D readers, then PDF417 family)
262+
// matrix → scan_all(&crop)
243263
}
244264
```
245265

266+
`detect::FrameDetector` wraps this as a `Detect` that also reuses `Hints`: a candidate
267+
is matched to a previously decoded symbol when it sits where that symbol was *and* looks
268+
like it (a noise- and shift-tolerant ink fingerprint), so known codes skip re-analysis
269+
without a swapped-in code inheriting the old value.
270+
246271
Benchmark (`cargo bench --bench detect`, dependency-free, `std::time::Instant`),
247272
locating **all** codes of all families per frame vs. a full QR decode:
248273

249274
| resolution | locate median | locate FPS | full QR decode | detect speedup |
250275
|-----------:|--------------:|-----------:|---------------:|---------------:|
251-
| 640×480 | 0.29 ms | ~3400 | 12.1 ms | ~41× |
252-
| 1280×720 | 0.70 ms | ~1430 | 14.1 ms | ~20× |
253-
| 1920×1080 | 1.53 ms | ~650 | 17.4 ms | ~11× |
276+
| 640×480 | 0.43 ms | ~2300 | 4.5 ms | ~10× |
277+
| 1280×720 | 1.24 ms | ~800 | 6.6 ms | ~5× |
278+
| 1920×1080 | 2.75 ms | ~360 | 10.1 ms | ~4× |
279+
280+
Those frames are pristine renders on white, which any locator finds. The number that
281+
matters is end-to-end on camera-like scenes — codes planted off-centre in a 720p frame,
282+
rotated 0–90°, on a page, a dark surface or amid print, under a 2:1 lighting falloff,
283+
blurred and noisy — replayed through the same locate → crop → decode loop a front-end
284+
runs (`cargo run --release --example liveeval`):
285+
286+
| | read rate | wrong values | decode time / frame |
287+
|---|---:|---:|---:|
288+
| 1D (EAN-13, Code 128, Code 39, ITF) | 97.5% | 0 | ~50 ms crop batch |
289+
| 2D (QR, Aztec, PDF417, Data Matrix) | 83% | 0 | ~120 ms whole-frame pass |
254290

255-
Recall 100% and false-positive rate 0% on the benchmark's planted-code frames.
256-
Locating is comfortably real-time at every resolution; decoding then runs only on the
257-
regions the locator hands off (and `Hints` lets already-decoded codes be skipped
258-
across frames).
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
293+
must *not* read: text, half an EAN-13 (structurally a UPC-E), ITF fragments.
259294

260295
The demo page shows the intended live wiring: the main thread runs `locate` on a
261-
half-resolution grab ~30×/s, a crop worker decodes located regions off-thread —
262-
routed by family, so linear crops take the fast 1D path (`scan_1d` is
263-
rotation-complete via mirrored patterns and gradient-orientation derotation) — a
264-
second worker sweeps the full frame for self-localizing 2D codes, and decoded locks
265-
are position-tracked across frames so the overlay follows the code between reads.
296+
half-resolution grab ~30×/s; a crop worker decodes located regions off-thread, linear
297+
crops first and along their reported axis; a second worker reads self-localizing 2D
298+
codes off the frame, alternating the half-resolution frame with the central half at
299+
full resolution; and decoded locks are position-tracked across frames so the overlay
300+
follows the code between reads.
266301

267302
## Command-line tool (`anyd`)
268303

0 commit comments

Comments
 (0)