Skip to main content

projective_grid/
detect.rs

1//! Detection task facade.
2//!
3//! Square supports its three applicable input-feature kinds; hex supports
4//! position-only, single-family, and native three-family evidence.
5//! [`Evidence::Oriented2`] is the native square shape, assembled
6//! by the axis-driven topological grid finder (Delaunay → quad-mesh →
7//! flood-fill → validate → fit). [`Evidence::Positions`] (orientation-free) and
8//! [`Evidence::Oriented1`] (single-axis) are synthesized up to the Oriented2
9//! shape through the expert orientation utilities and then run the same
10//! assembler, with the geometry-only recovery schedule enabled to recover the recall the
11//! synthesized-axis frontier would otherwise leave on the table — so all three
12//! square input kinds share one back-half. All produce the same
13//! [`GridDetection`] shape.
14//!
15//! Unsupported lattice/evidence pairs return a typed
16//! [`GridError::UnsupportedCombination`] (see the support matrix on
17//! [`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::{OrientedFeature, PointFeature};
24use crate::lattice::{GridDimensions, LatticeKind};
25use crate::result::{GridDetection, GridSolution};
26use std::collections::HashSet;
27
28use crate::shared::recovery_schedule::{RecoverySchedule, SquareAxisProvenance};
29use crate::shared::validate::ValidationParams;
30use crate::topological::TopologicalParams;
31
32/// Evidence supplied to a detection task.
33#[derive(Clone, Copy, Debug)]
34#[non_exhaustive]
35pub enum Evidence<'a> {
36    /// Position-only point features.
37    Positions(&'a [PointFeature]),
38    /// Point features with one measured local lattice family. Square
39    /// synthesizes one missing direction; hex internally synthesizes two while
40    /// preserving the measured angle and uncertainty.
41    Oriented1(&'a [OrientedFeature<1>]),
42    /// Point features with two local lattice directions — the native square
43    /// input shape consumed by both algorithms.
44    Oriented2(&'a [OrientedFeature<2>]),
45    /// Point features with three local lattice directions. **Hex-native
46    /// evidence**: a hexagonal lattice has three axis families, and a feature
47    /// detector that recovers all three feeds them here. The hex detection
48    /// path consumes the axes as an unordered local set.
49    Oriented3(&'a [OrientedFeature<3>]),
50}
51
52impl Evidence<'_> {
53    /// Return this evidence's kind for dispatch and typed errors.
54    pub fn kind(&self) -> EvidenceKind {
55        match self {
56            Self::Positions(_) => EvidenceKind::Positions,
57            Self::Oriented1(_) => EvidenceKind::Oriented1,
58            Self::Oriented2(_) => EvidenceKind::Oriented2,
59            Self::Oriented3(_) => EvidenceKind::Oriented3,
60        }
61    }
62}
63
64/// Detection parameters for ordinary callers.
65///
66/// Defaults are the recommended production configuration. The only stable
67/// user-facing knob is the maximum lattice-fit residual. Stage-specific
68/// controls live in [`crate::expert::DetectionTuning`] and are opt-in.
69#[derive(Clone, Debug)]
70#[non_exhaustive]
71pub struct DetectionParams {
72    /// Residual threshold in image pixels for algorithms that fit a lattice.
73    max_residual_px: f32,
74    advanced: Option<Box<DetectionTuning>>,
75}
76
77impl Default for DetectionParams {
78    fn default() -> Self {
79        Self {
80            max_residual_px: 2.0,
81            advanced: None,
82        }
83    }
84}
85
86impl DetectionParams {
87    /// Construct detection parameters from just the residual threshold; the
88    /// sub-configs take their defaults.
89    pub fn new(max_residual_px: f32) -> Self {
90        Self {
91            max_residual_px,
92            ..Self::default()
93        }
94    }
95
96    /// Builder-style override: replace the max residual threshold.
97    #[must_use]
98    pub fn with_max_residual_px(mut self, max_residual_px: f32) -> Self {
99        self.max_residual_px = max_residual_px;
100        self
101    }
102
103    /// Attach opt-in expert tuning.
104    ///
105    /// Ordinary callers should leave this unset. The expert field set follows
106    /// algorithm stages and is intentionally less stable than this facade.
107    #[must_use]
108    pub fn with_advanced(mut self, tuning: DetectionTuning) -> Self {
109        self.advanced = Some(Box::new(tuning));
110        self
111    }
112
113    /// Maximum accepted model-to-image residual, in image pixels.
114    pub fn max_residual_px(&self) -> f32 {
115        self.max_residual_px
116    }
117
118    pub(crate) fn tuning(&self) -> &DetectionTuning {
119        self.advanced.as_deref().unwrap_or(&DEFAULT_TUNING)
120    }
121}
122
123static DEFAULT_TUNING: std::sync::LazyLock<DetectionTuning> =
124    std::sync::LazyLock::new(DetectionTuning::default);
125
126/// Expert-only, stage-specific detection tuning.
127///
128/// This type is re-exported from [`crate::expert`]. Its fields are useful for
129/// detector builders and diagnostic campaigns, but are intentionally absent
130/// from the ordinary [`DetectionParams`] workflow.
131#[derive(Clone, Debug, Default)]
132#[non_exhaustive]
133pub struct DetectionTuning {
134    /// Topological grid-finder tuning.
135    pub topological: TopologicalParams,
136    /// Post-detection structural validation tuning.
137    pub validation: ValidationParams,
138    /// Post-convergence recovery policy.
139    pub recovery: RecoverySchedule,
140}
141
142impl DetectionTuning {
143    /// Replace topological grid-finder tuning.
144    #[must_use]
145    pub fn with_topological(mut self, value: TopologicalParams) -> Self {
146        self.topological = value;
147        self
148    }
149
150    /// Replace structural validation tuning.
151    #[must_use]
152    pub fn with_validation(mut self, value: ValidationParams) -> Self {
153        self.validation = value;
154        self
155    }
156
157    /// Replace the post-convergence recovery policy.
158    #[must_use]
159    pub fn with_recovery(mut self, value: RecoverySchedule) -> Self {
160        self.recovery = value;
161        self
162    }
163}
164
165/// Detection request.
166#[derive(Clone, Debug)]
167#[non_exhaustive]
168pub struct DetectionRequest<'a> {
169    /// Lattice family to recover.
170    lattice: LatticeKind,
171    /// Evidence available to the detector.
172    evidence: Evidence<'a>,
173    /// Optional known grid dimensions.
174    dimensions: Option<GridDimensions>,
175    /// Detection parameters.
176    params: DetectionParams,
177}
178
179impl<'a> DetectionRequest<'a> {
180    /// Construct a request with production defaults and no positional optionals.
181    pub fn new(lattice: LatticeKind, evidence: Evidence<'a>) -> Self {
182        Self {
183            lattice,
184            evidence,
185            dimensions: None,
186            params: DetectionParams::default(),
187        }
188    }
189
190    /// Constrain the maximum feature-coordinate span of a detected grid.
191    #[must_use]
192    pub fn with_dimensions(mut self, dimensions: GridDimensions) -> Self {
193        self.dimensions = Some(dimensions);
194        self
195    }
196
197    /// Replace the default detection parameters.
198    #[must_use]
199    pub fn with_params(mut self, params: DetectionParams) -> Self {
200        self.params = params;
201        self
202    }
203
204    pub(crate) fn lattice(&self) -> LatticeKind {
205        self.lattice
206    }
207
208    pub(crate) fn evidence(&self) -> Evidence<'a> {
209        self.evidence
210    }
211
212    pub(crate) fn dimensions(&self) -> Option<GridDimensions> {
213        self.dimensions
214    }
215
216    pub(crate) fn params(&self) -> &DetectionParams {
217        &self.params
218    }
219}
220
221/// Detect a grid from feature evidence.
222///
223/// # Support matrix
224///
225/// | `(lattice, evidence)` | Status |
226/// |---|---|
227/// | `(Square, Oriented2)` | supported — topological assembler |
228/// | `(Square, Oriented1)` | supported — synthesize 2nd axis, then Oriented2 |
229/// | `(Square, Positions)` | supported — synthesize both axes, then Oriented2 |
230/// | `(Square, Oriented3)` | `UnsupportedCombination` |
231/// | `(Hex, Oriented3)` | supported — topological only |
232/// | `(Hex, Positions)` | supported — synthesize 3 axes, then hex topological |
233/// | `(Hex, Oriented1)` | supported — keep trusted family, synthesize 2 axes |
234/// | `(Hex, Oriented2)` | `UnsupportedCombination` |
235///
236/// * `(Square, Oriented2)` — the axis-driven SBF09 topological grid finder
237///   (Delaunay → quad-mesh → flood-fill → validate → fit) returns a labelled
238///   [`GridDetection`] with a fitted projective transform; downstream consumers
239///   stay agnostic.
240/// * `(Square, Positions)` — orientation-free input. Each corner's two
241///   local grid directions are synthesized from neighbour geometry
242///   ([`crate::expert::orientation::synthesize_oriented2`]) and then fed to the topological
243///   assembler, exactly as for `(Square, Oriented2)` — with the geometry-only
244///   [`RecoverySchedule`] enabled to recover the synthesized-axis recall.
245///   Use this for dot / circle grids and for chessboards whose corners carry
246///   no axis estimate.
247/// * `(Square, Oriented1)` — single-axis input. The supplied axis is kept
248///   and the orthogonal grid direction is recovered from neighbour geometry
249///   ([`crate::expert::orientation::synthesize_oriented2_from_oriented1`]); the resulting
250///   [`OrientedFeature<2>`] then runs the topological assembler, exactly as for
251///   `(Square, Positions)`. Use this for detectors that recover one dominant
252///   edge orientation per feature but not the orthogonal one.
253/// * `(Hex, Oriented3)` — hex-native triple-axis evidence. Runs the hex
254///   topological grid finder (Delaunay triangles *are* the unit cells; no
255///   diagonal class, no triangle-pair merge; axial `(q, r)` flood-fill walk).
256///   Hex is **topological-only** with **no recovery schedule**.
257/// * `(Hex, Positions)` — orientation-free hex input. The three local grid
258///   directions are synthesized from neighbour geometry
259///   ([`crate::expert::orientation::synthesize_oriented3`]) and then fed to the hex
260///   topological path, mirroring the `(Square, Positions)` seam.
261/// * `(Hex, Oriented1)` — one measured axis representing the same physical
262///   hex family at every feature. The measured observation is preserved and
263///   the two missing local directions are inferred from neighbour geometry.
264///
265/// `(Square, Oriented3)` (square does not consume triple-axis evidence) and
266/// `(Hex, Oriented2)` (no unambiguous physical-family contract)
267/// stay `UnsupportedCombination` — no working algorithm exists for those slots.
268///
269/// **Multi-component results.** The topological assembler can produce more
270/// than one connected component (it labels each connected quad-mesh component,
271/// then runs local component merge). This entry point returns the largest
272/// component only. Use [`detect_grid_all`] when secondary components must be
273/// preserved with their own `(u, v)` labels.
274pub fn detect_grid(request: DetectionRequest<'_>) -> Result<GridDetection> {
275    let mut detections = detect_grid_all(request)?;
276    if detections.is_empty() {
277        Err(GridError::InsufficientEvidence)
278    } else {
279        Ok(detections.remove(0))
280    }
281}
282
283/// Dispatch oriented-2 features (caller-supplied or synthesized) to the
284/// topological square assembler. The single dispatch point shared by the
285/// `Oriented2`, `Positions`, and `Oriented1` arms so the three input kinds
286/// reach identical strategy code.
287fn run_square_oriented2(
288    features: &[OrientedFeature<2>],
289    request: &DetectionRequest<'_>,
290    axis_provenance: SquareAxisProvenance,
291) -> Result<Vec<GridSolution>> {
292    crate::topological::detect_square_oriented2_all(
293        features,
294        request.dimensions(),
295        request.params(),
296        axis_provenance,
297    )
298}
299
300/// Dispatch hex triple-axis features (caller-supplied or synthesized) to the
301/// hex topological path.
302///
303/// Hex detection is **topological-only** (see the support matrix on
304/// [`detect_grid`]).
305fn run_hex_oriented3(
306    features: &[OrientedFeature<3>],
307    request: &DetectionRequest<'_>,
308) -> Result<Vec<GridSolution>> {
309    crate::topological::detect_hex_oriented3_topological_all(
310        features,
311        request.dimensions(),
312        request.params(),
313    )
314}
315
316/// Multi-component variant of [`detect_grid`].
317///
318/// Returns one [`GridDetection`] per
319/// qualifying connected component, ordered by labelled-count
320/// descending (ties broken by smallest labelled `source_index`). The
321/// topological assembler may return several solutions.
322///
323/// Rejected features and stage-level evidence belong to the opt-in
324/// `diagnostics` feature, not to this mandatory-result contract.
325///
326/// The same `UnsupportedCombination` matrix applies as for
327/// [`detect_grid`].
328pub fn detect_grid_all(request: DetectionRequest<'_>) -> Result<Vec<GridDetection>> {
329    Ok(detect_grid_all_internal(request)?
330        .into_iter()
331        .map(|solution| solution.detection)
332        .collect())
333}
334
335pub(crate) fn detect_grid_all_internal(request: DetectionRequest<'_>) -> Result<Vec<GridSolution>> {
336    validate_request(&request)?;
337    let solutions = match (request.lattice(), request.evidence()) {
338        (LatticeKind::Square, Evidence::Oriented2(features)) => {
339            // Native two-axis evidence: no synthesis, so the recovery schedule
340            // stays off under `RecoverySchedule::Auto` (byte-compat).
341            run_square_oriented2(features, &request, SquareAxisProvenance::FullyMeasured)?
342        }
343        (LatticeKind::Square, Evidence::Positions(features)) => {
344            // Orientation-free input: recover each corner's two local grid
345            // directions from neighbour geometry, then run the chosen square
346            // strategy. Both strategies consume `OrientedFeature<2>`, so the
347            // synthesized axes feed either path unchanged. The axes are
348            // synthesized, so `Auto` enables the recovery schedule.
349            let oriented = crate::orient::synthesize_oriented2(features);
350            run_square_oriented2(
351                &oriented,
352                &request,
353                SquareAxisProvenance::IncludesSynthesized,
354            )?
355        }
356        (LatticeKind::Square, Evidence::Oriented1(features)) => {
357            // Single-axis input: keep the supplied axis and recover the second
358            // local grid direction from neighbour geometry, then run the chosen
359            // square strategy. Same Oriented2 back-half as the Positions path;
360            // the second axis is synthesized, so `Auto` enables recovery.
361            let oriented = crate::orient::synthesize_oriented2_from_oriented1(features);
362            run_square_oriented2(
363                &oriented,
364                &request,
365                SquareAxisProvenance::IncludesSynthesized,
366            )?
367        }
368        (LatticeKind::Hex, Evidence::Oriented3(features)) => {
369            // Hex-native triple-axis evidence. Hex detection is
370            // topological-only.
371            run_hex_oriented3(features, &request)?
372        }
373        (LatticeKind::Hex, Evidence::Positions(features)) => {
374            // Orientation-free hex input: synthesize the three local grid
375            // directions from neighbour geometry, then run the hex topological
376            // path. Mirrors the `(Square, Positions)` synthesis seam.
377            let oriented = crate::orient::synthesize_oriented3(features);
378            run_hex_oriented3(&oriented, &request)?
379        }
380        (LatticeKind::Hex, Evidence::Oriented1(features)) => {
381            let oriented = crate::orient::synthesize_oriented3_from_oriented1(features);
382            run_hex_oriented3(&oriented, &request)?
383        }
384        _ => {
385            return Err(GridError::UnsupportedCombination {
386                task: GridTask::Detection,
387                lattice: request.lattice(),
388                evidence: request.evidence().kind(),
389            })
390        }
391    };
392    Ok(solutions)
393}
394
395pub(crate) fn validate_request(request: &DetectionRequest<'_>) -> Result<()> {
396    if let Some(dimensions) = request.dimensions() {
397        if dimensions.width == 0 || dimensions.height == 0 {
398            return Err(GridError::InconsistentInput(
399                "grid dimensions count feature positions and must be non-zero".to_owned(),
400            ));
401        }
402    }
403
404    let residual = request.params().max_residual_px();
405    if residual.is_nan() || residual < 0.0 {
406        return Err(GridError::InconsistentInput(
407            "max_residual_px must be non-negative or +infinity".to_owned(),
408        ));
409    }
410    validate_tuning(request.params().tuning())?;
411
412    match request.evidence() {
413        Evidence::Positions(features) => validate_points(features.iter()),
414        Evidence::Oriented1(features) => validate_oriented(features),
415        Evidence::Oriented2(features) => validate_oriented(features),
416        Evidence::Oriented3(features) => validate_oriented(features),
417    }
418}
419
420fn validate_points<'a>(points: impl Iterator<Item = &'a PointFeature>) -> Result<()> {
421    let mut source_indices = HashSet::new();
422    for point in points {
423        if !point.position.x.is_finite() || !point.position.y.is_finite() {
424            return Err(GridError::InconsistentInput(format!(
425                "feature {} has a non-finite image position",
426                point.source_index
427            )));
428        }
429        if !source_indices.insert(point.source_index) {
430            return Err(GridError::InconsistentInput(format!(
431                "duplicate feature source_index {}",
432                point.source_index
433            )));
434        }
435    }
436    Ok(())
437}
438
439fn validate_oriented<const N: usize>(features: &[OrientedFeature<N>]) -> Result<()> {
440    validate_points(features.iter().map(|feature| &feature.point))?;
441    for feature in features {
442        for (slot, axis) in feature.axes.iter().enumerate() {
443            if !axis.angle_rad.is_finite() {
444                return Err(GridError::InconsistentInput(format!(
445                    "feature {} axis {slot} has a non-finite angle",
446                    feature.point.source_index
447                )));
448            }
449            if axis
450                .sigma_rad
451                .is_some_and(|sigma| !sigma.is_finite() || sigma < 0.0)
452            {
453                return Err(GridError::InconsistentInput(format!(
454                    "feature {} axis {slot} has an invalid sigma",
455                    feature.point.source_index
456                )));
457            }
458        }
459    }
460    Ok(())
461}
462
463fn validate_tuning(tuning: &DetectionTuning) -> Result<()> {
464    let topo = &tuning.topological;
465    let finite_positive = |value: f32| value.is_finite() && value > 0.0;
466    if !finite_positive(topo.axis_align_tol_rad)
467        || !finite_positive(topo.max_axis_sigma_rad)
468        || !finite_positive(topo.cluster_axis_tol_rad)
469        || !topo.opposing_edge_ratio_max.is_finite()
470        || topo.opposing_edge_ratio_max < 1.0
471        || !topo.edge_length_min_rel.is_finite()
472        || topo.edge_length_min_rel < 0.0
473        || topo.edge_length_max_rel.is_nan()
474        || topo.edge_length_max_rel <= 0.0
475        || topo.edge_length_min_rel > topo.edge_length_max_rel
476        || topo.min_corners_for_component < 4
477        || topo.min_quads_per_component == 0
478        || topo
479            .axis_cluster_centers
480            .is_some_and(|centers| centers.iter().any(|center| !center.is_finite()))
481    {
482        return Err(GridError::InconsistentInput(
483            "invalid expert topological tuning".to_owned(),
484        ));
485    }
486
487    let validation = &tuning.validation;
488    let non_negative_or_inf = |value: f32| !value.is_nan() && value >= 0.0;
489    if !non_negative_or_inf(validation.line_tol_rel)
490        || validation.line_min_members < 2
491        || !non_negative_or_inf(validation.local_h_tol_rel)
492        || !validation.step_deviation_thresh_rel.is_finite()
493        || validation.step_deviation_thresh_rel < 0.0
494    {
495        return Err(GridError::InconsistentInput(
496            "invalid expert validation tuning".to_owned(),
497        ));
498    }
499    Ok(())
500}