use crate::bspline::{BSpline, BSplineTrait}; use crate::error::Error; use crate::metric::{FixedMu, MattesMetric, ResidualFit, SamplingArg, Sigma}; use crate::optimize::{AsgdConfig, asgd_minimize, lbfgs_minimize}; use crate::transform::{Transform, try_matrix_inverse}; use algos::{ObjectiveFunction, OptimizationConfig}; use ndarray::{Array2, AsArray, Dimension}; use num::cast::AsPrimitive; use serde::{Deserialize, Serialize}; use std::marker::PhantomData; /// Optimizer type for registration #[derive(Clone, Debug, PartialEq, Default, Serialize, Deserialize)] pub enum Optimizer { /// L-BFGS optimizer (fast, requires consistent gradients) #[default] LBFGS, /// Adaptive Stochastic Gradient Descent (robust, handles noisy gradients) ASGD, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] pub struct RegistrationStep { pub sigma: Sigma, pub samples: SamplingArg, pub n_bins: usize, pub tolerance: f64, pub edge: f64, pub max_iterations: usize, pub learning_rate: f64, /// Optimizer to use for this level pub optimizer: Optimizer, } impl RegistrationStep { #[expect(clippy::too_many_arguments)] pub fn new( sigma: Sigma, samples: SamplingArg, n_bins: usize, tolerance: f64, edge: f64, max_iterations: usize, learning_rate: f64, optimizer: Optimizer, ) -> Self { Self { sigma, samples, n_bins, tolerance, edge, max_iterations, learning_rate, optimizer, } } pub fn default_steps(ndim: usize, n: usize) -> Vec { vec![ Self { sigma: Sigma::Absolute(vec![8.0; ndim]), samples: SamplingArg::Random((n / 256).max(1024)), n_bins: 16, tolerance: 1e-1, edge: 0.05, max_iterations: 128, learning_rate: 1.0, optimizer: Optimizer::ASGD, }, Self { sigma: Sigma::Absolute(vec![4.0; ndim]), samples: SamplingArg::Random((n / 64).max(4096)), n_bins: 24, tolerance: 1e-2, edge: 0.05, max_iterations: 256, learning_rate: 1.0, optimizer: Optimizer::ASGD, }, Self { sigma: Sigma::Absolute(vec![2.0; ndim]), samples: SamplingArg::Random((n / 16).max(4096)), n_bins: 32, tolerance: 1e-4, edge: 0.05, max_iterations: 512, learning_rate: 1.0, optimizer: Optimizer::ASGD, }, Self { sigma: Sigma::Absolute(vec![1.0; ndim]), samples: SamplingArg::Random((n / 4).max(8192)), n_bins: 48, tolerance: 1e-6, edge: 0.05, max_iterations: 1024, learning_rate: 1.0, optimizer: Optimizer::ASGD, }, Self { sigma: Sigma::None, samples: SamplingArg::Fixed(n.max(16384)), n_bins: 64, tolerance: 1e-8, edge: 0.05, max_iterations: 2048, learning_rate: 1.0, optimizer: Optimizer::LBFGS, }, ] } } #[derive(Clone, Debug)] pub struct RegistrationResult { pub sigma_fixed: Option>, pub sigma_moving: Option>, pub n_bins: usize, pub n_samples: usize, pub tolerance: f64, pub edge: f64, pub max_iterations: usize, pub learning_rate: f64, pub optimal_point: Vec, pub optimal_value: f64, pub iterations: usize, pub converged: bool, } pub struct Registration { fixed_mu: FixedMu, steps: Option>, initial_guess: Option>, optimizer: Optimizer, dimension: PhantomData, } impl Registration { pub fn new>(fixed_mu: F) -> Result { let fixed_mu = fixed_mu.into(); if let Some(ndim) = D::NDIM && fixed_mu.len() != (ndim * ndim + ndim) { Err(Error::FixedMuLength(ndim * ndim + ndim, fixed_mu.len())) } else { Ok(Self { fixed_mu, steps: None, initial_guess: None, optimizer: Optimizer::default(), dimension: PhantomData, }) } } pub fn new_affine() -> Result { let ndim = if let Some(ndim) = D::NDIM { ndim } else { return Err(Error::NumberOfDimensionsNotDefined); }; Ok(Self { fixed_mu: FixedMu::new_none(ndim), steps: None, initial_guess: None, optimizer: Optimizer::default(), dimension: PhantomData, }) } pub fn new_translation() -> Result { let ndim = if let Some(ndim) = D::NDIM { ndim } else { return Err(Error::NumberOfDimensionsNotDefined); }; let mut fixed_mu = vec![Some(0.0); ndim * ndim + ndim]; for i in 0..ndim { fixed_mu[i * (ndim + 1)] = Some(1.0); fixed_mu[ndim * ndim + i] = None; } Ok(Self { fixed_mu: fixed_mu.into(), steps: None, initial_guess: None, optimizer: Optimizer::default(), dimension: PhantomData, }) } pub fn with_steps(mut self, steps: Vec) -> Self { self.steps = Some(steps); self } pub fn with_initial_guess(mut self, initial_guess: Vec) -> Result { if self.fixed_mu.len_variable() != initial_guess.len() { Err(Error::InitialGuessLength( self.fixed_mu.len(), initial_guess.len(), )) } else { self.initial_guess = Some(initial_guess); Ok(self) } } pub fn with_optimizer(mut self, optimizer: Optimizer) -> Self { self.optimizer = optimizer; self } pub fn set_steps(&mut self, steps: Vec) { self.steps = Some(steps); } pub fn set_initial_guess(&mut self, initial_guess: Vec) -> Result<(), Error> { if self.fixed_mu.len_variable() != initial_guess.len() { Err(Error::InitialGuessLength( self.fixed_mu.len(), initial_guess.len(), )) } else { self.initial_guess = Some(initial_guess); Ok(()) } } pub fn set_optimizer(&mut self, optimizer: Optimizer) { self.optimizer = optimizer; } /// Compute automatic transform initialization by aligning geometric centers fn compute_initial_transform( fixed_shape: &[usize], moving_shape: &[usize], ndim: usize, ) -> Vec { let mut params = vec![0.0; ndim * ndim + ndim]; for i in 0..ndim { params[(ndim + 1) * i] = 1.0; } for i in 0..ndim { let fixed_center = (fixed_shape[i] as f64 - 1.0) / 2.0; let moving_center = (moving_shape[i] as f64 - 1.0) / 2.0; params[ndim * ndim + i] = fixed_center - moving_center; } params } /// Compute golden standard scales for ASGD parameter normalization. /// Matches elastix ScaledCostFunction convention: scales = sqrt(C[i][i]). /// For affine transform v = R*(u-c) + t + c: /// - Rotation/scale params: C[i][i] = Var(u-c) = (N^2-1)/12, scales[i] = sqrt(C[i][i]) /// - Translation params: C[i][i] = 1.0, scales[i] = 1.0 fn golden_standard_scales(fixed_shape: &[usize], ndim: usize) -> Vec { let n_params = ndim * ndim + ndim; let mut scales = Vec::with_capacity(n_params); for _ in 0..ndim { scales.extend( fixed_shape .iter() .map(|&i| ((i * i - 1) as f64 / 12.0).sqrt()), ); } scales.extend(vec![1.0; ndim]); scales } /// find the transform which transforms moving into fixed pub fn register<'a, F, M, T>(&self, fixed: F, moving: M) -> Result, Error> where F: AsArray<'a, T, D>, M: AsArray<'a, T, D>, T: 'a + Clone + AsPrimitive, { let fixed = fixed.into().mapv(|i| i.as_()); let moving = moving.into().mapv(|i| i.as_()); let ndim = fixed.ndim(); let n = fixed.len(); let steps = self .steps .as_ref() .cloned() .unwrap_or_else(|| RegistrationStep::default_steps(ndim, n)); let mut p_var = self.initial_guess.as_ref().cloned().unwrap_or_else(|| { let full = Self::compute_initial_transform(fixed.shape(), moving.shape(), ndim); self.fixed_mu.extract_variable(&full) }); let mut last_metric: Option> = None; let mut last_result: Option> = None; for RegistrationStep { sigma, samples, n_bins, tolerance, edge, max_iterations, learning_rate, optimizer, } in steps.into_iter() { let f = sigma.smooth(fixed.view())?; let m = sigma.smooth(moving.view())?; if (f.std(0.0) == 0.0) || (m.std(0.0) == 0.0) { continue; } // Golden standard scales from downsampled shape let scales = Self::golden_standard_scales(f.shape(), ndim); let bf = BSpline::<0, _>::new(f.view()); let bm = BSpline::<3, _>::new(m.view()); let metric = MattesMetric::new(bf, bm, samples, n_bins, edge)? .with_fixed_mu(self.fixed_mu.clone()); let optimization_result = Self::optimize_metric( &metric, &p_var, &optimizer, max_iterations, tolerance, learning_rate, &scales, ); last_metric = Some(metric); last_result = optimization_result; if let Some(result) = last_result.as_ref() && result.optimal_point.iter().all(|i| i.is_finite()) { p_var = result.optimal_point.clone(); } } let mut transform = Transform::::new(self.fixed_mu.combine(p_var.as_slice())); if let (Some(metric), Some(result)) = (&last_metric, last_result.as_ref()) && result.converged && let Some(fit) = metric.residual_fit(&p_var) { let dparameters = dparameters_from_residual_fit(&fit); transform = transform.with_dparameters(self.fixed_mu.combine_dparameters(&dparameters)); } Ok(transform) } fn optimize_metric>( metric: &M, p_var: &[f64], optimizer: &Optimizer, max_iterations: usize, tolerance: f64, learning_rate: f64, scales: &[f64], ) -> Option> { match optimizer { Optimizer::LBFGS => { let config = OptimizationConfig { max_iterations, tolerance, learning_rate, }; Some(lbfgs_minimize(metric, p_var, &config)) } Optimizer::ASGD => { let config = AsgdConfig { max_iterations, tolerance, maximum_step_length: 1.0, sp_a: 20.0, sp_alpha: 0.602, scales: Some(scales.to_vec()), ..Default::default() }; Some(asgd_minimize(metric, p_var, &config)) } } } /// find the transform which transforms moving into fixed and return the results of each /// optimization step pub fn register_debug<'a, F, M, T>( &self, fixed: F, moving: M, ) -> Result<(Transform, Vec), Error> where F: AsArray<'a, T, D>, M: AsArray<'a, T, D>, T: 'a + Clone + AsPrimitive, { let fixed = fixed.into().mapv(|i| i.as_()); let moving = moving.into().mapv(|i| i.as_()); let ndim = fixed.ndim(); let n = fixed.len(); let steps = self .steps .as_ref() .cloned() .unwrap_or_else(|| RegistrationStep::default_steps(ndim, n)); let mut p_var = self.initial_guess.as_ref().cloned().unwrap_or_else(|| { let full = Self::compute_initial_transform(fixed.shape(), moving.shape(), ndim); self.fixed_mu.extract_variable(&full) }); let mut p = vec![0.0; ndim * ndim + ndim]; let mut last_metric: Option> = None; let mut last_result: Option> = None; let mut registration_results = Vec::new(); for RegistrationStep { sigma, samples, n_bins, tolerance, edge, max_iterations, learning_rate, optimizer, } in steps.into_iter() { let f = sigma.smooth(fixed.view())?; let m = sigma.smooth(moving.view())?; let f_orig_shape: Vec = f.shape().to_vec(); let m_orig_shape: Vec = m.shape().to_vec(); if (f.std(0.0) == 0.0) || (m.std(0.0) == 0.0) { continue; } let bf = BSpline::<0, _>::new(f.view()); let bm = BSpline::<3, _>::new(m.view()); let n_samples = match &samples { SamplingArg::Fixed(n) => *n, SamplingArg::Random(n) => *n, SamplingArg::FixedAt(n) => n.len(), }; let metric = MattesMetric::new(bf, bm, samples, n_bins, edge)? .with_fixed_mu(self.fixed_mu.clone()); // Golden standard scales from downsampled shape let scales = Self::golden_standard_scales(f.shape(), ndim); let optimization_result = match &optimizer { Optimizer::LBFGS => { let config = OptimizationConfig { max_iterations, tolerance, learning_rate, }; lbfgs_minimize(&metric, &p_var, &config) } Optimizer::ASGD => { let config = AsgdConfig { max_iterations, tolerance, maximum_step_length: 1.0, sp_a: 20.0, sp_alpha: 0.602, scales: Some(scales), ..Default::default() }; asgd_minimize(&metric, &p_var, &config) } }; if optimization_result .optimal_point .iter() .all(|i| i.is_finite()) { p_var = optimization_result.optimal_point.clone(); p = metric .fixed_mu() .combine(&optimization_result.optimal_point); } last_metric = Some(metric); last_result = Some(optimization_result.clone()); registration_results.push(RegistrationResult { sigma_fixed: sigma.sigma(f_orig_shape.as_slice()), sigma_moving: sigma.sigma(m_orig_shape.as_slice()), n_bins, n_samples, tolerance, edge, max_iterations, learning_rate, optimal_point: optimization_result.optimal_point, optimal_value: optimization_result.optimal_value, iterations: optimization_result.iterations, converged: optimization_result.converged, }); } let mut transform = Transform::::new(p); if let (Some(metric), Some(result)) = (&last_metric, last_result.as_ref()) && result.converged && let Some(fit) = metric.residual_fit(&p_var) { let dparameters = dparameters_from_residual_fit(&fit); transform = transform.with_dparameters(self.fixed_mu.combine_dparameters(&dparameters)); } Ok((transform, registration_results)) } } /// Diagonal of the inverse of a symmetric positive-definite matrix. /// /// Non-finite entries are treated as zero. Returns `None` if the matrix is /// singular, because a singular normal matrix means the corresponding /// directions are not determined by the data. Diagonal entries of the inverse /// that are not finite and positive are reported as 0. fn variance_from_hessian(hessian: &[Vec]) -> Option> { let n = hessian.len(); if hessian.iter().any(|row| row.len() != n) { return None; } let mut data: Vec = Vec::with_capacity(n * n); data.extend( hessian .iter() .flatten() .map(|v| if v.is_finite() { *v } else { 0.0 }), ); let matrix = Array2::from_shape_vec((n, n), data).ok()?; let inv = try_matrix_inverse(matrix)?; Some( (0..n) .map(|i| { let v = inv[[i, i]]; if v.is_finite() && v > 0.0 { v } else { 0.0 } }) .collect(), ) } /// Compute parameter uncertainty (standard error, the `dparameters` field) from /// the intensity fit at the optimum. /// /// The moving image is modelled as `moving(T(x)) = fixed(x) + noise` with /// independent residuals. Around the optimum this is a linear least-squares /// problem with Jacobian `J`, so the parameter covariance is /// /// ```text /// Cov = σ² (JᵀWJ)⁻¹ /// ``` /// /// with `σ²` the residual variance and `JᵀWJ` the weighted Gauss-Newton normal /// matrix, both computed by [`MattesMetric::residual_fit`]. This is the usual /// standard error of a nonlinear least-squares fit: it is calibrated in /// parameter units and grows when the images fit poorly or when the image /// gradients are small. /// /// This deliberately does *not* use the inverse Hessian of `-MI`: mutual /// information is not a likelihood, so its curvature has an arbitrary scale /// (it changes with the number of histogram bins and the Parzen width) and /// `H⁻¹` is not a standard error. /// /// Each uncertainty is the square root of the corresponding diagonal. If the /// normal matrix is singular or the residuals cannot be estimated, the result /// is 0 for the affected parameters, meaning "not estimated". pub(crate) fn dparameters_from_residual_fit(fit: &ResidualFit) -> Vec { let n = fit.hessian.len(); if !fit.variance.is_finite() || fit.variance <= 0.0 { return vec![0.0; n]; } variance_from_hessian(&fit.hessian) .map(|variance| { variance .into_iter() .map(|v| { let v = fit.variance * v; if v.is_finite() && v > 0.0 { v.sqrt() } else { 0.0 } }) .collect() }) .unwrap_or_else(|| vec![0.0; n]) } #[cfg(test)] mod tests { use super::*; use ndarray::{Array1, Ix1}; #[test] fn dparameters_from_residual_fit_1d() { // Cov = variance / (JᵀWJ), so with JᵀWJ = 4 and variance = 1/4 the // standard error is sqrt(1/16) = 1/4. let fit = ResidualFit { hessian: vec![vec![4.0]], variance: 0.25, n_samples: 10, }; let d = dparameters_from_residual_fit(&fit); assert!((d[0] - 0.25).abs() < 1e-12); // the covariance scales linearly with the residual variance let fit = ResidualFit { variance: 1.0, ..fit }; let d = dparameters_from_residual_fit(&fit); assert!((d[0] - 0.5).abs() < 1e-12); } #[test] fn dparameters_from_residual_fit_2d() { // inverse of [[2, 1], [1, 2]] has diagonal 2/3 let fit = ResidualFit { hessian: vec![vec![2.0, 1.0], vec![1.0, 2.0]], variance: 0.5, n_samples: 20, }; let d = dparameters_from_residual_fit(&fit); let expected: f64 = (1.0f64 / 3.0).sqrt(); assert!((d[0] - expected).abs() < 1e-9); assert!((d[1] - expected).abs() < 1e-9); } #[test] fn dparameters_from_residual_fit_not_estimated() { // a singular normal matrix leaves the parameters undetermined; there // is no diagonal fallback let singular = ResidualFit { hessian: vec![vec![2.0, -2.0], vec![-2.0, 2.0]], variance: 1.0, n_samples: 20, }; assert_eq!(dparameters_from_residual_fit(&singular), vec![0.0, 0.0]); // a non-positive-definite or non-finite normal matrix is not // informative either for hessian in [vec![vec![0.0]], vec![vec![-4.0]], vec![vec![f64::NAN]]] { let fit = ResidualFit { hessian, variance: 1.0, n_samples: 10, }; assert_eq!(dparameters_from_residual_fit(&fit), vec![0.0]); } // no residual variance means no standard error is available for variance in [0.0, -1.0, f64::NAN, f64::INFINITY] { let fit = ResidualFit { hessian: vec![vec![4.0]], variance, n_samples: 10, }; assert_eq!(dparameters_from_residual_fit(&fit), vec![0.0]); } } #[test] fn combine_dparameters() { let fixed_mu: FixedMu = vec![Some(1.0), None, None].into(); assert_eq!( fixed_mu.combine_dparameters(&[0.1, 0.2]), vec![0.0, 0.1, 0.2] ); } #[test] fn register_deterministic_dparameters() -> Result<(), Box> { // dense, non-periodic 1D signal; b is a, shifted right by 2: the // optimal translation is +2 // incommensurate frequencies keep the signal effectively aperiodic, // so the mutual information peaks at the true shift let n = 256; let a: Array1 = (0..n) .map(|i| { let x = i as f64; 1.5 + (0.131 * x).sin() + 0.7 * (0.397 * x + 1.3).sin() + 0.3 * (0.883 * x + 0.7).sin() }) .collect(); let b: Array1 = (0..n) .map(|i| if i < 2 { 0.0 } else { a[[i - 2]] }) .collect(); let points: Vec> = (0..n).map(|i| vec![i as f64]).collect(); let steps = vec![ RegistrationStep::new( Sigma::Absolute(vec![2.0]), SamplingArg::FixedAt(points.clone()), 24, 1e-6, 0.05, 1024, 1.0, Optimizer::LBFGS, ), RegistrationStep::new( Sigma::None, SamplingArg::FixedAt(points), 48, 1e-6, 0.05, 2048, 1.0, Optimizer::LBFGS, ), ]; let registration = Registration::::new_translation()?.with_steps(steps); let transform = registration.register(a.view(), b.view())?; assert_eq!(transform.parameters.len(), 2); assert_eq!(transform.dparameters.len(), 2); // the fixed scale parameter has no uncertainty assert_eq!(transform.dparameters[0], 0.0); // the data are a perfect shifted copy, so the residual variance is // (numerically) zero and so is the translation uncertainty assert!(transform.dparameters[1].is_finite()); assert!(transform.dparameters[1] < 0.05); // the found translation should be close to the applied one assert!((transform.parameters[1] - 2.0).abs() < 1.0); Ok(()) } #[test] fn noisy_fit_has_positive_dparameters() -> Result<(), Box> { let n = 128; let a: Array1 = (0..n) .map(|i| { let x = i as f64; 1.5 + (0.131 * x).sin() + 0.7 * (0.397 * x + 1.3).sin() + 0.3 * (0.883 * x + 0.7).sin() }) .collect(); let b: Array1 = (0..n) .map(|i| { let base = if i < 2 { 0.0 } else { a[[i - 2]] }; // deterministic broadband perturbation base + 0.05 * ((i * 37 % 19) as f64 / 18.0 - 0.5) }) .collect(); let points: Vec> = (0..n).map(|i| vec![i as f64]).collect(); let metric = MattesMetric::new_from_arrays( a.view(), b.view(), SamplingArg::FixedAt(points), 16, 0.05, )? .with_fixed_mu(vec![Some(1.0), None]); let fit = metric.residual_fit(&[2.0]).expect("residual fit"); assert!(fit.variance > 0.0); let d = dparameters_from_residual_fit(&fit); assert!(d[0] > 0.0); Ok(()) } }