- fix default value

- implement dparameter estimation
This commit is contained in:
w.pomp committed 2026-10-27 16:14:15 +01:00
1 parent 9257dd1fcf
commit 0049267997
7 files changed
+672 -73

No files matched your search

+287 -10
View File
@@ -1,10 +1,10 @@
use crate::bspline::{BSpline, BSplineTrait};
use crate::error::Error;
use crate::metric::{FixedMu, MattesMetric, SamplingArg, Sigma};
use crate::metric::{FixedMu, MattesMetric, ResidualFit, SamplingArg, Sigma};
use crate::optimize::{AsgdConfig, asgd_minimize, lbfgs_minimize};
use crate::transform::Transform;
use crate::transform::{Transform, try_matrix_inverse};
use algos::{ObjectiveFunction, OptimizationConfig};
use ndarray::{AsArray, Dimension};
use ndarray::{Array2, AsArray, Dimension};
use num::cast::AsPrimitive;
use serde::{Deserialize, Serialize};
use std::marker::PhantomData;
@@ -286,8 +286,11 @@ impl<D: Dimension> Registration<D> {
.unwrap_or_else(|| RegistrationStep::default_steps(ndim, n));
let mut p_var = self.initial_guess.as_ref().cloned().unwrap_or_else(|| {
Self::compute_initial_transform(fixed.shape(), moving.shape(), ndim)
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,
@@ -324,13 +327,23 @@ impl<D: Dimension> Registration<D> {
&scales,
);
if let Some(result) = optimization_result
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;
p_var = result.optimal_point.clone();
}
}
Ok(Transform::<D>::new(self.fixed_mu.combine(p_var.as_slice())))
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>>(
@@ -389,9 +402,12 @@ impl<D: Dimension> Registration<D> {
.unwrap_or_else(|| RegistrationStep::default_steps(ndim, n));
let mut p_var = self.initial_guess.as_ref().cloned().unwrap_or_else(|| {
Self::compute_initial_transform(fixed.shape(), moving.shape(), ndim)
let full = Self::compute_initial_transform(fixed.shape(), moving.shape(), ndim);
self.fixed_mu.extract_variable(&full)
});
let mut p = vec![0.0; n * n + n];
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 {
@@ -461,6 +477,8 @@ impl<D: Dimension> Registration<D> {
.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()),
@@ -477,6 +495,265 @@ impl<D: Dimension> Registration<D> {
converged: optimization_result.converged,
});
}
Ok((Transform::<D>::new(p), registration_results))
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(())
}
}