1mod circular;
52
53use std::f32::consts::PI;
54
55use serde::Serialize;
56
57pub use circular::{
58 angle_to_bin, angular_dist_pi, bin_to_angle, pick_two_peaks, refine_2means_double_angle,
59 smooth_circular_5, wrap_pi, AngleVote, PeakPickOptions,
60};
61
62#[derive(Clone, Copy, Debug, PartialEq, Serialize)]
68#[non_exhaustive]
69pub struct AxisObservation {
70 pub angle: f32,
72 pub sigma: f32,
74}
75
76impl AxisObservation {
77 pub fn new(angle: f32, sigma: f32) -> Self {
79 Self { angle, sigma }
80 }
81}
82
83#[derive(Clone, Copy, Debug, PartialEq, Serialize)]
86#[non_exhaustive]
87pub struct AxisFeature {
88 pub axes: [AxisObservation; 2],
90 pub strength: f32,
92}
93
94impl AxisFeature {
95 pub fn new(axes: [AxisObservation; 2], strength: f32) -> Self {
97 Self { axes, strength }
98 }
99}
100
101#[derive(Clone, Copy, Debug, PartialEq, Serialize)]
103#[non_exhaustive]
104pub struct ClusterParams {
105 pub num_bins: usize,
107 pub min_peak_weight_fraction: f32,
109 pub peak_min_separation_rad: f32,
111 pub max_iters_2means: usize,
113 pub base_tol_rad: f32,
115 pub cluster_sigma_k: f32,
117}
118
119impl ClusterParams {
120 pub fn new(
122 num_bins: usize,
123 min_peak_weight_fraction: f32,
124 peak_min_separation_rad: f32,
125 max_iters_2means: usize,
126 base_tol_rad: f32,
127 cluster_sigma_k: f32,
128 ) -> Self {
129 Self {
130 num_bins,
131 min_peak_weight_fraction,
132 peak_min_separation_rad,
133 max_iters_2means,
134 base_tol_rad,
135 cluster_sigma_k,
136 }
137 }
138}
139
140#[derive(Clone, Copy, Debug, PartialEq, Serialize)]
143#[non_exhaustive]
144pub struct AxisClusterCenters {
145 pub theta0: f32,
147 pub theta1: f32,
149}
150
151impl AxisClusterCenters {
152 pub fn new(theta0: f32, theta1: f32) -> Self {
156 Self { theta0, theta1 }
157 }
158
159 pub fn sorted(a: f32, b: f32) -> Self {
161 if a <= b {
162 Self {
163 theta0: a,
164 theta1: b,
165 }
166 } else {
167 Self {
168 theta0: b,
169 theta1: a,
170 }
171 }
172 }
173}
174
175#[derive(Clone, Copy, Debug, PartialEq, Serialize)]
180#[non_exhaustive]
181pub enum AxisAssignment {
182 Canonical {
184 max_d_rad: f32,
186 },
187 Swapped {
189 max_d_rad: f32,
191 },
192 None {
194 max_d_rad: f32,
196 },
197}
198
199#[derive(Clone, Debug, Serialize)]
204#[non_exhaustive]
205pub struct AxisClusterDebug {
206 pub num_bins: usize,
208 pub histogram: Vec<f32>,
210 pub smoothed: Vec<f32>,
212 pub total_weight: f32,
214 pub peak_seeds_rad: Option<[f32; 2]>,
217 pub refined_centers_rad: Option<[f32; 2]>,
220}
221
222impl AxisClusterDebug {
223 fn empty(num_bins: usize) -> Self {
224 Self {
225 num_bins,
226 histogram: Vec::new(),
227 smoothed: Vec::new(),
228 total_weight: 0.0,
229 peak_seeds_rad: None,
230 refined_centers_rad: None,
231 }
232 }
233}
234
235pub fn cluster_axes(
247 features: &[AxisFeature],
248 params: &ClusterParams,
249) -> (
250 Option<AxisClusterCenters>,
251 Vec<AxisAssignment>,
252 AxisClusterDebug,
253) {
254 let mut debug = AxisClusterDebug::empty(params.num_bins);
255
256 if features.is_empty() || params.num_bins < 4 {
257 return (None, Vec::new(), debug);
258 }
259
260 let hist = build_histogram(features, params.num_bins);
261 debug.histogram = hist.bins.clone();
262 debug.total_weight = hist.total_weight;
263 if hist.total_weight <= 0.0 {
264 return (None, Vec::new(), debug);
265 }
266
267 let smoothed = smooth_circular_5(&hist.bins);
268 debug.smoothed = smoothed.clone();
269
270 let peak_opts = PeakPickOptions::new(
271 params.min_peak_weight_fraction,
272 params.peak_min_separation_rad,
273 );
274 let Some((theta0_seed, theta1_seed)) = pick_two_peaks(&smoothed, hist.total_weight, &peak_opts)
275 else {
276 return (None, Vec::new(), debug);
277 };
278 debug.peak_seeds_rad = Some([theta0_seed, theta1_seed]);
279
280 let votes = collect_axis_votes(features);
281 let (theta0, theta1) =
282 refine_2means_double_angle(&votes, [theta0_seed, theta1_seed], params.max_iters_2means);
283 debug.refined_centers_rad = Some([theta0, theta1]);
284
285 let centers = AxisClusterCenters::sorted(theta0, theta1);
286
287 let mut assignments = Vec::with_capacity(features.len());
288 for feature in features {
289 let tol_rad = effective_tol_rad(&feature.axes, params.base_tol_rad, params.cluster_sigma_k);
290 assignments.push(assign_axes(&feature.axes, centers, tol_rad));
291 }
292
293 (Some(centers), assignments, debug)
294}
295
296struct Histogram {
299 bins: Vec<f32>,
300 total_weight: f32,
301}
302
303fn build_histogram(features: &[AxisFeature], num_bins: usize) -> Histogram {
304 let mut bins = vec![0.0_f32; num_bins];
305 let mut total = 0.0_f32;
306 for feature in features {
307 for axis in &feature.axes {
308 if !axis.sigma.is_finite() || axis.sigma >= PI - f32::EPSILON {
309 continue;
311 }
312 let w = vote_weight(feature.strength, axis.sigma);
313 if w <= 0.0 {
314 continue;
315 }
316 let bin = angle_to_bin(wrap_pi(axis.angle), num_bins);
317 bins[bin] += w;
318 total += w;
319 }
320 }
321 Histogram {
322 bins,
323 total_weight: total,
324 }
325}
326
327#[inline]
328fn vote_weight(strength: f32, sigma: f32) -> f32 {
329 let s = strength.max(0.0);
330 let base = if s > 0.0 { s } else { 1.0 };
331 base / (1.0 + sigma.max(0.0))
332}
333
334fn collect_axis_votes(features: &[AxisFeature]) -> Vec<AngleVote> {
337 let mut votes: Vec<AngleVote> = Vec::new();
338 for feature in features {
339 for axis in &feature.axes {
340 if !axis.sigma.is_finite() || axis.sigma >= PI - f32::EPSILON {
341 continue;
342 }
343 let w = vote_weight(feature.strength, axis.sigma);
344 if w <= 0.0 {
345 continue;
346 }
347 votes.push(AngleVote {
348 angle: wrap_pi(axis.angle),
349 weight: w,
350 });
351 }
352 }
353 votes
354}
355
356#[inline]
362pub fn effective_tol_rad(axes: &[AxisObservation; 2], base_tol_rad: f32, sigma_k: f32) -> f32 {
363 if sigma_k <= 0.0 {
364 return base_tol_rad;
365 }
366 let sigma_cap = 10.0_f32.to_radians();
367 let s0 = axes[0].sigma.clamp(0.0, sigma_cap);
368 let s1 = axes[1].sigma.clamp(0.0, sigma_cap);
369 let bonus = sigma_k * s0.max(s1);
370 let max_bonus = 3.0_f32.to_radians();
371 base_tol_rad + bonus.min(max_bonus)
372}
373
374pub fn assign_axes(
380 axes: &[AxisObservation; 2],
381 centers: AxisClusterCenters,
382 tol_rad: f32,
383) -> AxisAssignment {
384 let a0 = wrap_pi(axes[0].angle);
385 let a1 = wrap_pi(axes[1].angle);
386
387 let d_a0_t0 = angular_dist_pi(a0, centers.theta0);
388 let d_a0_t1 = angular_dist_pi(a0, centers.theta1);
389 let d_a1_t0 = angular_dist_pi(a1, centers.theta0);
390 let d_a1_t1 = angular_dist_pi(a1, centers.theta1);
391
392 let canon_cost = d_a0_t0 + d_a1_t1;
393 let canon_max = d_a0_t0.max(d_a1_t1);
394 let swap_cost = d_a0_t1 + d_a1_t0;
395 let swap_max = d_a0_t1.max(d_a1_t0);
396
397 let (canonical, max_d) = if canon_cost <= swap_cost {
398 (true, canon_max)
399 } else {
400 (false, swap_max)
401 };
402
403 if max_d <= tol_rad {
404 if canonical {
405 AxisAssignment::Canonical { max_d_rad: max_d }
406 } else {
407 AxisAssignment::Swapped { max_d_rad: max_d }
408 }
409 } else {
410 AxisAssignment::None { max_d_rad: max_d }
411 }
412}
413
414pub fn refit_centers(
425 axes: &[[AxisObservation; 2]],
426 old_centers: AxisClusterCenters,
427 min_samples: usize,
428) -> Option<AxisClusterCenters> {
429 if axes.len() < min_samples {
430 return None;
431 }
432 let mut s0_re = 0.0_f32;
433 let mut s0_im = 0.0_f32;
434 let mut s1_re = 0.0_f32;
435 let mut s1_im = 0.0_f32;
436 for pair in axes {
437 let a0 = wrap_pi(pair[0].angle);
438 let a1 = wrap_pi(pair[1].angle);
439 let d_a0_t0 = angular_dist_pi(a0, old_centers.theta0);
440 let d_a0_t1 = angular_dist_pi(a0, old_centers.theta1);
441 let d_a1_t0 = angular_dist_pi(a1, old_centers.theta0);
442 let d_a1_t1 = angular_dist_pi(a1, old_centers.theta1);
443 let canon_cost = d_a0_t0 + d_a1_t1;
444 let swap_cost = d_a0_t1 + d_a1_t0;
445 let (a_to_t0, a_to_t1) = if canon_cost <= swap_cost {
446 (a0, a1)
447 } else {
448 (a1, a0)
449 };
450 s0_re += (2.0 * a_to_t0).cos();
451 s0_im += (2.0 * a_to_t0).sin();
452 s1_re += (2.0 * a_to_t1).cos();
453 s1_im += (2.0 * a_to_t1).sin();
454 }
455 let mut t0 = 0.5 * s0_im.atan2(s0_re);
456 let mut t1 = 0.5 * s1_im.atan2(s1_re);
457 while t0 < 0.0 {
458 t0 += PI;
459 }
460 while t0 >= PI {
461 t0 -= PI;
462 }
463 while t1 < 0.0 {
464 t1 += PI;
465 }
466 while t1 >= PI {
467 t1 -= PI;
468 }
469 if t0 > t1 {
470 std::mem::swap(&mut t0, &mut t1);
471 }
472 Some(AxisClusterCenters {
473 theta0: t0,
474 theta1: t1,
475 })
476}
477
478#[cfg(test)]
479mod tests {
480 use super::*;
481
482 fn obs(angle: f32, sigma: f32) -> AxisObservation {
483 AxisObservation::new(angle, sigma)
484 }
485
486 fn feat(a0_deg: f32, sigma_deg: f32, strength: f32) -> AxisFeature {
487 let a0 = a0_deg.to_radians();
488 let a1 = a0 + std::f32::consts::FRAC_PI_2;
489 let sigma = sigma_deg.to_radians();
490 AxisFeature::new([obs(wrap_pi(a0), sigma), obs(wrap_pi(a1), sigma)], strength)
491 }
492
493 fn default_params() -> ClusterParams {
494 ClusterParams::new(
495 90,
496 0.02,
497 60.0_f32.to_radians(),
498 10,
499 12.0_f32.to_radians(),
500 0.5,
501 )
502 }
503
504 fn jitter(i: usize, amp_deg: f32) -> f32 {
505 let x = (i as u32).wrapping_mul(2_654_435_761);
506 let frac = ((x >> 8) as f32) / ((1u32 << 24) as f32);
507 (frac - 0.5) * amp_deg
508 }
509
510 #[test]
511 fn recovers_centers_30_120() {
512 let mut features = Vec::new();
513 for i in 0..50 {
514 features.push(feat(30.0 + jitter(i, 10.0), 0.05, 1.0));
515 }
516 for i in 0..50 {
517 features.push(feat(120.0 + jitter(i + 1000, 10.0), 0.05, 1.0));
518 }
519 let params = default_params();
520 let (centers, assignments, _dbg) = cluster_axes(&features, ¶ms);
521 let centers = centers.expect("centers");
522 let expected_low = 30.0_f32.to_radians();
523 let expected_high = 120.0_f32.to_radians();
524 assert!(angular_dist_pi(centers.theta0, expected_low) < 2.0_f32.to_radians());
525 assert!(angular_dist_pi(centers.theta1, expected_high) < 2.0_f32.to_radians());
526 assert!(assignments
527 .iter()
528 .all(|a| !matches!(a, AxisAssignment::None { .. })));
529 }
530
531 #[test]
532 fn far_feature_is_unassigned() {
533 let mut features = Vec::new();
534 for _ in 0..40 {
535 features.push(feat(0.0, 0.01_f32.to_degrees(), 1.0));
536 }
537 features.push(feat(25.0, 0.01_f32.to_degrees(), 1.0));
538 let params = default_params();
539 let (_centers, assignments, _dbg) = cluster_axes(&features, ¶ms);
540 assert!(matches!(
541 assignments.last().expect("non-empty"),
542 AxisAssignment::None { .. }
543 ));
544 }
545
546 #[test]
547 fn empty_input_returns_none() {
548 let params = default_params();
549 let (centers, assignments, _dbg) = cluster_axes(&[], ¶ms);
550 assert!(centers.is_none());
551 assert!(assignments.is_empty());
552 }
553}