760 lines
25 KiB
Rust
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(())
|
|
}
|
|
}
|