Skip to main content

qsm_core/
crop.rs

1//! Cropping a volume to the region that actually carries signal, and putting it back.
2//!
3//! Every FFT-based stage — background removal, dipole inversion, Laplacian unwrapping — costs
4//! `O(N log N)` in the *whole grid*, not in the brain. A brain typically fills a fifth of an
5//! acquired volume and a tenth of one that has been resampled to a cardinal grid (resampling
6//! wraps a box around a tilted slab, so half the result is empty corner). Reconstructing inside a
7//! box around the mask and padding the answer back afterwards is therefore close to free, and on
8//! a UK Biobank SWI it takes the resampled grid from 4.5× the cost of the acquired grid down to
9//! parity.
10//!
11//! Two things make the difference, and they are worth separating because their risk differs:
12//!
13//! - **Fewer voxels** ([`crop_box_for_mask`]). Mask bounding box plus a margin, rather than the
14//!   full field of view. This moves the FFT's periodic boundary *closer* to the object.
15//! - **Sizes an FFT likes** ([`fft_pad_box`]). An extent with a large prime factor pushes
16//!   `rustfft` onto Bluestein's algorithm. An axially-resampled UK Biobank grid comes out
17//!   272×339×77 — that is 2⁴·17, 3·113 and 7·11, awkward on every axis, which is what resampling
18//!   to a bounding box tends to produce. Padding to 280×343×80 costs 8% more voxels and takes the
19//!   transform from 131 ms to 71 ms, a 1.85× speedup. Padding moves the boundary *further* from
20//!   the object, so unlike cropping it carries no wrap-around risk.
21//!
22//! Both are expressed as a [`CropBox`], which may sit inside the grid (cropping), extend beyond
23//! it (padding), or do both on different axes.
24//!
25//! ## This changes the numbers, not just the speed
26//!
27//! This caveat applies to **cropping**, not to padding. FFT-based reconstruction is periodic, so
28//! moving the boundary closer to the object brings wrap-around with it, and the dipole kernel has
29//! infinite support. Measured on a UK Biobank acquisition, a crop that actually removed voxels
30//! changed χ by ~0.6% of its dynamic range at the median and ~4% at the 99th percentile.
31//! [`margin_voxels`] takes the margin in **millimetres** so anisotropic voxels get a
32//! geometrically equal margin on every side, but a caller should still validate a cropped
33//! reconstruction against an uncropped one rather than assume the two agree.
34//!
35//! [`fft_pad_box`] has no such caveat: it discards nothing and only moves the boundary outward.
36
37/// A box within a larger grid: where reconstruction actually happens.
38#[derive(Debug, Clone, Copy, PartialEq, Eq)]
39pub struct CropBox {
40    /// Index of the box's first voxel in the full grid. **May be negative**, which means the box
41    /// extends past the edge and those voxels are padding rather than data.
42    pub origin: (isize, isize, isize),
43    /// Size of the box.
44    pub dims: (usize, usize, usize),
45    /// Size of the grid the box sits in.
46    pub full_dims: (usize, usize, usize),
47}
48
49impl CropBox {
50    /// A box covering the whole grid — cropping is then a no-op.
51    pub fn full(full_dims: (usize, usize, usize)) -> Self {
52        Self { origin: (0, 0, 0), dims: full_dims, full_dims }
53    }
54
55    /// Whether any axis reaches outside the grid, i.e. whether this box pads.
56    pub fn pads(&self) -> bool {
57        let (ox, oy, oz) = self.origin;
58        let (dx, dy, dz) = self.dims;
59        let (fx, fy, fz) = self.full_dims;
60        ox < 0 || oy < 0 || oz < 0
61            || ox + dx as isize > fx as isize
62            || oy + dy as isize > fy as isize
63            || oz + dz as isize > fz as isize
64    }
65
66    /// Whether this box is the whole grid.
67    pub fn is_full(&self) -> bool {
68        self.origin == (0, 0, 0) && self.dims == self.full_dims
69    }
70
71    pub fn voxels(&self) -> usize {
72        self.dims.0 * self.dims.1 * self.dims.2
73    }
74
75    pub fn full_voxels(&self) -> usize {
76        self.full_dims.0 * self.full_dims.1 * self.full_dims.2
77    }
78
79    /// How many times fewer voxels than the full grid.
80    /// Full-grid voxels per box voxel. Above 1 the box is smaller than the grid (a net crop);
81    /// below 1 it is larger (a net pad).
82    pub fn reduction(&self) -> f64 {
83        if self.voxels() == 0 { 1.0 } else { self.full_voxels() as f64 / self.voxels() as f64 }
84    }
85
86    /// The affine of the cropped volume: same orientation, origin shifted to the box corner.
87    pub fn crop_affine(&self, affine: &[f64; 16]) -> [f64; 16] {
88        let (ox, oy, oz) = (self.origin.0 as f64, self.origin.1 as f64, self.origin.2 as f64);
89        let mut out = *affine;
90        for row in 0..3 {
91            out[4 * row + 3] = affine[4 * row + 3]
92                + affine[4 * row] * ox
93                + affine[4 * row + 1] * oy
94                + affine[4 * row + 2] * oz;
95        }
96        out
97    }
98}
99
100/// Smallest size `>= n` whose prime factors are all at most 7 — the radices `rustfft` has
101/// dedicated butterflies for. Sizes with a large prime factor fall back to Bluestein's algorithm
102/// and cost several times more despite holding the same data.
103pub fn next_fft_friendly_size(n: usize) -> usize {
104    if n <= 1 {
105        return n.max(1);
106    }
107    let smooth = |mut m: usize| {
108        for p in [2usize, 3, 5, 7] {
109            while m.is_multiple_of(p) {
110                m /= p;
111            }
112        }
113        m == 1
114    };
115    let mut candidate = n;
116    while !smooth(candidate) {
117        candidate += 1;
118    }
119    candidate
120}
121
122/// Margin in voxels per axis for a margin given in millimetres, at least one voxel where the
123/// margin is positive. Anisotropic voxels get a geometrically equal margin rather than an equal
124/// voxel count — 8 voxels is 6.4 mm in-plane but 24 mm through-plane at 0.8 × 0.8 × 3 mm.
125pub fn margin_voxels(margin_mm: f64, voxel_size: (f64, f64, f64)) -> (usize, usize, usize) {
126    let per_axis = |mm: f64, vs: f64| -> usize {
127        if mm <= 0.0 || vs <= 0.0 {
128            0
129        } else {
130            ((mm / vs).ceil() as usize).max(1)
131        }
132    };
133    (
134        per_axis(margin_mm, voxel_size.0),
135        per_axis(margin_mm, voxel_size.1),
136        per_axis(margin_mm, voxel_size.2),
137    )
138}
139
140/// The box to reconstruct in: the mask's bounding box, grown by `margin_mm` on every side, each
141/// axis then rounded up to an FFT-friendly size and clamped to the grid.
142///
143/// Returns the full grid when the mask is empty, when it already fills the volume, or when an
144/// axis cannot usefully shrink — so a caller can apply this unconditionally.
145pub fn crop_box_for_mask(
146    mask: &[u8],
147    full_dims: (usize, usize, usize),
148    voxel_size: (f64, f64, f64),
149    margin_mm: f64,
150) -> CropBox {
151    let (nx, ny, nz) = full_dims;
152    if mask.len() != nx * ny * nz {
153        return fft_pad_box(full_dims);
154    }
155
156    let (mut lo, mut hi) = ([usize::MAX; 3], [0usize; 3]);
157    let mut any = false;
158    for z in 0..nz {
159        for y in 0..ny {
160            for x in 0..nx {
161                if mask[x + y * nx + z * nx * ny] > 0 {
162                    any = true;
163                    for (d, v) in [x, y, z].iter().enumerate() {
164                        lo[d] = lo[d].min(*v);
165                        hi[d] = hi[d].max(*v);
166                    }
167                }
168            }
169        }
170    }
171    if !any {
172        return fft_pad_box(full_dims);
173    }
174
175    let margin = margin_voxels(margin_mm, voxel_size);
176    let margin = [margin.0, margin.1, margin.2];
177    let full = [nx as isize, ny as isize, nz as isize];
178    let (mut origin, mut dims) = ([0isize; 3], [0usize; 3]);
179
180    for d in 0..3 {
181        // Wanted extent: the mask plus its margin, clipped to what actually exists.
182        let start = (lo[d] as isize - margin[d] as isize).max(0);
183        let end = ((hi[d] + margin[d] + 1) as isize).min(full[d]);
184        let wanted = next_fft_friendly_size((end - start) as usize) as isize;
185        // Centre the FFT-friendly extent on that window. It may reach outside the grid, in which
186        // case those voxels are padding — which is safe, since padding only moves the periodic
187        // boundary further from the object.
188        let extra = wanted - (end - start);
189        let mut s0 = start - extra / 2;
190        // Prefer to stay inside the grid where the extent allows it.
191        if wanted <= full[d] {
192            s0 = s0.clamp(0, full[d] - wanted);
193        }
194        origin[d] = s0;
195        dims[d] = wanted as usize;
196    }
197
198    CropBox {
199        origin: (origin[0], origin[1], origin[2]),
200        dims: (dims[0], dims[1], dims[2]),
201        full_dims,
202    }
203}
204
205/// A box covering the whole grid, each axis grown outward to an FFT-friendly size.
206///
207/// The accuracy-neutral half of this module: no data is discarded and the periodic boundary moves
208/// *away* from the object, so the only cost is the padded voxels. Worth doing whenever an axis
209/// has an awkward length — 339 = 3·113 costs about a third more transform time than 343 = 7³
210/// despite holding fewer voxels.
211pub fn fft_pad_box(full_dims: (usize, usize, usize)) -> CropBox {
212    let full = [full_dims.0, full_dims.1, full_dims.2];
213    let (mut origin, mut dims) = ([0isize; 3], [0usize; 3]);
214    for d in 0..3 {
215        let wanted = next_fft_friendly_size(full[d]);
216        // Centre the grid in the padded extent so the object stays central.
217        origin[d] = -(((wanted - full[d]) / 2) as isize);
218        dims[d] = wanted;
219    }
220    CropBox {
221        origin: (origin[0], origin[1], origin[2]),
222        dims: (dims[0], dims[1], dims[2]),
223        full_dims,
224    }
225}
226
227/// Copy the box out of a full-grid volume.
228///
229/// # Panics
230/// If `data` does not match the box's `full_dims`.
231pub fn crop_volume<T: Copy + Default>(data: &[T], b: &CropBox) -> Vec<T> {
232    crop_volume_with(data, b, T::default())
233}
234
235/// Copy the box out of a full-grid volume, filling anything outside the grid with `fill`.
236///
237/// # Panics
238/// If `data` does not match the box's `full_dims`.
239pub fn crop_volume_with<T: Copy>(data: &[T], b: &CropBox, fill: T) -> Vec<T> {
240    let (nx, ny, nz) = b.full_dims;
241    assert_eq!(data.len(), b.full_voxels(), "crop_volume: data does not match the full grid");
242    if b.is_full() {
243        return data.to_vec();
244    }
245    let (cx, cy, cz) = b.dims;
246    let (ox, oy, oz) = b.origin;
247    let mut out = vec![fill; b.voxels()];
248    for z in 0..cz {
249        let sz = oz + z as isize;
250        if sz < 0 || sz >= nz as isize {
251            continue;
252        }
253        for y in 0..cy {
254            let sy = oy + y as isize;
255            if sy < 0 || sy >= ny as isize {
256                continue;
257            }
258            // Clip the row to the part that exists in the source.
259            let x0 = (-ox).max(0);
260            let x1 = (nx as isize - ox).min(cx as isize);
261            if x1 <= x0 {
262                continue;
263            }
264            let src = (ox + x0) as usize + sy as usize * nx + sz as usize * nx * ny;
265            let dst = x0 as usize + y * cx + z * cx * cy;
266            let len = (x1 - x0) as usize;
267            out[dst..dst + len].copy_from_slice(&data[src..src + len]);
268        }
269    }
270    out
271}
272
273/// Put a cropped volume back into a full-grid volume, filling everything outside the box with
274/// `fill` (zero for field maps and χ, which are undefined outside the mask anyway).
275///
276/// # Panics
277/// If `data` does not match the box's `dims`.
278pub fn uncrop_volume<T: Copy>(data: &[T], b: &CropBox, fill: T) -> Vec<T> {
279    assert_eq!(data.len(), b.voxels(), "uncrop_volume: data does not match the crop box");
280    if b.is_full() {
281        return data.to_vec();
282    }
283    let (nx, ny, nz) = b.full_dims;
284    let (cx, cy, cz) = b.dims;
285    let (ox, oy, oz) = b.origin;
286    let mut out = vec![fill; b.full_voxels()];
287    for z in 0..cz {
288        let dz = oz + z as isize;
289        if dz < 0 || dz >= nz as isize {
290            continue; // padding: nothing in the grid to write it to
291        }
292        for y in 0..cy {
293            let dy = oy + y as isize;
294            if dy < 0 || dy >= ny as isize {
295                continue;
296            }
297            let x0 = (-ox).max(0);
298            let x1 = (nx as isize - ox).min(cx as isize);
299            if x1 <= x0 {
300                continue;
301            }
302            let src = x0 as usize + y * cx + z * cx * cy;
303            let dst = (ox + x0) as usize + dy as usize * nx + dz as usize * nx * ny;
304            let len = (x1 - x0) as usize;
305            out[dst..dst + len].copy_from_slice(&data[src..src + len]);
306        }
307    }
308    out
309}
310
311#[cfg(test)]
312mod tests {
313    use super::*;
314
315    fn box_mask(full: (usize, usize, usize), lo: (usize, usize, usize), hi: (usize, usize, usize)) -> Vec<u8> {
316        let (nx, ny, nz) = full;
317        let mut m = vec![0u8; nx * ny * nz];
318        for z in lo.2..=hi.2 {
319            for y in lo.1..=hi.1 {
320                for x in lo.0..=hi.0 {
321                    m[x + y * nx + z * nx * ny] = 1;
322                }
323            }
324        }
325        m
326    }
327
328    #[test]
329    fn fft_friendly_sizes() {
330        assert_eq!(next_fft_friendly_size(339), 343); // 7^3; 339 = 3*113 would hit Bluestein
331        assert_eq!(next_fft_friendly_size(340), 343);
332        assert_eq!(next_fft_friendly_size(256), 256);
333        assert_eq!(next_fft_friendly_size(188), 189); // 27*7
334        assert_eq!(next_fft_friendly_size(1), 1);
335        for n in 1..2000 {
336            let s = next_fft_friendly_size(n);
337            assert!(s >= n);
338            let mut m = s;
339            for p in [2, 3, 5, 7] {
340                while m % p == 0 {
341                    m /= p;
342                }
343            }
344            assert_eq!(m, 1, "{s} (from {n}) is not 7-smooth");
345        }
346    }
347
348    #[test]
349    fn margin_is_geometric_not_voxel_count() {
350        // 8 mm on 0.8 x 0.8 x 3 mm voxels: 10 in-plane, 3 through-plane.
351        assert_eq!(margin_voxels(8.0, (0.8, 0.8, 3.0)), (10, 10, 3));
352        assert_eq!(margin_voxels(0.0, (1.0, 1.0, 1.0)), (0, 0, 0));
353    }
354
355    /// With nothing to crop to, fall back to padding — never to discarding data.
356    #[test]
357    fn empty_or_unusable_mask_falls_back_to_padding() {
358        let full = (16, 16, 8); // already 7-smooth, so the pad box is the grid itself
359        for mask in [vec![0u8; 16 * 16 * 8], vec![1u8; 16 * 16 * 8], vec![1u8; 10]] {
360            let b = crop_box_for_mask(&mask, full, (1.0, 1.0, 1.0), 4.0);
361            assert!(b.is_full(), "{:?}+{:?}", b.origin, b.dims);
362        }
363        // On an awkward grid the fallback pads instead of leaving it alone.
364        let awkward = (17, 16, 8);
365        let b = crop_box_for_mask(&vec![0u8; 17 * 16 * 8], awkward, (1.0, 1.0, 1.0), 4.0);
366        assert!(b.pads() && b.dims.0 == next_fft_friendly_size(17));
367    }
368
369    #[test]
370    fn box_contains_the_mask_with_its_margin() {
371        let full = (64, 64, 32);
372        let mask = box_mask(full, (20, 24, 10), (40, 44, 20));
373        let b = crop_box_for_mask(&mask, full, (1.0, 1.0, 1.0), 3.0);
374        assert!(!b.is_full(), "should have cropped something");
375        let lo = [b.origin.0, b.origin.1, b.origin.2];
376        let hi = [
377            b.origin.0 + b.dims.0 as isize,
378            b.origin.1 + b.dims.1 as isize,
379            b.origin.2 + b.dims.2 as isize,
380        ];
381        // Every mask voxel, and 3 mm around it, is inside the box.
382        for (d, (mlo, mhi)) in [(20, 40), (24, 44), (10, 20)].iter().enumerate() {
383            assert!(lo[d] <= mlo - 3, "axis {d}: box starts at {}, mask-3 at {}", lo[d], mlo - 3);
384            assert!(hi[d] >= mhi + 1 + 3, "axis {d}: box ends at {}, mask+3 at {}", hi[d], mhi + 4);
385        }
386        // This one fits inside the grid, so it should not pad.
387        assert!(!b.pads(), "box {:?}+{:?} should stay inside {:?}", b.origin, b.dims, full);
388        for d in [b.dims.0, b.dims.1, b.dims.2] {
389            assert_eq!(next_fft_friendly_size(d), d, "{d} should already be FFT-friendly");
390        }
391        assert!(b.reduction() > 1.0);
392    }
393
394    /// The realistic case this exists for.
395    #[test]
396    fn ukb_sized_volume_shrinks_substantially() {
397        let full = (256, 288, 48);
398        // A brain-ish box, roughly what HD-BET leaves on this acquisition.
399        let mask = box_mask(full, (42, 36, 2), (213, 251, 45));
400        let b = crop_box_for_mask(&mask, full, (0.8, 0.8, 3.0), 8.0);
401        assert!(b.reduction() > 1.2, "expected a worthwhile reduction, got {:.2}x", b.reduction());
402        assert!(b.voxels() < b.full_voxels());
403    }
404
405    // --- padding outward ---
406
407    /// A real axially-resampled UK Biobank grid. Every axis is awkward — 272 = 2⁴·17,
408    /// 339 = 3·113, 77 = 7·11 — which is exactly what resampling to a bounding box tends to
409    /// produce, and exactly what a radix-2/3/5/7 FFT is worst at.
410    #[test]
411    fn fft_pad_box_grows_every_awkward_axis() {
412        let b = fft_pad_box((272, 339, 77));
413        assert_eq!(b.dims, (280, 343, 80), "each axis should reach the next 7-smooth size");
414        assert!(b.pads());
415        // The grid stays centred in the padded extent.
416        assert_eq!(b.origin, (-4, -2, -1));
417        // Padding costs voxels rather than saving them, and not many.
418        assert!(b.reduction() < 1.0, "{}", b.reduction());
419        let overhead = b.voxels() as f64 / b.full_voxels() as f64 - 1.0;
420        assert!(overhead < 0.10, "padding overhead {overhead:.3} should stay under 10%");
421    }
422
423    #[test]
424    fn fft_pad_box_is_a_no_op_on_a_friendly_grid() {
425        let b = fft_pad_box((256, 288, 48));
426        assert!(b.is_full(), "already 7-smooth: {:?}+{:?}", b.origin, b.dims);
427        assert!(!b.pads());
428    }
429
430    /// Padding must not lose a single voxel: out and back is the identity.
431    #[test]
432    fn pad_then_unpad_is_lossless() {
433        let full = (17, 13, 5); // every axis awkward
434        let n = full.0 * full.1 * full.2;
435        let data: Vec<f64> = (0..n).map(|i| (i as f64) * 0.5 + 1.0).collect();
436        let b = fft_pad_box(full);
437        assert!(b.pads() && b.voxels() > n);
438
439        let padded = crop_volume(&data, &b);
440        assert_eq!(padded.len(), b.voxels());
441        // The original data is in there exactly once, and the rest is the fill.
442        assert_eq!(padded.iter().filter(|v| **v != 0.0).count(), n, "all data present, nothing extra");
443
444        let back = uncrop_volume(&padded, &b, f64::NAN);
445        assert_eq!(back.len(), n);
446        for (i, (got, want)) in back.iter().zip(data.iter()).enumerate() {
447            assert_eq!(got, want, "voxel {i} changed across a pad round trip");
448        }
449    }
450
451    #[test]
452    fn pad_fill_value_is_respected() {
453        let full = (11, 4, 2);
454        let b = fft_pad_box(full);
455        assert!(b.pads());
456        let padded = crop_volume_with(&vec![7.0f64; full.0 * full.1 * full.2], &b, -1.0);
457        assert!(padded.iter().any(|v| *v == -1.0), "padding should use the fill value");
458        assert_eq!(padded.iter().filter(|v| **v == 7.0).count(), full.0 * full.1 * full.2);
459    }
460
461    /// A box that crops one axis and pads another — both at once, which is the realistic case
462    /// once a margin is applied to an awkward grid.
463    #[test]
464    fn a_box_can_crop_and_pad_at_the_same_time() {
465        let full = (64, 17, 16);
466        let mask = box_mask(full, (20, 2, 4), (40, 14, 11));
467        let b = crop_box_for_mask(&mask, full, (1.0, 1.0, 1.0), 2.0);
468        // x has room to crop; y is awkward and the mask nearly fills it, so it pads.
469        assert!(b.dims.0 < full.0, "x should crop, got {}", b.dims.0);
470        assert!(b.pads(), "y should pad: {:?}+{:?}", b.origin, b.dims);
471        for d in [b.dims.0, b.dims.1, b.dims.2] {
472            assert_eq!(next_fft_friendly_size(d), d, "{d} not FFT-friendly");
473        }
474        // Round trip still restores every voxel the box covers.
475        let n = full.0 * full.1 * full.2;
476        let data: Vec<f64> = (0..n).map(|i| i as f64).collect();
477        let back = uncrop_volume(&crop_volume(&data, &b), &b, -1.0);
478        // Wherever the box covered the grid, the value is unchanged; elsewhere it is the fill.
479        let mut restored = 0usize;
480        for (i, v) in back.iter().enumerate() {
481            if *v != -1.0 {
482                assert_eq!(*v, data[i], "voxel {i} altered by the round trip");
483                restored += 1;
484            }
485        }
486        assert!(restored > 0 && restored < n, "expected a partial cover, got {restored} of {n}");
487    }
488
489    #[test]
490    fn crop_then_uncrop_round_trips_inside_the_box() {
491        let full = (12, 10, 6);
492        let (nx, ny, _) = full;
493        let data: Vec<f64> = (0..12 * 10 * 6).map(|i| i as f64).collect();
494        let b = CropBox { origin: (2, 3, 1), dims: (6, 4, 3), full_dims: full };
495        assert!(!b.pads());
496
497        let cropped = crop_volume(&data, &b);
498        assert_eq!(cropped.len(), b.voxels());
499        // Spot-check the mapping.
500        for z in 0..b.dims.2 {
501            for y in 0..b.dims.1 {
502                for x in 0..b.dims.0 {
503                    let got = cropped[x + y * b.dims.0 + z * b.dims.0 * b.dims.1];
504                    let want = data[(x + 2) + (y + 3) * nx + (z + 1) * nx * ny];
505                    assert_eq!(got, want, "at ({x},{y},{z})");
506                }
507            }
508        }
509
510        let restored = uncrop_volume(&cropped, &b, 0.0);
511        assert_eq!(restored.len(), data.len());
512        for z in 0..full.2 {
513            for y in 0..full.1 {
514                for x in 0..full.0 {
515                    let i = x + y * nx + z * nx * ny;
516                    let inside = (2..8).contains(&x) && (3..7).contains(&y) && (1..4).contains(&z);
517                    assert_eq!(restored[i], if inside { data[i] } else { 0.0 }, "at ({x},{y},{z})");
518                }
519            }
520        }
521    }
522
523    #[test]
524    fn full_box_is_a_no_op() {
525        let full = (5, 4, 3);
526        let data: Vec<f64> = (0..60).map(|i| i as f64).collect();
527        let b = CropBox::full(full);
528        assert_eq!(crop_volume(&data, &b), data);
529        assert_eq!(uncrop_volume(&data, &b, 0.0), data);
530        assert_eq!(b.reduction(), 1.0);
531    }
532
533    #[test]
534    fn masks_crop_and_uncrop_too() {
535        let full = (8, 8, 4);
536        let mask = box_mask(full, (2, 2, 1), (5, 5, 2));
537        let b = CropBox { origin: (1, 1, 0), dims: (6, 6, 4), full_dims: full };
538        let c = crop_volume(&mask, &b);
539        assert_eq!(c.iter().filter(|v| **v == 1).count(), mask.iter().filter(|v| **v == 1).count());
540        let back = uncrop_volume(&c, &b, 0u8);
541        assert_eq!(back, mask);
542    }
543
544    #[test]
545    fn crop_affine_shifts_the_origin_only() {
546        // 2 mm isotropic, origin at (-10, -20, -30).
547        let affine = [
548            2.0, 0.0, 0.0, -10.0,
549            0.0, 2.0, 0.0, -20.0,
550            0.0, 0.0, 2.0, -30.0,
551            0.0, 0.0, 0.0, 1.0,
552        ];
553        let b = CropBox { origin: (3, 4, 5), dims: (4, 4, 4), full_dims: (16, 16, 16) };
554        let out = b.crop_affine(&affine);
555        // Rotation/scale untouched.
556        for i in [0, 1, 2, 4, 5, 6, 8, 9, 10] {
557            assert_eq!(out[i], affine[i]);
558        }
559        // The box corner keeps its world position: -10 + 2*3, -20 + 2*4, -30 + 2*5.
560        assert_eq!((out[3], out[7], out[11]), (-4.0, -12.0, -20.0));
561    }
562}