Skip to content

Commit 5599c9a

Browse files
MagicalTuxclaude
andcommitted
qr: decode small-module curved captures — format brute force + biased sampling
Real-capture repro (anyd-capture-1 (7): coffee can, v2 QR at ~4.6 px/module with vertical motion smear on a curved label): the locator handed the crop worker a usable full-res crop, the sampler recovered the right geometry (both timing lines lock under the adaptive binarizations, 4 pattern + 24 timing anchors in the thin-plate dewarp), and decode still failed — first at the format information, then at Reed-Solomon. Two capability gaps, now fixed: - Format-information brute force: the format fields are single-module features hugging the finder rings, exactly where blur bleeds worst, and BCH tops out at 3 bit errors. Only 4 EC levels x 8 masks exist, so when the read fails (or lies), try every combination and let RS arbitrate. Gated behind a timing-pattern sanity score (>= 0.75 alternation) so the thousands of garbage grids a cluttered frame throws at the decoder do not each pay 32 RS attempts. - Bias-shifted module classification: directional smear drags dark modules above the local-contrast midpoint, so the dewarp pass now also tries the midpoint shifted +-15% (without the local-mean backstop; RS validates). The capture's QR now reads (https://zko.jp/ueacvj) from both the tight crop and the demo's actual merged label crop — 59 ms native / 231 ms wasm — so the live pipeline locks it instead of losing it. Pinned as testdata/real_qr_can.png + real_can_qr_curved_blurred_decode. Also: env-gated diagnostics kept from the investigation (ANYD_QR_DEBUG pass/ candidate/anchor traces, ANYD_LOC_DEBUG region/finder dumps, locdebug LOC_* option overrides). Bench recall/FP unchanged (100% / 0%). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 1a6a202 commit 5599c9a

6 files changed

Lines changed: 235 additions & 42 deletions

File tree

‎examples/locdebug.rs‎

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,23 @@ fn main() {
1010
.map(|p| ((p[0] as u32 * 299 + p[1] as u32 * 587 + p[2] as u32 * 114) / 1000) as u8)
1111
.collect();
1212
let frame = anyd::GrayFrame::new(&luma, w, h).unwrap();
13-
let cands = locate(&frame, &LocateOptions::default());
13+
// Env overrides for quick experiments: LOC_MAX (max_candidates), LOC_FRAC
14+
// (max_region_frac), LOC_DENSITY (edge_density).
15+
let env = |k: &str| std::env::var(k).ok().and_then(|v| v.parse().ok());
16+
let mut opts = LocateOptions::default();
17+
if let Some(v) = env("LOC_MAX") {
18+
opts.max_candidates = v as usize;
19+
}
20+
if let Some(v) = env("LOC_FRAC") {
21+
opts.max_region_frac = v;
22+
}
23+
if let Some(v) = env("LOC_DENSITY") {
24+
opts.edge_density = v;
25+
}
26+
if let Some(v) = env("LOC_DOWNSCALE") {
27+
opts.downscale = v as usize;
28+
}
29+
let cands = locate(&frame, &opts);
1430
let thr = anyd::imgproc::threshold::otsu_threshold(&frame);
1531
println!("frame {w}x{h}: {} candidates (otsu {thr})", cands.len());
1632
for c in &cands {

‎src/codes/qr/decode.rs‎

Lines changed: 94 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
77
use super::matrix::Canvas;
88
use super::tables::{char_count_bits, ec_blocks, mode_from_indicator, remainder_bits};
9-
use super::{QrMeta, Version};
9+
use super::{EcLevel, Mask, QrMeta, Version};
1010
use crate::error::{Error, Result};
1111
use crate::output::{BitMatrix, Encoding};
1212
use crate::segment::{Mode, Segment};
@@ -28,47 +28,107 @@ impl QrDecoder {
2828
pub fn decode_matrix(&self, matrix: &BitMatrix) -> Result<Symbol> {
2929
let version = version_from_size(matrix.width(), matrix.height())?;
3030
let canvas = Canvas::from_matrix(version, matrix);
31-
let (level, mask) = canvas
32-
.read_format()
33-
.ok_or_else(|| Error::undecodable("unreadable format information"))?;
3431

35-
// Read the masked data modules along the path, unmasking as we go.
36-
let path = canvas.data_path();
37-
let mut bits: Vec<bool> = Vec::with_capacity(path.len());
38-
for &(x, y) in &path {
39-
bits.push(canvas.get(x, y) ^ Canvas::mask_bit(mask, x, y));
32+
// The format information (EC level + mask) is the most fragile part of a real
33+
// capture: it lives in single-module features hugging the finder rings, exactly
34+
// where blur bleeds worst. Read it if possible — but a failed (or damaged and
35+
// silently mis-corrected) read is not fatal, because only 4 levels × 8 masks
36+
// exist: try the read result first, then every other combination, and let
37+
// Reed–Solomon arbitrate. RS makes a false accept astronomically unlikely, and
38+
// a failing combination is rejected in ~µs, so the exhaustive pass costs almost
39+
// nothing on grids that were going to fail anyway.
40+
let read = canvas.read_format();
41+
let mut last = Error::undecodable("unreadable format information");
42+
if let Some((level, mask)) = read {
43+
match decode_with_format(&canvas, version, level, mask) {
44+
Ok(sym) => return Ok(sym),
45+
Err(e) => last = e,
46+
}
4047
}
41-
42-
let ecb = ec_blocks(version, level);
43-
let total_cw = ecb.total_codewords();
44-
// Pack to codewords, dropping trailing remainder bits.
45-
if bits.len() < total_cw * 8 {
46-
return Err(Error::undecodable("not enough data modules"));
48+
// The exhaustive pass only makes sense on a grid that is plausibly a QR at all:
49+
// image-sampling front-ends throw thousands of garbage grids from false finder
50+
// triples at this decoder, and 32 RS attempts on each would dominate scan time.
51+
// A real symbol keeps most of its timing-pattern alternation even when blur has
52+
// wrecked the format modules; garbage reads as a coin flip.
53+
if timing_score(&canvas, version) < 0.75 {
54+
return Err(last);
4755
}
48-
let mut codewords = Vec::with_capacity(total_cw);
49-
for i in 0..total_cw {
50-
let mut byte = 0u8;
51-
for k in 0..8 {
52-
byte = (byte << 1) | bits[i * 8 + k] as u8;
56+
for (level, mask) in [EcLevel::L, EcLevel::M, EcLevel::Q, EcLevel::H]
57+
.into_iter()
58+
.flat_map(|level| (0..8).filter_map(move |m| Mask::new(m).map(|mk| (level, mk))))
59+
.filter(|&combo| Some(combo) != read)
60+
{
61+
match decode_with_format(&canvas, version, level, mask) {
62+
Ok(sym) => return Ok(sym),
63+
Err(e) => last = e,
5364
}
54-
codewords.push(byte);
5565
}
56-
let _ = remainder_bits(version); // remainder bits already excluded above
66+
Err(last)
67+
}
68+
}
69+
70+
/// Fraction of the two timing patterns (grid row 6 and column 6 between the finders)
71+
/// whose modules carry the expected dark/light alternation. `1.0` on a clean symbol;
72+
/// ≈`0.5` on a grid sampled from something that is not a QR.
73+
fn timing_score(canvas: &Canvas, version: Version) -> f32 {
74+
let dim = version.size();
75+
let mut ok = 0u32;
76+
let mut total = 0u32;
77+
for i in 8..dim - 8 {
78+
let expected = i % 2 == 0;
79+
ok += u32::from(canvas.get(i, 6) == expected);
80+
ok += u32::from(canvas.get(6, i) == expected);
81+
total += 2;
82+
}
83+
if total == 0 {
84+
return 0.0;
85+
}
86+
ok as f32 / total as f32
87+
}
5788

58-
let data = deinterleave_and_correct(&codewords, &ecb)?;
59-
let segments = parse_segments(&data, version)?;
89+
/// Decode the canvas under one assumed `(level, mask)` interpretation.
90+
fn decode_with_format(
91+
canvas: &Canvas,
92+
version: Version,
93+
level: EcLevel,
94+
mask: Mask,
95+
) -> Result<Symbol> {
96+
// Read the masked data modules along the path, unmasking as we go.
97+
let path = canvas.data_path();
98+
let mut bits: Vec<bool> = Vec::with_capacity(path.len());
99+
for &(x, y) in &path {
100+
bits.push(canvas.get(x, y) ^ Canvas::mask_bit(mask, x, y));
101+
}
60102

61-
let meta = QrMeta {
62-
version,
63-
ec_level: level,
64-
mask,
65-
};
66-
Ok(Symbol::new(
67-
Symbology::QrCode,
68-
segments,
69-
SymbolMeta::Qr(meta),
70-
))
103+
let ecb = ec_blocks(version, level);
104+
let total_cw = ecb.total_codewords();
105+
// Pack to codewords, dropping trailing remainder bits.
106+
if bits.len() < total_cw * 8 {
107+
return Err(Error::undecodable("not enough data modules"));
108+
}
109+
let mut codewords = Vec::with_capacity(total_cw);
110+
for i in 0..total_cw {
111+
let mut byte = 0u8;
112+
for k in 0..8 {
113+
byte = (byte << 1) | bits[i * 8 + k] as u8;
114+
}
115+
codewords.push(byte);
71116
}
117+
let _ = remainder_bits(version); // remainder bits already excluded above
118+
119+
let data = deinterleave_and_correct(&codewords, &ecb)?;
120+
let segments = parse_segments(&data, version)?;
121+
122+
let meta = QrMeta {
123+
version,
124+
ec_level: level,
125+
mask,
126+
};
127+
Ok(Symbol::new(
128+
Symbology::QrCode,
129+
segments,
130+
SymbolMeta::Qr(meta),
131+
))
72132
}
73133

74134
impl Decode for QrDecoder {

‎src/codes/qr/sample.rs‎

Lines changed: 68 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -124,7 +124,19 @@ pub fn scan(frame: &GrayFrame<'_>) -> Result<Symbol> {
124124
break;
125125
};
126126
let mut dewarps = 0;
127-
for located in candidates(frame, &bin) {
127+
let cands = candidates(frame, &bin);
128+
if std::env::var("ANYD_QR_DEBUG").is_ok() {
129+
eprintln!("pass {pass}: {} candidates", cands.len());
130+
for c in &cands {
131+
eprintln!(
132+
" cand dim={} ms={:.2} corners={:?}",
133+
c.dimension,
134+
c.module_size,
135+
c.corners.map(|p| (p.x as i32, p.y as i32))
136+
);
137+
}
138+
}
139+
for located in cands {
128140
let thr = located.threshold(&integral);
129141
let matrix = located.sample(frame, &thr);
130142
match decoder.decode_matrix(&matrix) {
@@ -227,7 +239,11 @@ fn refine_fourth_corner(
227239
// global Otsu cutoff. Blur and curvature can make either the better discriminator.
228240
let radius = (ms * 2.0).round().clamp(2.0, 64.0) as usize;
229241
let thresholds = [
230-
ModuleThreshold::Local { integral, radius },
242+
ModuleThreshold::Local {
243+
integral,
244+
radius,
245+
bias: 1.0,
246+
},
231247
ModuleThreshold::Global(located.threshold),
232248
];
233249
let reach = ms * 3.0;
@@ -634,6 +650,12 @@ enum ModuleThreshold<'a> {
634650
Local {
635651
integral: &'a IntegralImage,
636652
radius: usize,
653+
/// Multiplier applied to the local-contrast midpoint before classification.
654+
/// `1.0` is the standard cut; the dewarp pass also tries shifted variants,
655+
/// because directional blur bleeds ink asymmetrically and drags the true
656+
/// dark/light split away from the geometric midpoint. Biased variants skip the
657+
/// local-mean backstop (Reed-Solomon validates whatever they produce).
658+
bias: f64,
637659
},
638660
}
639661

@@ -658,7 +680,11 @@ impl Located {
658680
// Window a couple of modules across, so it always spans both dark and light
659681
// modules and its mean lands between them.
660682
let radius = (self.module_size * 2.0).round().clamp(2.0, 64.0) as usize;
661-
ModuleThreshold::Local { integral, radius }
683+
ModuleThreshold::Local {
684+
integral,
685+
radius,
686+
bias: 1.0,
687+
}
662688
} else {
663689
ModuleThreshold::Global(self.threshold)
664690
}
@@ -722,14 +748,20 @@ impl Located {
722748
let (px, py) = map(gx, gy);
723749
sample_dark_global(frame, px, py, tap, t)
724750
}
725-
ModuleThreshold::Local { integral, radius } => {
751+
ModuleThreshold::Local {
752+
integral,
753+
radius,
754+
bias,
755+
} => {
726756
let (cx, cy) = map(gx, gy);
727-
let midpoint = local_contrast_midpoint(frame, cx, cy, win);
757+
let midpoint = local_contrast_midpoint(frame, cx, cy, win) * bias;
758+
let backstop = (bias - 1.0).abs() < 1e-9;
728759
let mut votes_dark = 0u32;
729760
for &dv in &OFF {
730761
for &du in &OFF {
731762
let (px, py) = map(gx + du, gy + dv);
732-
if sample_dark_local(frame, px, py, integral, radius, midpoint) {
763+
if sample_dark_local(frame, px, py, integral, radius, midpoint, backstop)
764+
{
733765
votes_dark += 1;
734766
}
735767
}
@@ -810,8 +842,14 @@ fn sample_dark_local(
810842
integral: &IntegralImage,
811843
radius: usize,
812844
midpoint: f64,
845+
backstop: bool,
813846
) -> bool {
814847
let lum = sample_bilinear(frame, px, py);
848+
if !backstop {
849+
// Biased fallback variants classify on the shifted midpoint alone; the mean
850+
// backstop would fight the very shift they exist to apply.
851+
return lum < midpoint;
852+
}
815853
let cx = (px.round().max(0.0) as usize).min(integral.width().saturating_sub(1));
816854
let cy = (py.round().max(0.0) as usize).min(integral.height().saturating_sub(1));
817855
let (sum, count) = integral.window_sum_count(cx, cy, radius);
@@ -1393,6 +1431,15 @@ fn dewarp_decode(
13931431
) -> Option<Symbol> {
13941432
let base = collect_anchors(frame, bin, located);
13951433
let (tg, ti) = timing_anchors(bin, located);
1434+
if std::env::var("ANYD_QR_DEBUG").is_ok() {
1435+
eprintln!(
1436+
"dewarp: dim={} ms={:.2} anchors={:?} timing={}",
1437+
located.dimension,
1438+
located.module_size,
1439+
base.as_ref().map(|a| a.grid.len()),
1440+
tg.len(),
1441+
);
1442+
}
13961443

13971444
// Candidate anchor meshes, richest first. Timing-augmented meshes correct the module
13981445
// pitch that scatters errors on real prints; the un-augmented mesh is kept as a fallback
@@ -1431,8 +1478,22 @@ fn dewarp_decode(
14311478

14321479
let radius = (located.module_size * 2.0).round().clamp(2.0, 64.0) as usize;
14331480
let thresholds = [
1434-
ModuleThreshold::Local { integral, radius },
1481+
ModuleThreshold::Local {
1482+
integral,
1483+
radius,
1484+
bias: 1.0,
1485+
},
14351486
ModuleThreshold::Global(located.threshold),
1487+
ModuleThreshold::Local {
1488+
integral,
1489+
radius,
1490+
bias: 0.85,
1491+
},
1492+
ModuleThreshold::Local {
1493+
integral,
1494+
radius,
1495+
bias: 1.15,
1496+
},
14361497
];
14371498
for (g, im) in &meshes {
14381499
let Some(warp) = ThinPlateSpline::fit(g, im) else {

‎src/detect/mod.rs‎

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -206,8 +206,34 @@ pub fn locate(frame: &GrayFrame<'_>, opts: &LocateOptions) -> Vec<Candidate> {
206206
family: Family,
207207
hit: Option<&'a FinderHit>,
208208
}
209+
let debug = std::env::var("ANYD_LOC_DEBUG").is_ok();
210+
if debug {
211+
for f in &finders {
212+
eprintln!(
213+
"finder ({},{}) module={:.2} count={}",
214+
f.x as usize * grid.scale,
215+
f.y as usize * grid.scale,
216+
f.module,
217+
f.count
218+
);
219+
}
220+
}
209221
let mut scored: Vec<Scored<'_>> = Vec::new();
210222
for region in regions {
223+
if debug {
224+
eprintln!(
225+
"region ({},{})-({},{}) {:?} rh={} area={} lin_ok={} mat_ok={}",
226+
region.x0 * grid.scale,
227+
region.y0 * grid.scale,
228+
region.x1 * grid.scale,
229+
region.y1 * grid.scale,
230+
region.family,
231+
region.reads_horizontal,
232+
region.area(),
233+
linear_plausible(&grid, &region),
234+
matrix_plausible(&grid, &region),
235+
);
236+
}
211237
// A finder falling inside the region upgrades it to a matrix guess and lends
212238
// its module size.
213239
let hit = enclosing_finder(&finders, &region);

‎testdata/real_qr_can.png‎

31.7 KB
Loading

‎tests/real_world.rs‎

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -484,6 +484,36 @@ fn real_scene_locator_precision() {
484484
}
485485
}
486486

487+
/// Real camera photo of a version-2 QR on a *curved coffee-can label* at ≈4.6 px/module
488+
/// with visible vertical motion smear (`testdata/real_qr_can.png`, cropped from a
489+
/// hand-held 1080p frame). Three hardening layers must combine to read it, and this
490+
/// pins all of them:
491+
///
492+
/// - the **timing-anchored thin-plate dewarp** (both timing lines lock under the
493+
/// adaptive binarizations, bending the grid to the can's curvature),
494+
/// - the **format-information brute force** (blur wrecks the single-module format
495+
/// fields beyond BCH repair; only 32 (level, mask) combos exist, so RS arbitrates),
496+
/// - the **bias-shifted module classification** (the smear drags dark modules above
497+
/// the geometric local-contrast midpoint; the shifted variants recover them).
498+
#[cfg(feature = "cli")]
499+
#[test]
500+
fn real_can_qr_curved_blurred_decode() {
501+
use anyd::GrayFrame;
502+
503+
let bytes = std::fs::read("testdata/real_qr_can.png").expect("read fixture");
504+
let rgba = oxideav_png::decode_png_to_rgba(&bytes).expect("decode PNG");
505+
let (w, h) = (rgba.width as usize, rgba.height as usize);
506+
let luma: Vec<u8> = rgba
507+
.data
508+
.chunks_exact(4)
509+
.map(|p| ((p[0] as u32 * 299 + p[1] as u32 * 587 + p[2] as u32 * 114) / 1000) as u8)
510+
.collect();
511+
let frame = GrayFrame::new(&luma, w, h).expect("valid frame");
512+
513+
let sym = scan(&frame).expect("curved blurred can QR must decode");
514+
assert_eq!(sym.text().as_deref(), Some("https://zko.jp/ueacvj"));
515+
}
516+
487517
/// Real camera photo of an EAN-13 on a *curved, glossy gold bottle* — a 256×96 crop with
488518
/// module pitch ≈ 2.3 px, optical blur and mild cylinder curvature. The hard-quantized
489519
/// [`anyd::scan1d::scan_lines`] path cannot read it: blur merges the single-module start,

0 commit comments

Comments
 (0)