Skip to main content

anyd/detect/
mod.rs

1//! Fast, symbology-agnostic frame-level code **locator**.
2//!
3//! This is the cheap first half of the two-stage live pipeline (see [`crate::pipeline`]
4//! and [`crate::traits::Detect`]). Given a camera frame it answers one question as fast
5//! as possible: *where are the barcodes, and roughly what kind is each?* — a bounding
6//! quad plus a coarse family guess per code. It never decodes; the heavier
7//! [`Analyze`](crate::traits::Analyze) pass does that, and only for the regions found
8//! here.
9//!
10//! # How it works
11//!
12//! [`locate`] makes a single reduced-resolution pass:
13//!
14//! 1. **Downscale + binarize** (`grid`): box-average the frame down by
15//!    [`LocateOptions::downscale`] and Otsu-threshold the result once. Everything after
16//!    this works on the small image, which is what makes the locator fast enough to run
17//!    on every frame.
18//! 2. **Concentric finders** (`finder`): a run-length row scan with a vertical
19//!    cross-check flags QR/Aztec `1:1:3:1:1` fiducials. These give a high-confidence
20//!    matrix classification and a module-size estimate.
21//! 3. **Texture tiles** (`tiles`): tiles dense with dark/light edges are clustered
22//!    into regions; the horizontal-vs-vertical edge balance separates 1D barcode
23//!    regions from 2D matrix regions, catching every finder-less symbology.
24//! 4. **Merge + map back**: finder hits upgrade the region they fall in to a matrix
25//!    guess with a real module size; region boxes are scaled back to full resolution and
26//!    returned as [`Candidate`]s, ordered strongest-first and capped at
27//!    [`LocateOptions::max_candidates`].
28//!
29//! The locator favours **recall and speed over precision**: it would rather hand
30//! `Analyze` a few extra boxes than miss a code. It is fully deterministic; the only
31//! randomness available (via a seeded [`Prng`](crate::imgproc::Prng)) is unused by the
32//! default path and reserved for future sampling heuristics.
33//!
34//! # Family guess
35//!
36//! [`Candidate::symbology`] carries a *representative* symbology for the guessed family,
37//! not a decode claim: a finder-backed matrix reports [`Symbology::QrCode`], a
38//! finder-less matrix [`Symbology::DataMatrix`], and a linear region [`Symbology::Code128`].
39//! Recover just the family with [`Symbology::dimension`].
40
41mod finder;
42mod grid;
43mod tiles;
44
45use crate::geometry::{Location, Point, Quad};
46use crate::image::GrayFrame;
47use crate::pipeline::{Candidate, Fingerprint, Hints};
48use crate::symbology::Symbology;
49use crate::traits::Detect;
50
51use finder::FinderHit;
52use grid::DownGrid;
53use tiles::{Family, Region};
54
55/// Which layout families the locator should report.
56#[derive(Debug, Clone, Copy, PartialEq, Eq)]
57pub struct Families {
58    /// Report 2D matrix regions (QR, Aztec, Data Matrix, …).
59    pub matrix: bool,
60    /// Report 1D / stacked linear regions.
61    pub linear: bool,
62}
63
64impl Families {
65    /// Report every family.
66    pub const ALL: Families = Families {
67        matrix: true,
68        linear: true,
69    };
70    /// Report only 2D matrix regions.
71    pub const MATRIX: Families = Families {
72        matrix: true,
73        linear: false,
74    };
75    /// Report only 1D / stacked regions.
76    pub const LINEAR: Families = Families {
77        matrix: false,
78        linear: true,
79    };
80
81    fn allows(self, family: Family) -> bool {
82        match family {
83            Family::Matrix => self.matrix,
84            Family::Linear => self.linear,
85        }
86    }
87}
88
89impl Default for Families {
90    fn default() -> Self {
91        Families::ALL
92    }
93}
94
95/// Tuning knobs for [`locate`]. [`Default`] gives sensible live-camera values.
96#[derive(Debug, Clone, Copy)]
97pub struct LocateOptions {
98    /// Integer factor the frame is reduced by before analysis (`>= 1`). Larger is
99    /// faster but drops small codes. Default `2`.
100    pub downscale: usize,
101    /// Which families to report. Default [`Families::ALL`].
102    pub families: Families,
103    /// Maximum candidates returned, keeping the largest regions. Default `16`.
104    pub max_candidates: usize,
105    /// Tile edge length in reduced pixels for the texture pass. Default `8`.
106    pub tile: usize,
107    /// Minimum `(h + v) transitions / pixel` for a tile to count as active. Default `0.12`.
108    pub edge_density: f32,
109    /// Minimum active tiles for a region to be reported. Default `3`.
110    pub min_region_tiles: usize,
111    /// `|h - v| / (h + v)` above which a region is classed linear rather than matrix.
112    /// Default `0.55`.
113    pub anisotropy: f32,
114    /// Seed for any randomized heuristic, kept for reproducibility. Default `0x_D0D0_CAFE`.
115    pub seed: u64,
116}
117
118impl Default for LocateOptions {
119    fn default() -> Self {
120        LocateOptions {
121            downscale: 2,
122            families: Families::ALL,
123            max_candidates: 16,
124            tile: 8,
125            edge_density: 0.12,
126            min_region_tiles: 3,
127            anisotropy: 0.55,
128            seed: 0x_D0D0_CAFE,
129        }
130    }
131}
132
133/// Locate candidate code regions in `frame` without decoding them.
134///
135/// Returns a [`Candidate`] per region, each with a full-resolution bounding [`Quad`], a
136/// coarse family guess in [`Candidate::symbology`], a cheap [`Fingerprint`], and a
137/// module-size estimate in [`Location::module_size`] when a finder pattern backed the
138/// region. Candidates are ordered largest-region first and capped at
139/// [`LocateOptions::max_candidates`]. Deterministic for a given frame and options.
140pub fn locate(frame: &GrayFrame<'_>, opts: &LocateOptions) -> Vec<Candidate> {
141    // Too small to hold any code worth locating.
142    if frame.width() < 8 || frame.height() < 8 {
143        return Vec::new();
144    }
145
146    let grid = DownGrid::build(frame, opts.downscale);
147    let finders = finder::find(&grid);
148    let mut regions = tiles::regions(
149        &grid,
150        opts.tile,
151        opts.edge_density,
152        opts.min_region_tiles,
153        opts.anisotropy,
154    );
155
156    // Sort strongest (largest) first so max_candidates keeps the best.
157    regions.sort_by_key(|r| std::cmp::Reverse(r.area()));
158
159    let scale = grid.scale as f32;
160    let mut out: Vec<Candidate> = Vec::new();
161    for region in &regions {
162        if out.len() >= opts.max_candidates {
163            break;
164        }
165        // A finder falling inside the region upgrades it to a matrix guess and lends
166        // its module size.
167        let hit = enclosing_finder(&finders, region);
168        let family = if hit.is_some() {
169            Family::Matrix
170        } else {
171            region.family
172        };
173        if !opts.families.allows(family) {
174            continue;
175        }
176
177        let symbology = match (family, hit.is_some()) {
178            (Family::Matrix, true) => Symbology::QrCode,
179            (Family::Matrix, false) => Symbology::DataMatrix,
180            (Family::Linear, _) => Symbology::Code128,
181        };
182        let module_size = hit.map(|h| h.module * scale);
183
184        let outline = region_quad(region, scale);
185        let location = Location {
186            outline,
187            rotation: None,
188            module_size,
189        };
190        out.push(Candidate {
191            location,
192            symbology: Some(symbology),
193            fingerprint: Some(fingerprint(&grid, region, family)),
194            known: None,
195        });
196    }
197    out
198}
199
200/// The finder hit whose centre lies inside `region`, if any (highest count wins).
201fn enclosing_finder<'a>(finders: &'a [FinderHit], region: &Region) -> Option<&'a FinderHit> {
202    finders
203        .iter()
204        .filter(|f| {
205            let x = f.x as usize;
206            let y = f.y as usize;
207            x >= region.x0 && x < region.x1 && y >= region.y0 && y < region.y1
208        })
209        .max_by_key(|f| f.count)
210}
211
212/// A full-resolution bounding quad (clockwise from top-left) for a reduced-pixel region.
213fn region_quad(region: &Region, scale: f32) -> Quad {
214    let x0 = region.x0 as f32 * scale;
215    let y0 = region.y0 as f32 * scale;
216    let x1 = region.x1 as f32 * scale;
217    let y1 = region.y1 as f32 * scale;
218    Quad::new([
219        Point::new(x0, y0),
220        Point::new(x1, y0),
221        Point::new(x1, y1),
222        Point::new(x0, y1),
223    ])
224}
225
226/// A cheap, position-tolerant signature: a coarse `4×4` darkness grid sampled over the
227/// region, packed with the family tag. Robust enough to re-match a code across frames.
228fn fingerprint(grid: &DownGrid, region: &Region, family: Family) -> Fingerprint {
229    let mut bits: u64 = 0;
230    let w = (region.x1 - region.x0).max(1);
231    let h = (region.y1 - region.y0).max(1);
232    // Mean darkness of the region for a per-region threshold.
233    let mut sum = 0u64;
234    let mut n = 0u64;
235    for gy in 0..4 {
236        for gx in 0..4 {
237            let x = region.x0 + gx * w / 4 + w / 8;
238            let y = region.y0 + gy * h / 4 + h / 8;
239            sum += u64::from(grid.luma(x, y));
240            n += 1;
241        }
242    }
243    let mean = (sum / n.max(1)) as u8;
244    let mut i = 0;
245    for gy in 0..4 {
246        for gx in 0..4 {
247            let x = region.x0 + gx * w / 4 + w / 8;
248            let y = region.y0 + gy * h / 4 + h / 8;
249            if grid.luma(x, y) <= mean {
250                bits |= 1 << i;
251            }
252            i += 1;
253        }
254    }
255    let tag: u64 = match family {
256        Family::Matrix => 0x1,
257        Family::Linear => 0x2,
258    };
259    // Mix the 16 darkness bits with the family tag via a xorshift-style hash.
260    let mut v = bits | (tag << 62);
261    v ^= v >> 33;
262    v = v.wrapping_mul(0xFF51_AFD7_ED55_8CCD);
263    v ^= v >> 33;
264    Fingerprint(v)
265}
266
267/// A ready-to-use [`Detect`] wrapping [`locate`] with fixed options, reusing prior-frame
268/// [`Hints`] to short-circuit re-analysis: any candidate whose fingerprint matches a
269/// known symbol from a previous frame is returned with that symbol attached.
270#[derive(Debug, Clone, Copy, Default)]
271pub struct FrameDetector {
272    /// Locator options applied to every frame.
273    pub options: LocateOptions,
274}
275
276impl FrameDetector {
277    /// A detector with default options.
278    pub fn new() -> Self {
279        FrameDetector::default()
280    }
281
282    /// A detector with explicit options.
283    pub fn with_options(options: LocateOptions) -> Self {
284        FrameDetector { options }
285    }
286}
287
288impl Detect for FrameDetector {
289    fn detect(&self, frame: &GrayFrame<'_>, hints: &Hints) -> Vec<Candidate> {
290        let mut candidates = locate(frame, &self.options);
291        for c in &mut candidates {
292            if let Some(fp) = c.fingerprint
293                && let Some(known) = hints.find(fp)
294            {
295                c.known = Some(known.symbol.clone());
296            }
297        }
298        candidates
299    }
300}