use std::collections::HashMap;
use numeris::optim::{least_squares_lm_dyn, LmSettings};
use numeris::{DynMatrix, DynVector, Matrix3};
use tracing::debug;
use crate::centroid::Centroid;
use crate::solver::{SolveResult, SolverDatabase};
use super::polynomial::{num_coeffs, term_pairs, PolynomialDistortion};
use super::radial::{brown_conrady_forward, RadialDistortion};
use super::Distortion;
pub(super) const MIN_RADIAL_POINTS: usize = 8;
#[derive(Debug, Clone)]
pub struct DistortionFitConfig {
pub sigma_clip: f64,
pub max_iterations: u32,
pub stage2_threshold_px: Option<f64>,
}
impl Default for DistortionFitConfig {
fn default() -> Self {
Self {
sigma_clip: 3.0,
max_iterations: 20,
stage2_threshold_px: Some(5.0),
}
}
}
#[derive(Debug, Clone)]
pub struct DistortionFitResult {
pub model: Distortion,
pub focal_scale: f64,
pub rmse_before_px: f64,
pub rmse_after_px: f64,
pub n_inliers: usize,
pub n_outliers: usize,
pub iterations: u32,
}
impl DistortionFitResult {
fn no_fit(rmse_px: f64, n_inliers: usize) -> Self {
Self {
model: Distortion::None,
focal_scale: 1.0,
rmse_before_px: rmse_px,
rmse_after_px: rmse_px,
n_inliers,
n_outliers: 0,
iterations: 0,
}
}
}
pub(super) struct MatchedPoint {
pub x_obs: f64,
pub y_obs: f64,
pub x_ideal: f64,
pub y_ideal: f64,
}
pub fn fit_radial_distortion(
solve_results: &[&SolveResult],
centroids: &[&[Centroid]],
database: &SolverDatabase,
image_width: u32,
config: &DistortionFitConfig,
) -> DistortionFitResult {
assert_eq!(
solve_results.len(),
centroids.len(),
"solve_results and centroids must have the same length"
);
let id_to_idx = build_id_lookup(database);
let points = gather_matched_points(solve_results, centroids, database, &id_to_idx, image_width);
if points.len() < MIN_RADIAL_POINTS {
return DistortionFitResult::no_fit(0.0, 0);
}
let n = points.len();
let fit = fit_radial_centered_sigma_clip(&points, config);
let model = fit.rescaled_model();
let rmse_before = masked_rms(
&intrinsics_residuals(&points, &[0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0]),
&fit.mask,
);
let residuals = intrinsics_residuals(
&points,
&[
fit.cx, fit.cy, fit.gamma, fit.k1, fit.k2, fit.k3, fit.p1, fit.p2,
],
);
let rmse_after = masked_rms(&residuals, &fit.mask);
let n_inliers = fit.mask.iter().filter(|&&m| m).count();
debug!(
"Brown-Conrady fit: cx={:.2}, cy={:.2}, gamma={:.6}, k1={:.3e}, k2={:.3e}, k3={:.3e}, p1={:.3e}, p2={:.3e}, inliers={}/{}, RMSE {:.3} → {:.3} px",
fit.cx, fit.cy, fit.gamma, fit.k1, fit.k2, fit.k3, fit.p1, fit.p2, n_inliers, n, rmse_before, rmse_after
);
DistortionFitResult {
model: Distortion::Radial(model),
focal_scale: fit.gamma,
rmse_before_px: rmse_before,
rmse_after_px: rmse_after,
n_inliers,
n_outliers: n - n_inliers,
iterations: fit.iterations,
}
}
pub(super) struct CenteredRadialFitResult {
pub cx: f64,
pub cy: f64,
pub gamma: f64,
pub k1: f64,
pub k2: f64,
pub k3: f64,
pub p1: f64,
pub p2: f64,
pub mask: Vec<bool>,
pub iterations: u32,
}
impl CenteredRadialFitResult {
pub(super) fn rescaled_model(&self) -> RadialDistortion {
let g = self.gamma;
let g2 = g * g;
RadialDistortion::with_center(
self.cx,
self.cy,
self.k1 / g2,
self.k2 / (g2 * g2),
self.k3 / (g2 * g2 * g2),
self.p1 / g,
self.p2 / g,
)
}
}
pub(super) fn fit_radial_centered_sigma_clip(
points: &[MatchedPoint],
config: &DistortionFitConfig,
) -> CenteredRadialFitResult {
let norm = points
.iter()
.map(|p| p.x_ideal.hypot(p.y_ideal))
.fold(0.0_f64, f64::max)
.max(1.0);
let npoints: Vec<MatchedPoint> = points
.iter()
.map(|p| MatchedPoint {
x_obs: p.x_obs / norm,
y_obs: p.y_obs / norm,
x_ideal: p.x_ideal / norm,
y_ideal: p.y_ideal / norm,
})
.collect();
let initial_mask = vec![true; npoints.len()];
let (k1_init, k2_init, k3_init) = fit_radial_ls(&npoints, &initial_mask);
let mut x = DynVector::<f64>::from_vec(vec![
0.0, 0.0, 1.0, k1_init, k2_init, k3_init, 0.0, 0.0, ]);
let mut mask = initial_mask;
let mut total_lm_iters = 0u32;
for _outer in 0..config.max_iterations {
let n_inliers = mask.iter().filter(|&&m| m).count();
if n_inliers < 8 {
break;
}
let prev_x = x.clone();
match run_intrinsics_lm(&npoints, &mask, &x) {
Ok((new_x, iters)) => {
x = new_x;
total_lm_iters += iters;
}
Err(()) => break,
}
let residuals = intrinsics_residuals(&npoints, x.as_slice());
let inlier_resids: Vec<f64> = residuals
.iter()
.zip(&mask)
.filter(|(_, &m)| m)
.map(|(&r, _)| r)
.collect();
if inlier_resids.is_empty() {
break;
}
let sigma = mad_sigma(&inlier_resids);
if sigma < 1e-12 / norm {
break;
}
let threshold = config.sigma_clip * sigma;
let new_mask: Vec<bool> = residuals.iter().map(|&r| r <= threshold).collect();
let mask_changed = mask.iter().zip(&new_mask).any(|(&a, &b)| a != b);
mask = new_mask;
let params_changed = (0..8).any(|i| (x[i] - prev_x[i]).abs() > 1e-12);
if !mask_changed && !params_changed {
break;
}
if mask.iter().filter(|&&m| m).count() < 8 {
break;
}
}
if let Some(threshold_px) = config.stage2_threshold_px {
let residuals = intrinsics_residuals(&npoints, x.as_slice());
let threshold = threshold_px / norm;
let mask_s2: Vec<bool> = residuals.iter().map(|&r| r <= threshold).collect();
let n_recovered = mask_s2
.iter()
.zip(&mask)
.filter(|(&s2, &s1)| s2 && !s1)
.count();
if n_recovered > 0 && mask_s2.iter().filter(|&&m| m).count() >= 8 {
mask = mask_s2;
if let Ok((new_x, iters)) = run_intrinsics_lm(&npoints, &mask, &x) {
x = new_x;
total_lm_iters += iters;
}
}
}
let n2 = norm * norm;
CenteredRadialFitResult {
cx: x[0] * norm,
cy: x[1] * norm,
gamma: x[2],
k1: x[3] / n2,
k2: x[4] / (n2 * n2),
k3: x[5] / (n2 * n2 * n2),
p1: x[6] / norm,
p2: x[7] / norm,
mask,
iterations: total_lm_iters,
}
}
fn intrinsics_predict(p: &MatchedPoint, params: &[f64]) -> (f64, f64) {
let (cx, cy, gamma) = (params[0], params[1], params[2]);
let (k1, k2, k3, p1, p2) = (params[3], params[4], params[5], params[6], params[7]);
let e = brown_conrady_forward(k1, k2, k3, p1, p2, p.x_ideal - cx, p.y_ideal - cy);
(cx + gamma * e.fx, cy + gamma * e.fy)
}
pub(super) fn intrinsics_residuals(points: &[MatchedPoint], params: &[f64]) -> Vec<f64> {
points
.iter()
.map(|p| {
let (px, py) = intrinsics_predict(p, params);
(p.x_obs - px).hypot(p.y_obs - py)
})
.collect()
}
fn run_intrinsics_lm(
points: &[MatchedPoint],
mask: &[bool],
x0: &DynVector<f64>,
) -> Result<(DynVector<f64>, u32), ()> {
let inlier_indices: Vec<usize> = mask
.iter()
.enumerate()
.filter_map(|(i, &m)| if m { Some(i) } else { None })
.collect();
if inlier_indices.len() < 8 {
return Err(());
}
const SQRT_MU_TIE: f64 = 1e-4;
let m = 2 * inlier_indices.len() + 4;
let residual = |x: &DynVector<f64>| -> DynVector<f64> {
let mut r = DynVector::<f64>::zeros(m);
for (row_pair, &i) in inlier_indices.iter().enumerate() {
let p = &points[i];
let (px, py) = intrinsics_predict(p, x.as_slice());
r[2 * row_pair] = p.x_obs - px;
r[2 * row_pair + 1] = p.y_obs - py;
}
r[m - 4] = SQRT_MU_TIE * x[0];
r[m - 3] = SQRT_MU_TIE * x[1];
r[m - 2] = SQRT_MU_TIE * x[6];
r[m - 1] = SQRT_MU_TIE * x[7];
r
};
let jacobian = |x: &DynVector<f64>| -> DynMatrix<f64> {
let cx = x[0];
let cy = x[1];
let gamma = x[2];
let k1 = x[3];
let k2 = x[4];
let k3 = x[5];
let p1 = x[6];
let p2 = x[7];
let mut j = DynMatrix::<f64>::zeros(m, 8);
for (row_pair, &i) in inlier_indices.iter().enumerate() {
let p = &points[i];
let xn = p.x_ideal - cx;
let yn = p.y_ideal - cy;
let e = brown_conrady_forward(k1, k2, k3, p1, p2, xn, yn);
let (r2, r4, r6) = (e.r2, e.r4, e.r6);
let (dx, dy) = (e.fx, e.fy);
let ddx_dxn = e.j11;
let ddx_dyn = e.j12;
let ddy_dyn = e.j22;
let ddy_dxn = ddx_dyn; let row_x = 2 * row_pair;
let row_y = row_x + 1;
j[(row_x, 0)] = -1.0 + gamma * ddx_dxn;
j[(row_x, 1)] = gamma * ddx_dyn;
j[(row_x, 2)] = -dx;
j[(row_x, 3)] = -gamma * xn * r2;
j[(row_x, 4)] = -gamma * xn * r4;
j[(row_x, 5)] = -gamma * xn * r6;
j[(row_x, 6)] = -gamma * 2.0 * xn * yn;
j[(row_x, 7)] = -gamma * (r2 + 2.0 * xn * xn);
j[(row_y, 0)] = gamma * ddy_dxn;
j[(row_y, 1)] = -1.0 + gamma * ddy_dyn;
j[(row_y, 2)] = -dy;
j[(row_y, 3)] = -gamma * yn * r2;
j[(row_y, 4)] = -gamma * yn * r4;
j[(row_y, 5)] = -gamma * yn * r6;
j[(row_y, 6)] = -gamma * (r2 + 2.0 * yn * yn);
j[(row_y, 7)] = -gamma * 2.0 * xn * yn;
}
j[(m - 4, 0)] = SQRT_MU_TIE;
j[(m - 3, 1)] = SQRT_MU_TIE;
j[(m - 2, 6)] = SQRT_MU_TIE;
j[(m - 1, 7)] = SQRT_MU_TIE;
j
};
let settings = LmSettings::<f64> {
max_iter: 500,
f_tol: 1e-11,
x_tol: 1e-11,
..LmSettings::default()
};
let result = least_squares_lm_dyn(residual, jacobian, x0, &settings).map_err(|_| ())?;
Ok((result.x, result.iterations as u32))
}
pub(super) fn masked_rms(residuals: &[f64], mask: &[bool]) -> f64 {
let mut sum_sq = 0.0_f64;
let mut n = 0usize;
for (&r, &m) in residuals.iter().zip(mask) {
if m {
sum_sq += r * r;
n += 1;
}
}
if n == 0 {
0.0
} else {
(sum_sq / n as f64).sqrt()
}
}
fn fit_radial_ls(points: &[MatchedPoint], mask: &[bool]) -> (f64, f64, f64) {
let inlier_count: usize = mask.iter().filter(|&&m| m).count();
if inlier_count < 3 {
return (0.0, 0.0, 0.0);
}
let nrows = inlier_count * 2;
let mut a_mat = DynMatrix::<f64>::zeros(nrows, 3);
let mut b_vec = DynVector::<f64>::zeros(nrows);
let mut row = 0;
for (i, p) in points.iter().enumerate() {
if !mask[i] {
continue;
}
let r2 = p.x_ideal * p.x_ideal + p.y_ideal * p.y_ideal;
let r4 = r2 * r2;
let r6 = r2 * r4;
a_mat[(row, 0)] = p.x_ideal * r2;
a_mat[(row, 1)] = p.x_ideal * r4;
a_mat[(row, 2)] = p.x_ideal * r6;
b_vec[row] = p.x_obs - p.x_ideal;
row += 1;
a_mat[(row, 0)] = p.y_ideal * r2;
a_mat[(row, 1)] = p.y_ideal * r4;
a_mat[(row, 2)] = p.y_ideal * r6;
b_vec[row] = p.y_obs - p.y_ideal;
row += 1;
}
let coeffs = a_mat
.solve_qr(&b_vec)
.unwrap_or_else(|_| DynVector::zeros(3));
(coeffs[0], coeffs[1], coeffs[2])
}
pub(super) struct PolyFitResult {
pub a_coeffs: Vec<f64>,
pub b_coeffs: Vec<f64>,
pub mask: Vec<bool>,
pub iterations: u32,
}
fn poly_point_residuals(
points: &[MatchedPoint],
pairs: &[(u32, u32)],
scale: f64,
a_coeffs: &[f64],
b_coeffs: &[f64],
) -> Vec<f64> {
points
.iter()
.map(|p| {
let u = p.x_ideal / scale;
let v = p.y_ideal / scale;
let dx_model: f64 = pairs
.iter()
.enumerate()
.map(|(i, &(pp, qq))| a_coeffs[i] * u.powi(pp as i32) * v.powi(qq as i32))
.sum();
let dy_model: f64 = pairs
.iter()
.enumerate()
.map(|(i, &(pp, qq))| b_coeffs[i] * u.powi(pp as i32) * v.powi(qq as i32))
.sum();
let rx = p.x_obs - p.x_ideal - dx_model * scale;
let ry = p.y_obs - p.y_ideal - dy_model * scale;
(rx * rx + ry * ry).sqrt()
})
.collect()
}
pub(super) fn fit_polynomial_sigma_clip(
points: &[MatchedPoint],
order: u32,
scale: f64,
config: &DistortionFitConfig,
) -> PolyFitResult {
let n = points.len();
let ncoeffs = num_coeffs(order);
let pairs = term_pairs(order);
let mut mask = vec![true; n];
let mut iterations = 0u32;
let mut a_coeffs = vec![0.0; ncoeffs];
let mut b_coeffs = vec![0.0; ncoeffs];
fit_poly_ls(points, &mask, &pairs, scale, &mut a_coeffs, &mut b_coeffs);
for iter in 0..config.max_iterations {
iterations = iter + 1;
let residuals = poly_point_residuals(points, &pairs, scale, &a_coeffs, &b_coeffs);
let inlier_resids: Vec<f64> = residuals
.iter()
.zip(&mask)
.filter(|(_, &m)| m)
.map(|(&r, _)| r)
.collect();
if inlier_resids.is_empty() {
break;
}
let sigma = mad_sigma(&inlier_resids);
if sigma < 1e-12 {
break;
}
let threshold = config.sigma_clip * sigma;
let new_mask: Vec<bool> = residuals.iter().map(|&r| r <= threshold).collect();
let changed = mask.iter().zip(&new_mask).any(|(&a, &b)| a != b);
mask = new_mask;
if !changed {
break;
}
let n_inliers = mask.iter().filter(|&&m| m).count();
if n_inliers < ncoeffs {
debug!(
"Too few inliers ({}) for polynomial fit after sigma-clip",
n_inliers
);
break;
}
fit_poly_ls(points, &mask, &pairs, scale, &mut a_coeffs, &mut b_coeffs);
}
if let Some(threshold_px) = config.stage2_threshold_px {
let residuals = poly_point_residuals(points, &pairs, scale, &a_coeffs, &b_coeffs);
let mask_s2: Vec<bool> = residuals.iter().map(|&r| r <= threshold_px).collect();
let n_recovered = mask_s2
.iter()
.zip(&mask)
.filter(|(&s2, &s1)| s2 && !s1)
.count();
if n_recovered > 0 {
mask = mask_s2;
let n_inliers = mask.iter().filter(|&&m| m).count();
if n_inliers >= ncoeffs {
fit_poly_ls(points, &mask, &pairs, scale, &mut a_coeffs, &mut b_coeffs);
}
}
}
PolyFitResult {
a_coeffs,
b_coeffs,
mask,
iterations,
}
}
pub fn fit_polynomial_distortion(
solve_results: &[&SolveResult],
centroids: &[&[Centroid]],
database: &SolverDatabase,
image_width: u32,
order: u32,
config: &DistortionFitConfig,
) -> DistortionFitResult {
assert_eq!(
solve_results.len(),
centroids.len(),
"solve_results and centroids must have the same length"
);
assert!(
(2..=6).contains(&order),
"polynomial order must be in [2, 6]"
);
let id_to_idx = build_id_lookup(database);
let points = gather_matched_points(solve_results, centroids, database, &id_to_idx, image_width);
if points.is_empty() {
return DistortionFitResult::no_fit(0.0, 0);
}
let n = points.len();
let ncoeffs = num_coeffs(order);
let scale = image_width as f64 / 2.0;
if n < ncoeffs {
let rmse_raw = compute_rmse_px(&points);
debug!(
"Too few matched points ({}) for order-{} polynomial fit ({} coefficients needed)",
n, order, ncoeffs
);
return DistortionFitResult::no_fit(rmse_raw, n);
}
let fit = fit_polynomial_sigma_clip(&points, order, scale, config);
let model = PolynomialDistortion::new(order, scale, fit.a_coeffs, fit.b_coeffs);
let dist = Distortion::Polynomial(model.clone());
let rmse_before = compute_corrected_rmse(&points, &fit.mask, &Distortion::None);
let rmse_after = compute_corrected_rmse(&points, &fit.mask, &dist);
let n_inliers = fit.mask.iter().filter(|&&m| m).count();
debug!(
"Polynomial (order {}) fit: {} coefficients/axis, inliers={}/{}, RMSE {:.3} → {:.3} px",
order, ncoeffs, n_inliers, n, rmse_before, rmse_after
);
DistortionFitResult {
model: dist,
focal_scale: 1.0,
rmse_before_px: rmse_before,
rmse_after_px: rmse_after,
n_inliers,
n_outliers: n - n_inliers,
iterations: fit.iterations,
}
}
pub(super) fn build_id_lookup(database: &SolverDatabase) -> HashMap<i64, usize> {
database
.star_catalog_ids
.iter()
.enumerate()
.map(|(i, &id)| (id, i))
.collect()
}
pub(super) fn matched_pairs<'a>(
sol: &'a crate::solver::Solution,
n_centroids: usize,
id_to_idx: &'a HashMap<i64, usize>,
) -> impl Iterator<Item = (usize, usize)> + 'a {
sol.matched_catalog_ids
.iter()
.zip(sol.matched_centroid_indices.iter())
.filter_map(move |(&cat_id, ¢_idx)| {
if cent_idx >= n_centroids {
return None;
}
id_to_idx.get(&cat_id).map(|&star_idx| (cent_idx, star_idx))
})
}
fn gather_matched_points(
solve_results: &[&SolveResult],
centroids: &[&[Centroid]],
database: &SolverDatabase,
id_to_idx: &HashMap<i64, usize>,
image_width: u32,
) -> Vec<MatchedPoint> {
let mut points = Vec::new();
for (sr, cents) in solve_results.iter().zip(centroids.iter()) {
let Ok(sr) = sr else {
continue; };
let pixel_scale = crate::solver::pixel_scale_from_fov(image_width, sr.fov_rad as f64);
let rot: Matrix3<f32> = sr.qicrs2cam.to_rotation_matrix();
let parity_sign: f64 = if sr.parity_flip { -1.0 } else { 1.0 };
for (cent_idx, star_idx) in matched_pairs(sr, cents.len(), id_to_idx) {
let sv = &database.star_vectors[star_idx];
let x_obs = cents[cent_idx].x as f64;
let y_obs = cents[cent_idx].y as f64;
if let Some(mp) =
project_to_matched_point(rot, sv, parity_sign, pixel_scale, x_obs, y_obs)
{
points.push(mp);
}
}
}
points
}
pub(super) fn project_to_matched_point(
rot: Matrix3<f32>,
sv: &[f32; 3],
parity_sign: f64,
pixel_scale: f64,
x_obs: f64,
y_obs: f64,
) -> Option<MatchedPoint> {
let icrs_v = numeris::Vector3::from_array([sv[0], sv[1], sv[2]]);
let cam_v = rot * icrs_v;
if cam_v[2] <= 0.0 {
return None;
}
let x_ideal = parity_sign * (cam_v[0] as f64) / (cam_v[2] as f64) / pixel_scale;
let y_ideal = (cam_v[1] as f64) / (cam_v[2] as f64) / pixel_scale;
Some(MatchedPoint {
x_obs,
y_obs,
x_ideal,
y_ideal,
})
}
fn compute_rmse_px(points: &[MatchedPoint]) -> f64 {
if points.is_empty() {
return 0.0;
}
let sum_sq: f64 = points
.iter()
.map(|p| {
let dx = p.x_obs - p.x_ideal;
let dy = p.y_obs - p.y_ideal;
dx * dx + dy * dy
})
.sum();
(sum_sq / points.len() as f64).sqrt()
}
pub(super) fn compute_corrected_rmse(
points: &[MatchedPoint],
mask: &[bool],
distortion: &Distortion,
) -> f64 {
let mut sum_sq = 0.0;
let mut count = 0;
for (i, p) in points.iter().enumerate() {
if !mask[i] {
continue;
}
let (xu, yu) = distortion.undistort(p.x_obs, p.y_obs);
let dx = xu - p.x_ideal;
let dy = yu - p.y_ideal;
sum_sq += dx * dx + dy * dy;
count += 1;
}
if count == 0 {
return 0.0;
}
(sum_sq / count as f64).sqrt()
}
fn percentile_sorted(sorted: &[f64], p: f64) -> f64 {
if sorted.is_empty() {
return 0.0;
}
let idx = (p * (sorted.len() - 1) as f64).round() as usize;
sorted[idx.min(sorted.len() - 1)]
}
fn mad_sigma(inlier_resids: &[f64]) -> f64 {
let mut buf: Vec<f64> = inlier_resids.to_vec();
buf.sort_by(f64::total_cmp);
let median = percentile_sorted(&buf, 0.5);
for v in buf.iter_mut() {
*v = (*v - median).abs();
}
buf.sort_by(f64::total_cmp);
let mad = percentile_sorted(&buf, 0.5);
mad * 1.4826
}
pub(super) fn fit_poly_ls(
points: &[MatchedPoint],
mask: &[bool],
pairs: &[(u32, u32)],
scale: f64,
a_coeffs: &mut [f64],
b_coeffs: &mut [f64],
) {
let ncoeffs = pairs.len();
let n_inliers: usize = mask.iter().filter(|&&m| m).count();
if n_inliers < ncoeffs {
return;
}
let mut a_mat = DynMatrix::<f64>::zeros(n_inliers, ncoeffs);
let mut bx_vec = DynVector::<f64>::zeros(n_inliers);
let mut by_vec = DynVector::<f64>::zeros(n_inliers);
let mut row = 0;
for (i, p) in points.iter().enumerate() {
if !mask[i] {
continue;
}
let u = p.x_ideal / scale;
let v = p.y_ideal / scale;
for (j, &(pp, qq)) in pairs.iter().enumerate() {
a_mat[(row, j)] = u.powi(pp as i32) * v.powi(qq as i32);
}
bx_vec[row] = (p.x_obs - p.x_ideal) / scale;
by_vec[row] = (p.y_obs - p.y_ideal) / scale;
row += 1;
}
match a_mat.solve_qr(&bx_vec) {
Ok(cx) => {
for j in 0..ncoeffs {
a_coeffs[j] = cx[j];
}
}
Err(_) => debug!("fit_poly_ls: x-axis QR solve failed; keeping prior coeffs"),
}
match a_mat.solve_qr(&by_vec) {
Ok(cy) => {
for j in 0..ncoeffs {
b_coeffs[j] = cy[j];
}
}
Err(_) => debug!("fit_poly_ls: y-axis QR solve failed; keeping prior coeffs"),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_fit_radial_synthetic() {
let true_k1 = -7e-9;
let true_k2 = 2e-15;
let true_k3 = 0.0;
let true_distortion = RadialDistortion::new(true_k1, true_k2, true_k3);
let mut points = Vec::new();
for ix in -5..=5 {
for iy in -5..=5 {
let x_ideal = ix as f64 * 100.0;
let y_ideal = iy as f64 * 100.0;
let (x_obs, y_obs) = true_distortion.distort(x_ideal, y_ideal);
points.push(MatchedPoint {
x_obs,
y_obs,
x_ideal,
y_ideal,
});
}
}
let mask = vec![true; points.len()];
let (k1, k2, k3) = fit_radial_ls(&points, &mask);
assert!(
(k1 - true_k1).abs() < 1e-12,
"k1: fitted={:.6e}, true={:.6e}",
k1,
true_k1,
);
assert!(
(k2 - true_k2).abs() < 1e-18,
"k2: fitted={:.6e}, true={:.6e}",
k2,
true_k2,
);
assert!(k3.abs() < 1e-18, "k3: fitted={:.3e}, expected ~0", k3);
}
#[test]
fn test_fit_radial_recovers_focal_scale() {
let true_gamma = 1.009;
let mut points = Vec::new();
for ix in -5..=5 {
for iy in -5..=5 {
let x_ideal = ix as f64 * 100.0;
let y_ideal = iy as f64 * 100.0;
points.push(MatchedPoint {
x_obs: x_ideal * true_gamma,
y_obs: y_ideal * true_gamma,
x_ideal,
y_ideal,
});
}
}
let config = DistortionFitConfig::default();
let fit = fit_radial_centered_sigma_clip(&points, &config);
assert!(
(fit.gamma - true_gamma).abs() < 1e-6,
"gamma: fitted={:.8}, true={:.8}",
fit.gamma,
true_gamma,
);
let model = fit.rescaled_model();
let (xd, yd) = model.distort(500.0, 500.0);
assert!(
(xd - 500.0).abs() < 1e-3 && (yd - 500.0).abs() < 1e-3,
"rescaled model not ~identity: distort(500, 500) = ({xd}, {yd})",
);
}
#[test]
fn test_fit_radial_scale_and_distortion_jointly() {
let true_gamma = 0.995;
let true_k1 = -7e-9;
let true_distortion = RadialDistortion::new(true_k1, 0.0, 0.0);
let mut points = Vec::new();
for ix in -7..=7 {
for iy in -7..=7 {
let x_ideal = ix as f64 * 100.0;
let y_ideal = iy as f64 * 100.0;
let (xd, yd) = true_distortion.distort(x_ideal, y_ideal);
points.push(MatchedPoint {
x_obs: xd * true_gamma,
y_obs: yd * true_gamma,
x_ideal,
y_ideal,
});
}
}
let config = DistortionFitConfig::default();
let fit = fit_radial_centered_sigma_clip(&points, &config);
assert!(
(fit.gamma - true_gamma).abs() < 1e-5,
"gamma: fitted={:.8}, true={:.8}",
fit.gamma,
true_gamma,
);
assert!(
(fit.k1 - true_k1).abs() < 1e-11,
"k1: fitted={:.6e}, true={:.6e}",
fit.k1,
true_k1,
);
}
#[test]
fn test_fit_radial_mosaic_corner_center() {
let true_gamma = 1.009;
let (true_cx, true_cy) = (-1100.0, -1080.0);
let true_d = RadialDistortion::with_tangential(-5e-9, 1e-15, -1e-21, 1e-7, -2e-7);
let mut points = Vec::new();
let mut k = 0u32;
for ix in -31..=31 {
for iy in -31..=31 {
let x_ideal = ix as f64 * 33.0 + (k % 17) as f64; let y_ideal = iy as f64 * 33.0 + (k % 13) as f64;
k += 1;
let (dx, dy) = true_d.distort(x_ideal - true_cx, y_ideal - true_cy);
points.push(MatchedPoint {
x_obs: true_cx + true_gamma * dx,
y_obs: true_cy + true_gamma * dy,
x_ideal,
y_ideal,
});
}
}
let config = DistortionFitConfig::default();
let fit = fit_radial_centered_sigma_clip(&points, &config);
let resid = intrinsics_residuals(
&points,
&[
fit.cx, fit.cy, fit.gamma, fit.k1, fit.k2, fit.k3, fit.p1, fit.p2,
],
);
let rms = masked_rms(&resid, &fit.mask);
assert!(rms < 0.01, "rms {rms:.4} px on noiseless synthetic data");
assert!(
(fit.gamma - true_gamma).abs() < 1e-4,
"gamma: fitted={:.8}, true={:.8}",
fit.gamma,
true_gamma,
);
assert!(
(fit.cx - true_cx).abs() < 20.0 && (fit.cy - true_cy).abs() < 20.0,
"center: fitted=({:.1}, {:.1}), true=({true_cx}, {true_cy})",
fit.cx,
fit.cy,
);
}
}