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 ®ions {
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}