anyd 0.1.3

From-scratch encoding and decoding of 1D and 2D barcodes with lossless round-trip and live-video detection
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
//! Real-world robustness harness for the QR image sampler.
//!
//! `tests/harness_image.rs` proves the sampler on the clean geometric transforms. This
//! file targets the degradations a *camera photo* adds that a single global threshold
//! cannot survive — uneven lighting / glare, contrast loss from blur, and curved
//! surfaces — plus a committed real bottle-label photo.
//!
//! ## Achieved real-world envelope (asserted below)
//!
//! Symbols are rendered at 6 px/module. On top of that the sampler is asserted to decode:
//!
//! | Degradation                          | Envelope                                    |
//! |--------------------------------------|---------------------------------------------|
//! | Uneven lighting (linear gradient)    | 2.5:1 brightness ramp across the symbol,    |
//! |                                      | either direction — a single global Otsu     |
//! |                                      | threshold cannot binarize this (see the     |
//! |                                      | `imgproc::threshold` gradient test)         |
//! | Lighting gradient + Gaussian blur    | ~2.2:1 ramp with σ = 1.5 px                  |
//! | Combined camera capture              | gradient + 6° rotation + σ1.2 blur + σ12    |
//! |                                      | noise together                              |
//! | Perspective tilt + rotation together | tilt 0.12 + 7° rotation (versions ≥ 2)      |
//! | Cylinder curvature (bottle/can)      | half-arc 0.8 (dim 25) / 0.6 (dim 45); 0.7   |
//! |                                      | with σ1.0 blur — well past the 0.45 the     |
//! |                                      | flat homography reached                     |
//! | Non-planar ripple / crease           | a sine ripple (amp ≈ 1.7 modules) and a     |
//! |                                      | dihedral fold that **no** single homography |
//! |                                      | spans — decoded via the alignment-anchor    |
//! |                                      | thin-plate-spline dewarp (versions ≥ 7)     |
//!
//! These are driven by the adaptive (Bradley/Sauvola) binarization ladder, per-module
//! local thresholding, dual-axis + synthesized finder detection, the interior
//! bottom-right anchor sweep, and — for genuinely non-planar surfaces — a multi-anchor
//! thin-plate-spline dewarp (fit through every detected finder and alignment pattern)
//! added to `codes::qr::sample`.
//!
//! ## Real bottle fixture (`testdata/real_qr_bottle.png`)
//!
//! A tight crop of a QR on a curved, glossy bottle sticker, noticeably blurred (module
//! pitch ≈ 4 px with blur on the same order). The hardened front-end **locates** the
//! symbol and recovers a correctly-sized 25×25 (version 2) grid where the previous global
//! sampler could not even find three finder patterns. The multi-anchor thin-plate-spline
//! dewarp now also runs on it and reaches the point of *reading the format information*,
//! but full payload recovery of this specific capture is still out of reach: it fails with
//! `unreadable format information`. That is a **residual-blur** limit, not a curvature one
//! — with module pitch ≈ 4 px and a blur radius on the same order, the format modules bleed
//! into their neighbours below what version 2's error correction can repair, independent of
//! how well the surface is dewarped (the synthetic `strong_cylinder_beyond_flat_envelope`
//! test decodes far tighter *un-blurred* curvature on this same version). The feature-gated
//! test therefore asserts the recovered front-end geometry and deliberately does not assert
//! full decode.

use anyd::GrayImage;
use anyd::codes::qr::{EcLevel, QrEncoder, scan};
use anyd::output::Encoding;
use anyd::render::render_matrix;
use anyd::segment::Segment;
use anyd::traits::Encode;
use anyd::transform::{self, Axis, Rng};

/// Pixels per module for every rendered symbol here.
const SCALE: usize = 6;

/// Encode `segments` at `level`, returning the upright render, the expected payload and
/// the module dimension.
fn encode(segments: Vec<Segment>, level: EcLevel) -> (GrayImage, Vec<u8>, usize) {
    let enc = QrEncoder::new();
    let symbol = enc.build(segments, level).expect("in-capacity payload");
    let payload = symbol.payload_bytes();
    let matrix = match enc.encode(&symbol).expect("encode") {
        Encoding::Matrix(m) => m,
        Encoding::Linear(_) => unreachable!("QR encodes to a matrix"),
    };
    let dim = matrix.width();
    (render_matrix(&matrix, SCALE), payload, dim)
}

/// Impose a horizontal illumination gradient: luminance is scaled by a factor ramping
/// linearly from `lo` (left) to `hi` (right). This is the degradation a global threshold
/// cannot handle but an adaptive/local one can.
fn lighting_gradient(img: &GrayImage, lo: f32, hi: f32) -> GrayImage {
    let (w, h) = (img.width(), img.height());
    let mut out = GrayImage::new(w, h);
    for y in 0..h {
        for x in 0..w {
            let t = x as f32 / (w.max(2) - 1) as f32;
            let g = lo + (hi - lo) * t;
            let v = (img.get(x, y) as f32 * g).round().clamp(0.0, 255.0) as u8;
            out.set(x, y, v);
        }
    }
    out
}

/// Decode `img` and assert the recovered payload equals `expected`.
fn assert_decodes(img: &GrayImage, expected: &[u8], label: &str) {
    match scan(&img.as_frame()) {
        Ok(sym) => assert_eq!(
            sym.payload_bytes(),
            expected,
            "{label}: payload mismatch after real-world degradation"
        ),
        Err(e) => panic!("{label}: sampler failed to decode ({e})"),
    }
}

/// Payloads spanning QR versions 1–7 (dims 21, 25, 29, 33, 45), mirroring the geometric
/// harness so the real-world envelope is exercised across sizes.
fn cases() -> Vec<(Vec<Segment>, EcLevel)> {
    vec![
        (vec![Segment::byte(b"HI".to_vec())], EcLevel::M), // v1 (21)
        (
            vec![Segment::alphanumeric(b"ABCDEFGH IJKLMN".to_vec())],
            EcLevel::M,
        ), // v2 (25)
        (
            vec![Segment::byte(b"https://example.com/x?q=hello".to_vec())],
            EcLevel::Q,
        ), // v3 (29)
        (
            vec![Segment::byte(b"MIXED payload 2026 test-suite".to_vec())],
            EcLevel::H,
        ), // v4 (33)
        (vec![Segment::byte(vec![b'B'; 120])], EcLevel::M), // v7 (45)
    ]
}

#[test]
fn uneven_lighting_gradient() {
    // A 2.5:1 brightness ramp across the symbol, both directions. No single global
    // threshold can binarize both the bright and dark halves — the adaptive ladder does.
    for (payload, level) in cases() {
        let (base, expected, dim) = encode(payload, level);
        let dark_right = lighting_gradient(&base, 1.0, 0.4);
        assert_decodes(
            &dark_right,
            &expected,
            &format!("dim{dim} gradient→dark-right"),
        );
        let dark_left = lighting_gradient(&base, 0.4, 1.0);
        assert_decodes(
            &dark_left,
            &expected,
            &format!("dim{dim} gradient→dark-left"),
        );
    }
}

#[test]
fn lighting_gradient_with_blur() {
    // Glare-like gradient plus optical blur — the pairing that collapses a global
    // threshold on real photos.
    for (payload, level) in cases() {
        let (base, expected, dim) = encode(payload, level);
        let img = transform::gaussian_blur(&lighting_gradient(&base, 1.0, 0.45), 1.5);
        assert_decodes(&img, &expected, &format!("dim{dim} gradient+blur"));
    }
}

#[test]
fn combined_capture_degradations() {
    // A realistic hand-held capture: uneven lighting, a small rotation, optical blur and
    // sensor noise, all at once.
    for (payload, level) in cases() {
        let (base, expected, dim) = encode(payload, level);
        let mut img = lighting_gradient(&base, 1.0, 0.5);
        img = transform::rotate(&img, 6.0f32.to_radians());
        img = transform::gaussian_blur(&img, 1.2);
        let mut rng = Rng::new(7);
        img = transform::add_noise(&img, 12.0, &mut rng);
        assert_decodes(&img, &expected, &format!("dim{dim} combined-capture"));
    }
}

#[test]
fn tilt_and_rotation_together() {
    // Perspective tilt and in-plane rotation combined. Perspective recovery needs the
    // bottom-right alignment pattern, present from version 2 (dim 25) up.
    for (payload, level) in cases() {
        let (base, expected, dim) = encode(payload, level);
        if dim < 25 {
            continue;
        }
        let mut img = transform::rotate(&base, 7.0f32.to_radians());
        img = transform::tilt_right(&img, 0.12);
        assert_decodes(&img, &expected, &format!("dim{dim} tilt+rotation"));
    }
}

#[test]
fn cylinder_curvature() {
    // A label wrapped on a bottle/can: the flat symbol is projected onto a cylinder. The
    // interior bottom-right anchor lets the homography bow to follow the curve.
    for (payload, level) in cases() {
        let (base, expected, dim) = encode(payload, level);
        let curved = transform::cylinder(&base, 0.45, Axis::Vertical);
        assert_decodes(&curved, &expected, &format!("dim{dim} cylinder0.45"));
        if dim >= 25 {
            let curved_blur =
                transform::gaussian_blur(&transform::cylinder(&base, 0.35, Axis::Vertical), 1.0);
            assert_decodes(&curved_blur, &expected, &format!("dim{dim} cylinder+blur"));
        }
    }
}

/// Byte payloads that render at versions carrying a genuine interior alignment mesh
/// (dims 45, 61, 81 — versions 7, 12, 16). The non-planar dewarp needs those interior
/// anchors, so it is exercised where the mesh actually exists.
fn mesh_cases() -> Vec<(Vec<Segment>, EcLevel)> {
    vec![
        (vec![Segment::byte(vec![b'B'; 120])], EcLevel::M), // dim 45 (v7)
        (vec![Segment::byte(vec![b'C'; 220])], EcLevel::M), // dim 61 (v12)
        (vec![Segment::byte(vec![b'D'; 440])], EcLevel::M), // dim 81 (v16)
    ]
}

#[test]
fn strong_cylinder_beyond_flat_envelope() {
    // Half-arc well past the 0.45 the flat/homography path reached. Version 2 (dim 25) is
    // small enough that even the interior bottom-right anchor carries it a long way; the
    // larger dim-45 mesh follows a 0.6 arc.
    let (base2, exp2, _) = encode(vec![Segment::byte(vec![b'A'; 20])], EcLevel::M); // dim 25
    for &c in &[0.6f32, 0.8] {
        let curved = transform::cylinder(&base2, c, Axis::Vertical);
        assert_decodes(&curved, &exp2, &format!("dim25 cylinder{c}"));
    }
    // And a 0.7 arc that additionally carries σ1.0 blur.
    let cb = transform::gaussian_blur(&transform::cylinder(&base2, 0.7, Axis::Vertical), 1.0);
    assert_decodes(&cb, &exp2, "dim25 cylinder0.7+blur");

    let (base7, exp7, _) = encode(vec![Segment::byte(vec![b'B'; 120])], EcLevel::M); // dim 45
    let curved7 = transform::cylinder(&base7, 0.6, Axis::Vertical);
    assert_decodes(&curved7, &exp7, "dim45 cylinder0.6");
}

#[test]
fn nonplanar_ripple_and_fold_dewarp() {
    // The headline non-planar cases: surfaces a *single* homography — even the corner-swept
    // one — provably cannot sample, so they decode only through the multi-anchor thin-plate
    // spline. (Verified out-of-band by disabling the TPS path: with it off, none of the
    // asserts below decode; with it on, all do.)

    // Rippled paper: roughly one sine cycle across the symbol, amplitude 10 px (~1.7
    // modules) — a bend the finder-triangle homography cannot track, but the interior
    // alignment anchors pin the spline to.
    for (payload, level) in mesh_cases() {
        let (base, expected, dim) = encode(payload, level);
        let wl = (dim * SCALE) as f32 * 1.5;
        let waved = transform::wave(&base, 10.0, wl, Axis::Vertical);
        assert_decodes(&waved, &expected, &format!("dim{dim} ripple"));
    }

    // A dihedral crease through the middle: two planar halves meeting at an angle no one
    // homography spans. The spline bridges the fold because it has anchors on both faces.
    let (base7, exp7, _) = encode(vec![Segment::byte(vec![b'B'; 120])], EcLevel::M); // dim 45
    let folded7 = transform::fold(&base7, 0.5, 40.0, Axis::Vertical);
    assert_decodes(&folded7, &exp7, "dim45 fold40");

    let (base16, exp16, _) = encode(vec![Segment::byte(vec![b'D'; 440])], EcLevel::M); // dim 81
    let folded16 = transform::fold(&base16, 0.5, 25.0, Axis::Vertical);
    assert_decodes(&folded16, &exp16, "dim81 fold25");
}

/// Real camera photo of a QR on a curved bottle sticker. Gated on the `cli` feature so it
/// can borrow that feature's PNG decoder without adding a library dependency; CI's
/// `cargo test --all-features` runs it, plain `cargo test` skips it.
#[cfg(feature = "cli")]
#[test]
fn real_bottle_photo_front_end() {
    use anyd::GrayFrame;
    use anyd::codes::qr::{QrScanner, sample_grid};
    use anyd::pipeline::Hints;
    use anyd::traits::Detect;

    let bytes = std::fs::read("testdata/real_qr_bottle.png").expect("read fixture");
    let rgba = oxideav_png::decode_png_to_rgba(&bytes).expect("decode PNG");
    let (w, h) = (rgba.width as usize, rgba.height as usize);
    // ITU-R BT.601 luma, as the CLI does.
    let luma: Vec<u8> = rgba
        .data
        .chunks_exact(4)
        .map(|p| ((p[0] as u32 * 299 + p[1] as u32 * 587 + p[2] as u32 * 114) / 1000) as u8)
        .collect();
    let frame = GrayFrame::new(&luma, w, h).expect("valid frame");

    // The hardened front-end locates the symbol on this real, blurred, curved capture —
    // where the previous global-Otsu sampler reported "fewer than three finder patterns
    // found". Detection yields a candidate with a plausible module size...
    let candidates = QrScanner::new().detect(&frame, &Hints::new());
    assert_eq!(candidates.len(), 1, "front-end should locate the bottle QR");
    let module_size = candidates[0]
        .location
        .module_size
        .expect("candidate carries a module size");
    assert!(
        (2.0..8.0).contains(&module_size),
        "module size {module_size} implausible for the fixture"
    );

    // ...and grid recovery produces a valid, square QR module matrix of the correct
    // version-2 size (25×25).
    let grid = sample_grid(&frame).expect("front-end recovers a module grid");
    assert_eq!(grid.width(), grid.height(), "recovered grid must be square");
    assert!(
        grid.width() >= 21 && (grid.width() - 21).is_multiple_of(4),
        "recovered dimension {} is not a valid QR size",
        grid.width()
    );
    assert_eq!(grid.width(), 25, "fixture is a version-2 (25×25) symbol");

    // Full payload decode of this specific heavy-blur + curvature capture is documented
    // as not yet achieved; exercise the path without asserting success so a future
    // dewarping improvement can light it up without editing this test.
    let _ = scan(&frame);
}

/// End-to-end locate → crop → decode on a real captured photo of a 1D barcode, mirroring
/// exactly what the live demo does per frame. `testdata/real_scene_ean.png` is a
/// native-resolution region of a camera capture: a gold PET-bottle label with an EAN-13
/// ("4901085663356") amid dense Japanese text and artwork.
///
/// This is the regression for the locator's per-tile labelling fix. Previously the whole
/// edge-dense scene flood-filled into one frame-spanning matrix blob whose averaged
/// anisotropy read as 2D, so the locator emitted **no** linear box on the barcode and the
/// demo had nothing to crop-decode — even though the decoder reads the barcode fine when
/// handed the right crop. Labelling each tile by its own edge balance before clustering
/// keeps the barcode's directionally-coherent tiles out of the isotropic text, isolating
/// it as a linear candidate.
#[cfg(feature = "cli")]
#[test]
fn real_scene_locate_then_decode_ean() {
    use anyd::GrayFrame;
    use anyd::detect::{LocateOptions, locate};

    let bytes = std::fs::read("testdata/real_scene_ean.png").expect("read fixture");
    let rgba = oxideav_png::decode_png_to_rgba(&bytes).expect("decode PNG");
    let (w, h) = (rgba.width as usize, rgba.height as usize);
    let luma: Vec<u8> = rgba
        .data
        .chunks_exact(4)
        .map(|p| ((p[0] as u32 * 299 + p[1] as u32 * 587 + p[2] as u32 * 114) / 1000) as u8)
        .collect();
    let frame = GrayFrame::new(&luma, w, h).expect("valid frame");

    // Axis-aligned bounds of a candidate's outline, clamped to the frame.
    let bounds = |c: &anyd::pipeline::Candidate| {
        let cs = c.location.outline.corners;
        let x0 = cs.iter().map(|p| p.x).fold(f32::MAX, f32::min).max(0.0) as usize;
        let y0 = cs.iter().map(|p| p.y).fold(f32::MAX, f32::min).max(0.0) as usize;
        let x1 = (cs.iter().map(|p| p.x).fold(0.0, f32::max) as usize).min(w);
        let y1 = (cs.iter().map(|p| p.y).fold(0.0, f32::max) as usize).min(h);
        (x0, y0, x1, y1)
    };

    // The barcode occupies roughly (250,205)-(790,405); its centre is ~(512,295).
    let (bx, by) = (512usize, 295usize);
    let cands = locate(&frame, &LocateOptions::default());
    let bar = cands
        .iter()
        .find(|c| {
            c.symbology.map(|s| s.dimension()) == Some(anyd::Dimension::Linear) && {
                let (x0, y0, x1, y1) = bounds(c);
                x0 <= bx && bx < x1 && y0 <= by && by < y1
            }
        })
        .expect("locate() must isolate the barcode as a linear candidate over its centre");

    // Crop the located box and decode it end-to-end, exactly as the demo's worker does.
    let (x0, y0, x1, y1) = bounds(bar);
    let (cw, ch) = (x1 - x0, y1 - y0);
    let mut crop = vec![0u8; cw * ch];
    for y in 0..ch {
        for x in 0..cw {
            crop[y * cw + x] = frame.get_unchecked(x0 + x, y0 + y);
        }
    }
    let cframe = GrayFrame::new(&crop, cw, ch).expect("valid crop");

    let lines = anyd::scan1d::scan_lines(&cframe, &anyd::scan1d::ScanOptions::default());
    let dec = anyd::codes::ean::EanDecoder::new();
    let text = lines
        .iter()
        .find_map(|cand| anyd::scan1d::try_decode(cand, &dec).and_then(|s| s.text()))
        .expect("the located region must decode as EAN-13");
    assert_eq!(
        text, "4901085663356",
        "wrong payload from the located barcode"
    );
}

/// Locator *precision* on the real cluttered captures — the live-demo side of the
/// recall tests above. The locator is recall-biased, but a hand-held camera pointed at
/// dense print used to drown the HUD (and the crop-decode budget) in candidates: the
/// EAN scene produced 16 boxes led by a frame-spanning text blob, and the close-up QR
/// shattered into seven overlapping fragments. This pins the gates that fixed it —
/// scene-blob size cap, duplicate suppression, text-structure rejection for linear
/// regions, ink-coverage banding for finderless matrix guesses — on the exact frames
/// that exposed the noise.
#[cfg(feature = "cli")]
#[test]
fn real_scene_locator_precision() {
    use anyd::GrayFrame;
    use anyd::detect::{LocateOptions, locate};

    let load = |path: &str| {
        let bytes = std::fs::read(path).expect("read fixture");
        let rgba = oxideav_png::decode_png_to_rgba(&bytes).expect("decode PNG");
        let (w, h) = (rgba.width as usize, rgba.height as usize);
        let luma: Vec<u8> = rgba
            .data
            .chunks_exact(4)
            .map(|p| ((p[0] as u32 * 299 + p[1] as u32 * 587 + p[2] as u32 * 114) / 1000) as u8)
            .collect();
        (luma, w, h)
    };
    let bbox = |c: &anyd::pipeline::Candidate| {
        let cs = c.location.outline.corners;
        (
            cs.iter().map(|p| p.x).fold(f32::MAX, f32::min),
            cs.iter().map(|p| p.y).fold(f32::MAX, f32::min),
            cs.iter().map(|p| p.x).fold(f32::MIN, f32::max),
            cs.iter().map(|p| p.y).fold(f32::MIN, f32::max),
        )
    };

    // EAN amid dense text: few candidates, the true barcode ranked first, and no
    // near-frame-sized scene blob.
    let (luma, w, h) = load("testdata/real_scene_ean.png");
    let frame = GrayFrame::new(&luma, w, h).unwrap();
    let cands = locate(&frame, &LocateOptions::default());
    assert!(
        (1..=6).contains(&cands.len()),
        "EAN scene: expected a handful of candidates, got {}",
        cands.len()
    );
    let first = bbox(&cands[0]);
    assert_eq!(
        cands[0].symbology.map(|s| s.dimension()),
        Some(anyd::Dimension::Linear),
        "EAN scene: strongest candidate should be the linear barcode"
    );
    assert!(
        first.0 <= 512.0 && 512.0 < first.2 && first.1 <= 295.0 && 295.0 < first.3,
        "EAN scene: strongest candidate {first:?} does not cover the barcode centre"
    );
    for c in &cands {
        let (x0, y0, x1, y1) = bbox(c);
        assert!(
            (x1 - x0) * (y1 - y0) <= 0.7 * (w * h) as f32,
            "EAN scene: scene-sized blob ({x0},{y0})-({x1},{y1}) not capped"
        );
    }

    // Close-up QR filling ~80% of the frame: exactly one box, over the code.
    let (luma, w, h) = load("testdata/real_scene_qr.png");
    let frame = GrayFrame::new(&luma, w, h).unwrap();
    let cands = locate(&frame, &LocateOptions::default());
    assert_eq!(
        cands.len(),
        1,
        "close-up QR scene should collapse to a single candidate"
    );
    let (x0, y0, x1, y1) = bbox(&cands[0]);
    assert!(
        x0 <= 152.0 && 152.0 < x1 && y0 <= 152.0 && 152.0 < y1,
        "QR scene: candidate ({x0},{y0})-({x1},{y1}) misses the symbol centre"
    );
    assert_eq!(
        cands[0].symbology.map(|s| s.dimension()),
        Some(anyd::Dimension::Matrix),
    );

    // Text-with-corner-barcode: every candidate stays on the barcode corner; the pure
    // text (left/top of the frame) yields nothing.
    let (luma, w, h) = load("testdata/real_scene_text.png");
    let frame = GrayFrame::new(&luma, w, h).unwrap();
    let cands = locate(&frame, &LocateOptions::default());
    for c in &cands {
        let (x0, y0, x1, y1) = bbox(c);
        let (cx, cy) = ((x0 + x1) / 2.0, (y0 + y1) / 2.0);
        assert!(
            cx >= 150.0 && cy >= 100.0,
            "text scene: candidate centred at ({cx},{cy}) sits on plain text"
        );
    }
}

/// Real camera photo of a version-2 QR on a *curved coffee-can label* at ≈4.6 px/module
/// with visible vertical motion smear (`testdata/real_qr_can.png`, cropped from a
/// hand-held 1080p frame). Three hardening layers must combine to read it, and this
/// pins all of them:
///
/// - the **timing-anchored thin-plate dewarp** (both timing lines lock under the
///   adaptive binarizations, bending the grid to the can's curvature),
/// - the **format-information brute force** (blur wrecks the single-module format
///   fields beyond BCH repair; only 32 (level, mask) combos exist, so RS arbitrates),
/// - the **bias-shifted module classification** (the smear drags dark modules above
///   the geometric local-contrast midpoint; the shifted variants recover them).
#[cfg(feature = "cli")]
#[test]
fn real_can_qr_curved_blurred_decode() {
    use anyd::GrayFrame;

    let bytes = std::fs::read("testdata/real_qr_can.png").expect("read fixture");
    let rgba = oxideav_png::decode_png_to_rgba(&bytes).expect("decode PNG");
    let (w, h) = (rgba.width as usize, rgba.height as usize);
    let luma: Vec<u8> = rgba
        .data
        .chunks_exact(4)
        .map(|p| ((p[0] as u32 * 299 + p[1] as u32 * 587 + p[2] as u32 * 114) / 1000) as u8)
        .collect();
    let frame = GrayFrame::new(&luma, w, h).expect("valid frame");

    let sym = scan(&frame).expect("curved blurred can QR must decode");
    assert_eq!(sym.text().as_deref(), Some("https://zko.jp/ueacvj"));
}

/// Real camera photo of an EAN-13 on a *curved, glossy gold bottle* — a 256×96 crop with
/// module pitch ≈ 2.3 px, optical blur and mild cylinder curvature. The hard-quantized
/// [`anyd::scan1d::scan_lines`] path cannot read it: blur merges the single-module start,
/// centre and end guards, and different scanlines corrupt different modules, so no
/// integer-module grid matches. The width-ratio edge path ([`anyd::codes::ean::scan`])
/// does: it classifies each digit from its four element widths normalized to that digit's
/// own local 7-module width (tolerating the curvature's varying pitch) off the fine
/// peak-based edges, then votes across every scanline. This is the regression guarding
/// that robust real-capture 1D decode.
#[cfg(feature = "cli")]
#[test]
fn real_scene_curved_ean_edge_decode() {
    use anyd::GrayFrame;
    use anyd::scan1d::ScanOptions;

    let bytes = std::fs::read("testdata/real_scene_ean_curved.png").expect("read fixture");
    let rgba = oxideav_png::decode_png_to_rgba(&bytes).expect("decode PNG");
    let (w, h) = (rgba.width as usize, rgba.height as usize);
    let luma: Vec<u8> = rgba
        .data
        .chunks_exact(4)
        .map(|p| ((p[0] as u32 * 299 + p[1] as u32 * 587 + p[2] as u32 * 114) / 1000) as u8)
        .collect();
    let frame = GrayFrame::new(&luma, w, h).expect("valid frame");

    // The full scan1d -> EAN edge path must recover the GTIN off this real capture.
    let sym = anyd::codes::ean::scan(&frame, &ScanOptions::default())
        .expect("curved bottle EAN-13 must decode via the width-ratio edge path");
    assert_eq!(
        sym.text().as_deref(),
        Some("4901085663356"),
        "wrong payload from the curved-bottle capture"
    );
    assert_eq!(sym.symbology, anyd::Symbology::Ean13);
}

/// A region of the same capture holding only printed text and artwork — no barcode.
/// The edge decoder is deliberately permissive per scanline (it must recover blurred
/// codes), so individual lines occasionally satisfy a checksum by chance, most often as
/// UPC-E. The cross-scanline consensus floor in [`anyd::codes::ean::scan`] must reject
/// those: a live camera pointed at packaging would otherwise flash phantom barcodes over
/// plain text. This pins that guard against the real texture that first exposed it.
#[cfg(feature = "cli")]
#[test]
fn real_scene_text_is_not_a_barcode() {
    use anyd::GrayFrame;
    use anyd::scan1d::ScanOptions;

    let bytes = std::fs::read("testdata/real_scene_text.png").expect("read fixture");
    let rgba = oxideav_png::decode_png_to_rgba(&bytes).expect("decode PNG");
    let (w, h) = (rgba.width as usize, rgba.height as usize);
    let luma: Vec<u8> = rgba
        .data
        .chunks_exact(4)
        .map(|p| ((p[0] as u32 * 299 + p[1] as u32 * 587 + p[2] as u32 * 114) / 1000) as u8)
        .collect();
    let frame = GrayFrame::new(&luma, w, h).expect("valid frame");

    assert!(
        anyd::codes::ean::scan(&frame, &ScanOptions::default()).is_none(),
        "consensus floor must reject stray checksum-passing misreads on plain text"
    );
}

/// The shared [`anyd::pipeline::scan_all`] entry point — the one both the CLI and the
/// WebAssembly demo call — must read a real QR capture. `testdata/real_scene_qr.png` is
/// the located crop of an omron package QR from a hand-held frame. This guards the
/// consolidated decode path so a front-end can never again ship a decoder list that has
/// drifted from what actually works (the demo previously ran a hand-copied list missing
/// the current 1D reader).
#[cfg(feature = "cli")]
#[test]
fn real_scene_qr_via_scan_all() {
    use anyd::GrayFrame;

    let bytes = std::fs::read("testdata/real_scene_qr.png").expect("read fixture");
    let rgba = oxideav_png::decode_png_to_rgba(&bytes).expect("decode PNG");
    let (w, h) = (rgba.width as usize, rgba.height as usize);
    let luma: Vec<u8> = rgba
        .data
        .chunks_exact(4)
        .map(|p| ((p[0] as u32 * 299 + p[1] as u32 * 587 + p[2] as u32 * 114) / 1000) as u8)
        .collect();
    let frame = GrayFrame::new(&luma, w, h).expect("valid frame");

    let found = anyd::pipeline::scan_all(&frame);
    let qr = found
        .iter()
        .find(|s| s.symbology == anyd::Symbology::QrCode)
        .expect("scan_all must read the QR capture");
    assert_eq!(
        qr.text().as_deref(),
        Some(
            "https://www.omronconnect.com/devices/?utm_source=qrcode&utm_medium=package&utm_campaign=mc-6800b"
        ),
    );
}