Skip to main content

projective_grid/
result.rs

1//! Shared output types for detection and consistency tasks.
2//!
3//! The detection surface is pinned to `f32`; see [`crate::feature`] for
4//! the rationale.
5
6use std::collections::HashMap;
7
8use nalgebra::{Point2, Projective2};
9
10use crate::lattice::{Coord, GridDimensions, LatticeKind};
11
12/// One labelled grid feature in a solved grid.
13#[derive(Clone, Copy, Debug, PartialEq)]
14#[non_exhaustive]
15pub struct GridEntry {
16    /// Lattice coordinate assigned to this feature.
17    pub coord: Coord,
18    /// Caller-owned feature source index.
19    pub source_index: usize,
20    /// Image-frame pixel-center position.
21    pub image_position: Point2<f32>,
22    /// Reprojection residual in image pixels, when a fit was computed.
23    pub residual_px: Option<f32>,
24}
25
26impl GridEntry {
27    /// Construct a labelled grid entry.
28    pub fn new(
29        coord: Coord,
30        source_index: usize,
31        image_position: Point2<f32>,
32        residual_px: Option<f32>,
33    ) -> Self {
34        Self {
35            coord,
36            source_index,
37            image_position,
38            residual_px,
39        }
40    }
41}
42
43/// A labelled grid component.
44#[derive(Clone, Debug, PartialEq)]
45#[non_exhaustive]
46pub struct LabelledGrid {
47    /// Lattice family of this grid.
48    pub lattice: LatticeKind,
49    /// Labelled feature entries.
50    pub entries: Vec<GridEntry>,
51    /// Inclusive coordinate bounding box, if the grid is non-empty.
52    pub bbox: Option<(Coord, Coord)>,
53    /// Optional known dimensions supplied by the caller.
54    pub dimensions: Option<GridDimensions>,
55}
56
57impl LabelledGrid {
58    /// Construct a labelled grid.
59    pub fn new(
60        lattice: LatticeKind,
61        entries: Vec<GridEntry>,
62        dimensions: Option<GridDimensions>,
63    ) -> Self {
64        let bbox = bbox_for_entries(&entries);
65        Self {
66            lattice,
67            entries,
68            bbox,
69            dimensions,
70        }
71    }
72
73    /// Linear-scan lookup of the labelled entry with the given source index.
74    pub fn find(&self, source_index: usize) -> Option<&GridEntry> {
75        self.entries.iter().find(|e| e.source_index == source_index)
76    }
77
78    /// Normalize the labelled grid in place to the canonical output frame.
79    ///
80    /// Three steps, in order:
81    ///
82    /// 1. **Rebase** the coordinate bounding-box minimum to `(0, 0)`, so every
83    ///    `coord` is non-negative (the hard non-negative-label invariant for
84    ///    overlay / calibration consumers).
85    /// 2. **Canonicalize orientation** so the first lattice axis (`u`) points
86    ///    roughly `+x` (right) and the second (`v`) roughly `+y` (down) in image
87    ///    pixels. The grid finder assigns `(u, v)` from its internal axis-slot
88    ///    convention, which has no relation to image orientation; without this
89    ///    step `(0, 0)` can land anywhere on the detected grid. The decision is
90    ///    driven by [`GridEntry::image_position`] (averaged step vectors over all
91    ///    adjacent labelled pairs); positions are never modified, only labels are
92    ///    permuted / sign-flipped. When this step transposes the two axes, any
93    ///    caller-supplied [`dimensions`](LabelledGrid::dimensions) have their
94    ///    `width`/`height` swapped too, so they stay aligned with the new axes.
95    /// 3. **Sort** entries by `(v, u)` for a stable output order, and recompute
96    ///    [`bbox`](LabelledGrid::bbox).
97    ///
98    /// This is the single source of truth for grid-result normalization: target
99    /// detectors call it instead of re-implementing rebase / canonicalize / sort
100    /// at their output stage. It operates only on the labelled grid, so any
101    /// [`LatticeFit`] computed against the *pre*-normalization labels is no
102    /// longer valid afterwards — normalize before fitting, or refit.
103    pub fn normalize(&mut self) {
104        rebase_entries_to_origin(&mut self.entries);
105        let swapped = canonicalize_to_image_axes(&mut self.entries);
106        if swapped {
107            // The `u` ↔ `v` transpose must carry to caller-supplied dimensions,
108            // or `dimensions` would describe the pre-swap axes while `entries`
109            // and `bbox` describe the new ones.
110            if let Some(dims) = self.dimensions.as_mut() {
111                std::mem::swap(&mut dims.width, &mut dims.height);
112            }
113        }
114        self.entries.sort_by_key(|e| (e.coord.v, e.coord.u));
115        self.bbox = bbox_for_entries(&self.entries);
116    }
117}
118
119/// Residual summary in image pixels.
120#[derive(Clone, Copy, Debug, PartialEq)]
121#[non_exhaustive]
122pub struct ResidualSummary {
123    /// Number of residuals included in the summary.
124    pub count: usize,
125    /// Mean residual in pixels.
126    pub mean_px: f32,
127    /// Maximum residual in pixels.
128    pub max_px: f32,
129}
130
131impl ResidualSummary {
132    /// Construct a residual summary.
133    pub fn new(count: usize, mean_px: f32, max_px: f32) -> Self {
134        Self {
135            count,
136            mean_px,
137            max_px,
138        }
139    }
140}
141
142/// Fitted lattice-to-image transform plus residual summary.
143#[derive(Clone, Debug, PartialEq)]
144#[non_exhaustive]
145pub struct LatticeFit {
146    /// Projective mapping from model-plane lattice coordinates to image pixels.
147    pub model_to_image: Projective2<f32>,
148    /// Residual summary in image pixels.
149    pub residuals: ResidualSummary,
150}
151
152impl LatticeFit {
153    /// Construct a lattice fit.
154    pub fn new(model_to_image: Projective2<f32>, residuals: ResidualSummary) -> Self {
155        Self {
156            model_to_image,
157            residuals,
158        }
159    }
160}
161
162/// Reason why an observed feature did not pass a task gate.
163#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
164#[non_exhaustive]
165pub enum RejectionReason {
166    /// Reprojection residual exceeded the configured threshold.
167    ResidualTooHigh,
168    /// Feature was never labelled by the detection pipeline (e.g. noise
169    /// outside the recovered lattice support).
170    Unlabelled,
171    /// Feature was labelled by the topological pass but dropped by the
172    /// post-build validation stage (line collinearity, local-H residual,
173    /// or edge-length band).
174    ValidationDropped,
175}
176
177/// Rejected feature record.
178#[derive(Clone, Copy, Debug, PartialEq)]
179#[non_exhaustive]
180pub struct RejectedFeature {
181    /// Caller-owned source index.
182    pub source_index: usize,
183    /// Coordinate associated with the rejection, if one was proposed.
184    pub coord: Option<Coord>,
185    /// Residual in image pixels, if available.
186    pub residual_px: Option<f32>,
187    /// Rejection reason.
188    pub reason: RejectionReason,
189}
190
191impl RejectedFeature {
192    /// Construct a rejected-feature record.
193    pub fn new(
194        source_index: usize,
195        coord: Option<Coord>,
196        residual_px: Option<f32>,
197        reason: RejectionReason,
198    ) -> Self {
199        Self {
200            source_index,
201            coord,
202            residual_px,
203            reason,
204        }
205    }
206}
207
208/// Shared successful solution shape for grid tasks.
209#[derive(Clone, Debug, PartialEq)]
210#[non_exhaustive]
211pub struct GridSolution {
212    /// Labelled grid entries.
213    pub grid: LabelledGrid,
214    /// Lattice fit, when the task computed one.
215    pub fit: Option<LatticeFit>,
216    /// Features rejected by task gates.
217    pub rejected: Vec<RejectedFeature>,
218}
219
220impl GridSolution {
221    /// Construct a grid solution.
222    pub fn new(
223        grid: LabelledGrid,
224        fit: Option<LatticeFit>,
225        rejected: Vec<RejectedFeature>,
226    ) -> Self {
227        Self {
228            grid,
229            fit,
230            rejected,
231        }
232    }
233
234    /// Linear-scan lookup of the rejection record for the given source index, if any.
235    pub fn rejected_for(&self, source_index: usize) -> Option<&RejectedFeature> {
236        self.rejected
237            .iter()
238            .find(|r| r.source_index == source_index)
239    }
240}
241
242/// Report returned by coordinate-hypothesis consistency checks.
243#[derive(Clone, Debug, PartialEq)]
244#[non_exhaustive]
245pub struct ConsistencyReport {
246    /// `true` when all residuals satisfy the configured threshold.
247    pub passed: bool,
248    /// Labelled solution and residual diagnostics.
249    pub solution: GridSolution,
250}
251
252impl ConsistencyReport {
253    /// Construct a consistency report.
254    pub fn new(passed: bool, solution: GridSolution) -> Self {
255        Self { passed, solution }
256    }
257
258    /// Convenience accessor for the maximum residual in pixels from the fitted lattice,
259    /// when one was computed.
260    pub fn max_residual_px(&self) -> Option<f32> {
261        Some(self.solution.fit.as_ref()?.residuals.max_px)
262    }
263}
264
265/// Shift every entry's coordinate so the bounding-box minimum is `(0, 0)`.
266fn rebase_entries_to_origin(entries: &mut [GridEntry]) {
267    if entries.is_empty() {
268        return;
269    }
270    let (min_u, min_v) = entries.iter().fold((i32::MAX, i32::MAX), |(a, b), e| {
271        (a.min(e.coord.u), b.min(e.coord.v))
272    });
273    if min_u != 0 || min_v != 0 {
274        for e in entries.iter_mut() {
275            e.coord.u -= min_u;
276            e.coord.v -= min_v;
277        }
278    }
279}
280
281/// Permute / sign-flip the lattice axes so `+u` points roughly `+x` and `+v`
282/// roughly `+y` in image pixels, keeping labels non-negative. Uses only
283/// [`GridEntry::image_position`]; positions are unchanged.
284///
285/// The mean `+u` and `+v` step vectors are accumulated over adjacent labelled
286/// pairs in a deterministic coordinate order (sorted keys), so the `f32` sums do
287/// not depend on map iteration order — the swap / flip decision is a function of
288/// signs and magnitude comparisons that is robust to ULP-level summation
289/// differences.
290/// Canonicalize entry labels to the image-axis frame, returning `true` when the
291/// two lattice axes were transposed (`u` ↔ `v`). Callers that hold an axis-keyed
292/// side value (e.g. [`GridDimensions`]) must apply the same transpose when this
293/// returns `true`; sign flips alone (return `false`) keep the axis assignment.
294fn canonicalize_to_image_axes(entries: &mut [GridEntry]) -> bool {
295    if entries.len() < 2 {
296        return false;
297    }
298    let pos_by_uv: HashMap<(i32, i32), (f32, f32)> = entries
299        .iter()
300        .map(|e| {
301            (
302                (e.coord.u, e.coord.v),
303                (e.image_position.x, e.image_position.y),
304            )
305        })
306        .collect();
307
308    let mut keys: Vec<(i32, i32)> = pos_by_uv.keys().copied().collect();
309    keys.sort_unstable();
310    let mut vu_sum = (0.0_f32, 0.0_f32);
311    let mut vv_sum = (0.0_f32, 0.0_f32);
312    let mut vu_n = 0u32;
313    let mut vv_n = 0u32;
314    for &(u, v) in &keys {
315        let (x, y) = pos_by_uv[&(u, v)];
316        if let Some(&(xn, yn)) = pos_by_uv.get(&(u + 1, v)) {
317            vu_sum.0 += xn - x;
318            vu_sum.1 += yn - y;
319            vu_n += 1;
320        }
321        if let Some(&(xn, yn)) = pos_by_uv.get(&(u, v + 1)) {
322            vv_sum.0 += xn - x;
323            vv_sum.1 += yn - y;
324            vv_n += 1;
325        }
326    }
327    if vu_n == 0 || vv_n == 0 {
328        return false;
329    }
330    let vu = (vu_sum.0 / vu_n as f32, vu_sum.1 / vu_n as f32);
331    let vv = (vv_sum.0 / vv_n as f32, vv_sum.1 / vv_n as f32);
332
333    // Make the axis with the larger |x| component the horizontal (`u`) axis.
334    let swap = vu.0.abs() < vv.0.abs();
335    let new_vu = if swap { vv } else { vu };
336    let new_vv = if swap { vu } else { vv };
337    let flip_u = new_vu.0 < 0.0;
338    let flip_v = new_vv.1 < 0.0;
339
340    if !swap && !flip_u && !flip_v {
341        return false;
342    }
343
344    // Post-swap extents, so the sign flip stays within the non-negative domain.
345    let mut umax = i32::MIN;
346    let mut vmax = i32::MIN;
347    for e in entries.iter() {
348        let (nu, nv) = if swap {
349            (e.coord.v, e.coord.u)
350        } else {
351            (e.coord.u, e.coord.v)
352        };
353        umax = umax.max(nu);
354        vmax = vmax.max(nv);
355    }
356
357    for e in entries.iter_mut() {
358        let (mut nu, mut nv) = if swap {
359            (e.coord.v, e.coord.u)
360        } else {
361            (e.coord.u, e.coord.v)
362        };
363        if flip_u {
364            nu = umax - nu;
365        }
366        if flip_v {
367            nv = vmax - nv;
368        }
369        e.coord.u = nu;
370        e.coord.v = nv;
371    }
372
373    swap
374}
375
376fn bbox_for_entries(entries: &[GridEntry]) -> Option<(Coord, Coord)> {
377    let first = entries.first()?;
378    let mut min = first.coord;
379    let mut max = first.coord;
380    for entry in &entries[1..] {
381        min.u = min.u.min(entry.coord.u);
382        min.v = min.v.min(entry.coord.v);
383        max.u = max.u.max(entry.coord.u);
384        max.v = max.v.max(entry.coord.v);
385    }
386    Some((min, max))
387}
388
389#[cfg(test)]
390mod tests {
391    use nalgebra::{Point2, Projective2};
392
393    use super::*;
394
395    fn make_identity_fit() -> LatticeFit {
396        LatticeFit::new(
397            Projective2::identity(),
398            ResidualSummary::new(1, 0.5_f32, 1.0_f32),
399        )
400    }
401
402    #[test]
403    fn max_residual_px_none_when_fit_absent() {
404        let grid = LabelledGrid::new(LatticeKind::Square, vec![], None);
405        let solution = GridSolution::new(grid, None, vec![]);
406        let report = ConsistencyReport::new(true, solution);
407        assert_eq!(report.max_residual_px(), None);
408    }
409
410    #[test]
411    fn max_residual_px_some_when_fit_present() {
412        let grid = LabelledGrid::new(LatticeKind::Square, vec![], None);
413        let fit = make_identity_fit();
414        let solution = GridSolution::new(grid, Some(fit), vec![]);
415        let report = ConsistencyReport::new(true, solution);
416        assert_eq!(report.max_residual_px(), Some(1.0_f32));
417    }
418
419    #[test]
420    fn labelled_grid_find_present_and_absent() {
421        let entry = GridEntry::new(Coord::new(0, 0), 42, Point2::new(1.0_f32, 2.0), None);
422        let grid = LabelledGrid::new(LatticeKind::Square, vec![entry], None);
423        assert!(grid.find(42).is_some());
424        assert!(grid.find(99).is_none());
425    }
426
427    fn mk_entry(u: i32, v: i32, x: f32, y: f32) -> GridEntry {
428        GridEntry::new(Coord::new(u, v), 0, Point2::new(x, y), None)
429    }
430
431    fn coord_by_pos(grid: &LabelledGrid) -> HashMap<(i32, i32), (i32, i32)> {
432        grid.entries
433            .iter()
434            .map(|e| {
435                (
436                    (e.image_position.x as i32, e.image_position.y as i32),
437                    (e.coord.u, e.coord.v),
438                )
439            })
440            .collect()
441    }
442
443    #[test]
444    fn normalize_rebases_and_sorts_already_canonical() {
445        // +u already points +x, +v already points +y; only an offset to remove.
446        let entries = vec![
447            mk_entry(3, 5, 10.0, 10.0),
448            mk_entry(4, 5, 20.0, 10.0),
449            mk_entry(3, 6, 10.0, 20.0),
450            mk_entry(4, 6, 20.0, 20.0),
451        ];
452        let mut grid = LabelledGrid::new(LatticeKind::Square, entries, None);
453        grid.normalize();
454        let by_pos = coord_by_pos(&grid);
455        assert_eq!(by_pos[&(10, 10)], (0, 0));
456        assert_eq!(by_pos[&(20, 10)], (1, 0));
457        assert_eq!(by_pos[&(10, 20)], (0, 1));
458        assert_eq!(by_pos[&(20, 20)], (1, 1));
459        assert_eq!(grid.bbox, Some((Coord::new(0, 0), Coord::new(1, 1))));
460        // Stable (v, u) order.
461        let order: Vec<(i32, i32)> = grid
462            .entries
463            .iter()
464            .map(|e| (e.coord.u, e.coord.v))
465            .collect();
466        assert_eq!(order, vec![(0, 0), (1, 0), (0, 1), (1, 1)]);
467    }
468
469    #[test]
470    fn normalize_canonicalizes_rotated_axes() {
471        // Builder assigned +u along +y and +v along +x (a 90° rotation);
472        // normalize must swap so +u ≈ +x and +v ≈ +y, putting (0, 0) at the
473        // smallest (x, y) corner.
474        let entries = vec![
475            mk_entry(0, 0, 10.0, 10.0),
476            mk_entry(0, 1, 20.0, 10.0),
477            mk_entry(1, 0, 10.0, 20.0),
478            mk_entry(1, 1, 20.0, 20.0),
479        ];
480        let mut grid = LabelledGrid::new(LatticeKind::Square, entries, None);
481        grid.normalize();
482        let by_pos = coord_by_pos(&grid);
483        assert_eq!(
484            by_pos[&(10, 10)],
485            (0, 0),
486            "(0,0) must land at smallest (x,y)"
487        );
488        assert_eq!(by_pos[&(20, 10)], (1, 0), "+u must point +x");
489        assert_eq!(by_pos[&(10, 20)], (0, 1), "+v must point +y");
490    }
491
492    #[test]
493    fn normalize_transposes_dimensions_on_axis_swap() {
494        // Same 90°-rotated geometry as the test above (builder put +u along +y
495        // and +v along +x), so normalize swaps u ↔ v. Caller-supplied
496        // rectangular dimensions must transpose with the axes, or they would
497        // describe the pre-swap frame while entries/bbox describe the new one.
498        let entries = vec![
499            mk_entry(0, 0, 10.0, 10.0),
500            mk_entry(0, 1, 20.0, 10.0),
501            mk_entry(1, 0, 10.0, 20.0),
502            mk_entry(1, 1, 20.0, 20.0),
503        ];
504        let mut grid = LabelledGrid::new(
505            LatticeKind::Square,
506            entries,
507            Some(GridDimensions::new(5, 3)),
508        );
509        grid.normalize();
510        assert_eq!(
511            grid.dimensions,
512            Some(GridDimensions::new(3, 5)),
513            "axis swap must transpose width/height"
514        );
515    }
516
517    #[test]
518    fn normalize_keeps_dimensions_when_axes_only_flip() {
519        // +u along -x, +v along +y: a sign flip on u, NO transpose. Dimensions
520        // must be left untouched.
521        let entries = vec![
522            mk_entry(0, 0, 20.0, 10.0),
523            mk_entry(1, 0, 10.0, 10.0),
524            mk_entry(0, 1, 20.0, 20.0),
525            mk_entry(1, 1, 10.0, 20.0),
526        ];
527        let mut grid = LabelledGrid::new(
528            LatticeKind::Square,
529            entries,
530            Some(GridDimensions::new(5, 3)),
531        );
532        grid.normalize();
533        assert_eq!(
534            grid.dimensions,
535            Some(GridDimensions::new(5, 3)),
536            "a sign flip without a transpose must not touch dimensions"
537        );
538    }
539
540    #[test]
541    fn grid_solution_rejected_for_present_and_absent() {
542        let rejected =
543            RejectedFeature::new(5, None, Some(3.0_f32), RejectionReason::ResidualTooHigh);
544        let grid = LabelledGrid::new(LatticeKind::Square, vec![], None);
545        let solution = GridSolution::new(grid, None, vec![rejected]);
546        assert!(solution.rejected_for(5).is_some());
547        assert!(solution.rejected_for(0).is_none());
548    }
549}