Skip to content

Commit 2e02c57

Browse files
MagicalTuxclaude
andcommitted
1d: decode linear codes at any rotation
The scanline sweep only covered ~+-6 degrees of horizontal, so a barcode held sideways (or upside down) never decoded even though the locator boxes rotated linear regions correctly. pipeline::scan_1d now closes the gap in two layers, decoder-agnostically: - 180 degrees: every scan1d candidate is also tried with its module pattern mirrored — a scanline as easily runs against the reading direction as with it, and all six wired linear decoders are checksummed, so a mirrored misread cannot false-accept. - any other angle: when the sweep finds nothing, the crop's dominant gradient orientation (new imgproc::orient::dominant_gradient_angle — a magnitude-weighted mod-pi histogram with a coherence gate, so only genuinely bar-like texture triggers it) gives the reading axis; the crop is derotated with the existing transform::rotate resampler and rescanned, and decoded outlines are mapped back into frame coordinates. The live demo and wasm decode1d inherit this unchanged — a vertical EAN crop reads in ~6 ms of wasm. Covered by tests/rotated1d.rs (EAN-13 and Code 128 at 90/180/oblique angles, plus outline map-back) and unit tests for the orientation estimator, whose bar fixture uses soft tanh edges: real optics always blur transitions, and hard staircase edges inject diagonal gradient energy no camera capture has. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 5599c9a commit 2e02c57

6 files changed

Lines changed: 345 additions & 6 deletions

File tree

‎README.md‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,10 @@ sampling → decode. Robust to any-angle rotation, moderate tilt, blur and noise
8787
run-length → normalized `LinearPattern`), feeding each symbology's decoder. The
8888
end-to-end pixel→symbol path is verified for Code 128, Code 39 and EAN-13 in
8989
`tests/scan1d_pipeline.rs`; it applies to any standard bar/space linear code.
90-
DataBar (finder-pattern based) and Pharmacode need dedicated samplers.
90+
Through `pipeline::scan_1d` the read is rotation-complete: mirrored patterns cover
91+
180°, and any other angle is handled by estimating the crop's dominant texture
92+
orientation and derotating before the rescan (`tests/rotated1d.rs`). DataBar
93+
(finder-pattern based) and Pharmacode need dedicated samplers.
9194

9295
³ Data Matrix ships an image sampler: Otsu binarization → largest-component isolation
9396
→ solid-L finder + timing-edge line fitting → perspective corners → `imgproc` grid

‎src/imgproc/mod.rs‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@
2121
//! - `sample` — [`sample_grid`], the sub-pixel bilinear grid reader that turns a
2222
//! warped frame plus a homography into a [`BitMatrix`].
2323
//! - [`edges`] — Sobel gradient magnitude and binary morphology (erode/dilate).
24+
//! - [`orient`] — [`dominant_gradient_angle`], the texture-orientation estimate that
25+
//! lets the 1D pipeline derotate an arbitrarily rotated barcode crop.
2426
//! - [`rng`] — a small explicit seeded [`Prng`] so any randomness is reproducible.
2527
//!
2628
//! Everything here is deterministic and dependency-free.
@@ -34,6 +36,7 @@ pub mod edges;
3436
pub mod homography;
3537
pub mod integral;
3638
pub mod line;
39+
pub mod orient;
3740
pub mod rng;
3841
pub mod sample;
3942
pub mod threshold;
@@ -45,6 +48,7 @@ pub use edges::{dilate, erode, sobel_magnitude};
4548
pub use homography::Homography;
4649
pub use integral::IntegralImage;
4750
pub use line::{Line, fit_line_least_squares, ransac_line};
51+
pub use orient::dominant_gradient_angle;
4852
pub use rng::Prng;
4953
pub use sample::{sample_bilinear, sample_grid, sample_grid_binary};
5054
pub use threshold::{

‎src/imgproc/orient.rs‎

Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
//! Dominant gradient-orientation estimation.
2+
//!
3+
//! A linear barcode is the most orientation-coherent texture there is: every bar edge
4+
//! points the gradient along the *reading* axis, so the gradient-orientation histogram
5+
//! of a barcode crop has one overwhelming peak (modulo 180° — a gradient cannot tell a
6+
//! code from its upside-down self). [`dominant_gradient_angle`] measures that peak and
7+
//! how dominant it is, letting a scanning pipeline *derotate* a crop whose code is not
8+
//! within its scanline sweep instead of blindly re-scanning at every angle.
9+
10+
use crate::image::GrayFrame;
11+
12+
/// Histogram bins across the half-circle: 3° per bin.
13+
const BINS: usize = 60;
14+
15+
/// Minimum squared gradient magnitude for a pixel to vote; rejects sensor noise and
16+
/// flat background so the histogram is built from real edges.
17+
const MIN_MAG2: f32 = 24.0 * 24.0;
18+
19+
/// Fraction of total edge energy that must fall within ±2 bins (±7.5°) of the peak for
20+
/// the orientation to count as *dominant*. Barcodes score far above this; text, matrix
21+
/// codes and scene texture spread their energy and fall below.
22+
const MIN_COHERENCE: f32 = 0.4;
23+
24+
/// Estimate the dominant edge orientation of `frame` in radians, in `(-π/2, π/2]`,
25+
/// measured from the +x axis — for a barcode this is the *reading* direction (bars are
26+
/// perpendicular to it). Returns `None` when the texture has no sufficiently dominant
27+
/// orientation (nothing bar-like to derotate for).
28+
pub fn dominant_gradient_angle(frame: &GrayFrame<'_>) -> Option<f32> {
29+
let w = frame.width();
30+
let h = frame.height();
31+
if w < 8 || h < 8 {
32+
return None;
33+
}
34+
35+
// Histogram of gradient orientation mod π, weighted by squared magnitude.
36+
let mut hist = [0.0f32; BINS];
37+
let mut total = 0.0f32;
38+
for y in 1..h - 1 {
39+
for x in 1..w - 1 {
40+
let gx = f32::from(frame.get_unchecked(x + 1, y))
41+
- f32::from(frame.get_unchecked(x - 1, y));
42+
let gy = f32::from(frame.get_unchecked(x, y + 1))
43+
- f32::from(frame.get_unchecked(x, y - 1));
44+
let mag2 = gx * gx + gy * gy;
45+
if mag2 < MIN_MAG2 {
46+
continue;
47+
}
48+
let a = gy.atan2(gx).rem_euclid(core::f32::consts::PI);
49+
let bin = ((a / core::f32::consts::PI) * BINS as f32) as usize % BINS;
50+
hist[bin] += mag2;
51+
total += mag2;
52+
}
53+
}
54+
if total <= 0.0 {
55+
return None;
56+
}
57+
58+
// Light circular 1-2-1 smoothing so the peak does not split across a bin edge.
59+
let mut smooth = [0.0f32; BINS];
60+
for i in 0..BINS {
61+
let l = hist[(i + BINS - 1) % BINS];
62+
let r = hist[(i + 1) % BINS];
63+
smooth[i] = 0.25 * l + 0.5 * hist[i] + 0.25 * r;
64+
}
65+
let peak = (0..BINS)
66+
.max_by(|&a, &b| smooth[a].partial_cmp(&smooth[b]).unwrap())
67+
.unwrap();
68+
69+
// Coherence: energy near the peak vs everywhere. Uses the raw histogram so the
70+
// smoothing above cannot inflate it.
71+
let near: f32 = (-2i32..=2)
72+
.map(|d| hist[(peak as i32 + d).rem_euclid(BINS as i32) as usize])
73+
.sum();
74+
if near / total < MIN_COHERENCE {
75+
return None;
76+
}
77+
78+
// Sub-bin angle: circular mean over the peak neighbourhood in double-angle space
79+
// (which keeps 0 and π identified, exactly the mod-π symmetry of orientations).
80+
let (mut sx, mut sy) = (0.0f64, 0.0f64);
81+
for d in -2i32..=2 {
82+
let i = (peak as i32 + d).rem_euclid(BINS as i32) as usize;
83+
let centre = (i as f64 + 0.5) / BINS as f64 * core::f64::consts::PI;
84+
sx += f64::from(hist[i]) * (2.0 * centre).cos();
85+
sy += f64::from(hist[i]) * (2.0 * centre).sin();
86+
}
87+
if sx == 0.0 && sy == 0.0 {
88+
return None;
89+
}
90+
let mut angle = (sy.atan2(sx) / 2.0) as f32; // (-π/2, π/2]
91+
if angle <= -core::f32::consts::FRAC_PI_2 {
92+
angle += core::f32::consts::PI;
93+
}
94+
Some(angle)
95+
}
96+
97+
#[cfg(test)]
98+
mod tests {
99+
use super::*;
100+
use crate::image::GrayImage;
101+
102+
/// Bars of period 8 px perpendicular to `angle` (so the *reading* axis is `angle`),
103+
/// with soft (tanh-profile, ~1 px) edges. A camera never delivers hard staircase
104+
/// edges — real optics blur every transition — and aliased steps would put
105+
/// spurious diagonal energy into the gradient histogram that no real capture has.
106+
fn bars(angle: f32) -> GrayImage {
107+
let (s, c) = angle.sin_cos();
108+
let mut img = GrayImage::new(128, 128);
109+
for y in 0..128 {
110+
for x in 0..128 {
111+
// Position along the reading axis, phase within the 2-bar period.
112+
let t = c * x as f32 + s * y as f32;
113+
let ph = (t / 8.0).rem_euclid(2.0);
114+
// Distance (px) to the nearest bar edge; dark half is ph < 1.
115+
let d = ph.min((1.0 - ph).abs()).min(2.0 - ph) * 8.0;
116+
let soft = (d / 1.2).tanh();
117+
let side = if ph < 1.0 { -1.0 } else { 1.0 };
118+
img.set(x, y, (127.5 + 127.4 * side * soft).round() as u8);
119+
}
120+
}
121+
img
122+
}
123+
124+
#[test]
125+
fn recovers_bar_orientation() {
126+
for deg in [0.0f32, 20.0, 45.0, 90.0, -30.0, -75.0] {
127+
let angle = deg.to_radians();
128+
let img = bars(angle);
129+
let got = dominant_gradient_angle(&img.as_frame())
130+
.unwrap_or_else(|| panic!("no orientation for {deg}°"));
131+
// Compare modulo π.
132+
let mut diff = (got - angle).rem_euclid(core::f32::consts::PI);
133+
if diff > core::f32::consts::FRAC_PI_2 {
134+
diff = core::f32::consts::PI - diff;
135+
}
136+
assert!(
137+
diff.to_degrees() < 4.0,
138+
"angle {deg}°: got {:.1}° (diff {:.1}°)",
139+
got.to_degrees(),
140+
diff.to_degrees()
141+
);
142+
}
143+
}
144+
145+
#[test]
146+
fn rejects_isotropic_texture() {
147+
// A checkerboard has equal horizontal and vertical edge energy.
148+
let mut img = GrayImage::filled(96, 96, 255);
149+
for y in 0..96 {
150+
for x in 0..96 {
151+
if (x / 6 + y / 6) % 2 == 0 {
152+
img.set(x, y, 0);
153+
}
154+
}
155+
}
156+
assert!(dominant_gradient_angle(&img.as_frame()).is_none());
157+
}
158+
}

‎src/pipeline.rs‎

Lines changed: 79 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -123,15 +123,72 @@ pub fn scan_2d(frame: &crate::image::GrayFrame<'_>) -> Vec<Symbol> {
123123
/// frame. The EAN/UPC edge reader (width ratios, voted across scanlines) reads curved and
124124
/// blurred captures the quantized grid cannot; the quantized `scan1d` front-end then feeds
125125
/// the remaining checksummed linear decoders.
126+
///
127+
/// The scanline sweep itself only covers a few degrees around horizontal, but the code's
128+
/// orientation in the crop is arbitrary: each candidate pattern is also tried mirrored
129+
/// (an upside-down code scans in reverse), and when the sweep finds nothing the frame's
130+
/// dominant texture orientation is measured ([`crate::imgproc::orient`]) and the whole
131+
/// crop is derotated and rescanned — so a 1D code is read at any rotation, not just the
132+
/// near-horizontal band.
126133
pub fn scan_1d(frame: &crate::image::GrayFrame<'_>) -> Vec<Symbol> {
134+
let scan_opts = crate::scan1d::ScanOptions::default();
135+
let found = scan_1d_sweep(frame, &scan_opts);
136+
if !found.is_empty() {
137+
return found;
138+
}
139+
140+
// Nothing near horizontal. If the crop's texture has one dominant orientation —
141+
// the signature of a rotated barcode — derotate the whole crop and rescan. The
142+
// sweep already covers ±6°, so only meaningfully rotated codes are worth the
143+
// resample.
144+
let Some(angle) = crate::imgproc::orient::dominant_gradient_angle(frame) else {
145+
return found;
146+
};
147+
if angle.abs() < MIN_DEROTATE_ANGLE {
148+
return found;
149+
}
150+
let image = to_image(frame);
151+
let rotated = crate::transform::rotate(&image, -angle);
152+
let mut found = scan_1d_sweep(&rotated.as_frame(), &scan_opts);
153+
154+
// Map each symbol's outline from derotated coordinates back into the frame.
155+
let (s, c) = (-angle).sin_cos();
156+
let (cx, cy) = (frame.width() as f32 / 2.0, frame.height() as f32 / 2.0);
157+
let (ncx, ncy) = (
158+
rotated.width() as f32 / 2.0,
159+
rotated.height() as f32 / 2.0,
160+
);
161+
for sym in &mut found {
162+
if let Some(loc) = &mut sym.location {
163+
for p in &mut loc.outline.corners {
164+
let (dx, dy) = (p.x - ncx, p.y - ncy);
165+
p.x = c * dx + s * dy + cx;
166+
p.y = -s * dx + c * dy + cy;
167+
}
168+
loc.rotation = Some(loc.rotation.unwrap_or(0.0) + angle);
169+
}
170+
}
171+
found
172+
}
173+
174+
/// Smallest texture rotation (radians) worth a derotate-and-rescan; the plain sweep
175+
/// already covers a ±6° band around horizontal.
176+
const MIN_DEROTATE_ANGLE: f32 = 8.0 * core::f32::consts::PI / 180.0;
177+
178+
/// One near-horizontal 1D pass: the EAN/UPC edge reader plus the quantized `scan1d`
179+
/// front-end feeding every checksummed linear decoder, each candidate tried in both
180+
/// reading directions.
181+
fn scan_1d_sweep(
182+
frame: &crate::image::GrayFrame<'_>,
183+
scan_opts: &crate::scan1d::ScanOptions,
184+
) -> Vec<Symbol> {
127185
use crate::traits::Decode;
128186

129187
let mut found: Vec<Symbol> = Vec::new();
130-
let scan_opts = crate::scan1d::ScanOptions::default();
131-
if let Some(s) = crate::codes::ean::scan(frame, &scan_opts) {
188+
if let Some(s) = crate::codes::ean::scan(frame, scan_opts) {
132189
found.push(s);
133190
}
134-
let candidates = crate::scan1d::scan_lines(frame, &scan_opts);
191+
let candidates = crate::scan1d::scan_lines(frame, scan_opts);
135192
let linear: [Box<dyn Decode>; 6] = [
136193
Box::new(crate::codes::code128::Code128Decoder::new()),
137194
Box::new(crate::codes::ean::EanDecoder::new()),
@@ -141,15 +198,34 @@ pub fn scan_1d(frame: &crate::image::GrayFrame<'_>) -> Vec<Symbol> {
141198
Box::new(crate::codes::codabar::CodabarDecoder::new()),
142199
];
143200
for cand in &candidates {
201+
// A scanline as easily runs against the code's reading direction as with it
202+
// (and a 180°-rotated code always does): try the mirrored pattern too. The
203+
// decoders are all checksummed, so a mirrored misread cannot false-accept.
204+
let mut mirrored = cand.clone();
205+
mirrored.pattern.modules.reverse();
206+
mirrored.edges.clear();
144207
for dec in &linear {
145208
if let Some(s) = crate::scan1d::try_decode(cand, dec.as_ref()) {
146209
dedup_push(&mut found, s);
147210
}
211+
if let Some(s) = crate::scan1d::try_decode(&mirrored, dec.as_ref()) {
212+
dedup_push(&mut found, s);
213+
}
148214
}
149215
}
150216
found
151217
}
152218

219+
/// Copy a borrowed frame into an owned [`crate::image::GrayImage`] (for resampling).
220+
fn to_image(frame: &crate::image::GrayFrame<'_>) -> crate::image::GrayImage {
221+
let (w, h) = (frame.width(), frame.height());
222+
let mut data = Vec::with_capacity(w * h);
223+
for y in 0..h {
224+
data.extend_from_slice(frame.row(y).expect("row in range"));
225+
}
226+
crate::image::GrayImage::from_raw(w, h, data)
227+
}
228+
153229
/// Push `sym` unless an equal `(symbology, text)` is already present.
154230
fn dedup_push(found: &mut Vec<Symbol>, sym: Symbol) {
155231
let key = (sym.symbology, sym.text().unwrap_or_default());

‎src/scan1d/mod.rs‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,8 +37,12 @@
3737
//! - **Blur.** Mild defocus (separable box blur up to a few pixels radius) — edges
3838
//! are recovered from midpoint crossings which are blur-stable.
3939
//! - **Rotation.** A few degrees, handled by scanning at several small angles
40-
//! (default ±`MAX_ANGLE_DEG`). Full rotation invariance is **out of scope**: a
41-
//! scan line must cross all bars, so near-perpendicular capture is assumed.
40+
//! (default ±`MAX_ANGLE_DEG`). Full rotation invariance is deliberately **not**
41+
//! this module's job: a scan line must cross all bars, so near-perpendicular
42+
//! capture is assumed here. [`crate::pipeline::scan_1d`] supplies it instead, by
43+
//! trying every candidate pattern mirrored (180°) and — when the sweep finds
44+
//! nothing — measuring the crop's dominant texture orientation and derotating the
45+
//! whole crop before rescanning (any other angle).
4246
//! - **Framing.** The barcode need not span the full frame width, but a light
4347
//! quiet zone must be present on both sides of the scan line.
4448
//!

0 commit comments

Comments
 (0)