calib_targets_core/
orientation_clustering.rs

1/// Orientation clustering for ChESS-style angles in [0, π) (mod π).
2///
3/// Given a set of angles θ_k (diagonals of light squares) and optional
4/// weights, this finds two dominant orientation clusters on the circle
5/// and assigns each angle to cluster 0, 1, or None (outlier).
6///
7/// This is purely angular; no geometry or projective assumptions here.
8use crate::Corner;
9use log::warn;
10use nalgebra::Vector2;
11use serde::{Deserialize, Serialize};
12use std::f32::consts::{FRAC_PI_2, PI};
13
14/// Parameters for orientation clustering.
15#[derive(Clone, Debug, Deserialize, Serialize)]
16pub struct OrientationClusteringParams {
17    /// Number of histogram bins on [0, π).
18    pub num_bins: usize,
19    /// Max k-means iterations.
20    pub max_iters: usize,
21    /// Minimal separation between initial peaks (degrees).
22    pub peak_min_separation_deg: f32,
23    /// Max allowed distance from both centers before marking as outlier (degrees).
24    pub outlier_threshold_deg: f32,
25    /// Minimal total weight required for a peak to be considered (fraction of total sum).
26    pub min_peak_weight_fraction: f32,
27    /// Whether to use weights when clustering.
28    pub use_weights: bool,
29}
30
31impl Default for OrientationClusteringParams {
32    fn default() -> Self {
33        Self {
34            num_bins: 90, // ~2° per bin
35            max_iters: 10,
36            peak_min_separation_deg: 10f32,
37            outlier_threshold_deg: 30f32,
38            min_peak_weight_fraction: 0.05, // 5% of total weight
39            use_weights: true,
40        }
41    }
42}
43
44/// Result of orientation clustering.
45#[derive(Clone, Debug, Serialize, Deserialize)]
46pub struct OrientationClusteringResult {
47    /// Cluster centers in [0, π), indices 0 and 1.
48    pub centers: [f32; 2],
49    /// For each input angle, the assigned cluster (0 or 1) or None if outlier.
50    pub labels: Vec<Option<usize>>,
51    /// Sum of weights in each cluster (excluding outliers).
52    pub cluster_weights: [f32; 2],
53    /// Smoothed histogram for debugging/visualization.
54    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
63/// Compute smoothed orientation histogram for debug/visualization.
64pub 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
74/// Cluster ChESS orientations into two dominant directions on [0, π).
75///
76/// `angles` must be radians; they will be wrapped to [0, π).
77/// If `weights` is None, all weights are treated as 1.0.
78pub 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    // 1. Wrap angles and build histogram on [0, π) once (shared with debug output).
89    let SmoothedHistogramData {
90        values: hist_smoothed,
91        bin_centers,
92        total_weight,
93        corner_bins,
94    } = build_smoothed_histogram(corners, params)?;
95
96    // 3. Find local maxima as peak candidates.
97    let peaks = find_peaks(&hist_smoothed);
98    if peaks.is_empty() {
99        warn!("Orientation peaks not found");
100        return None;
101    }
102
103    // 4. Group peaks (contiguous bins) and pick top 2 by total weight, with minimal separation.
104    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    // 5. Circular 2-means refinement (with outliers).
149    let mut labels: Vec<Option<usize>> = vec![None; n];
150
151    for _ in 0..params.max_iters {
152        // Assignment step.
153        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        // Update step: recompute centers as circular means.
175        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    // Final cluster weights.
209    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    // expand left
324    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    // expand right
334    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
364/// Wrap an angle to [0, π).
365fn 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
373/// Smallest angular distance on the circle with period π (result in [0, π/2]).
374fn angular_dist_pi(a: f32, b: f32) -> f32 {
375    let mut d = a - b;
376    // wrap to [-π/2, π/2]
377    while d > FRAC_PI_2 {
378        d -= PI;
379    }
380    while d < -FRAC_PI_2 {
381        d += PI;
382    }
383    d.abs()
384}
385
386/// Convert angle in [0, π) to bin index.
387fn 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
400/// Convert bin index back to angle (center of bin).
401fn 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
406/// Smooth a circular histogram with a small symmetric kernel.
407fn smooth_circular_histogram(hist: &[f32]) -> Vec<f32> {
408    let n = hist.len();
409    if n == 0 {
410        return Vec::new();
411    }
412
413    // Simple discrete Gaussian-like kernel: [1, 4, 6, 4, 1] / 16
414    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
435/// Find local maxima on a circular 1D array.
436fn 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
456/// Estimate a dominant grid axis from orientations using a double-angle mean.
457pub fn estimate_grid_axes_from_orientations(corners: &[Corner]) -> Option<f32> {
458    if corners.is_empty() {
459        return None;
460    }
461
462    // Accumulate in double-angle space to handle θ ≡ θ + π.
463    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, &params).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)); // outlier
569
570        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}