Skip to content

Commit b7d423b

Browse files
MagicalTuxclaude
andcommitted
pipeline, detect: read stacked codes from linear regions; grow matrix boxes
A PDF417 is rows of bars, so the locator (rightly) reports it as a linear region - and linear crops only ran the 1D readers, so a located PDF417 was never decoded. scan_linear_at runs the 1D readers and then the stacked samplers, derotating the crop by the locator's axis first when the symbol is beyond the samplers' +-12 degree envelope (trying both ways up, since an axis is mod 180). The wasm decode1d export uses it. liveeval: PDF417 live 13% -> 77%. Matrix candidate boxes are grown one tile: the texture cluster stops at the last edge-dense tile, clipping the corners and quiet zone of a rotated symbol. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
1 parent 349cb3d commit b7d423b

4 files changed

Lines changed: 98 additions & 9 deletions

File tree

‎examples/liveeval.rs‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -406,7 +406,7 @@ fn run(code: &Code, t: &Trial) -> Outcome {
406406
continue;
407407
};
408408
let syms = if let Some(axis) = lin {
409-
anyd::pipeline::scan_1d_at(&frame(&c), axis)
409+
anyd::pipeline::scan_linear_at(&frame(&c), axis)
410410
} else {
411411
anyd::pipeline::scan_all(&frame(&c))
412412
};

‎src/detect/mod.rs‎

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -374,11 +374,23 @@ pub fn locate(frame: &GrayFrame<'_>, opts: &LocateOptions) -> Vec<Candidate> {
374374
module_size,
375375
}
376376
}
377-
_ => Location {
378-
outline: box_quad(core, scale),
379-
rotation: None,
380-
module_size,
381-
},
377+
_ => {
378+
// The texture cluster stops at the last edge-dense tile, which on a
379+
// rotated or small symbol cuts its corners and quiet zone off; a
380+
// sampler needs both. One tile out restores them.
381+
let t = opts.tile.max(1);
382+
let grown = [
383+
core[0].saturating_sub(t),
384+
core[1].saturating_sub(t),
385+
(core[2] + t).min(grid.width),
386+
(core[3] + t).min(grid.height),
387+
];
388+
Location {
389+
outline: box_quad(grown, scale),
390+
rotation: None,
391+
module_size,
392+
}
393+
}
382394
};
383395
out.push(Candidate {
384396
location,

‎src/pipeline.rs‎

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -203,6 +203,83 @@ pub fn scan_1d_at(frame: &crate::image::GrayFrame<'_>, angle: f32) -> Vec<Symbol
203203
scan_1d(frame)
204204
}
205205

206+
/// Decode a region the locator classed as **linear** — a 1D code *or* a stacked one —
207+
/// whose reading axis is `angle` (radians, clockwise from +x, as in
208+
/// [`Location::rotation`]): [`scan_1d_at`] first, then [`scan_stacked_at`].
209+
///
210+
/// A stacked symbol (PDF417, MicroPDF417) is rows of bars, so to a texture-based
211+
/// locator it *is* a linear region; only the decoders can tell the two apart.
212+
pub fn scan_linear_at(frame: &crate::image::GrayFrame<'_>, angle: f32) -> Vec<Symbol> {
213+
let found = scan_1d_at(frame, angle);
214+
if !found.is_empty() {
215+
return found;
216+
}
217+
scan_stacked_at(frame, angle)
218+
}
219+
220+
/// Decode the stacked symbologies (PDF417, MicroPDF417) from a crop whose rows run
221+
/// along `angle` (radians, clockwise from +x).
222+
///
223+
/// Their samplers trace the start/stop guard columns down the image rows, which only
224+
/// works with the symbol within about ±12° of upright — so beyond a few degrees the crop
225+
/// is derotated by the known axis first, and the decoded outline mapped back.
226+
pub fn scan_stacked_at(frame: &crate::image::GrayFrame<'_>, angle: f32) -> Vec<Symbol> {
227+
#[allow(unused_mut)]
228+
let mut found: Vec<Symbol> = Vec::new();
229+
#[cfg(feature = "pdf417")]
230+
{
231+
let stacked = |f: &crate::image::GrayFrame<'_>| {
232+
let mut out: Vec<Symbol> = Vec::new();
233+
out.extend(crate::codes::pdf417::scan(f));
234+
out.extend(crate::codes::pdf417::scan_micro(f).ok());
235+
out
236+
};
237+
if !angle.is_finite() || angle.abs() < MIN_DEROTATE_ANGLE {
238+
found = stacked(frame);
239+
}
240+
if found.is_empty() && angle.is_finite() && angle.abs() >= MIN_DEROTATE_ANGLE {
241+
let (w, h) = (frame.width(), frame.height());
242+
let mut data = Vec::with_capacity(w * h);
243+
for y in 0..h {
244+
data.extend_from_slice(frame.row(y).expect("row in range"));
245+
}
246+
let image = crate::image::GrayImage::from_raw(w, h, data);
247+
// An axis is mod 180°: the derotated symbol may be upside down, which the
248+
// samplers cannot read, so try both.
249+
for turn in [0.0, core::f32::consts::PI] {
250+
let by = -angle + turn;
251+
let rotated = crate::transform::rotate(&image, by);
252+
found = stacked(&rotated.as_frame());
253+
if found.is_empty() {
254+
continue;
255+
}
256+
// Map outlines from the derotated image back into the crop.
257+
let (s, c) = by.sin_cos();
258+
let (cx, cy) = (w as f32 / 2.0, h as f32 / 2.0);
259+
let (ncx, ncy) = (rotated.width() as f32 / 2.0, rotated.height() as f32 / 2.0);
260+
for sym in &mut found {
261+
if let Some(loc) = &mut sym.location {
262+
for p in &mut loc.outline.corners {
263+
let (dx, dy) = (p.x - ncx, p.y - ncy);
264+
p.x = c * dx + s * dy + cx;
265+
p.y = -s * dx + c * dy + cy;
266+
}
267+
loc.rotation = Some(loc.rotation.unwrap_or(0.0) - by);
268+
}
269+
}
270+
break;
271+
}
272+
}
273+
}
274+
let _ = (frame, angle); // unused when no stacked symbology is enabled
275+
found
276+
}
277+
278+
/// Smallest rotation (radians) worth derotating a stacked-code crop for; their samplers
279+
/// tolerate about ±12° on their own.
280+
#[cfg(feature = "pdf417")]
281+
const MIN_DEROTATE_ANGLE: f32 = 8.0 * core::f32::consts::PI / 180.0;
282+
206283
/// Half-width (degrees) of the band of reading axes one sweep covers: its outermost
207284
/// scan angle plus the slant a full-height scan line tolerates.
208285
const SWEEP_COVER_DEG: f32 = 8.0;

‎src/wasm.rs‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -164,8 +164,8 @@ pub unsafe extern "C" fn decode(w: usize, h: usize, luma_ptr: *const u8) -> u64
164164
export(symbols_json(&crate::pipeline::scan_all(&frame)))
165165
}
166166

167-
/// Decode only the **1D / linear** symbologies in a `w`×`h` luminance frame; returns
168-
/// the same JSON shape as `decode`.
167+
/// Decode only the **linear-textured** symbologies — 1D, and the stacked PDF417 family —
168+
/// in a `w`×`h` luminance frame; returns the same JSON shape as `decode`.
169169
///
170170
/// This exists for the live pipeline: a crop the locator classified as *linear* should
171171
/// never pay for the 2D samplers — their finder/geometry fallbacks are the expensive
@@ -182,7 +182,7 @@ pub unsafe extern "C" fn decode1d(w: usize, h: usize, luma_ptr: *const u8, axis:
182182
let Some(frame) = (unsafe { frame_input(w, h, luma_ptr) }) else {
183183
return export(b"[]".to_vec());
184184
};
185-
export(symbols_json(&crate::pipeline::scan_1d_at(&frame, axis)))
185+
export(symbols_json(&crate::pipeline::scan_linear_at(&frame, axis)))
186186
}
187187

188188
/// Borrow a `w`×`h` luminance frame from wasm memory, or `None` for dimensions that do

0 commit comments

Comments
 (0)