use apex_camera_models::{CameraModel, DistortionModel, DoubleSphereCamera, PinholeParams};
use apex_manifolds::LieGroup;
use apex_solver::JacobianMode;
use apex_solver::ManifoldType;
use apex_solver::core::problem::Problem;
use apex_solver::factors::ProjectionFactor;
use apex_solver::factors::SelfCalibration;
use apex_solver::optimizer::OptimizationStatus;
use apex_solver::optimizer::levenberg_marquardt::{LevenbergMarquardt, LevenbergMarquardtConfig};
use nalgebra::{DVector, Matrix2xX, Vector2};
use std::collections::HashMap;
mod camera_test_utils;
use camera_test_utils::*;
type TestResult = Result<(), Box<dyn std::error::Error>>;
#[test]
fn test_double_sphere_multi_camera_calibration_200_points() -> TestResult {
let true_camera = DoubleSphereCamera::new(
PinholeParams {
fx: 200.0,
fy: 200.0,
cx: 300.0,
cy: 200.0,
},
DistortionModel::DoubleSphere {
alpha: 0.5,
xi: 0.5,
},
)?;
let img_width = 600.0;
let img_height = 400.0;
let true_landmarks = generate_scene_points(200, 42);
assert_eq!(
true_landmarks.len(),
200,
"Should generate exactly 200 calibration points"
);
let true_poses = generate_arc_camera_poses(5, 0.8, 3.0);
assert_eq!(true_poses.len(), 5, "Should generate 5 camera poses");
let mut all_observations: Vec<Vec<Vector2<f64>>> = Vec::new();
for (cam_idx, pose) in true_poses.iter().enumerate() {
let mut cam_observations = Vec::with_capacity(true_landmarks.len());
for (lm_idx, landmark) in true_landmarks.iter().enumerate() {
let p_cam = pose.act(landmark, None, None);
let uv = true_camera.project(&p_cam)?;
assert!(
uv.x >= 0.0 && uv.x < img_width && uv.y >= 0.0 && uv.y < img_height,
"Camera {} landmark {} projects outside image: uv = ({:.1}, {:.1})",
cam_idx,
lm_idx,
uv.x,
uv.y
);
cam_observations.push(uv);
}
assert_eq!(
cam_observations.len(),
true_landmarks.len(),
"Camera {} should see all {} landmarks",
cam_idx,
true_landmarks.len()
);
all_observations.push(cam_observations);
}
let noisy_landmarks = perturb_landmarks(&true_landmarks, 0.01, 100);
let noisy_poses: Vec<_> = true_poses
.iter()
.enumerate()
.map(|(i, p)| perturb_pose(p, 0.02, 1.0, 200 + i as u64 * 10))
.collect();
let true_intrinsics = [200.0, 200.0, 300.0, 200.0, 0.5, 0.5];
let noisy_intrinsics = perturb_intrinsics(&true_intrinsics, 0.02, 300);
let mut problem = Problem::new(JacobianMode::Sparse);
for (cam_idx, observations) in all_observations.iter().enumerate() {
let obs_matrix = Matrix2xX::from_columns(observations);
let factor: ProjectionFactor<DoubleSphereCamera, SelfCalibration> =
ProjectionFactor::new(obs_matrix, true_camera);
let pose_name = format!("pose_{}", cam_idx);
problem.add_residual_block(
&[&pose_name, "landmarks", "intrinsics"],
Box::new(factor),
None, );
}
for dof in 0..6 {
problem.fix_variable("pose_0", dof);
}
let mut initial_values = HashMap::new();
for (i, pose) in noisy_poses.iter().enumerate() {
initial_values.insert(
format!("pose_{}", i),
(ManifoldType::SE3, pose.clone().into()),
);
}
initial_values.insert(
"landmarks".to_string(),
(ManifoldType::RN, flatten_landmarks(&noisy_landmarks)),
);
initial_values.insert(
"intrinsics".to_string(),
(
ManifoldType::RN,
DVector::from_vec(noisy_intrinsics.clone()),
),
);
let config = LevenbergMarquardtConfig::new()
.with_max_iterations(100)
.with_cost_tolerance(1e-8)
.with_parameter_tolerance(1e-8)
.with_gradient_tolerance(1e-10)
.with_damping(1e-3);
let mut solver = LevenbergMarquardt::with_config(config);
let result = solver.optimize(&problem, &initial_values)?;
assert!(
matches!(
result.status,
OptimizationStatus::Converged
| OptimizationStatus::CostToleranceReached
| OptimizationStatus::ParameterToleranceReached
| OptimizationStatus::GradientToleranceReached
),
"Optimization should converge, got: {:?}",
result.status
);
let cost_reduction = (result.initial_cost - result.final_cost) / result.initial_cost;
assert!(
cost_reduction > 0.95,
"Cost should reduce by >95%, got {:.2}% reduction (initial={:.4e}, final={:.4e})",
cost_reduction * 100.0,
result.initial_cost,
result.final_cost
);
let total_observations: usize = all_observations.iter().map(|o| o.len()).sum();
let rmse = (result.final_cost / total_observations as f64).sqrt();
assert!(
rmse < 2.0, "Reprojection RMSE should be < 2 pixels, got {:.4} pixels",
rmse
);
let final_intrinsics = result
.parameters
.get("intrinsics")
.ok_or("Missing intrinsics in result")?
.to_vector();
let param_names = ["fx", "fy", "cx", "cy", "xi", "alpha"];
let tolerances = [0.05, 0.05, 0.05, 0.05, 0.10, 0.10];
for i in 0..6 {
let relative_error =
(final_intrinsics[i] - true_intrinsics[i]).abs() / true_intrinsics[i].abs().max(0.1);
assert!(
relative_error < tolerances[i],
"{} should recover within {:.0}% of ground truth, got {:.2}% error \
(true={:.4}, final={:.4})",
param_names[i],
tolerances[i] * 100.0,
relative_error * 100.0,
true_intrinsics[i],
final_intrinsics[i]
);
}
Ok(())
}
#[test]
fn test_double_sphere_3_cameras_calibration() -> TestResult {
let true_camera = DoubleSphereCamera::new(
PinholeParams {
fx: 200.0,
fy: 200.0,
cx: 300.0,
cy: 200.0,
},
DistortionModel::DoubleSphere {
alpha: 0.5,
xi: 0.5,
},
)?;
let img_width = 600.0;
let img_height = 400.0;
let true_landmarks = generate_scene_points(200, 42);
let true_poses = generate_arc_camera_poses(3, 0.6, 3.0);
let mut all_observations: Vec<Vec<Vector2<f64>>> = Vec::new();
for pose in &true_poses {
let mut cam_obs = Vec::new();
for landmark in &true_landmarks {
let p_cam = pose.act(landmark, None, None);
if let Ok(uv) = true_camera.project(&p_cam) {
if uv.x >= 0.0 && uv.x < img_width && uv.y >= 0.0 && uv.y < img_height {
cam_obs.push(uv);
}
}
}
all_observations.push(cam_obs);
}
let noisy_landmarks = perturb_landmarks(&true_landmarks, 0.01, 100);
let noisy_poses: Vec<_> = true_poses
.iter()
.enumerate()
.map(|(i, p)| perturb_pose(p, 0.02, 1.0, 200 + i as u64 * 10))
.collect();
let true_intrinsics = [200.0, 200.0, 300.0, 200.0, 0.5, 0.5];
let noisy_intrinsics = perturb_intrinsics(&true_intrinsics, 0.02, 300);
let mut problem = Problem::new(JacobianMode::Sparse);
for (cam_idx, observations) in all_observations.iter().enumerate() {
let obs_matrix = Matrix2xX::from_columns(observations);
let factor: ProjectionFactor<DoubleSphereCamera, SelfCalibration> =
ProjectionFactor::new(obs_matrix, true_camera);
problem.add_residual_block(
&[&format!("pose_{}", cam_idx), "landmarks", "intrinsics"],
Box::new(factor),
None,
);
}
for dof in 0..6 {
problem.fix_variable("pose_0", dof);
}
let mut initial_values = HashMap::new();
for (i, pose) in noisy_poses.iter().enumerate() {
initial_values.insert(
format!("pose_{}", i),
(ManifoldType::SE3, pose.clone().into()),
);
}
initial_values.insert(
"landmarks".to_string(),
(ManifoldType::RN, flatten_landmarks(&noisy_landmarks)),
);
initial_values.insert(
"intrinsics".to_string(),
(ManifoldType::RN, DVector::from_vec(noisy_intrinsics)),
);
let config = LevenbergMarquardtConfig::new()
.with_max_iterations(100)
.with_cost_tolerance(1e-8)
.with_parameter_tolerance(1e-8)
.with_damping(1e-3);
let mut solver = LevenbergMarquardt::with_config(config);
let result = solver.optimize(&problem, &initial_values)?;
assert!(
matches!(
result.status,
OptimizationStatus::Converged
| OptimizationStatus::CostToleranceReached
| OptimizationStatus::ParameterToleranceReached
| OptimizationStatus::GradientToleranceReached
),
"3-camera calibration should converge, got: {:?}",
result.status
);
let cost_reduction = (result.initial_cost - result.final_cost) / result.initial_cost;
assert!(
cost_reduction > 0.90,
"Cost should reduce by >90%, got {:.2}%",
cost_reduction * 100.0
);
Ok(())
}