Skip to main content

projective_grid/cluster/
circular.rs

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