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