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, bin_to_angle, pick_two_peaks, refine_2means_double_angle,
59    smooth_circular_5, 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/// Refit cluster centres from a labelled subset's axes only.
415///
416/// For each feature in `axes`, pick the slot assignment (canonical /
417/// swapped) that minimises the cost under `old_centers` — same tie-break
418/// as [`assign_axes`] — to decide which of its two axes belongs to slot 0
419/// vs slot 1. Accumulate `(cos 2θ, sin 2θ)` per slot (undirected circular
420/// mean), halve the atan2, wrap to `[0, π)`, and order so `θ0 ≤ θ1`.
421///
422/// Returns `None` if `axes.len() < min_samples` (the caller should keep
423/// the original centres).
424pub fn refit_centers(
425    axes: &[[AxisObservation; 2]],
426    old_centers: AxisClusterCenters,
427    min_samples: usize,
428) -> Option<AxisClusterCenters> {
429    if axes.len() < min_samples {
430        return None;
431    }
432    let mut s0_re = 0.0_f32;
433    let mut s0_im = 0.0_f32;
434    let mut s1_re = 0.0_f32;
435    let mut s1_im = 0.0_f32;
436    for pair in axes {
437        let a0 = wrap_pi(pair[0].angle);
438        let a1 = wrap_pi(pair[1].angle);
439        let d_a0_t0 = angular_dist_pi(a0, old_centers.theta0);
440        let d_a0_t1 = angular_dist_pi(a0, old_centers.theta1);
441        let d_a1_t0 = angular_dist_pi(a1, old_centers.theta0);
442        let d_a1_t1 = angular_dist_pi(a1, old_centers.theta1);
443        let canon_cost = d_a0_t0 + d_a1_t1;
444        let swap_cost = d_a0_t1 + d_a1_t0;
445        let (a_to_t0, a_to_t1) = if canon_cost <= swap_cost {
446            (a0, a1)
447        } else {
448            (a1, a0)
449        };
450        s0_re += (2.0 * a_to_t0).cos();
451        s0_im += (2.0 * a_to_t0).sin();
452        s1_re += (2.0 * a_to_t1).cos();
453        s1_im += (2.0 * a_to_t1).sin();
454    }
455    let mut t0 = 0.5 * s0_im.atan2(s0_re);
456    let mut t1 = 0.5 * s1_im.atan2(s1_re);
457    while t0 < 0.0 {
458        t0 += PI;
459    }
460    while t0 >= PI {
461        t0 -= PI;
462    }
463    while t1 < 0.0 {
464        t1 += PI;
465    }
466    while t1 >= PI {
467        t1 -= PI;
468    }
469    if t0 > t1 {
470        std::mem::swap(&mut t0, &mut t1);
471    }
472    Some(AxisClusterCenters {
473        theta0: t0,
474        theta1: t1,
475    })
476}
477
478#[cfg(test)]
479mod tests {
480    use super::*;
481
482    fn obs(angle: f32, sigma: f32) -> AxisObservation {
483        AxisObservation::new(angle, sigma)
484    }
485
486    fn feat(a0_deg: f32, sigma_deg: f32, strength: f32) -> AxisFeature {
487        let a0 = a0_deg.to_radians();
488        let a1 = a0 + std::f32::consts::FRAC_PI_2;
489        let sigma = sigma_deg.to_radians();
490        AxisFeature::new([obs(wrap_pi(a0), sigma), obs(wrap_pi(a1), sigma)], strength)
491    }
492
493    fn default_params() -> ClusterParams {
494        ClusterParams::new(
495            90,
496            0.02,
497            60.0_f32.to_radians(),
498            10,
499            12.0_f32.to_radians(),
500            0.5,
501        )
502    }
503
504    fn jitter(i: usize, amp_deg: f32) -> f32 {
505        let x = (i as u32).wrapping_mul(2_654_435_761);
506        let frac = ((x >> 8) as f32) / ((1u32 << 24) as f32);
507        (frac - 0.5) * amp_deg
508    }
509
510    #[test]
511    fn recovers_centers_30_120() {
512        let mut features = Vec::new();
513        for i in 0..50 {
514            features.push(feat(30.0 + jitter(i, 10.0), 0.05, 1.0));
515        }
516        for i in 0..50 {
517            features.push(feat(120.0 + jitter(i + 1000, 10.0), 0.05, 1.0));
518        }
519        let params = default_params();
520        let (centers, assignments, _dbg) = cluster_axes(&features, &params);
521        let centers = centers.expect("centers");
522        let expected_low = 30.0_f32.to_radians();
523        let expected_high = 120.0_f32.to_radians();
524        assert!(angular_dist_pi(centers.theta0, expected_low) < 2.0_f32.to_radians());
525        assert!(angular_dist_pi(centers.theta1, expected_high) < 2.0_f32.to_radians());
526        assert!(assignments
527            .iter()
528            .all(|a| !matches!(a, AxisAssignment::None { .. })));
529    }
530
531    #[test]
532    fn far_feature_is_unassigned() {
533        let mut features = Vec::new();
534        for _ in 0..40 {
535            features.push(feat(0.0, 0.01_f32.to_degrees(), 1.0));
536        }
537        features.push(feat(25.0, 0.01_f32.to_degrees(), 1.0));
538        let params = default_params();
539        let (_centers, assignments, _dbg) = cluster_axes(&features, &params);
540        assert!(matches!(
541            assignments.last().expect("non-empty"),
542            AxisAssignment::None { .. }
543        ));
544    }
545
546    #[test]
547    fn empty_input_returns_none() {
548        let params = default_params();
549        let (centers, assignments, _dbg) = cluster_axes(&[], &params);
550        assert!(centers.is_none());
551        assert!(assignments.is_empty());
552    }
553}