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}