1use crate::Corner;
9use log::warn;
10use nalgebra::Vector2;
11use serde::{Deserialize, Serialize};
12use std::f32::consts::{FRAC_PI_2, PI};
13
14#[derive(Clone, Debug, Deserialize, Serialize)]
16pub struct OrientationClusteringParams {
17 pub num_bins: usize,
19 pub max_iters: usize,
21 pub peak_min_separation_deg: f32,
23 pub outlier_threshold_deg: f32,
25 pub min_peak_weight_fraction: f32,
27 pub use_weights: bool,
29}
30
31impl Default for OrientationClusteringParams {
32 fn default() -> Self {
33 Self {
34 num_bins: 90, max_iters: 10,
36 peak_min_separation_deg: 10f32,
37 outlier_threshold_deg: 30f32,
38 min_peak_weight_fraction: 0.05, use_weights: true,
40 }
41 }
42}
43
44#[derive(Clone, Debug, Serialize, Deserialize)]
46pub struct OrientationClusteringResult {
47 pub centers: [f32; 2],
49 pub labels: Vec<Option<usize>>,
51 pub cluster_weights: [f32; 2],
53 pub histogram: Option<OrientationHistogram>,
55}
56
57#[derive(Clone, Debug, Serialize, Deserialize)]
58pub struct OrientationHistogram {
59 pub bin_centers: Vec<f32>,
60 pub values: Vec<f32>,
61}
62
63pub fn compute_orientation_histogram(
65 corners: &[Corner],
66 params: &OrientationClusteringParams,
67) -> Option<OrientationHistogram> {
68 build_smoothed_histogram(corners, params).map(|h| OrientationHistogram {
69 bin_centers: h.bin_centers,
70 values: h.values,
71 })
72}
73
74pub fn cluster_orientations(
79 corners: &[Corner],
80 params: &OrientationClusteringParams,
81) -> Option<OrientationClusteringResult> {
82 let n = corners.len();
83 if n == 0 || params.num_bins < 4 {
84 warn!("n = {n} num_bins = {}", params.num_bins);
85 return None;
86 }
87
88 let SmoothedHistogramData {
90 values: hist_smoothed,
91 bin_centers,
92 total_weight,
93 corner_bins,
94 } = build_smoothed_histogram(corners, params)?;
95
96 let peaks = find_peaks(&hist_smoothed);
98 if peaks.is_empty() {
99 warn!("Orientation peaks not found");
100 return None;
101 }
102
103 let mut supports: Vec<PeakSupport> = peaks
105 .into_iter()
106 .map(|p| build_peak_support(&hist_smoothed, p.bin, &bin_centers))
107 .collect();
108
109 let min_peak_weight = total_weight * params.min_peak_weight_fraction;
110 supports.retain(|p| p.weight >= min_peak_weight);
111 supports.sort_by(|a, b| {
112 b.weight
113 .partial_cmp(&a.weight)
114 .unwrap_or(std::cmp::Ordering::Equal)
115 });
116
117 if supports.len() < 2 {
118 warn!(
119 "{} grouped peaks, total {total_weight:.2}, min {min_peak_weight:.2}",
120 supports.len()
121 );
122 return None;
123 }
124
125 let mut phi1 = None;
126 let mut phi2 = None;
127
128 for sup in &supports {
129 if phi1.is_none() {
130 phi1 = Some(sup.angle_from_corners(corners, &corner_bins, params));
131 continue;
132 }
133 let c1 = phi1.unwrap();
134 let cand = sup.angle_from_corners(corners, &corner_bins, params);
135 if angular_dist_pi(c1, cand) >= params.peak_min_separation_deg.to_radians() {
136 phi2 = Some(cand);
137 break;
138 }
139 }
140
141 let (phi1, phi2) = match (phi1, phi2) {
142 (Some(a), Some(b)) => (a, b),
143 _ => return None,
144 };
145
146 let mut centers = [phi1, phi2];
147
148 let mut labels: Vec<Option<usize>> = vec![None; n];
150
151 for _ in 0..params.max_iters {
152 let mut changed = false;
154 for (i, c) in corners.iter().enumerate() {
155 let t = wrap_angle_pi(c.orientation);
156
157 let d0 = angular_dist_pi(t, centers[0]);
158 let d1 = angular_dist_pi(t, centers[1]);
159
160 let (best_cluster, best_dist) = if d0 <= d1 { (0usize, d0) } else { (1usize, d1) };
161
162 let new_label = if best_dist <= params.outlier_threshold_deg.to_radians() {
163 Some(best_cluster)
164 } else {
165 None
166 };
167
168 if labels[i] != new_label {
169 labels[i] = new_label;
170 changed = true;
171 }
172 }
173
174 let mut sum_vec = [[0.0f32; 2], [0.0f32; 2]];
176 let mut sum_w = [0.0f32; 2];
177
178 for (i, lbl) in labels.iter().enumerate() {
179 if let Some(c) = lbl {
180 let w = if params.use_weights {
181 corners[i].strength.max(0.0)
182 } else {
183 1.0
184 };
185 let t = wrap_angle_pi(corners[i].orientation);
186 let vx = t.cos();
187 let vy = t.sin();
188 sum_vec[*c][0] += w * vx;
189 sum_vec[*c][1] += w * vy;
190 sum_w[*c] += w;
191 }
192 }
193
194 for c in 0..2 {
195 if sum_w[c] > 0.0 {
196 let vx = sum_vec[c][0] / sum_w[c];
197 let vy = sum_vec[c][1] / sum_w[c];
198 let new_center = vy.atan2(vx);
199 centers[c] = wrap_angle_pi(new_center);
200 }
201 }
202
203 if !changed {
204 break;
205 }
206 }
207
208 let mut cluster_weights = [0.0f32; 2];
210 for (i, lbl) in labels.iter().enumerate() {
211 if let Some(c) = lbl {
212 let w = if params.use_weights {
213 corners[i].strength.max(0.0)
214 } else {
215 1.0
216 };
217 cluster_weights[*c] += w;
218 }
219 }
220
221 Some(OrientationClusteringResult {
222 centers,
223 labels,
224 cluster_weights,
225 histogram: Some(OrientationHistogram {
226 bin_centers,
227 values: hist_smoothed,
228 }),
229 })
230}
231
232struct SmoothedHistogramData {
233 values: Vec<f32>,
234 bin_centers: Vec<f32>,
235 total_weight: f32,
236 corner_bins: Vec<usize>,
237}
238
239fn build_smoothed_histogram(
240 corners: &[Corner],
241 params: &OrientationClusteringParams,
242) -> Option<SmoothedHistogramData> {
243 if params.num_bins < 1 {
244 return None;
245 }
246
247 let mut hist = vec![0.0f32; params.num_bins];
248 let mut total_weight = 0.0f32;
249 let mut corner_bins = Vec::with_capacity(corners.len());
250
251 for c in corners {
252 let t = wrap_angle_pi(c.orientation);
253 let bin = angle_to_bin(t, params.num_bins);
254 let w = if params.use_weights {
255 c.strength.max(0.0)
256 } else {
257 1.0
258 };
259 hist[bin] += w;
260 total_weight += w;
261 corner_bins.push(bin);
262 }
263
264 if total_weight <= 0.0 {
265 return None;
266 }
267
268 let values = smooth_circular_histogram(&hist);
269 let bin_centers: Vec<f32> = (0..params.num_bins)
270 .map(|b| bin_to_angle(b, params.num_bins))
271 .collect();
272
273 Some(SmoothedHistogramData {
274 values,
275 bin_centers,
276 total_weight,
277 corner_bins,
278 })
279}
280
281#[derive(Clone, Debug)]
282struct PeakSupport {
283 bins: Vec<usize>,
284 weight: f32,
285 weighted_angle: f32,
286}
287
288impl PeakSupport {
289 fn angle_from_corners(
290 &self,
291 corners: &[Corner],
292 corner_bins: &[usize],
293 params: &OrientationClusteringParams,
294 ) -> f32 {
295 let mut sum = [0.0f32; 2];
296 let mut w_sum = 0.0f32;
297 for (corner, &bin) in corners.iter().zip(corner_bins.iter()) {
298 if self.bins.contains(&bin) {
299 let w = if params.use_weights {
300 corner.strength.max(0.0)
301 } else {
302 1.0
303 };
304 let t = wrap_angle_pi(corner.orientation);
305 sum[0] += w * t.cos();
306 sum[1] += w * t.sin();
307 w_sum += w;
308 }
309 }
310
311 if w_sum > 0.0 {
312 wrap_angle_pi(sum[1].atan2(sum[0]))
313 } else {
314 self.weighted_angle
315 }
316 }
317}
318
319fn build_peak_support(hist: &[f32], peak_bin: usize, bin_centers: &[f32]) -> PeakSupport {
320 let n = hist.len();
321 let mut bins = vec![peak_bin];
322
323 let mut i = (peak_bin + n - 1) % n;
325 while hist[i] <= hist[(i + 1) % n] && hist[i] > 0.0 {
326 bins.push(i);
327 i = (i + n - 1) % n;
328 if i == peak_bin {
329 break;
330 }
331 }
332
333 let mut i = (peak_bin + 1) % n;
335 while hist[i] <= hist[(i + n - 1) % n] && hist[i] > 0.0 {
336 bins.push(i);
337 i = (i + 1) % n;
338 if i == peak_bin {
339 break;
340 }
341 }
342
343 bins.sort();
344 bins.dedup();
345
346 let mut weight = 0.0f32;
347 let mut sum = [0.0f32; 2];
348 for &b in &bins {
349 let w = hist[b];
350 weight += w;
351 let t = bin_centers[b];
352 sum[0] += w * t.cos();
353 sum[1] += w * t.sin();
354 }
355 let weighted_angle = wrap_angle_pi(sum[1].atan2(sum[0]));
356
357 PeakSupport {
358 bins,
359 weight,
360 weighted_angle,
361 }
362}
363
364fn wrap_angle_pi(theta: f32) -> f32 {
366 let mut t = theta % PI;
367 if t < 0.0 {
368 t += PI;
369 }
370 t
371}
372
373fn angular_dist_pi(a: f32, b: f32) -> f32 {
375 let mut d = a - b;
376 while d > FRAC_PI_2 {
378 d -= PI;
379 }
380 while d < -FRAC_PI_2 {
381 d += PI;
382 }
383 d.abs()
384}
385
386fn angle_to_bin(theta: f32, num_bins: usize) -> usize {
388 let t = wrap_angle_pi(theta);
389 let x = t / PI * num_bins as f32;
390 let mut idx = x.floor() as isize;
391 if idx < 0 {
392 idx = 0;
393 }
394 if idx as usize >= num_bins {
395 idx = (num_bins - 1) as isize;
396 }
397 idx as usize
398}
399
400fn bin_to_angle(bin: usize, num_bins: usize) -> f32 {
402 let step = PI / num_bins as f32;
403 (bin as f32 + 0.5) * step
404}
405
406fn smooth_circular_histogram(hist: &[f32]) -> Vec<f32> {
408 let n = hist.len();
409 if n == 0 {
410 return Vec::new();
411 }
412
413 const K: [f32; 5] = [1.0, 4.0, 6.0, 4.0, 1.0];
415 const K_SUM: f32 = 16.0;
416
417 let mut out = vec![0.0f32; n];
418 for (i, item) in out.iter_mut().enumerate() {
419 let mut acc = 0.0f32;
420 for (k, &w) in K.iter().enumerate() {
421 let offset = k as isize - 2;
422 let j = ((i as isize + offset).rem_euclid(n as isize)) as usize;
423 acc += w * hist[j];
424 }
425 *item = acc / K_SUM;
426 }
427 out
428}
429
430#[derive(Clone, Debug)]
431struct Peak {
432 bin: usize,
433}
434
435fn find_peaks(hist: &[f32]) -> Vec<Peak> {
437 let n = hist.len();
438 let mut peaks = Vec::new();
439 if n == 0 {
440 return peaks;
441 }
442
443 for i in 0..n {
444 let prev = hist[(i + n - 1) % n];
445 let curr = hist[i];
446 let next = hist[(i + 1) % n];
447
448 if curr >= prev && curr >= next && curr > 0.0 {
449 peaks.push(Peak { bin: i });
450 }
451 }
452
453 peaks
454}
455
456pub fn estimate_grid_axes_from_orientations(corners: &[Corner]) -> Option<f32> {
458 if corners.is_empty() {
459 return None;
460 }
461
462 let mut sum = Vector2::<f32>::zeros();
464 let mut weight_sum = 0.0f32;
465
466 for c in corners {
467 let theta = c.orientation;
468 let w = c.strength.max(0.0);
469 if w <= 0.0 {
470 continue;
471 }
472
473 let two_theta = 2.0 * theta;
474 let v = Vector2::new(two_theta.cos(), two_theta.sin());
475 sum += w * v;
476 weight_sum += w;
477 }
478
479 if weight_sum <= 0.0 {
480 return None;
481 }
482
483 let mean = sum / weight_sum;
484 if mean.norm_squared() < 1e-6 {
485 return None;
486 }
487
488 let mean_two_angle = mean.y.atan2(mean.x);
489 let mean_theta = 0.5 * mean_two_angle;
490 Some(mean_theta)
491}
492
493#[cfg(test)]
494mod tests {
495 use super::*;
496 use nalgebra::Point2;
497 use std::f32::consts::{FRAC_PI_2, FRAC_PI_4};
498
499 fn make_corner(theta: f32, strength: f32) -> Corner {
500 Corner {
501 position: Point2::new(0.0, 0.0),
502 orientation: theta,
503 orientation_cluster: None,
504 strength,
505 }
506 }
507
508 #[test]
509 fn clusters_two_dominant_modes() {
510 let cluster_a = [
511 FRAC_PI_4 - 0.05,
512 FRAC_PI_4,
513 FRAC_PI_4 + 0.04,
514 FRAC_PI_4 + 0.02,
515 ];
516 let cluster_b = [
517 3.0 * FRAC_PI_4 - 0.03,
518 3.0 * FRAC_PI_4,
519 3.0 * FRAC_PI_4 + 0.02,
520 3.0 * FRAC_PI_4 + 0.04,
521 ];
522
523 let mut corners = Vec::new();
524 for &theta in &cluster_a {
525 corners.push(make_corner(theta, 1.0));
526 }
527 for &theta in &cluster_b {
528 corners.push(make_corner(theta, 1.5));
529 }
530
531 let params = OrientationClusteringParams {
532 max_iters: 5,
533 ..Default::default()
534 };
535
536 let result = cluster_orientations(&corners, ¶ms).expect("expected two clusters");
537 assert_eq!(corners.len(), result.labels.len());
538
539 let center_a = if angular_dist_pi(result.centers[0], cluster_a[0])
540 < angular_dist_pi(result.centers[1], cluster_a[0])
541 {
542 0
543 } else {
544 1
545 };
546 let center_b = 1 - center_a;
547
548 for lbl in result.labels.iter().take(cluster_a.len()) {
549 assert_eq!(Some(center_a), *lbl);
550 }
551 for lbl in result.labels.iter().skip(cluster_a.len()) {
552 assert_eq!(Some(center_b), *lbl);
553 }
554
555 let separation = angular_dist_pi(result.centers[0], result.centers[1]);
556 assert!((separation - FRAC_PI_2).abs() < 0.2);
557 }
558
559 #[test]
560 fn marks_far_angles_as_outliers() {
561 let mut corners = Vec::new();
562 for _ in 0..5 {
563 corners.push(make_corner(FRAC_PI_4, 1.0));
564 }
565 for _ in 0..5 {
566 corners.push(make_corner(3.0 * FRAC_PI_4, 1.0));
567 }
568 corners.push(make_corner(0.0, 1.0)); let result = cluster_orientations(&corners, &OrientationClusteringParams::default())
571 .expect("clustering should succeed");
572
573 assert_eq!(corners.len(), result.labels.len());
574 assert_eq!(
575 corners.len() - 1,
576 result.labels.iter().filter(|l| l.is_some()).count()
577 );
578 assert!(result.labels.last().unwrap().is_none());
579 }
580
581 #[test]
582 fn returns_none_when_only_one_peak() {
583 let corners = vec![make_corner(0.1, 1.0); 6];
584 assert!(cluster_orientations(&corners, &OrientationClusteringParams::default()).is_none());
585 }
586}