Files
image-registration/src/register.rs
T
w.pomp 0049267997 - fix default value
- implement dparameter estimation
2026-10-27 16:14:15 +01:00

760 lines
25 KiB
Rust

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<Self> {
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<Vec<f64>>,
pub sigma_moving: Option<Vec<f64>>,
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<f64>,
pub optimal_value: f64,
pub iterations: usize,
pub converged: bool,
}
pub struct Registration<D: Dimension> {
fixed_mu: FixedMu,
steps: Option<Vec<RegistrationStep>>,
initial_guess: Option<Vec<f64>>,
optimizer: Optimizer,
dimension: PhantomData<D>,
}
impl<D: Dimension> Registration<D> {
pub fn new<F: Into<FixedMu>>(fixed_mu: F) -> Result<Self, Error> {
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<Self, Error> {
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<Self, Error> {
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<RegistrationStep>) -> Self {
self.steps = Some(steps);
self
}
pub fn with_initial_guess(mut self, initial_guess: Vec<f64>) -> Result<Self, 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(self)
}
}
pub fn with_optimizer(mut self, optimizer: Optimizer) -> Self {
self.optimizer = optimizer;
self
}
pub fn set_steps(&mut self, steps: Vec<RegistrationStep>) {
self.steps = Some(steps);
}
pub fn set_initial_guess(&mut self, initial_guess: Vec<f64>) -> 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<f64> {
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<f64> {
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<Transform<D>, Error>
where
F: AsArray<'a, T, D>,
M: AsArray<'a, T, D>,
T: 'a + Clone + AsPrimitive<f64>,
{
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<MattesMetric<D>> = None;
let mut last_result: Option<algos::OptimizationResult<f64>> = 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::<D>::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<M: ObjectiveFunction<f64>>(
metric: &M,
p_var: &[f64],
optimizer: &Optimizer,
max_iterations: usize,
tolerance: f64,
learning_rate: f64,
scales: &[f64],
) -> Option<algos::OptimizationResult<f64>> {
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<D>, Vec<RegistrationResult>), Error>
where
F: AsArray<'a, T, D>,
M: AsArray<'a, T, D>,
T: 'a + Clone + AsPrimitive<f64>,
{
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<MattesMetric<D>> = None;
let mut last_result: Option<algos::OptimizationResult<f64>> = 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<usize> = f.shape().to_vec();
let m_orig_shape: Vec<usize> = 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::<D>::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<f64>]) -> Option<Vec<f64>> {
let n = hessian.len();
if hessian.iter().any(|row| row.len() != n) {
return None;
}
let mut data: Vec<f64> = 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<f64> {
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<dyn std::error::Error>> {
// 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<f64> = (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<f64> = (0..n)
.map(|i| if i < 2 { 0.0 } else { a[[i - 2]] })
.collect();
let points: Vec<Vec<f64>> = (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::<Ix1>::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<dyn std::error::Error>> {
let n = 128;
let a: Array1<f64> = (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<f64> = (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<Vec<f64>> = (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(())
}
}