Skip to main content

projective_grid/square/
regular.rs

1//! Zero-config regular square-grid detection from a bare point cloud.
2//!
3//! [`detect_regular_grid`] and [`RegularGridDetector`] turn a slice of
4//! 2D points into a labelled `(i, j)` grid **without the caller writing
5//! any validator scaffolding**. They are the onboarding entry point for
6//! `projective-grid`: the pattern hooks ([`SeedQuadValidator`],
7//! [`GrowValidator`], [`detect_square_grid`]) stay public under
8//! [`crate::square`] for callers who need pattern-specific gates
9//! (parity, marker slots), but a caller with only a point cloud should
10//! reach for this module first.
11//!
12//! # What "regular" means here
13//!
14//! The detector assumes the points form a single roughly-uniform
15//! square lattice (clean, rotated, or perspective-warped). It estimates
16//! the global cell size and the two dominant grid-axis directions from
17//! the cloud's nearest-neighbour offsets, then drives the generic
18//! seed → grow → extend → fill → validate pipeline with an internal
19//! **open regular-grid policy** that accepts any geometrically-valid
20//! parallelogram seed and attachment. There is no colour, parity, or
21//! marker reasoning — those belong to the pattern-specific detectors
22//! built on top of [`detect_square_grid`].
23//!
24//! [`SeedQuadValidator`]: crate::square::seed::finder::SeedQuadValidator
25//! [`GrowValidator`]: crate::square::grow::GrowValidator
26//! [`detect_square_grid`]: crate::square::detect::detect_square_grid
27
28use std::collections::HashMap;
29use std::f32::consts::FRAC_PI_2;
30
31use nalgebra::{Point2, Vector2};
32
33use crate::circular_stats::{
34    angle_to_bin, pick_two_peaks, smooth_circular_5, wrap_pi, PeakPickOptions,
35};
36use crate::global_step::{estimate_global_cell_size, GlobalStepParams};
37use crate::square::alignment::GridTransform;
38use crate::square::cleanup::{canonicalize_top_left, prune_to_main_component, sorted_grid_points};
39use crate::square::detect::{
40    detect_square_grid, detect_square_grid_all, ExtensionStrategy, MultiComponentParams,
41    SquareGridParams,
42};
43use crate::square::grow::{Admit, GrowValidator, LabelledNeighbour};
44use crate::square::seed::finder::SeedQuadValidator;
45use crate::topological::AxisEstimate;
46
47/// Tuning knobs for [`RegularGridDetector`].
48///
49/// `#[non_exhaustive]`: new knobs may land in future releases. Build
50/// fully-specified instances with [`RegularGridParams::new`] or start
51/// from [`RegularGridParams::default`] and override fields.
52#[non_exhaustive]
53#[derive(Clone, Debug)]
54pub struct RegularGridParams {
55    /// Core seed → grow → extend → fill → validate tuning. The internal
56    /// regular-grid policy fills in the pattern hooks; this struct
57    /// carries the geometric knobs only.
58    ///
59    /// The boundary-extension strategy lives on
60    /// [`SquareGridParams::extension`] (an [`ExtensionStrategy`]) — it is
61    /// the single source of truth and is honoured directly. Use
62    /// [`Self::with_extension`] to override it builder-style.
63    pub pipeline: SquareGridParams,
64    /// When `true`, [`detect_regular_grid`] canonicalises the labelled
65    /// grid to a visual top-left origin (`+i` → right, `+j` → down in
66    /// pixel space) before returning. When `false`, the grid keeps the
67    /// orientation BFS-grow produced (still rebased to `(0, 0)`).
68    pub canonicalize_top_left: bool,
69    /// When `true`, [`detect_regular_grid`] drops corners not
70    /// 4-connected to the largest labelled component. Off-grid spurious
71    /// points and bridged sub-grids both manifest as extra components;
72    /// pruning is a pattern-agnostic precision guard.
73    pub prune_disconnected: bool,
74}
75
76impl Default for RegularGridParams {
77    fn default() -> Self {
78        Self {
79            pipeline: SquareGridParams::default(),
80            canonicalize_top_left: true,
81            prune_disconnected: true,
82        }
83    }
84}
85
86impl RegularGridParams {
87    /// Construct params from a [`SquareGridParams`], defaulting the
88    /// `canonicalize_top_left` and `prune_disconnected` toggles to their
89    /// [`Default`] values (`true` for both).
90    ///
91    /// The struct is `#[non_exhaustive]`, so this named constructor (or
92    /// [`RegularGridParams::default`]) is the supported way to build one
93    /// outside the crate. Layer the `with_*` builders on top to override
94    /// individual fields. The boundary-extension strategy is configured
95    /// via [`SquareGridParams::extension`] inside `pipeline`, or
96    /// builder-style with [`Self::with_extension`].
97    pub fn new(pipeline: SquareGridParams) -> Self {
98        Self {
99            pipeline,
100            ..Self::default()
101        }
102    }
103
104    /// Override the boundary-extension strategy. Builder-style setter
105    /// that writes [`SquareGridParams::extension`] inside `pipeline` —
106    /// the single source of truth for the extension stage.
107    pub fn with_extension(mut self, extension: ExtensionStrategy) -> Self {
108        self.pipeline.extension = extension;
109        self
110    }
111
112    /// Override the top-left canonicalisation toggle. Builder-style
113    /// setter; see [`Self::with_extension`].
114    pub fn with_canonicalize_top_left(mut self, on: bool) -> Self {
115        self.canonicalize_top_left = on;
116        self
117    }
118
119    /// Override the connectivity-pruning toggle. Builder-style setter;
120    /// see [`Self::with_extension`].
121    pub fn with_prune_disconnected(mut self, on: bool) -> Self {
122        self.prune_disconnected = on;
123        self
124    }
125}
126
127/// One labelled point in a [`RegularGridDetection`].
128///
129/// Data carrier — fields are read directly; not `#[non_exhaustive]`.
130#[derive(Clone, Copy, Debug, PartialEq)]
131pub struct DetectedGridPoint {
132    /// Integer grid coordinate `(i, j)`. Rebased so the labelled
133    /// bounding box starts at `(0, 0)`.
134    pub grid: (i32, i32),
135    /// Pixel position of this corner (copied from the input slice).
136    pub position: Point2<f32>,
137    /// Index back into the caller's input `&[Point2<f32>]` slice.
138    pub source_index: usize,
139}
140
141/// Per-stage diagnostics returned alongside a [`RegularGridDetection`].
142///
143/// `#[non_exhaustive]`: new counters may be added in future releases.
144#[non_exhaustive]
145#[derive(Clone, Debug, Default)]
146pub struct RegularGridStats {
147    /// Number of input points fed to the detector.
148    pub input_points: usize,
149    /// Number of distinct connected components considered before
150    /// pruning. `1` on a clean single-board cloud.
151    pub components_found: usize,
152    /// Number of labelled corners in the chosen (largest) component
153    /// before connectivity pruning ran.
154    pub labelled_before_prune: usize,
155    /// Number of corners dropped by connectivity pruning. `0` when
156    /// pruning was disabled or the component was already connected.
157    pub pruned_disconnected: usize,
158    /// Number of corners flagged and dropped by the validation stage.
159    pub dropped_by_validation: usize,
160    /// `true` when the labelled grid was canonicalised to a visual
161    /// top-left origin.
162    pub canonicalized: bool,
163}
164
165/// Result of a regular-grid detection.
166///
167/// Data carrier — not `#[non_exhaustive]` (callers read fields and
168/// build fixtures). Carries the labelled grid as a `(j, i)`-sorted
169/// vector plus the inferred grid geometry and per-stage diagnostics.
170#[derive(Clone, Debug)]
171pub struct RegularGridDetection {
172    /// Labelled corners sorted by `(j, i)` — row-major, top-to-bottom
173    /// then left-to-right.
174    pub points: Vec<DetectedGridPoint>,
175    /// Pixel-space unit vector along the grid's `+i` direction.
176    pub axis_i: Vector2<f32>,
177    /// Pixel-space unit vector along the grid's `+j` direction.
178    pub axis_j: Vector2<f32>,
179    /// Estimated cell size in pixels (mean lattice spacing).
180    pub cell_size: f32,
181    /// Per-stage diagnostic counters.
182    pub stats: RegularGridStats,
183}
184
185/// Failure modes of [`detect_regular_grid`] / [`RegularGridDetector::detect`].
186///
187/// `#[non_exhaustive]`: future failure modes may be added. Each variant
188/// corresponds to a distinct early-exit in the detector.
189#[non_exhaustive]
190#[derive(Clone, Copy, Debug, PartialEq, Eq)]
191pub enum RegularGridError {
192    /// Fewer than four input points were supplied. Four is the minimum
193    /// for a 2×2 seed quad, so no detection is possible.
194    TooFewPoints {
195        /// Number of points actually supplied.
196        found: usize,
197    },
198    /// The point cloud is degenerate: collinear, pure noise, or
199    /// otherwise carries no inferable square lattice. The grid-axis
200    /// estimator could not extract a usable axis pair.
201    DegeneratePointCloud,
202    /// The cloud has four or more points and a usable axis estimate,
203    /// but no roughly-square parallelogram seed quad could be found.
204    NoGridFound,
205}
206
207impl std::fmt::Display for RegularGridError {
208    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
209        match self {
210            RegularGridError::TooFewPoints { found } => write!(
211                f,
212                "too few points: {found} supplied, at least 4 required for a 2x2 seed"
213            ),
214            RegularGridError::DegeneratePointCloud => write!(
215                f,
216                "degenerate point cloud: no square lattice could be inferred from the input"
217            ),
218            RegularGridError::NoGridFound => {
219                write!(
220                    f,
221                    "no grid found: no valid 2x2 seed quad in the point cloud"
222                )
223            }
224        }
225    }
226}
227
228impl std::error::Error for RegularGridError {}
229
230impl RegularGridDetection {
231    /// Reconstruct the `(i, j) → source_index` map from [`Self::points`].
232    pub fn labelled_map(&self) -> HashMap<(i32, i32), usize> {
233        self.points
234            .iter()
235            .map(|p| (p.grid, p.source_index))
236            .collect()
237    }
238}
239
240/// Zero-config regular square-grid detection.
241///
242/// Equivalent to `RegularGridDetector::default().detect(points)`.
243/// Returns [`RegularGridError`] when detection cannot proceed — see
244/// that enum for the distinct failure modes (too few points,
245/// degenerate cloud, no seed quad).
246///
247/// # Example
248///
249/// ```rust
250/// use nalgebra::Point2;
251/// use projective_grid::detect_regular_grid;
252///
253/// // A clean 5×4 axis-aligned grid at 30 px pitch.
254/// let mut points = Vec::new();
255/// for j in 0..4 {
256///     for i in 0..5 {
257///         points.push(Point2::new(i as f32 * 30.0, j as f32 * 30.0));
258///     }
259/// }
260///
261/// let grid = detect_regular_grid(&points).expect("clean grid detects");
262/// assert_eq!(grid.points.len(), 20);
263/// // Labels are rebased so the bounding box starts at (0, 0).
264/// assert!(grid.points.iter().any(|p| p.grid == (0, 0)));
265/// ```
266pub fn detect_regular_grid(
267    points: &[Point2<f32>],
268) -> Result<RegularGridDetection, RegularGridError> {
269    RegularGridDetector::default().detect(points)
270}
271
272/// Configurable regular square-grid detector.
273///
274/// Holds a [`RegularGridParams`]. Use [`RegularGridDetector::default`]
275/// for the zero-config path or construct one with custom params.
276#[derive(Clone, Debug, Default)]
277pub struct RegularGridDetector {
278    /// Tuning knobs. See [`RegularGridParams`].
279    pub params: RegularGridParams,
280}
281
282impl RegularGridDetector {
283    /// Construct a detector with explicit params.
284    pub fn new(params: RegularGridParams) -> Self {
285        Self { params }
286    }
287
288    /// Detect a regular square grid in `points`.
289    ///
290    /// Runs the generic seed → grow → extend → fill → validate pipeline
291    /// with an internal open regular-grid policy, applies generic
292    /// output cleanup (connectivity pruning, top-left canonicalisation,
293    /// `(j, i)` sort), and returns a [`RegularGridDetection`].
294    ///
295    /// Returns [`RegularGridError`] when detection cannot proceed: too
296    /// few points, a degenerate cloud with no inferable lattice, or no
297    /// valid seed quad.
298    #[cfg_attr(
299        feature = "tracing",
300        tracing::instrument(
301            level = "info",
302            skip_all,
303            fields(num_points = points.len()),
304        )
305    )]
306    pub fn detect(&self, points: &[Point2<f32>]) -> Result<RegularGridDetection, RegularGridError> {
307        if points.len() < 4 {
308            return Err(RegularGridError::TooFewPoints {
309                found: points.len(),
310            });
311        }
312
313        let policy =
314            OpenRegularPolicy::new(points).ok_or(RegularGridError::DegeneratePointCloud)?;
315        let pipeline = self.pipeline_params();
316
317        let detection = detect_square_grid(points, &policy, &policy, &pipeline)
318            .ok_or(RegularGridError::NoGridFound)?;
319
320        let mut stats = RegularGridStats {
321            input_points: points.len(),
322            components_found: 1,
323            canonicalized: self.params.canonicalize_top_left,
324            dropped_by_validation: detection.stats.dropped_by_validation,
325            ..Default::default()
326        };
327
328        let labelled = detection.labelled;
329        stats.labelled_before_prune = labelled.len();
330
331        let labelled = if self.params.prune_disconnected {
332            let pruned = prune_to_main_component(labelled);
333            stats.pruned_disconnected = stats.labelled_before_prune - pruned.len();
334            pruned
335        } else {
336            labelled
337        };
338
339        let (labelled, transform) = if self.params.canonicalize_top_left {
340            canonicalize_top_left(labelled, points)
341        } else {
342            (labelled, GridTransform::IDENTITY)
343        };
344
345        // Map the grid basis vectors through the canonicalisation
346        // transform so `axis_i` / `axis_j` stay consistent with the
347        // returned labels.
348        let (axis_i, axis_j) = transform_basis(detection.axis_i, detection.axis_j, transform);
349
350        Ok(build_detection(
351            &labelled,
352            points,
353            axis_i,
354            axis_j,
355            detection.cell_size,
356            stats,
357        ))
358    }
359
360    /// Detect every disjoint regular grid in `points`.
361    ///
362    /// Multi-component variant of [`Self::detect`]: peels off one
363    /// component at a time and returns each as its own
364    /// [`RegularGridDetection`], in detection order. Each component is
365    /// cleaned up independently (pruned, canonicalised, sorted).
366    ///
367    /// Returns an **empty `Vec`** when nothing is detected (too few
368    /// points, a degenerate cloud, or no seed quad). Unlike
369    /// [`Self::detect`], a multi-component sweep has no single failure
370    /// mode to report, so this method does not return a
371    /// [`RegularGridError`].
372    #[cfg_attr(
373        feature = "tracing",
374        tracing::instrument(
375            level = "info",
376            skip_all,
377            fields(num_points = points.len()),
378        )
379    )]
380    pub fn detect_all(&self, points: &[Point2<f32>]) -> Vec<RegularGridDetection> {
381        if points.len() < 4 {
382            return Vec::new();
383        }
384        let Some(policy) = OpenRegularPolicy::new(points) else {
385            return Vec::new();
386        };
387        let pipeline = self.pipeline_params();
388
389        let raw = detect_square_grid_all(
390            points,
391            &policy,
392            &policy,
393            &pipeline,
394            &MultiComponentParams::default(),
395        );
396        let components_found = raw.len();
397
398        raw.into_iter()
399            .map(|detection| {
400                let mut stats = RegularGridStats {
401                    input_points: points.len(),
402                    components_found,
403                    canonicalized: self.params.canonicalize_top_left,
404                    dropped_by_validation: detection.stats.dropped_by_validation,
405                    ..Default::default()
406                };
407                let labelled = detection.labelled;
408                stats.labelled_before_prune = labelled.len();
409
410                let labelled = if self.params.prune_disconnected {
411                    let pruned = prune_to_main_component(labelled);
412                    stats.pruned_disconnected = stats.labelled_before_prune - pruned.len();
413                    pruned
414                } else {
415                    labelled
416                };
417                let (labelled, transform) = if self.params.canonicalize_top_left {
418                    canonicalize_top_left(labelled, points)
419                } else {
420                    (labelled, GridTransform::IDENTITY)
421                };
422                let (axis_i, axis_j) =
423                    transform_basis(detection.axis_i, detection.axis_j, transform);
424                build_detection(
425                    &labelled,
426                    points,
427                    axis_i,
428                    axis_j,
429                    detection.cell_size,
430                    stats,
431                )
432            })
433            .collect()
434    }
435
436    /// The [`SquareGridParams`] handed to the generic pipeline.
437    ///
438    /// Returns `self.params.pipeline` verbatim: the boundary-extension
439    /// strategy lives on [`SquareGridParams::extension`] and is the
440    /// single source of truth, so no remapping is needed.
441    fn pipeline_params(&self) -> SquareGridParams {
442        self.params.pipeline.clone()
443    }
444}
445
446/// Assemble a [`RegularGridDetection`] from a cleaned labelled map.
447fn build_detection(
448    labelled: &HashMap<(i32, i32), usize>,
449    points: &[Point2<f32>],
450    axis_i: Vector2<f32>,
451    axis_j: Vector2<f32>,
452    cell_size: f32,
453    stats: RegularGridStats,
454) -> RegularGridDetection {
455    let detected: Vec<DetectedGridPoint> = sorted_grid_points(labelled)
456        .into_iter()
457        .map(|(grid, idx)| DetectedGridPoint {
458            grid,
459            position: points[idx],
460            source_index: idx,
461        })
462        .collect();
463    RegularGridDetection {
464        points: detected,
465        axis_i,
466        axis_j,
467        cell_size,
468        stats,
469    }
470}
471
472/// Map the grid basis vectors through a D4 canonicalisation transform.
473///
474/// The transform acts on integer grid coordinates; its action on the
475/// pixel-space basis is the same `2×2` integer matrix applied to the
476/// `(u, v)` columns. The result is renormalised.
477fn transform_basis(
478    axis_i: Vector2<f32>,
479    axis_j: Vector2<f32>,
480    transform: GridTransform,
481) -> (Vector2<f32>, Vector2<f32>) {
482    // The new +i grid direction is `inv·(1, 0)` in old grid coords, so
483    // its pixel image is `gi.i * u + gi.j * v`; likewise for +j.
484    let inv = transform.inverse().unwrap_or(GridTransform::IDENTITY);
485    let gi = inv.apply(1, 0);
486    let gj = inv.apply(0, 1);
487    let new_i = axis_i * gi.i as f32 + axis_j * gi.j as f32;
488    let new_j = axis_i * gj.i as f32 + axis_j * gj.j as f32;
489    let norm_i = new_i.norm().max(1e-6);
490    let norm_j = new_j.norm().max(1e-6);
491    (new_i / norm_i, new_j / norm_j)
492}
493
494// ---------------------------------------------------------------------------
495// Open regular-grid policy: the built-in `SeedQuadValidator` +
496// `GrowValidator` impl that accepts any geometrically-valid seed and
497// attachment. This is what frees a point-cloud caller from writing
498// validator scaffolding — it is the promotion of the `OpenValidator` /
499// `ToySeedValidator` idea from the advanced-policy smoke test into the
500// crate's built-in regular-grid policy.
501// ---------------------------------------------------------------------------
502
503/// Pattern-agnostic seed + grow policy for a single regular grid.
504///
505/// Holds the input positions and the two estimated grid-axis
506/// directions. Every corner is eligible as both an `A`/`D` and a `B`/`C`
507/// seed candidate (a regular grid has no colour split), every
508/// attachment is accepted, and no parity / edge constraint is imposed —
509/// the generic geometric checks inside `find_quad` / `bfs_grow` carry
510/// the recovery.
511struct OpenRegularPolicy {
512    positions: Vec<Point2<f32>>,
513    axes: [AxisEstimate; 2],
514}
515
516impl OpenRegularPolicy {
517    /// Build the policy, estimating the grid axes from the cloud's
518    /// nearest-neighbour offsets. Returns `None` when the cloud is too
519    /// small or degenerate to infer an axis pair.
520    fn new(points: &[Point2<f32>]) -> Option<Self> {
521        let axes = estimate_grid_axes(points)?;
522        Some(Self {
523            positions: points.to_vec(),
524            axes,
525        })
526    }
527}
528
529impl SeedQuadValidator for OpenRegularPolicy {
530    fn position(&self, idx: usize) -> Point2<f32> {
531        self.positions[idx]
532    }
533
534    fn axes(&self, _idx: usize) -> [AxisEstimate; 2] {
535        // Every corner shares the globally-estimated axis pair: a
536        // regular grid has one dominant orientation.
537        self.axes
538    }
539
540    fn a_candidates(&self) -> Vec<usize> {
541        // A regular grid has no colour split — every corner can serve
542        // as the seed's A/D corner.
543        (0..self.positions.len()).collect()
544    }
545
546    fn bc_candidates(&self) -> Vec<usize> {
547        // ...and likewise as a B/C corner. `find_quad` rejects the
548        // degenerate `A == B` / `A == C` cases internally.
549        (0..self.positions.len()).collect()
550    }
551}
552
553impl GrowValidator for OpenRegularPolicy {
554    fn is_eligible(&self, _idx: usize) -> bool {
555        true
556    }
557
558    fn required_label_at(&self, _i: i32, _j: i32) -> Option<u8> {
559        None
560    }
561
562    fn label_of(&self, _idx: usize) -> Option<u8> {
563        None
564    }
565
566    fn accept_candidate(
567        &self,
568        _idx: usize,
569        _at: (i32, i32),
570        _prediction: Point2<f32>,
571        _neighbours: &[LabelledNeighbour],
572    ) -> Admit {
573        Admit::Accept
574    }
575}
576
577/// Estimate the two dominant grid-axis directions from a point cloud.
578///
579/// Builds a weighted mod-π histogram of every corner's nearest-
580/// neighbour offset angle, smooths it, and picks the two strongest
581/// plateau-aware peaks. Falls back to the axis-aligned `(0, π/2)` pair
582/// when the histogram has no two qualifying peaks (e.g. an exactly
583/// axis-aligned grid produces a single sharp peak — the orthogonal
584/// direction is implied).
585fn estimate_grid_axes(points: &[Point2<f32>]) -> Option<[AxisEstimate; 2]> {
586    use kiddo::{KdTree, SquaredEuclidean};
587
588    if points.len() < 4 {
589        return None;
590    }
591    // A cell-size estimate confirms the cloud is grid-like; it is not
592    // used numerically here but guards against pure noise.
593    estimate_global_cell_size(points, &GlobalStepParams::<f32>::default())?;
594
595    let mut tree: KdTree<f32, 2> = KdTree::new();
596    for (idx, p) in points.iter().enumerate() {
597        tree.add(&[p.x, p.y], idx as u64);
598    }
599
600    const N_BINS: usize = 180;
601    let mut hist = vec![0.0_f32; N_BINS];
602    let mut total = 0.0_f32;
603    for (i, p) in points.iter().enumerate() {
604        // The four nearest neighbours capture both grid axes even when
605        // the closest neighbour all lie along one direction.
606        let hits = tree.nearest_n::<SquaredEuclidean>(&[p.x, p.y], 5);
607        for hit in hits {
608            let j = hit.item as usize;
609            if j == i {
610                continue;
611            }
612            let q = points[j];
613            let off = Vector2::new(q.x - p.x, q.y - p.y);
614            let len = off.norm();
615            if len < 1e-3 {
616                continue;
617            }
618            let ang = wrap_pi(off.y.atan2(off.x));
619            let bin = angle_to_bin(ang, N_BINS);
620            // Weight by length so the lattice step dominates over any
621            // sub-cell marker spacing.
622            hist[bin] += len;
623            total += len;
624        }
625    }
626    if total <= 0.0 {
627        return None;
628    }
629
630    let smoothed = smooth_circular_5(&hist);
631    let opts = PeakPickOptions::new(0.05, 30.0_f32.to_radians());
632    match pick_two_peaks(&smoothed, total, &opts) {
633        Some((t0, t1)) => {
634            // Order so axis 0 is the smaller angle, axis 1 the larger,
635            // matching the `SeedQuadValidator::axes` contract.
636            let (lo, hi) = if t0 <= t1 { (t0, t1) } else { (t1, t0) };
637            Some([AxisEstimate::from_angle(lo), AxisEstimate::from_angle(hi)])
638        }
639        None => {
640            // A single dominant direction: the orthogonal axis is
641            // implied. Pick the strongest bin and add π/2.
642            let peak = smoothed
643                .iter()
644                .enumerate()
645                .max_by(|a, b| a.1.total_cmp(b.1))
646                .map(|(b, _)| b)?;
647            let theta = wrap_pi(crate::circular_stats::bin_to_angle(peak, N_BINS));
648            let other = wrap_pi(theta + FRAC_PI_2);
649            let (lo, hi) = if theta <= other {
650                (theta, other)
651            } else {
652                (other, theta)
653            };
654            Some([AxisEstimate::from_angle(lo), AxisEstimate::from_angle(hi)])
655        }
656    }
657}
658
659#[cfg(test)]
660mod tests {
661    use super::*;
662    use nalgebra::Matrix3;
663
664    fn axis_aligned_grid(rows: i32, cols: i32, s: f32) -> Vec<Point2<f32>> {
665        let mut out = Vec::new();
666        for j in 0..rows {
667            for i in 0..cols {
668                out.push(Point2::new(i as f32 * s + 40.0, j as f32 * s + 40.0));
669            }
670        }
671        out
672    }
673
674    #[test]
675    fn detects_clean_axis_aligned_grid() {
676        let pts = axis_aligned_grid(6, 6, 25.0);
677        let grid = detect_regular_grid(&pts).expect("clean grid detects");
678        assert_eq!(grid.points.len(), 36);
679        assert_eq!(grid.stats.input_points, 36);
680    }
681
682    #[test]
683    fn returns_err_on_collinear_cloud() {
684        let pts: Vec<Point2<f32>> = (0..6).map(|i| Point2::new(i as f32 * 10.0, 0.0)).collect();
685        assert!(detect_regular_grid(&pts).is_err());
686    }
687
688    #[test]
689    fn estimate_grid_axes_recovers_rotation() {
690        // 5×5 grid rotated by ~30°.
691        let theta = 30.0_f32.to_radians();
692        let (c, s) = (theta.cos(), theta.sin());
693        let mut pts = Vec::new();
694        for j in 0..5 {
695            for i in 0..5 {
696                let (x, y) = (i as f32 * 20.0, j as f32 * 20.0);
697                pts.push(Point2::new(x * c - y * s + 100.0, x * s + y * c + 100.0));
698            }
699        }
700        let axes = estimate_grid_axes(&pts).expect("axes");
701        // One of the two axes should sit near 30° (mod π).
702        let near = axes
703            .iter()
704            .any(|a| crate::circular_stats::angular_dist_pi(a.angle, theta) < 0.15);
705        assert!(near, "expected an axis near 30°, got {axes:?}");
706    }
707
708    #[test]
709    fn perspective_warped_grid_is_recovered() {
710        let h = Matrix3::new(30.0_f32, 3.0, 50.0, 1.5, 30.0, 50.0, 2e-4, 1e-4, 1.0);
711        let mut pts = Vec::new();
712        for j in 0..7 {
713            for i in 0..7 {
714                let (x, y) = (i as f32, j as f32);
715                let w = h[(2, 0)] * x + h[(2, 1)] * y + h[(2, 2)];
716                let xp = (h[(0, 0)] * x + h[(0, 1)] * y + h[(0, 2)]) / w;
717                let yp = (h[(1, 0)] * x + h[(1, 1)] * y + h[(1, 2)]) / w;
718                pts.push(Point2::new(xp, yp));
719            }
720        }
721        let grid = detect_regular_grid(&pts).expect("warped grid detects");
722        assert!(grid.points.len() >= 40, "got {}", grid.points.len());
723    }
724}