Skip to main content

projective_grid/cluster/
mod.rs

1//! Global grid-direction recovery from per-feature dual-axis estimates.
2//!
3//! Given a set of features that each carry **two** undirected local
4//! lattice-axis estimates (e.g. chess-corner x-junctions whose two
5//! tanh-fit ridges approximate the local grid directions), this module
6//! recovers the two global grid-direction centres `(Θ₀, Θ₁)` and
7//! labels every feature by which of its two axes matches `Θ₀` vs `Θ₁`.
8//!
9//! It is the orientation-prior stage shared by every grid pipeline that
10//! ingests dual-oriented features. Target-specific glue (which features
11//! are eligible, how to map the canonical/swapped assignment onto a
12//! caller's own label type, parity-coherence repair) stays caller-side;
13//! this module is the pure direction-clustering math.
14//!
15//! **Tier:** stable facade — [`cluster_axes`] and its
16//! [`AxisClusterCenters`] / [`AxisAssignment`] types are re-exported at the
17//! crate root and follow normal semver intent.
18//!
19//! # Inputs / outputs
20//!
21//! * Input: a slice of [`AxisFeature`], each carrying its two
22//!   [`AxisObservation`]s `(angle, sigma)` and a detector `strength`.
23//!   Axes whose sigma is the no-info sentinel (≥ `π`) or non-finite are
24//!   skipped. Callers pre-filter to the features they want to vote
25//!   (e.g. chessboard passes only its `Strong` corners).
26//! * Output:
27//!   - [`AxisClusterCenters`] `{ theta0, theta1 }` in `[0, π)` with
28//!     `theta0 ≤ theta1`.
29//!   - A per-feature [`AxisAssignment`].
30//!
31//! # Algorithm
32//!
33//! 1. Build a smoothed circular histogram on `[0, π)` with
34//!    `num_bins` bins. For every feature and every axis `k ∈ {0, 1}`,
35//!    add a vote at `wrap_pi(axes[k].angle)` with weight
36//!    `strength / (1 + axes[k].sigma)`.
37//! 2. Smooth with a `[1, 4, 6, 4, 1] / 16` circular kernel.
38//! 3. Find local maxima. Keep peaks with total weight ≥
39//!    `min_peak_weight_fraction × total`. Pick the two strongest
40//!    peaks separated by at least `peak_min_separation_rad`.
41//! 4. Refine centres via **double-angle** 2-means over per-axis
42//!    votes. Each axis vote `θ` is mapped to `2θ` before averaging;
43//!    the average is halved back — this is the correct undirected-
44//!    angle (mod π) circular mean. Iterate up to `max_iters_2means`.
45//! 5. Per-feature label: for each feature compute the two possible
46//!    axis assignments (canonical vs swapped) and pick the cheaper.
47//!    Require the LARGER distance in the winning assignment to be
48//!    within the per-feature tolerance; otherwise the feature is
49//!    unassigned.
50
51mod circular;
52
53use std::f32::consts::PI;
54
55use serde::Serialize;
56
57pub use circular::{
58    angle_to_bin, angular_dist_pi, pick_two_peaks, refine_2means_double_angle, smooth_circular_5,
59    wrap_pi, AngleVote, PeakPickOptions,
60};
61
62/// One undirected local lattice-axis estimate feeding the clustering
63/// stage: an angle in radians and its 1σ angular uncertainty.
64///
65/// Sigma at the no-info sentinel (≥ `π`) or non-finite marks an axis
66/// with no usable direction information; such axes are skipped.
67#[derive(Clone, Copy, Debug, PartialEq, Serialize)]
68#[non_exhaustive]
69pub struct AxisObservation {
70    /// Axis angle in radians (interpreted on the mod-π circle).
71    pub angle: f32,
72    /// 1σ angular uncertainty in radians.
73    pub sigma: f32,
74}
75
76impl AxisObservation {
77    /// Construct an axis observation from its angle and 1σ uncertainty.
78    pub fn new(angle: f32, sigma: f32) -> Self {
79        Self { angle, sigma }
80    }
81}
82
83/// A feature with two undirected local lattice-axis estimates plus a
84/// detector strength used to weight its histogram votes.
85#[derive(Clone, Copy, Debug, PartialEq, Serialize)]
86#[non_exhaustive]
87pub struct AxisFeature {
88    /// The feature's two local lattice-axis estimates.
89    pub axes: [AxisObservation; 2],
90    /// Detector response used as the histogram-vote base weight.
91    pub strength: f32,
92}
93
94impl AxisFeature {
95    /// Construct an axis feature from its two axes and detector strength.
96    pub fn new(axes: [AxisObservation; 2], strength: f32) -> Self {
97        Self { axes, strength }
98    }
99}
100
101/// Tuning for [`cluster_axes`].
102#[derive(Clone, Copy, Debug, PartialEq, Serialize)]
103#[non_exhaustive]
104pub struct ClusterParams {
105    /// Number of histogram bins spanning the `[0, π)` axis-angle range.
106    pub num_bins: usize,
107    /// Minimum fraction of total vote weight a peak must carry.
108    pub min_peak_weight_fraction: f32,
109    /// Minimum angular separation between the two peaks (radians, mod-π).
110    pub peak_min_separation_rad: f32,
111    /// Maximum 2-means refinement iterations.
112    pub max_iters_2means: usize,
113    /// Base per-feature admission tolerance in radians.
114    pub base_tol_rad: f32,
115    /// Multiplier on per-feature axis sigma added to [`Self::base_tol_rad`].
116    pub cluster_sigma_k: f32,
117}
118
119impl ClusterParams {
120    /// Construct clustering parameters from the individual knobs.
121    pub fn new(
122        num_bins: usize,
123        min_peak_weight_fraction: f32,
124        peak_min_separation_rad: f32,
125        max_iters_2means: usize,
126        base_tol_rad: f32,
127        cluster_sigma_k: f32,
128    ) -> Self {
129        Self {
130            num_bins,
131            min_peak_weight_fraction,
132            peak_min_separation_rad,
133            max_iters_2means,
134            base_tol_rad,
135            cluster_sigma_k,
136        }
137    }
138}
139
140/// The two recovered global grid-direction centres in `[0, π)` with
141/// `theta0 ≤ theta1`.
142#[derive(Clone, Copy, Debug, PartialEq, Serialize)]
143#[non_exhaustive]
144pub struct AxisClusterCenters {
145    /// The smaller of the two direction centres (radians, `[0, π)`).
146    pub theta0: f32,
147    /// The larger of the two direction centres (radians, `[0, π)`).
148    pub theta1: f32,
149}
150
151impl AxisClusterCenters {
152    /// Construct cluster centres. The two centres are stored as given;
153    /// callers that need the `theta0 ≤ theta1` ordering should sort
154    /// first (e.g. via [`Self::sorted`]).
155    pub fn new(theta0: f32, theta1: f32) -> Self {
156        Self { theta0, theta1 }
157    }
158
159    /// Construct cluster centres, ordering them so `theta0 ≤ theta1`.
160    pub fn sorted(a: f32, b: f32) -> Self {
161        if a <= b {
162            Self {
163                theta0: a,
164                theta1: b,
165            }
166        } else {
167            Self {
168                theta0: b,
169                theta1: a,
170            }
171        }
172    }
173}
174
175/// Per-feature assignment produced by [`cluster_axes`].
176///
177/// The variants record which slot ordering matched and the worst
178/// per-axis distance in the winning assignment.
179#[derive(Clone, Copy, Debug, PartialEq, Serialize)]
180#[non_exhaustive]
181pub enum AxisAssignment {
182    /// `axes[0] → Θ₀`, `axes[1] → Θ₁`; both within tolerance.
183    Canonical {
184        /// Worst per-axis distance to its matched centre (radians).
185        max_d_rad: f32,
186    },
187    /// `axes[0] → Θ₁`, `axes[1] → Θ₀`; both within tolerance.
188    Swapped {
189        /// Worst per-axis distance to its matched centre (radians).
190        max_d_rad: f32,
191    },
192    /// The best assignment still left one axis outside tolerance.
193    None {
194        /// Worst per-axis distance to its matched centre (radians).
195        max_d_rad: f32,
196    },
197}
198
199/// Stage introspection captured during a single [`cluster_axes`] run.
200///
201/// Surfaced so an offline tool can plot the histogram and check whether
202/// 2-means refinement walked off the visible peaks.
203#[derive(Clone, Debug, Serialize)]
204#[non_exhaustive]
205pub struct AxisClusterDebug {
206    /// Number of histogram bins spanning the `[0, π)` axis-angle range.
207    pub num_bins: usize,
208    /// Raw per-bin weighted vote counts before smoothing.
209    pub histogram: Vec<f32>,
210    /// The histogram after circular smoothing — the curve peak-picking runs on.
211    pub smoothed: Vec<f32>,
212    /// Sum of all bin weights — the normaliser for the peak-weight floor.
213    pub total_weight: f32,
214    /// Peak seeds picked from the smoothed histogram, in radians (`[0, π)`),
215    /// before 2-means refinement. `None` when peak picking failed.
216    pub peak_seeds_rad: Option<[f32; 2]>,
217    /// Centres after 2-means refinement, in radians. `None` when peak
218    /// picking failed (refinement isn't run).
219    pub refined_centers_rad: Option<[f32; 2]>,
220}
221
222impl AxisClusterDebug {
223    fn empty(num_bins: usize) -> Self {
224        Self {
225            num_bins,
226            histogram: Vec::new(),
227            smoothed: Vec::new(),
228            total_weight: 0.0,
229            peak_seeds_rad: None,
230            refined_centers_rad: None,
231        }
232    }
233}
234
235/// Recover the two global grid-direction centres from a slice of
236/// dual-axis features and assign each feature to a slot ordering.
237///
238/// Returns `(centres, per_feature_assignments, debug)`. `centres` is
239/// `None` when fewer than two qualifying peaks were found; in that case
240/// the assignment vector is empty. Otherwise the assignment vector has
241/// one [`AxisAssignment`] per input feature, in input order.
242///
243/// The histogram and votes iterate the features in the given order;
244/// callers wanting byte-stable results across runs must pass features
245/// in a stable order.
246pub fn cluster_axes(
247    features: &[AxisFeature],
248    params: &ClusterParams,
249) -> (
250    Option<AxisClusterCenters>,
251    Vec<AxisAssignment>,
252    AxisClusterDebug,
253) {
254    let mut debug = AxisClusterDebug::empty(params.num_bins);
255
256    if features.is_empty() || params.num_bins < 4 {
257        return (None, Vec::new(), debug);
258    }
259
260    let hist = build_histogram(features, params.num_bins);
261    debug.histogram = hist.bins.clone();
262    debug.total_weight = hist.total_weight;
263    if hist.total_weight <= 0.0 {
264        return (None, Vec::new(), debug);
265    }
266
267    let smoothed = smooth_circular_5(&hist.bins);
268    debug.smoothed = smoothed.clone();
269
270    let peak_opts = PeakPickOptions::new(
271        params.min_peak_weight_fraction,
272        params.peak_min_separation_rad,
273    );
274    let Some((theta0_seed, theta1_seed)) = pick_two_peaks(&smoothed, hist.total_weight, &peak_opts)
275    else {
276        return (None, Vec::new(), debug);
277    };
278    debug.peak_seeds_rad = Some([theta0_seed, theta1_seed]);
279
280    let votes = collect_axis_votes(features);
281    let (theta0, theta1) =
282        refine_2means_double_angle(&votes, [theta0_seed, theta1_seed], params.max_iters_2means);
283    debug.refined_centers_rad = Some([theta0, theta1]);
284
285    let centers = AxisClusterCenters::sorted(theta0, theta1);
286
287    let mut assignments = Vec::with_capacity(features.len());
288    for feature in features {
289        let tol_rad = effective_tol_rad(&feature.axes, params.base_tol_rad, params.cluster_sigma_k);
290        assignments.push(assign_axes(&feature.axes, centers, tol_rad));
291    }
292
293    (Some(centers), assignments, debug)
294}
295
296// --- internals ------------------------------------------------------------
297
298struct Histogram {
299    bins: Vec<f32>,
300    total_weight: f32,
301}
302
303fn build_histogram(features: &[AxisFeature], num_bins: usize) -> Histogram {
304    let mut bins = vec![0.0_f32; num_bins];
305    let mut total = 0.0_f32;
306    for feature in features {
307        for axis in &feature.axes {
308            if !axis.sigma.is_finite() || axis.sigma >= PI - f32::EPSILON {
309                // No-info sentinel → skip this axis.
310                continue;
311            }
312            let w = vote_weight(feature.strength, axis.sigma);
313            if w <= 0.0 {
314                continue;
315            }
316            let bin = angle_to_bin(wrap_pi(axis.angle), num_bins);
317            bins[bin] += w;
318            total += w;
319        }
320    }
321    Histogram {
322        bins,
323        total_weight: total,
324    }
325}
326
327#[inline]
328fn vote_weight(strength: f32, sigma: f32) -> f32 {
329    let s = strength.max(0.0);
330    let base = if s > 0.0 { s } else { 1.0 };
331    base / (1.0 + sigma.max(0.0))
332}
333
334/// Materialise per-axis votes in the shape expected by the
335/// [`refine_2means_double_angle`] helper.
336fn collect_axis_votes(features: &[AxisFeature]) -> Vec<AngleVote> {
337    let mut votes: Vec<AngleVote> = Vec::new();
338    for feature in features {
339        for axis in &feature.axes {
340            if !axis.sigma.is_finite() || axis.sigma >= PI - f32::EPSILON {
341                continue;
342            }
343            let w = vote_weight(feature.strength, axis.sigma);
344            if w <= 0.0 {
345                continue;
346            }
347            votes.push(AngleVote {
348                angle: wrap_pi(axis.angle),
349                weight: w,
350            });
351        }
352    }
353    votes
354}
355
356/// Per-feature cluster admission threshold in radians.
357///
358/// `base_tol_rad + min(sigma_k · max(σ_a0, σ_a1), 3°)` — sigma bonus is
359/// capped so a single noisy feature cannot blow open the gate. Sigmas
360/// at the no-info sentinel (≈ π) are clamped to a finite ceiling (10°).
361#[inline]
362pub fn effective_tol_rad(axes: &[AxisObservation; 2], base_tol_rad: f32, sigma_k: f32) -> f32 {
363    if sigma_k <= 0.0 {
364        return base_tol_rad;
365    }
366    let sigma_cap = 10.0_f32.to_radians();
367    let s0 = axes[0].sigma.clamp(0.0, sigma_cap);
368    let s1 = axes[1].sigma.clamp(0.0, sigma_cap);
369    let bonus = sigma_k * s0.max(s1);
370    let max_bonus = 3.0_f32.to_radians();
371    base_tol_rad + bonus.min(max_bonus)
372}
373
374/// Assign one feature's two axes to a slot ordering given known centres.
375///
376/// Computes the canonical (`axes[0]→Θ₀, axes[1]→Θ₁`) and swapped costs,
377/// picks the cheaper (ties → canonical), and admits it when the worst
378/// per-axis distance in the winning assignment is within `tol_rad`.
379pub fn assign_axes(
380    axes: &[AxisObservation; 2],
381    centers: AxisClusterCenters,
382    tol_rad: f32,
383) -> AxisAssignment {
384    let a0 = wrap_pi(axes[0].angle);
385    let a1 = wrap_pi(axes[1].angle);
386
387    let d_a0_t0 = angular_dist_pi(a0, centers.theta0);
388    let d_a0_t1 = angular_dist_pi(a0, centers.theta1);
389    let d_a1_t0 = angular_dist_pi(a1, centers.theta0);
390    let d_a1_t1 = angular_dist_pi(a1, centers.theta1);
391
392    let canon_cost = d_a0_t0 + d_a1_t1;
393    let canon_max = d_a0_t0.max(d_a1_t1);
394    let swap_cost = d_a0_t1 + d_a1_t0;
395    let swap_max = d_a0_t1.max(d_a1_t0);
396
397    let (canonical, max_d) = if canon_cost <= swap_cost {
398        (true, canon_max)
399    } else {
400        (false, swap_max)
401    };
402
403    if max_d <= tol_rad {
404        if canonical {
405            AxisAssignment::Canonical { max_d_rad: max_d }
406        } else {
407            AxisAssignment::Swapped { max_d_rad: max_d }
408        }
409    } else {
410        AxisAssignment::None { max_d_rad: max_d }
411    }
412}
413
414#[cfg(test)]
415mod tests {
416    use super::*;
417
418    fn obs(angle: f32, sigma: f32) -> AxisObservation {
419        AxisObservation::new(angle, sigma)
420    }
421
422    fn feat(a0_deg: f32, sigma_deg: f32, strength: f32) -> AxisFeature {
423        let a0 = a0_deg.to_radians();
424        let a1 = a0 + std::f32::consts::FRAC_PI_2;
425        let sigma = sigma_deg.to_radians();
426        AxisFeature::new([obs(wrap_pi(a0), sigma), obs(wrap_pi(a1), sigma)], strength)
427    }
428
429    fn default_params() -> ClusterParams {
430        ClusterParams::new(
431            90,
432            0.02,
433            60.0_f32.to_radians(),
434            10,
435            12.0_f32.to_radians(),
436            0.5,
437        )
438    }
439
440    fn jitter(i: usize, amp_deg: f32) -> f32 {
441        let x = (i as u32).wrapping_mul(2_654_435_761);
442        let frac = ((x >> 8) as f32) / ((1u32 << 24) as f32);
443        (frac - 0.5) * amp_deg
444    }
445
446    #[test]
447    fn recovers_centers_30_120() {
448        let mut features = Vec::new();
449        for i in 0..50 {
450            features.push(feat(30.0 + jitter(i, 10.0), 0.05, 1.0));
451        }
452        for i in 0..50 {
453            features.push(feat(120.0 + jitter(i + 1000, 10.0), 0.05, 1.0));
454        }
455        let params = default_params();
456        let (centers, assignments, _dbg) = cluster_axes(&features, &params);
457        let centers = centers.expect("centers");
458        let expected_low = 30.0_f32.to_radians();
459        let expected_high = 120.0_f32.to_radians();
460        assert!(angular_dist_pi(centers.theta0, expected_low) < 2.0_f32.to_radians());
461        assert!(angular_dist_pi(centers.theta1, expected_high) < 2.0_f32.to_radians());
462        assert!(assignments
463            .iter()
464            .all(|a| !matches!(a, AxisAssignment::None { .. })));
465    }
466
467    #[test]
468    fn far_feature_is_unassigned() {
469        let mut features = Vec::new();
470        for _ in 0..40 {
471            features.push(feat(0.0, 0.01_f32.to_degrees(), 1.0));
472        }
473        features.push(feat(25.0, 0.01_f32.to_degrees(), 1.0));
474        let params = default_params();
475        let (_centers, assignments, _dbg) = cluster_axes(&features, &params);
476        assert!(matches!(
477            assignments.last().expect("non-empty"),
478            AxisAssignment::None { .. }
479        ));
480    }
481
482    #[test]
483    fn empty_input_returns_none() {
484        let params = default_params();
485        let (centers, assignments, _dbg) = cluster_axes(&[], &params);
486        assert!(centers.is_none());
487        assert!(assignments.is_empty());
488    }
489}