Skip to main content

openbnct_core/
registration.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! Rigid co-registration contracts (`openbnct.registration/0.1.0`).
4//!
5//! A `RigidTransform` maps patient-space LPS points from a moving image's
6//! frame into the fixed (target) frame: `p_fixed = R·p_moving + t` with
7//! `R` orthonormal and `det R = +1`. Because a [`GridGeometry`]'s
8//! direction matrix and origin are patient-space quantities, applying the
9//! transform to a volume reduces to transforming its geometry —
10//! `D' = R·D`, `o' = R·o + t` — after which ordinary world-space
11//! resampling (e.g. `openbnct-nifti`'s `resample_to_grid`) places the
12//! moving field onto the fixed grid. No voxel warping code is needed and
13//! oblique grids stay exact.
14//!
15//! A `Registration` document binds the transform to the method that
16//! produced it — closed-form landmark least squares or an
17//! operator-declared external transform — plus the landmark pairs and the
18//! achieved RMS residual, so the record carries the evidence for its own
19//! accuracy rather than asserting it.
20
21use serde::{Deserialize, Serialize};
22use thiserror::Error;
23
24use crate::{ContentReference, GridGeometry, ValidationError};
25
26/// Current schema token for registration documents.
27pub const REGISTRATION_SCHEMA: &str = "openbnct.registration/0.1.0";
28
29/// Orthonormality tolerance for a declared or fitted rotation matrix.
30/// Loose enough to admit transforms transcribed from external systems
31/// (which carry ~6 significant digits) while still rejecting scaling,
32/// shear, and reflections outright.
33const ROTATION_TOLERANCE: f64 = 1.0e-4;
34
35/// A rigid transform mapping moving-frame LPS points to fixed-frame LPS:
36/// `p_fixed = R·p_moving + t`. `rotation` is row-major.
37#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
38#[serde(deny_unknown_fields)]
39pub struct RigidTransform {
40    /// Row-major 3×3 rotation (orthonormal, determinant +1).
41    pub rotation: [f64; 9],
42    /// Translation in millimetres, applied after rotation.
43    pub translation_mm: [f64; 3],
44}
45
46impl RigidTransform {
47    pub fn identity() -> Self {
48        Self {
49            rotation: [1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0],
50            translation_mm: [0.0; 3],
51        }
52    }
53
54    /// Orthonormal rows/columns to `ROTATION_TOLERANCE` and determinant
55    /// +1 — a reflection or scaling is not a rigid transform.
56    pub fn validate(&self) -> Result<(), RegistrationError> {
57        let r = &self.rotation;
58        if r.iter()
59            .chain(self.translation_mm.iter())
60            .any(|v| !v.is_finite())
61        {
62            return Err(RegistrationError::InvalidTransform(
63                "transform contains non-finite values".into(),
64            ));
65        }
66        let dot = |a: [f64; 3], b: [f64; 3]| a[0].mul_add(b[0], a[1].mul_add(b[1], a[2] * b[2]));
67        let columns = |c: usize| [r[c], r[3 + c], r[6 + c]];
68        let (c0, c1, c2) = (columns(0), columns(1), columns(2));
69        if (dot(c0, c0) - 1.0).abs() > ROTATION_TOLERANCE
70            || (dot(c1, c1) - 1.0).abs() > ROTATION_TOLERANCE
71            || (dot(c2, c2) - 1.0).abs() > ROTATION_TOLERANCE
72            || dot(c0, c1).abs() > ROTATION_TOLERANCE
73            || dot(c0, c2).abs() > ROTATION_TOLERANCE
74            || dot(c1, c2).abs() > ROTATION_TOLERANCE
75        {
76            return Err(RegistrationError::InvalidTransform(
77                "rotation is not orthonormal within tolerance".into(),
78            ));
79        }
80        let determinant = r[0] * (r[4] * r[8] - r[5] * r[7]) - r[1] * (r[3] * r[8] - r[5] * r[6])
81            + r[2] * (r[3] * r[7] - r[4] * r[6]);
82        if (determinant - 1.0).abs() > ROTATION_TOLERANCE {
83            return Err(RegistrationError::InvalidTransform(format!(
84                "rotation determinant {determinant} is not +1 (reflection or scale)"
85            )));
86        }
87        Ok(())
88    }
89
90    /// `p_fixed = R·p + t`.
91    pub fn apply(&self, point: [f64; 3]) -> [f64; 3] {
92        let r = &self.rotation;
93        [
94            r[0].mul_add(point[0], r[1].mul_add(point[1], r[2] * point[2]))
95                + self.translation_mm[0],
96            r[3].mul_add(point[0], r[4].mul_add(point[1], r[5] * point[2]))
97                + self.translation_mm[1],
98            r[6].mul_add(point[0], r[7].mul_add(point[1], r[8] * point[2]))
99                + self.translation_mm[2],
100        ]
101    }
102
103    /// The rigid inverse: `Rᵀ`, `−Rᵀt`.
104    pub fn inverse(&self) -> Self {
105        let r = &self.rotation;
106        let rt = [r[0], r[3], r[6], r[1], r[4], r[7], r[2], r[5], r[8]];
107        let t = self.translation_mm;
108        Self {
109            rotation: rt,
110            translation_mm: [
111                -(rt[0] * t[0] + rt[1] * t[1] + rt[2] * t[2]),
112                -(rt[3] * t[0] + rt[4] * t[1] + rt[5] * t[2]),
113                -(rt[6] * t[0] + rt[7] * t[1] + rt[8] * t[2]),
114            ],
115        }
116    }
117
118    /// `outer ∘ inner`: apply `inner` first, then `outer`.
119    pub fn compose(outer: &Self, inner: &Self) -> Self {
120        let (ro, ri) = (&outer.rotation, &inner.rotation);
121        let mut rotation = [0.0; 9];
122        for row in 0..3 {
123            for col in 0..3 {
124                rotation[row * 3 + col] = ro[row * 3] * ri[col]
125                    + ro[row * 3 + 1] * ri[3 + col]
126                    + ro[row * 3 + 2] * ri[6 + col];
127            }
128        }
129        Self {
130            rotation,
131            translation_mm: outer.apply_from_rotation(inner.translation_mm),
132        }
133    }
134
135    fn apply_from_rotation(&self, point: [f64; 3]) -> [f64; 3] {
136        self.apply(point)
137    }
138
139    /// Transform a voxel grid's patient-space frame: `D' = R·D` on the
140    /// row-major direction and `o' = R·o + t` on the origin. The result
141    /// stays a valid orthonormal `GridGeometry` because R is.
142    pub fn apply_to_geometry(&self, geometry: &GridGeometry) -> GridGeometry {
143        let d = &geometry.direction;
144        let r = &self.rotation;
145        let mut direction = [0.0; 9];
146        for row in 0..3 {
147            for col in 0..3 {
148                direction[row * 3 + col] =
149                    r[row * 3] * d[col] + r[row * 3 + 1] * d[3 + col] + r[row * 3 + 2] * d[6 + col];
150            }
151        }
152        GridGeometry {
153            shape: geometry.shape,
154            spacing_mm: geometry.spacing_mm,
155            origin_mm: self.apply(geometry.origin_mm),
156            direction,
157        }
158    }
159}
160
161/// One named or anonymous correspondence: a point in the moving image's
162/// patient space and its matching point in the fixed image's space, both
163/// LPS millimetres.
164#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
165#[serde(deny_unknown_fields)]
166pub struct LandmarkPair {
167    #[serde(default, skip_serializing_if = "Option::is_none")]
168    pub name: Option<String>,
169    pub moving_lps_mm: [f64; 3],
170    pub fixed_lps_mm: [f64; 3],
171}
172
173/// How the transform was obtained.
174#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
175#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
176pub enum RegistrationMethod {
177    /// Closed-form least-squares fit over paired landmarks (Horn's
178    /// quaternion method). Requires `landmarks` and `rms_residual_mm`.
179    LandmarkLeastSquares,
180    /// Operator-declared transform transcribed from an external
181    /// registration (e.g. a TPS or third-party tool's matrix). The
182    /// record carries no residual evidence — accuracy is the declared
183    /// source's responsibility.
184    Declared,
185}
186
187/// A versioned registration record: the transform, its method, the
188/// landmarks that produced it, the achieved residual, and content
189/// bindings of the moving/fixed images when known.
190#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
191#[serde(deny_unknown_fields)]
192pub struct Registration {
193    #[serde(deserialize_with = "crate::deserialize_contract_id")]
194    pub schema_version: String,
195    pub id: String,
196    /// Content binding of the moving image artifact, when encoded.
197    #[serde(default, skip_serializing_if = "Option::is_none")]
198    pub moving: Option<ContentReference>,
199    /// Content binding of the fixed (target) image artifact.
200    #[serde(default, skip_serializing_if = "Option::is_none")]
201    pub fixed: Option<ContentReference>,
202    pub transform: RigidTransform,
203    pub method: RegistrationMethod,
204    /// The landmark pairs actually used by a landmark fit.
205    #[serde(default, skip_serializing_if = "Option::is_none")]
206    pub landmarks: Option<Vec<LandmarkPair>>,
207    /// RMS point residual of the fit in millimetres.
208    #[serde(default, skip_serializing_if = "Option::is_none")]
209    pub rms_residual_mm: Option<f64>,
210    /// Free-text provenance note (fiducial system, external tool).
211    #[serde(default, skip_serializing_if = "Option::is_none")]
212    pub note: Option<String>,
213}
214
215impl Registration {
216    pub fn validate(&self) -> Result<(), RegistrationError> {
217        if !crate::schema_matches(&self.schema_version, REGISTRATION_SCHEMA) {
218            return Err(RegistrationError::UnsupportedSchema(
219                self.schema_version.clone(),
220            ));
221        }
222        if self.id.trim().is_empty() {
223            return Err(RegistrationError::Invalid(
224                "registration id is empty".into(),
225            ));
226        }
227        self.transform.validate()?;
228        for reference in [&self.moving, &self.fixed].into_iter().flatten() {
229            reference
230                .validate()
231                .map_err(|_| RegistrationError::Invalid("image reference is invalid".into()))?;
232        }
233        match self.method {
234            RegistrationMethod::LandmarkLeastSquares => {
235                let landmarks = self.landmarks.as_ref().ok_or_else(|| {
236                    RegistrationError::Invalid(
237                        "landmark_least_squares records must carry the landmark pairs".into(),
238                    )
239                })?;
240                if landmarks.len() < 3 {
241                    return Err(RegistrationError::Invalid(
242                        "landmark_least_squares requires at least three pairs".into(),
243                    ));
244                }
245                for pair in landmarks {
246                    if pair
247                        .moving_lps_mm
248                        .iter()
249                        .chain(pair.fixed_lps_mm.iter())
250                        .any(|v| !v.is_finite())
251                    {
252                        return Err(RegistrationError::Invalid(
253                            "landmark coordinates must be finite".into(),
254                        ));
255                    }
256                }
257                if self
258                    .rms_residual_mm
259                    .is_none_or(|rms| !rms.is_finite() || rms < 0.0)
260                {
261                    return Err(RegistrationError::Invalid(
262                        "landmark_least_squares requires a finite non-negative rms_residual_mm"
263                            .into(),
264                    ));
265                }
266            }
267            RegistrationMethod::Declared => {
268                if self.landmarks.is_some() || self.rms_residual_mm.is_some() {
269                    return Err(RegistrationError::Invalid(
270                        "declared registrations carry no landmark evidence".into(),
271                    ));
272                }
273            }
274        }
275        Ok(())
276    }
277}
278
279/// Fit the rigid transform minimizing Σ|f_i − (R·m_i + t)|² over paired
280/// landmarks, returning the transform and RMS residual in mm.
281///
282/// Horn's closed-form quaternion method: the optimum rotation is the
283/// largest-eigenvalue eigenvector of the symmetric 4×4 matrix built from
284/// the centered cross-dispersion `S = Σ m·fᵀ`; `t = f̄ − R·m̄`. Requires
285/// at least three non-degenerate pairs — coincident or collinear point
286/// sets cannot determine a unique rotation.
287pub fn fit_landmark_transform(
288    landmarks: &[LandmarkPair],
289) -> Result<(RigidTransform, f64), RegistrationError> {
290    if landmarks.len() < 3 {
291        return Err(RegistrationError::DegenerateLandmarks(
292            "at least three landmark pairs are required",
293        ));
294    }
295    for (index, pair) in landmarks.iter().enumerate() {
296        if pair
297            .moving_lps_mm
298            .iter()
299            .chain(pair.fixed_lps_mm.iter())
300            .any(|v| !v.is_finite())
301        {
302            return Err(RegistrationError::Invalid(format!(
303                "landmark {index} contains non-finite coordinates"
304            )));
305        }
306    }
307    let n = landmarks.len() as f64;
308    let centroid = |pick: fn(&LandmarkPair) -> [f64; 3]| -> [f64; 3] {
309        let mut c = [0.0; 3];
310        for pair in landmarks {
311            let p = pick(pair);
312            for axis in 0..3 {
313                c[axis] += p[axis];
314            }
315        }
316        [c[0] / n, c[1] / n, c[2] / n]
317    };
318    let moving_centroid = centroid(|p| p.moving_lps_mm);
319    let fixed_centroid = centroid(|p| p.fixed_lps_mm);
320
321    let centered: Vec<([f64; 3], [f64; 3])> = landmarks
322        .iter()
323        .map(|pair| {
324            let m = pair.moving_lps_mm;
325            let f = pair.fixed_lps_mm;
326            (
327                [
328                    m[0] - moving_centroid[0],
329                    m[1] - moving_centroid[1],
330                    m[2] - moving_centroid[2],
331                ],
332                [
333                    f[0] - fixed_centroid[0],
334                    f[1] - fixed_centroid[1],
335                    f[2] - fixed_centroid[2],
336                ],
337            )
338        })
339        .collect();
340
341    // Degeneracy: the moving points must span 3D. Find the most distant
342    // pair, then require a point farther than a tolerance from that line
343    // (tolerance scales with the inter-point distance).
344    let mut max_d2 = 0.0;
345    let (mut a, mut b) = ([0.0; 3], [0.0; 3]);
346    for (m0, _) in &centered {
347        for (m1, _) in &centered {
348            let d2 = (0..3).map(|ax| (m1[ax] - m0[ax]).powi(2)).sum::<f64>();
349            if d2 > max_d2 {
350                max_d2 = d2;
351                a = *m0;
352                b = *m1;
353            }
354        }
355    }
356    let line_len = max_d2.sqrt();
357    if line_len <= f64::EPSILON {
358        return Err(RegistrationError::DegenerateLandmarks(
359            "moving landmarks are coincident",
360        ));
361    }
362    let ab = [b[0] - a[0], b[1] - a[1], b[2] - a[2]];
363    let mut max_area2 = 0.0;
364    for (m, _) in &centered {
365        let am = [m[0] - a[0], m[1] - a[1], m[2] - a[2]];
366        let cross = [
367            am[1] * ab[2] - am[2] * ab[1],
368            am[2] * ab[0] - am[0] * ab[2],
369            am[0] * ab[1] - am[1] * ab[0],
370        ];
371        let area2: f64 = cross.iter().map(|v| v * v).sum();
372        if area2 > max_area2 {
373            max_area2 = area2;
374        }
375    }
376    // |am × ab| = distance-to-line × |ab|; require a nonzero perpendicular
377    // distance relative to the point spread.
378    if max_area2.sqrt() <= line_len * line_len * 1.0e-9 {
379        return Err(RegistrationError::DegenerateLandmarks(
380            "moving landmarks are collinear",
381        ));
382    }
383
384    // Cross-dispersion S = Σ m·fᵀ (row-major), then Horn's 4×4 symmetric
385    // quaternion matrix.
386    let mut s = [[0.0; 3]; 3];
387    for (m, f) in &centered {
388        for row in 0..3 {
389            for col in 0..3 {
390                s[row][col] += m[row] * f[col];
391            }
392        }
393    }
394    let trace = s[0][0] + s[1][1] + s[2][2];
395    let n_matrix = [
396        [
397            trace,
398            s[1][2] - s[2][1],
399            s[2][0] - s[0][2],
400            s[0][1] - s[1][0],
401        ],
402        [
403            s[1][2] - s[2][1],
404            s[0][0] - s[1][1] - s[2][2],
405            s[0][1] + s[1][0],
406            s[2][0] + s[0][2],
407        ],
408        [
409            s[2][0] - s[0][2],
410            s[0][1] + s[1][0],
411            -s[0][0] + s[1][1] - s[2][2],
412            s[1][2] + s[2][1],
413        ],
414        [
415            s[0][1] - s[1][0],
416            s[2][0] + s[0][2],
417            s[1][2] + s[2][1],
418            -s[0][0] - s[1][1] + s[2][2],
419        ],
420    ];
421    let q = largest_eigenvector_symmetric_4(&n_matrix).ok_or(
422        RegistrationError::DegenerateLandmarks("landmark fit did not converge"),
423    )?;
424    let (w, x, y, z) = (q[0], q[1], q[2], q[3]);
425    let rotation = [
426        w.mul_add(w, x * x) - y.mul_add(y, z * z),
427        2.0 * x.mul_add(y, -(w * z)),
428        2.0 * x.mul_add(z, w * y),
429        2.0 * x.mul_add(y, w * z),
430        w.mul_add(w, y * y) - x.mul_add(x, z * z),
431        2.0 * y.mul_add(z, -(w * x)),
432        2.0 * x.mul_add(z, -(w * y)),
433        2.0 * y.mul_add(z, w * x),
434        w.mul_add(w, z * z) - x.mul_add(x, y * y),
435    ];
436    let transform = RigidTransform {
437        rotation,
438        translation_mm: [0.0; 3],
439    };
440    let rotated_centroid = transform.apply(moving_centroid);
441    let transform = RigidTransform {
442        rotation,
443        translation_mm: [
444            fixed_centroid[0] - rotated_centroid[0],
445            fixed_centroid[1] - rotated_centroid[1],
446            fixed_centroid[2] - rotated_centroid[2],
447        ],
448    };
449    transform.validate()?;
450
451    let rms = (landmarks
452        .iter()
453        .map(|pair| {
454            let fitted = transform.apply(pair.moving_lps_mm);
455            (0..3)
456                .map(|axis| (fitted[axis] - pair.fixed_lps_mm[axis]).powi(2))
457                .sum::<f64>()
458        })
459        .sum::<f64>()
460        / n)
461        .sqrt();
462    Ok((transform, rms))
463}
464
465/// Largest-eigenvalue eigenvector of a symmetric 4×4 matrix via cyclic
466/// Jacobi sweeps — sufficient at this size and free of external
467/// eigensolvers. Returns the normalized quaternion `[w, x, y, z]`.
468fn largest_eigenvector_symmetric_4(n: &[[f64; 4]; 4]) -> Option<[f64; 4]> {
469    let mut a = *n;
470    let mut v = [[0.0; 4]; 4];
471    for (i, row) in v.iter_mut().enumerate() {
472        row[i] = 1.0;
473    }
474    let scale: f64 = a
475        .iter()
476        .flat_map(|row| row.iter())
477        .map(|v| v.abs())
478        .fold(0.0, f64::max)
479        .max(1.0);
480    for _sweep in 0..64 {
481        // Largest off-diagonal element; stop when it is numerically
482        // exhausted relative to the matrix scale.
483        let mut max_off = 0.0;
484        let (mut p, mut q) = (0, 1);
485        for (i, row) in a.iter().enumerate() {
486            for (j, value) in row.iter().enumerate().skip(i + 1) {
487                if value.abs() > max_off {
488                    max_off = value.abs();
489                    p = i;
490                    q = j;
491                }
492            }
493        }
494        if max_off <= 1.0e-14 * scale {
495            break;
496        }
497        let app = a[p][p];
498        let aqq = a[q][q];
499        let apq = a[p][q];
500        // Jacobi angle zeroing the (p,q) element under A ← JᵀAJ with
501        // J = [[c, s], [−s, c]]: tan 2θ = 2a_pq/(a_qq − a_pp).
502        let theta = 0.5 * (2.0 * apq).atan2(aqq - app);
503        let (c, s) = (theta.cos(), theta.sin());
504        for row in a.iter_mut() {
505            let (akp, akq) = (row[p], row[q]);
506            row[p] = c * akp - s * akq;
507            row[q] = s * akp + c * akq;
508        }
509        let (before, after) = a.split_at_mut(q);
510        let (row_p, row_q) = (&mut before[p], &mut after[0]);
511        for (apk, aqk) in row_p.iter_mut().zip(row_q.iter_mut()) {
512            let (x, y) = (*apk, *aqk);
513            *apk = c * x - s * y;
514            *aqk = s * x + c * y;
515        }
516        for row in v.iter_mut() {
517            let (vkp, vkq) = (row[p], row[q]);
518            row[p] = c * vkp - s * vkq;
519            row[q] = s * vkp + c * vkq;
520        }
521    }
522    let (mut best, mut best_value) = (0, f64::NEG_INFINITY);
523    for (i, row) in a.iter().enumerate() {
524        if row[i] > best_value {
525            best_value = row[i];
526            best = i;
527        }
528    }
529    if !best_value.is_finite() {
530        return None;
531    }
532    let column = [v[0][best], v[1][best], v[2][best], v[3][best]];
533    let norm: f64 = column.iter().map(|x| x * x).sum::<f64>().sqrt();
534    if !matches!(norm.partial_cmp(&0.0), Some(std::cmp::Ordering::Greater)) {
535        return None;
536    }
537    let mut q = [
538        column[0] / norm,
539        column[1] / norm,
540        column[2] / norm,
541        column[3] / norm,
542    ];
543    // Sign convention: w ≥ 0 keeps the quaternion canonical.
544    if q[0] < 0.0 {
545        for component in &mut q {
546            *component = -*component;
547        }
548    }
549    Some(q)
550}
551
552/// Build a validated landmark-fit registration document.
553pub fn landmark_registration(
554    id: impl Into<String>,
555    moving: Option<ContentReference>,
556    fixed: Option<ContentReference>,
557    landmarks: Vec<LandmarkPair>,
558    note: Option<String>,
559) -> Result<Registration, RegistrationError> {
560    let (transform, rms) = fit_landmark_transform(&landmarks)?;
561    let registration = Registration {
562        schema_version: REGISTRATION_SCHEMA.into(),
563        id: id.into(),
564        moving,
565        fixed,
566        transform,
567        method: RegistrationMethod::LandmarkLeastSquares,
568        landmarks: Some(landmarks),
569        rms_residual_mm: Some(rms),
570        note,
571    };
572    registration.validate()?;
573    Ok(registration)
574}
575
576/// Build a validated operator-declared registration document.
577pub fn declared_registration(
578    id: impl Into<String>,
579    moving: Option<ContentReference>,
580    fixed: Option<ContentReference>,
581    transform: RigidTransform,
582    note: Option<String>,
583) -> Result<Registration, RegistrationError> {
584    let registration = Registration {
585        schema_version: REGISTRATION_SCHEMA.into(),
586        id: id.into(),
587        moving,
588        fixed,
589        transform,
590        method: RegistrationMethod::Declared,
591        landmarks: None,
592        rms_residual_mm: None,
593        note,
594    };
595    registration.validate()?;
596    Ok(registration)
597}
598
599#[derive(Debug, Error)]
600pub enum RegistrationError {
601    #[error("unsupported registration schema {0:?}")]
602    UnsupportedSchema(String),
603    #[error("invalid registration: {0}")]
604    Invalid(String),
605    #[error("invalid rigid transform: {0}")]
606    InvalidTransform(String),
607    #[error("degenerate landmark set: {0}")]
608    DegenerateLandmarks(&'static str),
609    #[error("invalid geometry: {0}")]
610    InvalidGeometry(#[from] ValidationError),
611}
612
613#[cfg(test)]
614mod tests {
615    use super::*;
616
617    fn landmarks(points: &[[f64; 3]], transform: &RigidTransform) -> Vec<LandmarkPair> {
618        points
619            .iter()
620            .map(|p| LandmarkPair {
621                name: None,
622                moving_lps_mm: transform.apply(*p),
623                fixed_lps_mm: *p,
624            })
625            .collect()
626    }
627
628    fn phantom_points() -> Vec<[f64; 3]> {
629        vec![
630            [0.0, 0.0, 0.0],
631            [80.0, 0.0, 0.0],
632            [0.0, 90.0, 0.0],
633            [0.0, 0.0, 70.0],
634            [40.0, 30.0, 50.0],
635            [-20.0, 60.0, 10.0],
636        ]
637    }
638
639    /// 30° about z then a translation — a realistic table shift.
640    fn known_transform() -> RigidTransform {
641        let (c, s) = (30.0_f64.to_radians().cos(), 30.0_f64.to_radians().sin());
642        RigidTransform {
643            rotation: [c, -s, 0.0, s, c, 0.0, 0.0, 0.0, 1.0],
644            translation_mm: [12.0, -7.5, 4.0],
645        }
646    }
647
648    #[test]
649    fn exact_landmarks_recover_the_transform() {
650        let known = known_transform();
651        // Fit maps moving→fixed; the landmarks were built with fixed→moving.
652        let pairs = landmarks(&phantom_points(), &known);
653        let (fit, rms) = fit_landmark_transform(&pairs).unwrap();
654        let expected = known.inverse();
655        for i in 0..9 {
656            assert!((fit.rotation[i] - expected.rotation[i]).abs() < 1e-9);
657        }
658        for i in 0..3 {
659            assert!((fit.translation_mm[i] - expected.translation_mm[i]).abs() < 1e-9);
660        }
661        assert!(rms < 1e-9, "rms {rms}");
662    }
663
664    #[test]
665    fn arbitrary_rotation_and_translation_recover() {
666        // Compose two rotations for a non-axial R, then translate.
667        let (c1, s1) = (0.35_f64.cos(), 0.35_f64.sin());
668        let (c2, s2) = (0.6_f64.cos(), 0.6_f64.sin());
669        let rz = RigidTransform {
670            rotation: [c1, -s1, 0.0, s1, c1, 0.0, 0.0, 0.0, 1.0],
671            translation_mm: [0.0; 3],
672        };
673        let ry = RigidTransform {
674            rotation: [c2, 0.0, s2, 0.0, 1.0, 0.0, -s2, 0.0, c2],
675            translation_mm: [0.0; 3],
676        };
677        let known = RigidTransform {
678            rotation: RigidTransform::compose(&rz, &ry).rotation,
679            translation_mm: [-30.0, 15.0, 22.5],
680        };
681        let pairs = landmarks(&phantom_points(), &known);
682        let (fit, _) = fit_landmark_transform(&pairs).unwrap();
683        let probe = [11.0, -23.0, 47.0];
684        let want = known.inverse().apply(probe);
685        let got = fit.apply(probe);
686        for axis in 0..3 {
687            assert!((got[axis] - want[axis]).abs() < 1e-8);
688        }
689    }
690
691    #[test]
692    fn degenerate_landmark_sets_are_rejected() {
693        let pairs = landmarks(&phantom_points()[..2], &known_transform());
694        assert!(matches!(
695            fit_landmark_transform(&pairs),
696            Err(RegistrationError::DegenerateLandmarks(_))
697        ));
698        let coincident = vec![
699            LandmarkPair {
700                name: None,
701                moving_lps_mm: [5.0, 5.0, 5.0],
702                fixed_lps_mm: [0.0, 0.0, 0.0],
703            },
704            LandmarkPair {
705                name: None,
706                moving_lps_mm: [5.0, 5.0, 5.0],
707                fixed_lps_mm: [1.0, 0.0, 0.0],
708            },
709            LandmarkPair {
710                name: None,
711                moving_lps_mm: [5.0, 5.0, 5.0],
712                fixed_lps_mm: [0.0, 1.0, 0.0],
713            },
714        ];
715        assert!(matches!(
716            fit_landmark_transform(&coincident),
717            Err(RegistrationError::DegenerateLandmarks(_))
718        ));
719        // Collinear moving points cannot determine the axial rotation.
720        let collinear: Vec<LandmarkPair> = (0..4)
721            .map(|i| LandmarkPair {
722                name: None,
723                moving_lps_mm: [i as f64 * 10.0, 0.0, 0.0],
724                fixed_lps_mm: [i as f64 * 10.0, 0.0, 0.0],
725            })
726            .collect();
727        assert!(matches!(
728            fit_landmark_transform(&collinear),
729            Err(RegistrationError::DegenerateLandmarks(_))
730        ));
731    }
732
733    #[test]
734    fn transform_inverse_and_compose_round_trip() {
735        let t = known_transform();
736        let p = [33.0, -14.0, 25.0];
737        let round = t.inverse().apply(t.apply(p));
738        for axis in 0..3 {
739            assert!((round[axis] - p[axis]).abs() < 1e-9);
740        }
741        let composed = RigidTransform::compose(&t, &t.inverse());
742        for i in 0..9 {
743            let want = if i % 4 == 0 { 1.0 } else { 0.0 };
744            assert!((composed.rotation[i] - want).abs() < 1e-9);
745        }
746        assert!(composed.translation_mm.iter().all(|v| v.abs() < 1e-9));
747    }
748
749    #[test]
750    fn declared_transforms_must_be_rigid() {
751        let mut t = RigidTransform::identity();
752        t.rotation[0] = 2.0; // scaling, not rotation
753        assert!(t.validate().is_err());
754        let reflection = RigidTransform {
755            rotation: [-1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0],
756            translation_mm: [0.0; 3],
757        };
758        assert!(reflection.validate().is_err());
759    }
760
761    #[test]
762    fn geometry_transform_preserves_voxel_mapping() {
763        // A point at a moving voxel center lands where the transformed
764        // geometry's same-index center sits.
765        let geometry = GridGeometry {
766            shape: [4, 4, 4],
767            spacing_mm: [2.0, 3.0, 4.0],
768            origin_mm: [-10.0, -5.0, 0.0],
769            direction: [1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0],
770        };
771        let t = known_transform();
772        let moved = t.apply_to_geometry(&geometry);
773        moved.voxel_count().unwrap(); // still orthonormal
774        let original = geometry.voxel_center_lps_mm([3, 2, 1]).unwrap();
775        let transformed = moved.voxel_center_lps_mm([3, 2, 1]).unwrap();
776        let want = t.apply(original);
777        for axis in 0..3 {
778            assert!((transformed[axis] - want[axis]).abs() < 1e-9);
779        }
780    }
781
782    #[test]
783    fn registration_documents_validate_method_evidence() {
784        let pairs = landmarks(&phantom_points(), &known_transform());
785        let registration = landmark_registration("reg.test", None, None, pairs, None).unwrap();
786        registration.validate().unwrap();
787        assert!(registration.rms_residual_mm.unwrap() < 1e-9);
788
789        let mut declared = declared_registration(
790            "reg.declared",
791            None,
792            None,
793            known_transform(),
794            Some("external TPS matrix".into()),
795        )
796        .unwrap();
797        declared.validate().unwrap();
798        // A declared record must not carry fabricated landmark evidence.
799        declared.landmarks = Some(vec![]);
800        assert!(declared.validate().is_err());
801    }
802}