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}