Skip to main content

projective_grid/check/
mod.rs

1//! Consistency-check task facade.
2
3use std::collections::{HashMap, HashSet};
4
5use nalgebra::Point2;
6
7use crate::error::{GridError, Result};
8use crate::feature::{CoordinateHypothesis, PointFeature};
9use crate::geometry::{apply_projective, estimate_projective};
10use crate::lattice::{GridDimensions, LatticeKind};
11use crate::result::{
12    ConsistencyReport, GridEntry, GridSolution, LabelledGrid, LatticeFit, RejectedFeature,
13    RejectionReason, ResidualSummary,
14};
15
16/// Parameters for coordinate-hypothesis consistency checks.
17#[derive(Clone, Copy, Debug, PartialEq)]
18#[non_exhaustive]
19pub struct ConsistencyParams {
20    /// Maximum accepted reprojection residual in image pixels.
21    pub max_residual_px: f32,
22}
23
24impl Default for ConsistencyParams {
25    fn default() -> Self {
26        Self {
27            max_residual_px: 2.0,
28        }
29    }
30}
31
32impl ConsistencyParams {
33    /// Construct consistency parameters from a residual threshold in pixels.
34    pub fn new(max_residual_px: f32) -> Self {
35        Self { max_residual_px }
36    }
37}
38
39/// Coordinate-hypothesis consistency request.
40#[derive(Clone, Copy, Debug)]
41#[non_exhaustive]
42pub struct ConsistencyRequest<'a> {
43    /// Lattice family to check.
44    pub lattice: LatticeKind,
45    /// Position-only features referenced by hypotheses.
46    pub features: &'a [PointFeature],
47    /// Caller-supplied coordinate hypotheses.
48    pub hypotheses: &'a [CoordinateHypothesis],
49    /// Optional known grid dimensions.
50    pub dimensions: Option<GridDimensions>,
51    /// Consistency parameters.
52    pub params: ConsistencyParams,
53}
54
55impl<'a> ConsistencyRequest<'a> {
56    /// Construct a consistency request.
57    pub fn new(
58        lattice: LatticeKind,
59        features: &'a [PointFeature],
60        hypotheses: &'a [CoordinateHypothesis],
61        dimensions: Option<GridDimensions>,
62        params: ConsistencyParams,
63    ) -> Self {
64        Self {
65            lattice,
66            features,
67            hypotheses,
68            dimensions,
69            params,
70        }
71    }
72}
73
74/// Check whether caller-supplied coordinate hypotheses are geometrically
75/// consistent under the requested lattice family.
76pub fn check_consistency(request: ConsistencyRequest<'_>) -> Result<ConsistencyReport> {
77    let positions_by_source = validate_features(request.features)?;
78    validate_hypotheses(request.hypotheses, &positions_by_source)?;
79
80    if request.hypotheses.len() < 4 {
81        return Err(GridError::InsufficientEvidence);
82    }
83
84    let mut model_points = Vec::with_capacity(request.hypotheses.len());
85    let mut image_points = Vec::with_capacity(request.hypotheses.len());
86    for hypothesis in request.hypotheses {
87        model_points.push(request.lattice.model_point(hypothesis.coord));
88        image_points.push(positions_by_source[&hypothesis.source_index]);
89    }
90
91    let model_to_image = estimate_projective(&model_points, &image_points)?;
92
93    let mut entries = Vec::with_capacity(request.hypotheses.len());
94    let mut rejected = Vec::new();
95    let mut residual_sum = 0.0_f32;
96    let mut residual_max = 0.0_f32;
97
98    for hypothesis in request.hypotheses {
99        let model = request.lattice.model_point(hypothesis.coord);
100        let actual = positions_by_source[&hypothesis.source_index];
101        let predicted =
102            apply_projective(&model_to_image, model).ok_or(GridError::DegenerateGeometry)?;
103        let residual = distance(actual, predicted);
104        residual_sum += residual;
105        if residual > residual_max {
106            residual_max = residual;
107        }
108        if residual > request.params.max_residual_px {
109            rejected.push(RejectedFeature::new(
110                hypothesis.source_index,
111                Some(hypothesis.coord),
112                Some(residual),
113                RejectionReason::ResidualTooHigh,
114            ));
115        }
116        entries.push(GridEntry::new(
117            hypothesis.coord,
118            hypothesis.source_index,
119            actual,
120            Some(residual),
121        ));
122    }
123
124    entries.sort_by_key(|entry| (entry.coord, entry.source_index));
125    rejected.sort_by_key(|entry| (entry.coord, entry.source_index));
126
127    let count = entries.len();
128    let residuals = ResidualSummary::new(count, residual_sum / count as f32, residual_max);
129    let fit = LatticeFit::new(model_to_image, residuals);
130    let grid = LabelledGrid::new(request.lattice, entries, request.dimensions);
131    let passed = rejected.is_empty();
132    let solution = GridSolution::new(grid, Some(fit), rejected);
133    Ok(ConsistencyReport::new(passed, solution))
134}
135
136fn validate_features(features: &[PointFeature]) -> Result<HashMap<usize, Point2<f32>>> {
137    let mut out = HashMap::with_capacity(features.len());
138    for feature in features {
139        if !feature.position.x.is_finite() || !feature.position.y.is_finite() {
140            return Err(GridError::InconsistentInput(format!(
141                "feature {} has non-finite position",
142                feature.source_index
143            )));
144        }
145        if out.insert(feature.source_index, feature.position).is_some() {
146            return Err(GridError::InconsistentInput(format!(
147                "duplicate feature source_index {}",
148                feature.source_index
149            )));
150        }
151    }
152    Ok(out)
153}
154
155fn validate_hypotheses(
156    hypotheses: &[CoordinateHypothesis],
157    positions_by_source: &HashMap<usize, Point2<f32>>,
158) -> Result<()> {
159    let mut seen_sources = HashSet::with_capacity(hypotheses.len());
160    let mut seen_coords = HashSet::with_capacity(hypotheses.len());
161    for hypothesis in hypotheses {
162        if !positions_by_source.contains_key(&hypothesis.source_index) {
163            return Err(GridError::InconsistentInput(format!(
164                "hypothesis references missing feature source_index {}",
165                hypothesis.source_index
166            )));
167        }
168        if !seen_sources.insert(hypothesis.source_index) {
169            return Err(GridError::InconsistentInput(format!(
170                "duplicate hypothesis for feature source_index {}",
171                hypothesis.source_index
172            )));
173        }
174        if !seen_coords.insert(hypothesis.coord) {
175            return Err(GridError::InconsistentInput(format!(
176                "duplicate hypothesis for coordinate ({}, {})",
177                hypothesis.coord.u, hypothesis.coord.v
178            )));
179        }
180    }
181    Ok(())
182}
183
184fn distance(a: Point2<f32>, b: Point2<f32>) -> f32 {
185    let dx = a.x - b.x;
186    let dy = a.y - b.y;
187    (dx * dx + dy * dy).sqrt()
188}