projective_grid/cluster/
circular.rs1use std::f32::consts::PI;
34
35#[inline]
37pub fn wrap_pi(theta: f32) -> f32 {
38 let mut t = theta % PI;
39 if t < 0.0 {
40 t += PI;
41 }
42 if t >= PI {
44 t -= PI;
45 }
46 t
47}
48
49#[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#[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#[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
81pub 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#[non_exhaustive]
106#[derive(Clone, Copy, Debug)]
107pub struct PeakPickOptions {
108 pub min_peak_weight_fraction: f32,
111 pub min_separation: f32,
116}
117
118impl PeakPickOptions {
119 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
129pub 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 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#[derive(Clone, Copy, Debug)]
204pub struct AngleVote {
205 pub angle: f32,
207 pub weight: f32,
209}
210
211pub 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 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 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 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}