mod circular;
use std::f32::consts::PI;
use serde::Serialize;
pub use circular::{
angle_to_bin, angular_dist_pi, bin_to_angle, pick_two_peaks, refine_2means_double_angle,
smooth_circular_5, wrap_pi, AngleVote, PeakPickOptions,
};
#[derive(Clone, Copy, Debug, PartialEq, Serialize)]
#[non_exhaustive]
pub struct AxisObservation {
pub angle: f32,
pub sigma: f32,
}
impl AxisObservation {
pub fn new(angle: f32, sigma: f32) -> Self {
Self { angle, sigma }
}
}
#[derive(Clone, Copy, Debug, PartialEq, Serialize)]
#[non_exhaustive]
pub struct AxisFeature {
pub axes: [AxisObservation; 2],
pub strength: f32,
}
impl AxisFeature {
pub fn new(axes: [AxisObservation; 2], strength: f32) -> Self {
Self { axes, strength }
}
}
#[derive(Clone, Copy, Debug, PartialEq, Serialize)]
#[non_exhaustive]
pub struct ClusterParams {
pub num_bins: usize,
pub min_peak_weight_fraction: f32,
pub peak_min_separation_rad: f32,
pub max_iters_2means: usize,
pub base_tol_rad: f32,
pub cluster_sigma_k: f32,
}
impl ClusterParams {
pub fn new(
num_bins: usize,
min_peak_weight_fraction: f32,
peak_min_separation_rad: f32,
max_iters_2means: usize,
base_tol_rad: f32,
cluster_sigma_k: f32,
) -> Self {
Self {
num_bins,
min_peak_weight_fraction,
peak_min_separation_rad,
max_iters_2means,
base_tol_rad,
cluster_sigma_k,
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Serialize)]
#[non_exhaustive]
pub struct AxisClusterCenters {
pub theta0: f32,
pub theta1: f32,
}
impl AxisClusterCenters {
pub fn new(theta0: f32, theta1: f32) -> Self {
Self { theta0, theta1 }
}
pub fn sorted(a: f32, b: f32) -> Self {
if a <= b {
Self {
theta0: a,
theta1: b,
}
} else {
Self {
theta0: b,
theta1: a,
}
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Serialize)]
#[non_exhaustive]
pub enum AxisAssignment {
Canonical {
max_d_rad: f32,
},
Swapped {
max_d_rad: f32,
},
None {
max_d_rad: f32,
},
}
#[derive(Clone, Debug, Serialize)]
#[non_exhaustive]
pub struct AxisClusterDebug {
pub num_bins: usize,
pub histogram: Vec<f32>,
pub smoothed: Vec<f32>,
pub total_weight: f32,
pub peak_seeds_rad: Option<[f32; 2]>,
pub refined_centers_rad: Option<[f32; 2]>,
}
impl AxisClusterDebug {
fn empty(num_bins: usize) -> Self {
Self {
num_bins,
histogram: Vec::new(),
smoothed: Vec::new(),
total_weight: 0.0,
peak_seeds_rad: None,
refined_centers_rad: None,
}
}
}
pub fn cluster_axes(
features: &[AxisFeature],
params: &ClusterParams,
) -> (
Option<AxisClusterCenters>,
Vec<AxisAssignment>,
AxisClusterDebug,
) {
let mut debug = AxisClusterDebug::empty(params.num_bins);
if features.is_empty() || params.num_bins < 4 {
return (None, Vec::new(), debug);
}
let hist = build_histogram(features, params.num_bins);
debug.histogram = hist.bins.clone();
debug.total_weight = hist.total_weight;
if hist.total_weight <= 0.0 {
return (None, Vec::new(), debug);
}
let smoothed = smooth_circular_5(&hist.bins);
debug.smoothed = smoothed.clone();
let peak_opts = PeakPickOptions::new(
params.min_peak_weight_fraction,
params.peak_min_separation_rad,
);
let Some((theta0_seed, theta1_seed)) = pick_two_peaks(&smoothed, hist.total_weight, &peak_opts)
else {
return (None, Vec::new(), debug);
};
debug.peak_seeds_rad = Some([theta0_seed, theta1_seed]);
let votes = collect_axis_votes(features);
let (theta0, theta1) =
refine_2means_double_angle(&votes, [theta0_seed, theta1_seed], params.max_iters_2means);
debug.refined_centers_rad = Some([theta0, theta1]);
let centers = AxisClusterCenters::sorted(theta0, theta1);
let mut assignments = Vec::with_capacity(features.len());
for feature in features {
let tol_rad = effective_tol_rad(&feature.axes, params.base_tol_rad, params.cluster_sigma_k);
assignments.push(assign_axes(&feature.axes, centers, tol_rad));
}
(Some(centers), assignments, debug)
}
struct Histogram {
bins: Vec<f32>,
total_weight: f32,
}
fn build_histogram(features: &[AxisFeature], num_bins: usize) -> Histogram {
let mut bins = vec![0.0_f32; num_bins];
let mut total = 0.0_f32;
for feature in features {
for axis in &feature.axes {
if !axis.sigma.is_finite() || axis.sigma >= PI - f32::EPSILON {
continue;
}
let w = vote_weight(feature.strength, axis.sigma);
if w <= 0.0 {
continue;
}
let bin = angle_to_bin(wrap_pi(axis.angle), num_bins);
bins[bin] += w;
total += w;
}
}
Histogram {
bins,
total_weight: total,
}
}
#[inline]
fn vote_weight(strength: f32, sigma: f32) -> f32 {
let s = strength.max(0.0);
let base = if s > 0.0 { s } else { 1.0 };
base / (1.0 + sigma.max(0.0))
}
fn collect_axis_votes(features: &[AxisFeature]) -> Vec<AngleVote> {
let mut votes: Vec<AngleVote> = Vec::new();
for feature in features {
for axis in &feature.axes {
if !axis.sigma.is_finite() || axis.sigma >= PI - f32::EPSILON {
continue;
}
let w = vote_weight(feature.strength, axis.sigma);
if w <= 0.0 {
continue;
}
votes.push(AngleVote {
angle: wrap_pi(axis.angle),
weight: w,
});
}
}
votes
}
#[inline]
pub fn effective_tol_rad(axes: &[AxisObservation; 2], base_tol_rad: f32, sigma_k: f32) -> f32 {
if sigma_k <= 0.0 {
return base_tol_rad;
}
let sigma_cap = 10.0_f32.to_radians();
let s0 = axes[0].sigma.clamp(0.0, sigma_cap);
let s1 = axes[1].sigma.clamp(0.0, sigma_cap);
let bonus = sigma_k * s0.max(s1);
let max_bonus = 3.0_f32.to_radians();
base_tol_rad + bonus.min(max_bonus)
}
pub fn assign_axes(
axes: &[AxisObservation; 2],
centers: AxisClusterCenters,
tol_rad: f32,
) -> AxisAssignment {
let a0 = wrap_pi(axes[0].angle);
let a1 = wrap_pi(axes[1].angle);
let d_a0_t0 = angular_dist_pi(a0, centers.theta0);
let d_a0_t1 = angular_dist_pi(a0, centers.theta1);
let d_a1_t0 = angular_dist_pi(a1, centers.theta0);
let d_a1_t1 = angular_dist_pi(a1, centers.theta1);
let canon_cost = d_a0_t0 + d_a1_t1;
let canon_max = d_a0_t0.max(d_a1_t1);
let swap_cost = d_a0_t1 + d_a1_t0;
let swap_max = d_a0_t1.max(d_a1_t0);
let (canonical, max_d) = if canon_cost <= swap_cost {
(true, canon_max)
} else {
(false, swap_max)
};
if max_d <= tol_rad {
if canonical {
AxisAssignment::Canonical { max_d_rad: max_d }
} else {
AxisAssignment::Swapped { max_d_rad: max_d }
}
} else {
AxisAssignment::None { max_d_rad: max_d }
}
}
pub fn refit_centers(
axes: &[[AxisObservation; 2]],
old_centers: AxisClusterCenters,
min_samples: usize,
) -> Option<AxisClusterCenters> {
if axes.len() < min_samples {
return None;
}
let mut s0_re = 0.0_f32;
let mut s0_im = 0.0_f32;
let mut s1_re = 0.0_f32;
let mut s1_im = 0.0_f32;
for pair in axes {
let a0 = wrap_pi(pair[0].angle);
let a1 = wrap_pi(pair[1].angle);
let d_a0_t0 = angular_dist_pi(a0, old_centers.theta0);
let d_a0_t1 = angular_dist_pi(a0, old_centers.theta1);
let d_a1_t0 = angular_dist_pi(a1, old_centers.theta0);
let d_a1_t1 = angular_dist_pi(a1, old_centers.theta1);
let canon_cost = d_a0_t0 + d_a1_t1;
let swap_cost = d_a0_t1 + d_a1_t0;
let (a_to_t0, a_to_t1) = if canon_cost <= swap_cost {
(a0, a1)
} else {
(a1, a0)
};
s0_re += (2.0 * a_to_t0).cos();
s0_im += (2.0 * a_to_t0).sin();
s1_re += (2.0 * a_to_t1).cos();
s1_im += (2.0 * a_to_t1).sin();
}
let mut t0 = 0.5 * s0_im.atan2(s0_re);
let mut t1 = 0.5 * s1_im.atan2(s1_re);
while t0 < 0.0 {
t0 += PI;
}
while t0 >= PI {
t0 -= PI;
}
while t1 < 0.0 {
t1 += PI;
}
while t1 >= PI {
t1 -= PI;
}
if t0 > t1 {
std::mem::swap(&mut t0, &mut t1);
}
Some(AxisClusterCenters {
theta0: t0,
theta1: t1,
})
}
#[cfg(test)]
mod tests {
use super::*;
fn obs(angle: f32, sigma: f32) -> AxisObservation {
AxisObservation::new(angle, sigma)
}
fn feat(a0_deg: f32, sigma_deg: f32, strength: f32) -> AxisFeature {
let a0 = a0_deg.to_radians();
let a1 = a0 + std::f32::consts::FRAC_PI_2;
let sigma = sigma_deg.to_radians();
AxisFeature::new([obs(wrap_pi(a0), sigma), obs(wrap_pi(a1), sigma)], strength)
}
fn default_params() -> ClusterParams {
ClusterParams::new(
90,
0.02,
60.0_f32.to_radians(),
10,
12.0_f32.to_radians(),
0.5,
)
}
fn jitter(i: usize, amp_deg: f32) -> f32 {
let x = (i as u32).wrapping_mul(2_654_435_761);
let frac = ((x >> 8) as f32) / ((1u32 << 24) as f32);
(frac - 0.5) * amp_deg
}
#[test]
fn recovers_centers_30_120() {
let mut features = Vec::new();
for i in 0..50 {
features.push(feat(30.0 + jitter(i, 10.0), 0.05, 1.0));
}
for i in 0..50 {
features.push(feat(120.0 + jitter(i + 1000, 10.0), 0.05, 1.0));
}
let params = default_params();
let (centers, assignments, _dbg) = cluster_axes(&features, ¶ms);
let centers = centers.expect("centers");
let expected_low = 30.0_f32.to_radians();
let expected_high = 120.0_f32.to_radians();
assert!(angular_dist_pi(centers.theta0, expected_low) < 2.0_f32.to_radians());
assert!(angular_dist_pi(centers.theta1, expected_high) < 2.0_f32.to_radians());
assert!(assignments
.iter()
.all(|a| !matches!(a, AxisAssignment::None { .. })));
}
#[test]
fn far_feature_is_unassigned() {
let mut features = Vec::new();
for _ in 0..40 {
features.push(feat(0.0, 0.01_f32.to_degrees(), 1.0));
}
features.push(feat(25.0, 0.01_f32.to_degrees(), 1.0));
let params = default_params();
let (_centers, assignments, _dbg) = cluster_axes(&features, ¶ms);
assert!(matches!(
assignments.last().expect("non-empty"),
AxisAssignment::None { .. }
));
}
#[test]
fn empty_input_returns_none() {
let params = default_params();
let (centers, assignments, _dbg) = cluster_axes(&[], ¶ms);
assert!(centers.is_none());
assert!(assignments.is_empty());
}
}