1mod circular;
52
53use std::f32::consts::PI;
54
55use serde::Serialize;
56
57pub use circular::{
58 angle_to_bin, angular_dist_pi, pick_two_peaks, refine_2means_double_angle, smooth_circular_5,
59 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
414#[cfg(test)]
415mod tests {
416 use super::*;
417
418 fn obs(angle: f32, sigma: f32) -> AxisObservation {
419 AxisObservation::new(angle, sigma)
420 }
421
422 fn feat(a0_deg: f32, sigma_deg: f32, strength: f32) -> AxisFeature {
423 let a0 = a0_deg.to_radians();
424 let a1 = a0 + std::f32::consts::FRAC_PI_2;
425 let sigma = sigma_deg.to_radians();
426 AxisFeature::new([obs(wrap_pi(a0), sigma), obs(wrap_pi(a1), sigma)], strength)
427 }
428
429 fn default_params() -> ClusterParams {
430 ClusterParams::new(
431 90,
432 0.02,
433 60.0_f32.to_radians(),
434 10,
435 12.0_f32.to_radians(),
436 0.5,
437 )
438 }
439
440 fn jitter(i: usize, amp_deg: f32) -> f32 {
441 let x = (i as u32).wrapping_mul(2_654_435_761);
442 let frac = ((x >> 8) as f32) / ((1u32 << 24) as f32);
443 (frac - 0.5) * amp_deg
444 }
445
446 #[test]
447 fn recovers_centers_30_120() {
448 let mut features = Vec::new();
449 for i in 0..50 {
450 features.push(feat(30.0 + jitter(i, 10.0), 0.05, 1.0));
451 }
452 for i in 0..50 {
453 features.push(feat(120.0 + jitter(i + 1000, 10.0), 0.05, 1.0));
454 }
455 let params = default_params();
456 let (centers, assignments, _dbg) = cluster_axes(&features, ¶ms);
457 let centers = centers.expect("centers");
458 let expected_low = 30.0_f32.to_radians();
459 let expected_high = 120.0_f32.to_radians();
460 assert!(angular_dist_pi(centers.theta0, expected_low) < 2.0_f32.to_radians());
461 assert!(angular_dist_pi(centers.theta1, expected_high) < 2.0_f32.to_radians());
462 assert!(assignments
463 .iter()
464 .all(|a| !matches!(a, AxisAssignment::None { .. })));
465 }
466
467 #[test]
468 fn far_feature_is_unassigned() {
469 let mut features = Vec::new();
470 for _ in 0..40 {
471 features.push(feat(0.0, 0.01_f32.to_degrees(), 1.0));
472 }
473 features.push(feat(25.0, 0.01_f32.to_degrees(), 1.0));
474 let params = default_params();
475 let (_centers, assignments, _dbg) = cluster_axes(&features, ¶ms);
476 assert!(matches!(
477 assignments.last().expect("non-empty"),
478 AxisAssignment::None { .. }
479 ));
480 }
481
482 #[test]
483 fn empty_input_returns_none() {
484 let params = default_params();
485 let (centers, assignments, _dbg) = cluster_axes(&[], ¶ms);
486 assert!(centers.is_none());
487 assert!(assignments.is_empty());
488 }
489}