Skip to main content

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}