projective_grid/detect.rs
1//! Detection task facade.
2//!
3//! The working implementations target the square lattice with any of the three
4//! input-feature kinds. [`Evidence::Oriented2`] is the native shape, assembled
5//! by the axis-driven topological grid finder (Delaunay → quad-mesh →
6//! flood-fill → validate → fit). [`Evidence::Positions`] (orientation-free) and
7//! [`Evidence::Oriented1`] (single-axis) are synthesized up to the Oriented2
8//! shape ([`crate::orient`]) and then run the same assembler, with the
9//! geometry-only [`RecoverySchedule`] enabled to recover the recall the
10//! synthesized-axis frontier would otherwise leave on the table — so all three
11//! square input kinds share one back-half. All produce the same
12//! [`GridSolution`] shape.
13//!
14//! The remaining combinations — [`Evidence::Oriented3`],
15//! [`Evidence::CoordinateHypotheses`], and every `(Hex, *)` variant — are
16//! typed [`GridError::UnsupportedCombination`] placeholders (see the support
17//! matrix on [`detect_grid`]).
18//!
19//! The detection surface is pinned to `f32`. The generic-`F` surface that
20//! remains in the crate is the pure-geometry [`crate::geometry`] module.
21
22use crate::error::{EvidenceKind, GridError, GridTask, Result};
23use crate::feature::{CoordinateHypothesis, OrientedFeature, PointFeature};
24use crate::lattice::{GridDimensions, LatticeKind};
25use crate::result::{GridSolution, RejectedFeature};
26
27pub use crate::shared::recovery_schedule::{RecoveryParams, RecoverySchedule};
28pub use crate::shared::validate::ValidationParams as ValidateParams;
29pub use crate::topological::TopologicalParams;
30
31/// Evidence supplied to a detection task.
32#[derive(Clone, Copy, Debug)]
33#[non_exhaustive]
34pub enum Evidence<'a> {
35 /// Position-only point features.
36 Positions(&'a [PointFeature]),
37 /// Point features with one local lattice direction. Supported for
38 /// `Square`: the orthogonal direction is synthesized from neighbour
39 /// geometry ([`crate::orient::synthesize_oriented2_from_oriented1`]).
40 Oriented1(&'a [OrientedFeature<1>]),
41 /// Point features with two local lattice directions — the native square
42 /// input shape consumed by both algorithms.
43 Oriented2(&'a [OrientedFeature<2>]),
44 /// Point features with three local lattice directions. **Hex-native
45 /// evidence**: a hexagonal lattice has three axis families, and a feature
46 /// detector that recovers all three feeds them here. The hex detection
47 /// path is the intended consumer; until a detector consumes it this stays
48 /// [`GridError::UnsupportedCombination`].
49 Oriented3(&'a [OrientedFeature<3>]),
50 /// Point features plus caller-supplied coordinate hypotheses. **Roadmap
51 /// slot** for decode-feedback labelling: a caller that has partially
52 /// decoded marker / ring IDs supplies them as `(source_index, coord)`
53 /// hypotheses to bias or seed the labelling. No detection algorithm
54 /// consumes hypotheses yet, so this stays
55 /// [`GridError::UnsupportedCombination`]; the
56 /// [`check_consistency`](crate::check::check_consistency) task is the only
57 /// current consumer of [`crate::feature::CoordinateHypothesis`].
58 CoordinateHypotheses {
59 /// Position-only features.
60 features: &'a [PointFeature],
61 /// Proposed coordinate labels.
62 hypotheses: &'a [CoordinateHypothesis],
63 },
64}
65
66impl Evidence<'_> {
67 /// Return this evidence's kind for dispatch and typed errors.
68 pub fn kind(&self) -> EvidenceKind {
69 match self {
70 Self::Positions(_) => EvidenceKind::Positions,
71 Self::Oriented1(_) => EvidenceKind::Oriented1,
72 Self::Oriented2(_) => EvidenceKind::Oriented2,
73 Self::Oriented3(_) => EvidenceKind::Oriented3,
74 Self::CoordinateHypotheses { .. } => EvidenceKind::CoordinateHypotheses,
75 }
76 }
77}
78
79/// Detection parameters.
80///
81/// The single `max_residual_px` knob from the Phase-A shell sits alongside the
82/// topological assembler's sub-config, the shared post-detection validation
83/// tuning, and the post-recovery schedule.
84///
85/// The sub-config types (`TopologicalParams`, `ValidateParams`,
86/// `RecoveryParams`) include advanced-engine configs that do not implement
87/// `PartialEq`, so neither does `DetectionParams`.
88#[derive(Clone, Debug)]
89#[non_exhaustive]
90pub struct DetectionParams {
91 /// Residual threshold in image pixels for algorithms that fit a lattice.
92 pub max_residual_px: f32,
93 /// Topological grid-finder tuning.
94 pub topological: TopologicalParams,
95 /// Post-detection validation tuning.
96 pub validate: ValidateParams,
97 /// Post-convergence recovery schedule. Defaults to
98 /// [`RecoverySchedule::Auto`]: the facade runs the geometry-only recovery
99 /// schedule for the synthesized-axis paths (`Evidence::Positions` /
100 /// `Evidence::Oriented1`) and skips it for native `Evidence::Oriented2`.
101 /// Callers that own a downstream recovery stage set this to
102 /// [`RecoverySchedule::Off`].
103 pub recovery: RecoverySchedule,
104}
105
106impl Default for DetectionParams {
107 fn default() -> Self {
108 Self {
109 max_residual_px: 2.0,
110 topological: TopologicalParams::default(),
111 validate: ValidateParams::default(),
112 recovery: RecoverySchedule::default(),
113 }
114 }
115}
116
117impl DetectionParams {
118 /// Construct detection parameters from just the residual threshold; the
119 /// sub-configs take their defaults.
120 pub fn new(max_residual_px: f32) -> Self {
121 Self {
122 max_residual_px,
123 ..Self::default()
124 }
125 }
126
127 /// Builder-style override: replace the topological sub-config.
128 pub fn with_topological(mut self, topological: TopologicalParams) -> Self {
129 self.topological = topological;
130 self
131 }
132
133 /// Builder-style override: replace the validate sub-config.
134 pub fn with_validate(mut self, validate: ValidateParams) -> Self {
135 self.validate = validate;
136 self
137 }
138
139 /// Builder-style override: replace the max residual threshold.
140 pub fn with_max_residual_px(mut self, max_residual_px: f32) -> Self {
141 self.max_residual_px = max_residual_px;
142 self
143 }
144
145 /// Builder-style override: set the post-convergence recovery schedule.
146 pub fn with_recovery(mut self, recovery: RecoverySchedule) -> Self {
147 self.recovery = recovery;
148 self
149 }
150}
151
152/// Detection request.
153#[derive(Clone, Debug)]
154#[non_exhaustive]
155pub struct DetectionRequest<'a> {
156 /// Lattice family to recover.
157 pub lattice: LatticeKind,
158 /// Evidence available to the detector.
159 pub evidence: Evidence<'a>,
160 /// Optional known grid dimensions.
161 pub dimensions: Option<GridDimensions>,
162 /// Detection parameters.
163 pub params: DetectionParams,
164}
165
166impl<'a> DetectionRequest<'a> {
167 /// Construct a detection request.
168 pub fn new(
169 lattice: LatticeKind,
170 evidence: Evidence<'a>,
171 dimensions: Option<GridDimensions>,
172 params: DetectionParams,
173 ) -> Self {
174 Self {
175 lattice,
176 evidence,
177 dimensions,
178 params,
179 }
180 }
181}
182
183/// Detect a grid from feature evidence.
184///
185/// # Support matrix
186///
187/// | `(lattice, evidence)` | Status |
188/// |---|---|
189/// | `(Square, Oriented2)` | supported — topological assembler |
190/// | `(Square, Oriented1)` | supported — synthesize 2nd axis, then Oriented2 |
191/// | `(Square, Positions)` | supported — synthesize both axes, then Oriented2 |
192/// | `(Square, Oriented3)` | `UnsupportedCombination` |
193/// | `(Square, CoordinateHypotheses)` | `UnsupportedCombination` (roadmap) |
194/// | `(Hex, Oriented3)` | supported — topological only |
195/// | `(Hex, Positions)` | supported — synthesize 3 axes, then hex topological |
196/// | `(Hex, Oriented1 / Oriented2)` | `UnsupportedCombination` |
197///
198/// * `(Square, Oriented2)` — the axis-driven SBF09 topological grid finder
199/// (Delaunay → quad-mesh → flood-fill → validate → fit) returns a labelled
200/// [`GridSolution`] with a fitted projective transform; downstream consumers
201/// stay agnostic.
202/// * `(Square, Positions)` — orientation-free input. Each corner's two
203/// local grid directions are synthesized from neighbour geometry
204/// ([`crate::orient::synthesize_oriented2`]) and then fed to the topological
205/// assembler, exactly as for `(Square, Oriented2)` — with the geometry-only
206/// [`RecoverySchedule`] enabled to recover the synthesized-axis recall.
207/// Use this for dot / circle grids and for chessboards whose corners carry
208/// no axis estimate.
209/// * `(Square, Oriented1)` — single-axis input. The supplied axis is kept
210/// and the orthogonal grid direction is recovered from neighbour geometry
211/// ([`crate::orient::synthesize_oriented2_from_oriented1`]); the resulting
212/// [`OrientedFeature<2>`] then runs the topological assembler, exactly as for
213/// `(Square, Positions)`. Use this for detectors that recover one dominant
214/// edge orientation per feature but not the orthogonal one.
215/// * Every other combination — typed [`GridError::UnsupportedCombination`].
216///
217/// * `(Hex, Oriented3)` — hex-native triple-axis evidence. Runs the hex
218/// topological grid finder (Delaunay triangles *are* the unit cells; no
219/// diagonal class, no triangle-pair merge; axial `(q, r)` flood-fill walk).
220/// Hex is **topological-only** with **no recovery schedule**.
221/// * `(Hex, Positions)` — orientation-free hex input. The three local grid
222/// directions are synthesized from neighbour geometry
223/// ([`crate::orient::synthesize_oriented3`]) and then fed to the hex
224/// topological path, mirroring the `(Square, Positions)` seam.
225///
226/// `(Square, Oriented3)` (square does not consume triple-axis evidence),
227/// `(Square, CoordinateHypotheses)` (a decode-feedback roadmap slot), and
228/// `(Hex, Oriented1)` / `(Hex, Oriented2)` (hex needs three axis families)
229/// stay `UnsupportedCombination` — no working algorithm exists for those slots.
230///
231/// **Multi-component results.** The topological assembler can produce more
232/// than one connected component (it labels each connected quad-mesh component,
233/// then runs local component merge). This entry point returns the largest
234/// component only. Use [`detect_grid_all`] when secondary components must be
235/// preserved with their own `(u, v)` labels.
236pub fn detect_grid(request: DetectionRequest<'_>) -> Result<GridSolution> {
237 let mut report = detect_grid_all(request)?;
238 if report.solutions.is_empty() {
239 Err(GridError::InsufficientEvidence)
240 } else {
241 Ok(report.solutions.remove(0))
242 }
243}
244
245/// Dispatch oriented-2 features (caller-supplied or synthesized) to the
246/// topological square assembler. The single dispatch point shared by the
247/// `Oriented2`, `Positions`, and `Oriented1` arms so the three input kinds
248/// reach identical strategy code.
249fn run_square_oriented2(
250 features: &[OrientedFeature<2>],
251 request: &DetectionRequest<'_>,
252 synthesized_axes: bool,
253) -> Result<Vec<GridSolution>> {
254 crate::topological::detect_square_oriented2_topological_all(
255 features,
256 request.dimensions,
257 &request.params,
258 synthesized_axes,
259 )
260}
261
262/// Dispatch hex triple-axis features (caller-supplied or synthesized) to the
263/// hex topological path.
264///
265/// Hex detection is **topological-only** (see the support matrix on
266/// [`detect_grid`]).
267fn run_hex_oriented3(
268 features: &[OrientedFeature<3>],
269 request: &DetectionRequest<'_>,
270) -> Result<Vec<GridSolution>> {
271 crate::topological::detect_hex_oriented3_topological_all(
272 features,
273 request.dimensions,
274 &request.params,
275 )
276}
277
278/// Multi-component variant of [`detect_grid`].
279///
280/// Returns a [`DetectionReport`] with one [`GridSolution`] per
281/// qualifying connected component, ordered by labelled-count
282/// descending (ties broken by smallest labelled `source_index`). The
283/// topological assembler may return several solutions.
284///
285/// Features that no component admitted are surfaced in the *first*
286/// solution's `rejected` vector (the same shape callers of
287/// [`detect_grid`] saw historically). Per-component validation drops
288/// and over-residual entries stay attached to their owning component's
289/// `rejected` vector.
290///
291/// The same `UnsupportedCombination` matrix applies as for
292/// [`detect_grid`].
293pub fn detect_grid_all(request: DetectionRequest<'_>) -> Result<DetectionReport> {
294 let solutions = match (request.lattice, request.evidence) {
295 (LatticeKind::Square, Evidence::Oriented2(features)) => {
296 // Native two-axis evidence: no synthesis, so the recovery schedule
297 // stays off under `RecoverySchedule::Auto` (byte-compat).
298 run_square_oriented2(features, &request, false)?
299 }
300 (LatticeKind::Square, Evidence::Positions(features)) => {
301 // Orientation-free input: recover each corner's two local grid
302 // directions from neighbour geometry, then run the chosen square
303 // strategy. Both strategies consume `OrientedFeature<2>`, so the
304 // synthesized axes feed either path unchanged. The axes are
305 // synthesized, so `Auto` enables the recovery schedule.
306 let oriented = crate::orient::synthesize_oriented2(features);
307 run_square_oriented2(&oriented, &request, true)?
308 }
309 (LatticeKind::Square, Evidence::Oriented1(features)) => {
310 // Single-axis input: keep the supplied axis and recover the second
311 // local grid direction from neighbour geometry, then run the chosen
312 // square strategy. Same Oriented2 back-half as the Positions path;
313 // the second axis is synthesized, so `Auto` enables recovery.
314 let oriented = crate::orient::synthesize_oriented2_from_oriented1(features);
315 run_square_oriented2(&oriented, &request, true)?
316 }
317 (LatticeKind::Hex, Evidence::Oriented3(features)) => {
318 // Hex-native triple-axis evidence. Hex detection is
319 // topological-only.
320 run_hex_oriented3(features, &request)?
321 }
322 (LatticeKind::Hex, Evidence::Positions(features)) => {
323 // Orientation-free hex input: synthesize the three local grid
324 // directions from neighbour geometry, then run the hex topological
325 // path. Mirrors the `(Square, Positions)` synthesis seam.
326 let oriented = crate::orient::synthesize_oriented3(features);
327 run_hex_oriented3(&oriented, &request)?
328 }
329 _ => {
330 return Err(GridError::UnsupportedCombination {
331 task: GridTask::Detection,
332 lattice: request.lattice,
333 evidence: request.evidence.kind(),
334 })
335 }
336 };
337 Ok(DetectionReport::new(solutions, Vec::new()))
338}
339
340/// Multi-component detection result returned by [`detect_grid_all`].
341///
342/// `solutions` is ordered by labelled-count descending; the first
343/// entry is the same `GridSolution` a single-component
344/// [`detect_grid`] caller historically received. `rejected` is a
345/// crate-level slot reserved for features that no component admitted
346/// when the orchestrator (rather than a particular component) is the
347/// authoritative source of that information. It is left empty for the
348/// topological assembler — the per-component rejected vectors already
349/// cover the wire shape.
350#[derive(Clone, Debug)]
351#[non_exhaustive]
352pub struct DetectionReport {
353 /// Per-component labelled solutions, ordered by component size
354 /// descending.
355 pub solutions: Vec<GridSolution>,
356 /// Features that no component admitted, scoped to the orchestrator
357 /// (not a particular component). Currently empty for the topological
358 /// assembler — see the struct-level docs.
359 pub rejected: Vec<RejectedFeature>,
360}
361
362impl DetectionReport {
363 /// Construct a detection report.
364 pub fn new(solutions: Vec<GridSolution>, rejected: Vec<RejectedFeature>) -> Self {
365 Self {
366 solutions,
367 rejected,
368 }
369 }
370}