projective_grid/
circular_stats.rs1use std::f32::consts::PI;
32
33#[inline]
35pub fn wrap_pi(theta: f32) -> f32 {
36 let mut t = theta % PI;
37 if t < 0.0 {
38 t += PI;
39 }
40 if t >= PI {
42 t -= PI;
43 }
44 t
45}
46
47#[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#[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#[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
79pub 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#[non_exhaustive]
104#[derive(Clone, Copy, Debug)]
105pub struct PeakPickOptions {
106 pub min_peak_weight_fraction: f32,
109 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
125pub 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 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#[derive(Clone, Copy, Debug)]
200pub struct AngleVote {
201 pub angle: f32,
202 pub weight: f32,
203}
204
205pub 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 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 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 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}