Skip to main content

projective_grid/
circular_stats.rs

1//! Circular-histogram + plateau-aware peak picking + double-angle
2//! 2-means helpers.
3//!
4//! These primitives are pattern-agnostic pieces of the chessboard
5//! detector's **global grid-direction** stage. The chessboard-
6//! specific wrapper in `calib_targets_chessboard::cluster` owns the
7//! histogram-accumulation step (iterates corners, reads per-axis
8//! `(angle, sigma, strength)`) and the final per-corner label
9//! assignment; this module provides the generic math underneath:
10//!
11//! * [`wrap_pi`] / [`angular_dist_pi`] — angle helpers over the
12//!   undirected mod-π circle.
13//! * [`smooth_circular_5`] — a 1-pass `[1, 4, 6, 4, 1] / 16` circular
14//!   convolution.
15//! * [`pick_two_peaks`] — plateau-aware local-maxima detection on a
16//!   smoothed circular histogram. Handles the edge case where a
17//!   physical direction's mass lands on both sides of a bin boundary
18//!   and the peak flat-tops across two adjacent bins.
19//! * [`refine_2means_double_angle`] — 2-means refinement over
20//!   mod-π-circular votes using the standard double-angle trick so
21//!   the circular mean stays correct across the `0 ≈ π` seam.
22//!
23//! # When to use this
24//!
25//! Any grid-detection pipeline that needs to identify "two dominant
26//! directions" from noisy axis-angle votes (chessboard x-junctions,
27//! line grids, woven lattices) can build a histogram of `(angle,
28//! weight)` pairs, run the peak + 2-means steps here, and consume
29//! the two centers as its grid axes.
30
31use std::f32::consts::PI;
32
33/// Wrap an angle to `[0, π)`. Works for any finite input.
34#[inline]
35pub fn wrap_pi(theta: f32) -> f32 {
36    let mut t = theta % PI;
37    if t < 0.0 {
38        t += PI;
39    }
40    // Guard against `t == PI` after FP wobble on the boundary.
41    if t >= PI {
42        t -= PI;
43    }
44    t
45}
46
47/// Smallest angular distance on the circle with period π. Result in
48/// `[0, π/2]`.
49#[inline]
50pub fn angular_dist_pi(a: f32, b: f32) -> f32 {
51    let diff = ((a - b) % PI + PI) % PI;
52    diff.min(PI - diff)
53}
54
55/// Map an angle in `[0, π)` to the bin index in a histogram of `n`
56/// equal-width bins over that range. Idempotent under prior
57/// [`wrap_pi`]; inputs outside `[0, π)` are wrapped first.
58#[inline]
59pub fn angle_to_bin(theta: f32, n: usize) -> usize {
60    let t = wrap_pi(theta);
61    let x = t / PI * n as f32;
62    let mut idx = x.floor() as isize;
63    if idx < 0 {
64        idx = 0;
65    }
66    if idx as usize >= n {
67        idx = (n - 1) as isize;
68    }
69    idx as usize
70}
71
72/// Inverse of [`angle_to_bin`]: bin center angle in `[0, π)`.
73#[inline]
74pub fn bin_to_angle(bin: usize, n: usize) -> f32 {
75    let step = PI / n as f32;
76    (bin as f32 + 0.5) * step
77}
78
79/// Smooth a circular histogram with a one-pass `[1, 4, 6, 4, 1] / 16`
80/// kernel. Handles the wrap boundary with `rem_euclid`. Empty input
81/// returns empty output.
82pub fn smooth_circular_5(hist: &[f32]) -> Vec<f32> {
83    let n = hist.len();
84    if n == 0 {
85        return Vec::new();
86    }
87    const K: [f32; 5] = [1.0, 4.0, 6.0, 4.0, 1.0];
88    const K_SUM: f32 = 16.0;
89    let mut out = vec![0.0_f32; n];
90    for (i, bin) in out.iter_mut().enumerate() {
91        let mut acc = 0.0_f32;
92        for (k, &w) in K.iter().enumerate() {
93            let offset = k as isize - 2;
94            let j = ((i as isize + offset).rem_euclid(n as isize)) as usize;
95            acc += w * hist[j];
96        }
97        *bin = acc / K_SUM;
98    }
99    out
100}
101
102/// Options for [`pick_two_peaks`].
103#[non_exhaustive]
104#[derive(Clone, Copy, Debug)]
105pub struct PeakPickOptions {
106    /// Minimum fraction of `total_weight` a peak must carry to be
107    /// considered. Rejects histogram noise.
108    pub min_peak_weight_fraction: f32,
109    /// Minimum angular separation between the two returned peaks
110    /// (in radians, on the mod-π circle). Ensures the two peaks
111    /// represent genuinely distinct directions rather than two
112    /// ridges of one cluster.
113    pub min_separation: f32,
114}
115
116impl PeakPickOptions {
117    pub fn new(min_peak_weight_fraction: f32, min_separation: f32) -> Self {
118        Self {
119            min_peak_weight_fraction,
120            min_separation,
121        }
122    }
123}
124
125/// Pick the two strongest plateau-aware peaks from a smoothed
126/// circular histogram, subject to a minimum-weight floor and minimum
127/// angular separation.
128///
129/// Returns `Some((theta0, theta1))` (bin-center angles in `[0, π)`,
130/// no ordering guarantee) or `None` when fewer than two qualifying
131/// peaks exist or no two peaks are far enough apart.
132///
133/// "Plateau-aware" means the peak detector handles a run of equal-
134/// valued bins bordered on both sides by strictly lower bins: the
135/// plateau's midpoint bin is reported as the peak. This is important
136/// when a direction's vote mass lands at a bin boundary and
137/// smoothing pushes symmetric mass into the two adjacent bins.
138pub fn pick_two_peaks(
139    smoothed: &[f32],
140    total_weight: f32,
141    opts: &PeakPickOptions,
142) -> Option<(f32, f32)> {
143    let n = smoothed.len();
144    if n == 0 {
145        return None;
146    }
147    let min_w = total_weight * opts.min_peak_weight_fraction;
148
149    let mut peaks: Vec<(usize, f32)> = Vec::new();
150    let mut visited = vec![false; n];
151    for start in 0..n {
152        if visited[start] {
153            continue;
154        }
155        let here = smoothed[start];
156        if here < min_w {
157            visited[start] = true;
158            continue;
159        }
160        let mut len = 1usize;
161        while len < n {
162            let next_idx = (start + len) % n;
163            if smoothed[next_idx] != here {
164                break;
165            }
166            len += 1;
167        }
168        for k in 0..len {
169            visited[(start + k) % n] = true;
170        }
171        if len == n {
172            // Completely flat histogram — no peak.
173            continue;
174        }
175        let left = smoothed[(start + n - 1) % n];
176        let right = smoothed[(start + len) % n];
177        if here > left && here > right {
178            let mid = (start + len / 2) % n;
179            peaks.push((mid, here));
180        }
181    }
182
183    peaks.sort_by(|a, b| b.1.total_cmp(&a.1));
184    if peaks.is_empty() {
185        return None;
186    }
187    let theta_of = |bin: usize| bin_to_angle(bin, n);
188    let first = theta_of(peaks[0].0);
189    for (bin, _w) in peaks.iter().skip(1) {
190        let cand = theta_of(*bin);
191        if angular_dist_pi(first, cand) >= opts.min_separation {
192            return Some((first, cand));
193        }
194    }
195    None
196}
197
198/// A single weighted vote over the mod-π circle.
199#[derive(Clone, Copy, Debug)]
200pub struct AngleVote {
201    pub angle: f32,
202    pub weight: f32,
203}
204
205/// Refine two cluster centers via weighted 2-means on mod-π-circular
206/// vote angles using the **double-angle** trick: accumulate
207/// `(w·cos 2θ, w·sin 2θ)` per cluster and halve the resulting atan2.
208/// This is the correct circular mean for undirected angles (mod π);
209/// accumulating raw `(cos θ, sin θ)` silently returns garbage near
210/// the 0°/180° seam.
211///
212/// Returns the refined `(center0, center1)`. Stops early when both
213/// centers stabilise to within `1e-5` radians or after `max_iters`.
214///
215/// With zero votes this returns `seed` unchanged.
216pub fn refine_2means_double_angle(
217    votes: &[AngleVote],
218    seed: [f32; 2],
219    max_iters: usize,
220) -> (f32, f32) {
221    if votes.is_empty() {
222        return (seed[0], seed[1]);
223    }
224
225    let mut centers = seed;
226
227    for _ in 0..max_iters {
228        let mut sum_2cos = [0.0_f32; 2];
229        let mut sum_2sin = [0.0_f32; 2];
230        let mut sum_w = [0.0_f32; 2];
231        for v in votes {
232            let d0 = angular_dist_pi(v.angle, centers[0]);
233            let d1 = angular_dist_pi(v.angle, centers[1]);
234            let k = if d0 <= d1 { 0 } else { 1 };
235            let two_theta = 2.0 * v.angle;
236            sum_2cos[k] += v.weight * two_theta.cos();
237            sum_2sin[k] += v.weight * two_theta.sin();
238            sum_w[k] += v.weight;
239        }
240        let mut new_centers = centers;
241        for k in 0..2 {
242            if sum_w[k] > 0.0 {
243                let two_theta = sum_2sin[k].atan2(sum_2cos[k]);
244                new_centers[k] = wrap_pi(two_theta * 0.5);
245            }
246        }
247        if (new_centers[0] - centers[0]).abs() < 1e-5 && (new_centers[1] - centers[1]).abs() < 1e-5
248        {
249            return (new_centers[0], new_centers[1]);
250        }
251        centers = new_centers;
252    }
253    (centers[0], centers[1])
254}
255
256#[cfg(test)]
257mod tests {
258    use super::*;
259    use std::f32::consts::FRAC_PI_2;
260
261    #[test]
262    fn wrap_pi_handles_boundary() {
263        assert!((wrap_pi(0.0) - 0.0).abs() < 1e-6);
264        assert!((wrap_pi(PI) - 0.0).abs() < 1e-6);
265        assert!((wrap_pi(PI + 0.1) - 0.1).abs() < 1e-5);
266        assert!((wrap_pi(-0.1) - (PI - 0.1)).abs() < 1e-5);
267    }
268
269    #[test]
270    fn angular_dist_pi_wraps() {
271        assert!((angular_dist_pi(0.1, PI - 0.1) - 0.2).abs() < 1e-5);
272        assert!((angular_dist_pi(0.0, FRAC_PI_2) - FRAC_PI_2).abs() < 1e-6);
273    }
274
275    #[test]
276    fn smooth_5_preserves_total() {
277        let hist = vec![0.0, 0.0, 16.0, 0.0, 0.0];
278        let out = smooth_circular_5(&hist);
279        let sum: f32 = out.iter().sum();
280        assert!((sum - 16.0).abs() < 1e-4, "got {sum}");
281    }
282
283    #[test]
284    fn pick_two_peaks_separates_orthogonal_peaks() {
285        // 18 bins of 10°, two peaks at 0° (bin 0) and 90° (bin 9).
286        let mut hist = vec![0.0_f32; 18];
287        hist[0] = 100.0;
288        hist[9] = 100.0;
289        let smoothed = smooth_circular_5(&hist);
290        let peaks = pick_two_peaks(
291            &smoothed,
292            200.0,
293            &PeakPickOptions::new(0.02, 60.0_f32.to_radians()),
294        )
295        .expect("two peaks");
296        let lo = peaks.0.min(peaks.1);
297        let hi = peaks.0.max(peaks.1);
298        assert!((lo).abs() < 0.1, "lo too far from 0: {lo}");
299        assert!((hi - FRAC_PI_2).abs() < 0.1, "hi too far from π/2: {hi}");
300    }
301
302    #[test]
303    fn pick_two_peaks_handles_plateau_at_boundary() {
304        // Simulate mass split across bins 0 and n-1 — the near-π wrap
305        // scenario that killed example8/example9 in testdata.
306        let n = 18;
307        let mut hist = vec![0.0_f32; n];
308        hist[0] = 50.0;
309        hist[n - 1] = 50.0;
310        hist[9] = 100.0;
311        let smoothed = smooth_circular_5(&hist);
312        // Expect a peak near angle 0 and a peak at ~90°.
313        let peaks = pick_two_peaks(
314            &smoothed,
315            200.0,
316            &PeakPickOptions::new(0.02, 60.0_f32.to_radians()),
317        )
318        .expect("should recover two peaks despite the boundary plateau");
319        let lo = peaks.0.min(peaks.1);
320        let hi = peaks.0.max(peaks.1);
321        assert!(
322            lo.abs() < 0.2 || (PI - lo).abs() < 0.2,
323            "low peak at wrong angle: {lo}"
324        );
325        assert!((hi - FRAC_PI_2).abs() < 0.2, "hi at wrong angle: {hi}");
326    }
327
328    #[test]
329    fn two_means_converges_on_orthogonal_votes() {
330        let votes: Vec<AngleVote> = (0..50)
331            .flat_map(|_| {
332                [
333                    AngleVote {
334                        angle: 0.1,
335                        weight: 1.0,
336                    },
337                    AngleVote {
338                        angle: FRAC_PI_2 - 0.1,
339                        weight: 1.0,
340                    },
341                ]
342            })
343            .collect();
344        let (c0, c1) = refine_2means_double_angle(&votes, [0.2, FRAC_PI_2 - 0.2], 10);
345        assert!((c0 - 0.1).abs() < 0.05 || (c1 - 0.1).abs() < 0.05);
346        assert!((c0 - (FRAC_PI_2 - 0.1)).abs() < 0.05 || (c1 - (FRAC_PI_2 - 0.1)).abs() < 0.05);
347    }
348
349    #[test]
350    fn two_means_empty_returns_seed() {
351        let seed = [0.1_f32, 1.2];
352        let (c0, c1) = refine_2means_double_angle(&[], seed, 5);
353        assert_eq!((c0, c1), (seed[0], seed[1]));
354    }
355}