use crate::Corner;
use log::warn;
use nalgebra::Vector2;
use serde::{Deserialize, Serialize};
use std::f32::consts::{FRAC_PI_2, PI};
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct OrientationClusteringParams {
pub num_bins: usize,
pub max_iters: usize,
pub peak_min_separation_deg: f32,
pub outlier_threshold_deg: f32,
pub min_peak_weight_fraction: f32,
pub use_weights: bool,
}
impl Default for OrientationClusteringParams {
fn default() -> Self {
Self {
num_bins: 90, max_iters: 10,
peak_min_separation_deg: 10f32,
outlier_threshold_deg: 30f32,
min_peak_weight_fraction: 0.05, use_weights: true,
}
}
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct OrientationClusteringResult {
pub centers: [f32; 2],
pub labels: Vec<Option<usize>>,
pub cluster_weights: [f32; 2],
pub histogram: Option<OrientationHistogram>,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct OrientationHistogram {
pub bin_centers: Vec<f32>,
pub values: Vec<f32>,
}
pub fn compute_orientation_histogram(
corners: &[Corner],
params: &OrientationClusteringParams,
) -> Option<OrientationHistogram> {
build_smoothed_histogram(corners, params).map(|h| OrientationHistogram {
bin_centers: h.bin_centers,
values: h.values,
})
}
#[derive(Clone, Copy, Debug)]
struct AxisVote {
angle: f32,
weight: f32,
}
pub fn cluster_orientations(
corners: &[Corner],
params: &OrientationClusteringParams,
) -> Option<OrientationClusteringResult> {
let n = corners.len();
if n == 0 || params.num_bins < 4 {
warn!("n = {n} num_bins = {}", params.num_bins);
return None;
}
let SmoothedHistogramData {
values: hist_smoothed,
bin_centers,
total_weight,
votes,
} = build_smoothed_histogram(corners, params)?;
let peaks = find_peaks(&hist_smoothed);
if peaks.is_empty() {
warn!("Orientation peaks not found");
return None;
}
let mut supports: Vec<PeakSupport> = peaks
.into_iter()
.map(|p| build_peak_support(&hist_smoothed, p.bin, &bin_centers))
.collect();
let min_peak_weight = total_weight * params.min_peak_weight_fraction;
supports.retain(|p| p.weight >= min_peak_weight);
supports.sort_by(|a, b| {
b.weight
.partial_cmp(&a.weight)
.unwrap_or(std::cmp::Ordering::Equal)
});
if supports.len() < 2 {
warn!(
"{} grouped peaks, total {total_weight:.2}, min {min_peak_weight:.2}",
supports.len()
);
return None;
}
let mut phi1 = None;
let mut phi2 = None;
for sup in &supports {
if phi1.is_none() {
phi1 = Some(sup.refined_angle(&votes));
continue;
}
let c1 = phi1.unwrap();
let cand = sup.refined_angle(&votes);
if angular_dist_pi(c1, cand) >= params.peak_min_separation_deg.to_radians() {
phi2 = Some(cand);
break;
}
}
let (phi1, phi2) = match (phi1, phi2) {
(Some(a), Some(b)) => (a, b),
_ => return None,
};
let mut centers = [phi1, phi2];
for _ in 0..params.max_iters {
let mut sum_vec = [[0.0f32; 2], [0.0f32; 2]];
let mut sum_w = [0.0f32; 2];
let mut changed = false;
for vote in &votes {
let d0 = angular_dist_pi(vote.angle, centers[0]);
let d1 = angular_dist_pi(vote.angle, centers[1]);
let best = if d0 <= d1 { 0usize } else { 1usize };
let best_dist = if best == 0 { d0 } else { d1 };
if best_dist > params.outlier_threshold_deg.to_radians() {
continue;
}
let two_theta = 2.0 * vote.angle;
let vx = two_theta.cos();
let vy = two_theta.sin();
sum_vec[best][0] += vote.weight * vx;
sum_vec[best][1] += vote.weight * vy;
sum_w[best] += vote.weight;
}
for c in 0..2 {
if sum_w[c] > 0.0 {
let vx = sum_vec[c][0] / sum_w[c];
let vy = sum_vec[c][1] / sum_w[c];
let new_center = wrap_angle_pi(0.5 * vy.atan2(vx));
if (new_center - centers[c]).abs() > 1e-6 {
changed = true;
}
centers[c] = new_center;
}
}
if !changed {
break;
}
}
let mut labels: Vec<Option<usize>> = vec![None; n];
let mut cluster_weights = [0.0f32; 2];
let outlier_rad = params.outlier_threshold_deg.to_radians();
for i in 0..n {
let corner = &corners[i];
let a0 = wrap_angle_pi(corner.axes[0].angle);
let a1 = wrap_angle_pi(corner.axes[1].angle);
let d_can_0 = angular_dist_pi(a0, centers[0]);
let d_can_1 = angular_dist_pi(a1, centers[1]);
let worst_canonical = d_can_0.max(d_can_1);
let total_canonical = d_can_0 + d_can_1;
let d_sw_0 = angular_dist_pi(a0, centers[1]);
let d_sw_1 = angular_dist_pi(a1, centers[0]);
let worst_swapped = d_sw_0.max(d_sw_1);
let total_swapped = d_sw_0 + d_sw_1;
let (label, worst) = if total_canonical <= total_swapped {
(0usize, worst_canonical)
} else {
(1usize, worst_swapped)
};
if worst <= outlier_rad {
labels[i] = Some(label);
let w0 = axis_vote_weight(corner, 0, params.use_weights);
let w1 = axis_vote_weight(corner, 1, params.use_weights);
if label == 0 {
cluster_weights[0] += w0;
cluster_weights[1] += w1;
} else {
cluster_weights[1] += w0;
cluster_weights[0] += w1;
}
}
}
Some(OrientationClusteringResult {
centers,
labels,
cluster_weights,
histogram: Some(OrientationHistogram {
bin_centers,
values: hist_smoothed,
}),
})
}
fn axis_vote_weight(corner: &Corner, axis_slot: usize, use_weights: bool) -> f32 {
let axis = &corner.axes[axis_slot];
if !axis.sigma.is_finite() || axis.sigma >= std::f32::consts::PI - f32::EPSILON {
return 0.0;
}
let sigma_term = 1.0 / (1.0 + axis.sigma.max(0.0));
if use_weights {
let s = corner.strength;
if s > 0.0 {
s * sigma_term
} else {
sigma_term
}
} else {
sigma_term
}
}
struct SmoothedHistogramData {
values: Vec<f32>,
bin_centers: Vec<f32>,
total_weight: f32,
votes: Vec<AxisVote>,
}
fn build_smoothed_histogram(
corners: &[Corner],
params: &OrientationClusteringParams,
) -> Option<SmoothedHistogramData> {
if params.num_bins < 1 {
return None;
}
let mut hist = vec![0.0f32; params.num_bins];
let mut total_weight = 0.0f32;
let mut votes: Vec<AxisVote> = Vec::with_capacity(corners.len() * 2);
for c in corners.iter() {
for slot in 0..2 {
let angle = wrap_angle_pi(c.axes[slot].angle);
let weight = axis_vote_weight(c, slot, params.use_weights);
if weight <= 0.0 {
continue;
}
let bin = angle_to_bin(angle, params.num_bins);
hist[bin] += weight;
total_weight += weight;
votes.push(AxisVote { angle, weight });
}
}
if total_weight <= 0.0 {
return None;
}
let values = smooth_circular_histogram(&hist);
let bin_centers: Vec<f32> = (0..params.num_bins)
.map(|b| bin_to_angle(b, params.num_bins))
.collect();
Some(SmoothedHistogramData {
values,
bin_centers,
total_weight,
votes,
})
}
#[derive(Clone, Debug)]
struct PeakSupport {
bins: Vec<usize>,
weight: f32,
weighted_angle: f32,
num_bins: usize,
}
impl PeakSupport {
fn refined_angle(&self, votes: &[AxisVote]) -> f32 {
let mut sum = [0.0f32; 2];
let mut w_sum = 0.0f32;
for vote in votes {
let bin = angle_to_bin(vote.angle, self.num_bins);
if self.bins.contains(&bin) {
let two_theta = 2.0 * vote.angle;
sum[0] += vote.weight * two_theta.cos();
sum[1] += vote.weight * two_theta.sin();
w_sum += vote.weight;
}
}
if w_sum > 0.0 {
wrap_angle_pi(0.5 * sum[1].atan2(sum[0]))
} else {
self.weighted_angle
}
}
}
fn build_peak_support(hist: &[f32], peak_bin: usize, bin_centers: &[f32]) -> PeakSupport {
let n = hist.len();
let mut bins = vec![peak_bin];
let mut i = (peak_bin + n - 1) % n;
while hist[i] <= hist[(i + 1) % n] && hist[i] > 0.0 {
bins.push(i);
i = (i + n - 1) % n;
if i == peak_bin {
break;
}
}
let mut i = (peak_bin + 1) % n;
while hist[i] <= hist[(i + n - 1) % n] && hist[i] > 0.0 {
bins.push(i);
i = (i + 1) % n;
if i == peak_bin {
break;
}
}
bins.sort();
bins.dedup();
let mut weight = 0.0f32;
let mut sum = [0.0f32; 2];
for &b in &bins {
let w = hist[b];
weight += w;
let two_t = 2.0 * bin_centers[b];
sum[0] += w * two_t.cos();
sum[1] += w * two_t.sin();
}
let weighted_angle = wrap_angle_pi(0.5 * sum[1].atan2(sum[0]));
PeakSupport {
bins,
weight,
weighted_angle,
num_bins: n,
}
}
fn wrap_angle_pi(theta: f32) -> f32 {
let mut t = theta % PI;
if t < 0.0 {
t += PI;
}
t
}
fn angular_dist_pi(a: f32, b: f32) -> f32 {
let mut d = a - b;
while d > FRAC_PI_2 {
d -= PI;
}
while d < -FRAC_PI_2 {
d += PI;
}
d.abs()
}
fn angle_to_bin(theta: f32, num_bins: usize) -> usize {
let t = wrap_angle_pi(theta);
let x = t / PI * num_bins as f32;
let mut idx = x.floor() as isize;
if idx < 0 {
idx = 0;
}
if idx as usize >= num_bins {
idx = (num_bins - 1) as isize;
}
idx as usize
}
fn bin_to_angle(bin: usize, num_bins: usize) -> f32 {
let step = PI / num_bins as f32;
(bin as f32 + 0.5) * step
}
fn smooth_circular_histogram(hist: &[f32]) -> Vec<f32> {
let n = hist.len();
if n == 0 {
return Vec::new();
}
const K: [f32; 5] = [1.0, 4.0, 6.0, 4.0, 1.0];
const K_SUM: f32 = 16.0;
let mut out = vec![0.0f32; n];
for (i, item) in out.iter_mut().enumerate() {
let mut acc = 0.0f32;
for (k, &w) in K.iter().enumerate() {
let offset = k as isize - 2;
let j = ((i as isize + offset).rem_euclid(n as isize)) as usize;
acc += w * hist[j];
}
*item = acc / K_SUM;
}
out
}
#[derive(Clone, Debug)]
struct Peak {
bin: usize,
}
fn find_peaks(hist: &[f32]) -> Vec<Peak> {
let n = hist.len();
let mut peaks = Vec::new();
if n == 0 {
return peaks;
}
for i in 0..n {
let prev = hist[(i + n - 1) % n];
let curr = hist[i];
let next = hist[(i + 1) % n];
if curr >= prev && curr >= next && curr > 0.0 {
peaks.push(Peak { bin: i });
}
}
peaks
}
pub fn estimate_grid_axes_from_orientations(corners: &[Corner]) -> Option<f32> {
if corners.is_empty() {
return None;
}
let mut sum = Vector2::<f32>::zeros();
let mut weight_sum = 0.0f32;
for c in corners {
let theta = c.axes[0].angle;
let sigma_term = 1.0 / (1.0 + c.axes[0].sigma.max(0.0));
let s = c.strength;
let w = if s > 0.0 { s * sigma_term } else { sigma_term };
if w <= 0.0 {
continue;
}
let two_theta = 2.0 * theta;
let v = Vector2::new(two_theta.cos(), two_theta.sin());
sum += w * v;
weight_sum += w;
}
if weight_sum <= 0.0 {
return None;
}
let mean = sum / weight_sum;
if mean.norm_squared() < 1e-6 {
return None;
}
let mean_two_angle = mean.y.atan2(mean.x);
let mean_theta = 0.5 * mean_two_angle;
Some(mean_theta)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::AxisEstimate;
use nalgebra::Point2;
use std::f32::consts::{FRAC_PI_2, FRAC_PI_4};
fn make_corner(theta: f32, strength: f32) -> Corner {
Corner {
position: Point2::new(0.0, 0.0),
orientation_cluster: None,
axes: [
AxisEstimate {
angle: theta,
sigma: 0.05,
},
AxisEstimate {
angle: theta + FRAC_PI_2,
sigma: 0.05,
},
],
strength,
..Corner::default()
}
}
fn make_corner_swapped(theta: f32, strength: f32) -> Corner {
Corner {
position: Point2::new(0.0, 0.0),
orientation_cluster: None,
axes: [
AxisEstimate {
angle: theta + FRAC_PI_2,
sigma: 0.05,
},
AxisEstimate {
angle: theta,
sigma: 0.05,
},
],
strength,
..Corner::default()
}
}
#[test]
fn clusters_two_dominant_modes() {
let canonical_primaries = [FRAC_PI_4 - 0.05, FRAC_PI_4, FRAC_PI_4 + 0.04];
let swapped_primaries = [FRAC_PI_4 - 0.03, FRAC_PI_4, FRAC_PI_4 + 0.02];
let mut corners = Vec::new();
for &theta in &canonical_primaries {
corners.push(make_corner(theta, 1.0));
}
for &theta in &swapped_primaries {
corners.push(make_corner_swapped(theta, 1.5));
}
let params = OrientationClusteringParams {
max_iters: 5,
..Default::default()
};
let result = cluster_orientations(&corners, ¶ms).expect("expected two clusters");
assert_eq!(corners.len(), result.labels.len());
let separation = angular_dist_pi(result.centers[0], result.centers[1]);
assert!(
(separation - FRAC_PI_2).abs() < 0.2,
"expected cluster centers ~90° apart, got {}",
separation.to_degrees()
);
let labels_a: Vec<Option<usize>> = result.labels[..canonical_primaries.len()].to_vec();
let labels_b: Vec<Option<usize>> = result.labels[canonical_primaries.len()..].to_vec();
let same_a = labels_a.iter().all(|l| l.is_some() && *l == labels_a[0]);
let same_b = labels_b.iter().all(|l| l.is_some() && *l == labels_b[0]);
assert!(
same_a,
"canonical-order corners must share a label: {:?}",
labels_a
);
assert!(
same_b,
"swapped-order corners must share a label: {:?}",
labels_b
);
assert_ne!(
labels_a[0], labels_b[0],
"swapped-order corners must flip label relative to canonical"
);
}
#[test]
fn marks_far_angles_as_outliers() {
let mut corners = Vec::new();
for _ in 0..5 {
corners.push(make_corner(FRAC_PI_4, 1.0));
}
for _ in 0..5 {
corners.push(make_corner_swapped(FRAC_PI_4, 1.0));
}
corners.push(Corner {
position: Point2::new(0.0, 0.0),
orientation_cluster: None,
axes: [
AxisEstimate {
angle: 0.0,
sigma: 0.05,
},
AxisEstimate {
angle: std::f32::consts::FRAC_PI_3,
sigma: 0.05,
},
],
strength: 1.0,
..Corner::default()
});
let result = cluster_orientations(&corners, &OrientationClusteringParams::default())
.expect("clustering should succeed");
assert_eq!(corners.len(), result.labels.len());
assert_eq!(
corners.len() - 1,
result.labels.iter().filter(|l| l.is_some()).count(),
"labels = {:?}",
result.labels
);
assert!(result.labels.last().unwrap().is_none());
}
#[test]
fn returns_none_when_only_one_peak() {
let corners: Vec<Corner> = (0..6)
.map(|_| Corner {
position: Point2::new(0.0, 0.0),
orientation_cluster: None,
axes: [
AxisEstimate {
angle: 0.1,
sigma: 0.05,
},
AxisEstimate {
angle: 0.1,
sigma: 0.05,
},
],
strength: 1.0,
..Corner::default()
})
.collect();
assert!(cluster_orientations(&corners, &OrientationClusteringParams::default()).is_none());
}
}