Skip to main content

qsm_core/
grid.rs

1//! Lightweight 3D volume grid descriptor.
2//!
3//! Contains only the geometric information needed by algorithm kernels:
4//! dimensions and voxel sizes. This eliminates the need to pass 6 separate
5//! parameters (nx, ny, nz, vsx, vsy, vsz) to every function.
6
7/// A 3D volume grid with dimensions and voxel sizes.
8#[derive(Clone, Copy, Debug, PartialEq)]
9pub struct Grid {
10    /// Volume dimensions (nx, ny, nz)
11    pub dims: (usize, usize, usize),
12    /// Voxel sizes in mm (vsx, vsy, vsz)
13    pub voxel_size: (f64, f64, f64),
14}
15
16impl Grid {
17    /// Create a new Grid from dimensions and voxel sizes.
18    #[inline]
19    pub fn new(nx: usize, ny: usize, nz: usize, vsx: f64, vsy: f64, vsz: f64) -> Self {
20        Self {
21            dims: (nx, ny, nz),
22            voxel_size: (vsx, vsy, vsz),
23        }
24    }
25
26    #[inline]
27    pub fn nx(&self) -> usize { self.dims.0 }
28    #[inline]
29    pub fn ny(&self) -> usize { self.dims.1 }
30    #[inline]
31    pub fn nz(&self) -> usize { self.dims.2 }
32    #[inline]
33    pub fn vsx(&self) -> f64 { self.voxel_size.0 }
34    #[inline]
35    pub fn vsy(&self) -> f64 { self.voxel_size.1 }
36    #[inline]
37    pub fn vsz(&self) -> f64 { self.voxel_size.2 }
38
39    /// Total number of voxels.
40    #[inline]
41    pub fn n_total(&self) -> usize {
42        self.dims.0 * self.dims.1 * self.dims.2
43    }
44
45    /// Spacing along one axis, in mm. `axis` is 0 = x, 1 = y, 2 = z.
46    ///
47    /// # Panics
48    /// If `axis` is not 0, 1 or 2.
49    #[inline]
50    pub fn spacing(&self, axis: usize) -> f64 {
51        match axis {
52            0 => self.voxel_size.0,
53            1 => self.voxel_size.1,
54            2 => self.voxel_size.2,
55            _ => panic!("axis must be 0, 1 or 2 (got {axis})"),
56        }
57    }
58
59    /// Number of samples along one axis. `axis` is 0 = x, 1 = y, 2 = z.
60    ///
61    /// # Panics
62    /// If `axis` is not 0, 1 or 2.
63    #[inline]
64    pub fn extent(&self, axis: usize) -> usize {
65        match axis {
66            0 => self.dims.0,
67            1 => self.dims.1,
68            2 => self.dims.2,
69            _ => panic!("axis must be 0, 1 or 2 (got {axis})"),
70        }
71    }
72
73    /// Check contiguity against slice geometry the acquisition may not have recorded.
74    ///
75    /// `None` means the acquisition did not say how thick its slices are, and a gap then
76    /// **cannot be detected** — nothing in a NIfTI distinguishes 3 mm slices every 3 mm from
77    /// 2 mm slices every 3 mm, because the spacing is the *pitch*. This returns `Ok` in that
78    /// case, deliberately and in one place rather than at each call site: refusing every
79    /// acquisition that failed to record a recommended BIDS field would block the vast
80    /// majority of data, which has no gap. The missing information is in the input, not in
81    /// this crate, and the right response is for the host to say so once.
82    ///
83    /// The name says `if_known` so no call site can read as though a gap had been ruled out.
84    /// Where the thickness *is* known, this is [`Grid::require_contiguous_slices`].
85    pub fn require_contiguous_slices_if_known(
86        &self,
87        slices: Option<SliceGeometry>,
88    ) -> Result<(), SliceGapError> {
89        match slices {
90            Some(s) => self.require_contiguous_slices(s.thickness, s.axis),
91            None => Ok(()),
92        }
93    }
94
95    /// Check that the volume is contiguous along the slice axis, as the FFT dipole kernel
96    /// requires.
97    ///
98    /// A 2D multi-slice acquisition often leaves a gap between slices: the excited slab is
99    /// thinner than the slice-to-slice pitch, so the sampled volume has holes in it. A
100    /// [`Grid`] cannot express that — its `voxel_size` is the pitch, and nothing records the
101    /// thickness — so the gap has to be supplied, normally from the acquisition's
102    /// `SliceThickness` sidecar field.
103    ///
104    /// It matters because the dipole kernel, and the spherical kernels of the SHARP family,
105    /// are evaluated as FFTs on a uniform grid. That is not an approximation that degrades
106    /// with the gap; the convolution being computed is simply not the one the physics calls
107    /// for, and the resulting map is wrong rather than noisy — wrong in a way that still
108    /// looks like a susceptibility map. So this refuses rather than warns.
109    ///
110    /// In-plane work is unaffected: a gap along z does not disturb a purely in-plane kernel,
111    /// so slice-wise unwrapping is valid on gapped data and deliberately does not call this.
112    /// Anything that convolves along the slice axis has to.
113    ///
114    /// # Arguments
115    /// * `slice_thickness` - Excited slice thickness in mm. For a contiguous acquisition this
116    ///   equals the slice spacing, which is what a 3D acquisition should pass.
117    /// * `slice_axis` - Axis the slices are stacked along: 0 = x, 1 = y, 2 = z.
118    ///
119    /// # Panics
120    /// If `slice_axis` is not 0, 1 or 2.
121    pub fn require_contiguous_slices(
122        &self,
123        slice_thickness: f64,
124        slice_axis: usize,
125    ) -> Result<(), SliceGapError> {
126        let pitch = self.spacing(slice_axis);
127        if !slice_thickness.is_finite() || slice_thickness <= 0.0 {
128            return Err(SliceGapError { pitch, thickness: slice_thickness, axis: slice_axis });
129        }
130        // 1% of the pitch, so a thickness read back from a sidecar that rounded does not trip
131        // it, while any gap worth the name does
132        if slice_thickness < pitch * 0.99 {
133            return Err(SliceGapError { pitch, thickness: slice_thickness, axis: slice_axis });
134        }
135        Ok(())
136    }
137}
138
139/// How a 2D multi-slice acquisition laid its slices out, when that is recorded.
140///
141/// Only the thickness is missing from a [`Grid`]: the grid's spacing along the slice axis is
142/// already the slice-to-slice *pitch*. The two together say whether the sampled volume has
143/// holes in it. Carried as a unit because a thickness without an axis says nothing.
144#[cfg_attr(feature = "introspection", derive(serde::Serialize))]
145#[derive(Clone, Copy, Debug, PartialEq)]
146pub struct SliceGeometry {
147    /// Excited slice thickness in mm, from the acquisition's `SliceThickness`.
148    pub thickness: f64,
149    /// Axis the slices are stacked along: 0 = x, 1 = y, 2 = z.
150    pub axis: usize,
151}
152
153impl SliceGeometry {
154    /// Slice geometry for an acquisition known to be contiguous: thickness equals the grid's
155    /// spacing along `axis`.
156    ///
157    /// Only correct when the acquisition really has no gap. If it might, read `SliceThickness`
158    /// from the sidecar instead — this constructor cannot discover a gap, by construction.
159    pub fn contiguous(grid: &Grid, axis: usize) -> Self {
160        Self { thickness: grid.spacing(axis), axis }
161    }
162}
163
164/// The volume is not contiguous along the slice axis: the slices are thinner than the gap
165/// between their centres, so there is unsampled tissue in between.
166///
167/// Returned by [`Grid::require_contiguous_slices`]. See that method for why this is refused
168/// rather than approximated.
169///
170/// # Why a `Result` and not a panic
171///
172/// The crate uses both, and the split is by *whose* mistake it is. A length that disagrees with
173/// the grid is the caller's — `rss_combine` and the bias correction panic on it, and so do the
174/// entry points in [`crate::unwrap::slicewise`] — and the only useful audience is a developer
175/// reading a backtrace. A slice gap is the *acquisition's*: the
176/// call is correct, the data simply cannot yield a right answer, and the audience is the person
177/// who scanned it. Only the second can be surfaced to a user, so only the second is a `Result`.
178///
179/// One case sits across the line and is worth naming because it looks like a precondition:
180/// a thickness that is zero, negative or not finite is refused here rather than asserted, since
181/// it arrives from a sidecar rather than from code. A host that mis-parses `SliceThickness`
182/// should be told its data is unusable, not handed a panic.
183#[derive(Clone, Copy, Debug, PartialEq)]
184pub struct SliceGapError {
185    /// Slice-to-slice pitch in mm, taken from the grid.
186    pub pitch: f64,
187    /// Excited slice thickness in mm, as supplied by the caller.
188    pub thickness: f64,
189    /// Axis the slices are stacked along.
190    pub axis: usize,
191}
192
193impl SliceGapError {
194    /// Size of the unsampled gap between consecutive slices, in mm.
195    pub fn gap(&self) -> f64 {
196        self.pitch - self.thickness
197    }
198}
199
200impl std::fmt::Display for SliceGapError {
201    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
202        if !self.thickness.is_finite() || self.thickness <= 0.0 {
203            return write!(
204                f,
205                "slice thickness must be a positive number of mm (got {})",
206                self.thickness
207            );
208        }
209        write!(
210            f,
211            "slices along axis {} are {:.4} mm thick but {:.4} mm apart, leaving a {:.4} mm gap: \
212the volume is not contiguous, and the FFT dipole and spherical-mean kernels are defined on a \
213uniform grid, so any field or susceptibility computed from it would be wrong rather than \
214approximate",
215            self.axis, self.thickness, self.pitch, self.gap()
216        )
217    }
218}
219
220impl std::error::Error for SliceGapError {}
221
222/// How the slices of a volume sit inside it, for code that works one slice at a time.
223///
224/// A 2D multi-slice acquisition is processed slice by slice, and an operation that does so
225/// — [`unwrap_slicewise`](crate::unwrap::unwrap_slicewise) — wants two things: the strides
226/// that pick a slice out of the volume, and a [`Grid`] describing one slice. Collapsing the
227/// slice axis to a length of 1 rather than writing a 2D variant of each kernel is what keeps
228/// that honest: the existing 3D code runs unmodified and its slice-axis neighbours simply
229/// fall outside the volume, so no unwrapper had to be touched to gain the mode.
230#[derive(Clone, Copy, Debug, PartialEq)]
231pub(crate) struct SliceLayout {
232    /// In-slice dimensions, in the order the extracted buffer uses.
233    pub d0: usize,
234    pub d1: usize,
235    /// Number of slices along the slice axis.
236    pub n_slices: usize,
237    /// Stride between successive slices, in voxels.
238    slice_stride: usize,
239    /// Strides of the two in-slice axes, in voxels.
240    stride0: usize,
241    stride1: usize,
242    /// Grid describing a single slice, with the slice axis collapsed to 1.
243    pub grid: Grid,
244}
245
246impl SliceLayout {
247    /// # Panics
248    /// If `axis` is not 0, 1 or 2.
249    pub fn new(grid: &Grid, axis: usize) -> Self {
250        let (nx, ny, nz) = grid.dims;
251        let (vsx, vsy, vsz) = grid.voxel_size;
252        let (nxy, sy, sz) = (nx * ny, nx, nx * ny);
253        match axis {
254            0 => Self {
255                d0: ny, d1: nz, n_slices: nx, slice_stride: 1, stride0: sy, stride1: sz,
256                grid: Grid::new(ny, nz, 1, vsy, vsz, vsx),
257            },
258            1 => Self {
259                d0: nx, d1: nz, n_slices: ny, slice_stride: sy, stride0: 1, stride1: sz,
260                grid: Grid::new(nx, nz, 1, vsx, vsz, vsy),
261            },
262            2 => Self {
263                d0: nx, d1: ny, n_slices: nz, slice_stride: nxy, stride0: 1, stride1: sy,
264                grid: Grid::new(nx, ny, 1, vsx, vsy, vsz),
265            },
266            _ => panic!("slice_axis must be 0, 1 or 2 (got {axis})"),
267        }
268    }
269
270    /// Number of voxels in one slice.
271    #[inline]
272    pub fn n_in_slice(&self) -> usize {
273        self.d0 * self.d1
274    }
275
276    /// Volume index of in-slice position `p` on slice `s`.
277    #[inline]
278    pub fn index(&self, s: usize, p: usize) -> usize {
279        let a = p % self.d0;
280        let b = p / self.d0;
281        s * self.slice_stride + a * self.stride0 + b * self.stride1
282    }
283
284    /// Copy slice `s` of `volume` into `out`. An empty `volume` leaves `out` alone, so
285    /// callers can pass an optional input through without branching.
286    pub fn gather<T: Copy>(&self, volume: &[T], s: usize, out: &mut [T]) {
287        if volume.is_empty() {
288            return;
289        }
290        for (p, o) in out.iter_mut().enumerate() {
291            *o = volume[self.index(s, p)];
292        }
293    }
294
295    /// Write `values` back into slice `s` of `volume`.
296    pub fn scatter<T: Copy>(&self, volume: &mut [T], s: usize, values: &[T]) {
297        for (p, &v) in values.iter().enumerate() {
298            volume[self.index(s, p)] = v;
299        }
300    }
301}
302
303#[cfg(test)]
304mod tests {
305    use super::*;
306
307    #[test]
308    fn test_grid_basic() {
309        let g = Grid::new(64, 64, 32, 1.0, 1.0, 2.0);
310        assert_eq!(g.nx(), 64);
311        assert_eq!(g.ny(), 64);
312        assert_eq!(g.nz(), 32);
313        assert_eq!(g.n_total(), 64 * 64 * 32);
314        assert_eq!(g.vsx(), 1.0);
315        assert_eq!(g.vsz(), 2.0);
316    }
317
318    #[test]
319    fn spacing_and_extent_follow_the_axis() {
320        let g = Grid::new(5, 7, 3, 1.0, 2.0, 4.0);
321        assert_eq!((g.extent(0), g.extent(1), g.extent(2)), (5, 7, 3));
322        assert_eq!((g.spacing(0), g.spacing(1), g.spacing(2)), (1.0, 2.0, 4.0));
323    }
324
325    #[test]
326    #[should_panic(expected = "axis must be 0, 1 or 2")]
327    fn spacing_rejects_a_bad_axis() {
328        Grid::new(2, 2, 2, 1.0, 1.0, 1.0).spacing(3);
329    }
330
331    #[test]
332    #[should_panic(expected = "axis must be 0, 1 or 2")]
333    fn extent_rejects_a_bad_axis() {
334        Grid::new(2, 2, 2, 1.0, 1.0, 1.0).extent(3);
335    }
336
337    #[test]
338    fn contiguous_slices_are_accepted_and_gapped_ones_refused() {
339        let g = Grid::new(8, 8, 4, 1.0, 1.0, 3.0);
340
341        // thickness == pitch: contiguous
342        assert!(g.require_contiguous_slices(3.0, 2).is_ok());
343        // and the slack absorbs a sidecar that rounded, but not a real gap
344        assert!(g.require_contiguous_slices(2.99, 2).is_ok());
345        assert!(g.require_contiguous_slices(2.9, 2).is_err());
346
347        let err = g.require_contiguous_slices(2.0, 2).unwrap_err();
348        assert_eq!(err.gap(), 1.0);
349        assert_eq!((err.pitch, err.thickness, err.axis), (3.0, 2.0, 2));
350        assert!(err.to_string().contains("not contiguous"));
351
352        // thicker than the pitch is overlap, not a gap, and is not this check's business
353        assert!(g.require_contiguous_slices(4.0, 2).is_ok());
354
355        // the axis is honoured: 1 mm slices along x are contiguous on the same grid
356        assert!(g.require_contiguous_slices(1.0, 0).is_ok());
357        assert!(g.require_contiguous_slices(0.5, 0).is_err());
358    }
359
360    #[test]
361    fn unrecorded_slice_geometry_cannot_refuse_and_does_not_pretend_to() {
362        // Nothing in a grid distinguishes a gapped acquisition from a contiguous one, so with
363        // no thickness there is nothing to check. The name of the method is what keeps a call
364        // site from reading as though a gap had been ruled out.
365        let g = Grid::new(8, 8, 4, 1.0, 1.0, 3.0);
366        assert!(g.require_contiguous_slices_if_known(None).is_ok());
367        assert!(g
368            .require_contiguous_slices_if_known(Some(SliceGeometry { thickness: 3.0, axis: 2 }))
369            .is_ok());
370        let err = g
371            .require_contiguous_slices_if_known(Some(SliceGeometry { thickness: 2.0, axis: 2 }))
372            .unwrap_err();
373        assert_eq!(err.gap(), 1.0);
374    }
375
376    #[test]
377    fn slice_geometry_contiguous_reads_the_grid_spacing() {
378        let g = Grid::new(8, 8, 4, 1.0, 2.0, 3.0);
379        assert_eq!(SliceGeometry::contiguous(&g, 2), SliceGeometry { thickness: 3.0, axis: 2 });
380        assert_eq!(SliceGeometry::contiguous(&g, 1), SliceGeometry { thickness: 2.0, axis: 1 });
381        // and it always passes its own check, which is the point and the limitation
382        for axis in 0..3 {
383            assert!(g
384                .require_contiguous_slices_if_known(Some(SliceGeometry::contiguous(&g, axis)))
385                .is_ok());
386        }
387    }
388
389    #[test]
390    fn a_nonsense_slice_thickness_is_refused_and_says_so() {
391        let g = Grid::new(8, 8, 4, 1.0, 1.0, 3.0);
392        for bad in [0.0, -1.0, f64::NAN, f64::INFINITY] {
393            let err = g.require_contiguous_slices(bad, 2).unwrap_err();
394            assert!(
395                err.to_string().contains("must be a positive number"),
396                "thickness {bad} gave: {err}"
397            );
398        }
399    }
400
401    #[test]
402    fn slice_layout_visits_every_voxel_exactly_once() {
403        let g = Grid::new(5, 7, 3, 1.0, 2.0, 3.0);
404        for axis in 0..3 {
405            let layout = SliceLayout::new(&g, axis);
406            assert_eq!(layout.n_slices * layout.n_in_slice(), g.n_total(), "axis {axis}");
407            let mut seen = vec![0u32; g.n_total()];
408            for s in 0..layout.n_slices {
409                for p in 0..layout.n_in_slice() {
410                    seen[layout.index(s, p)] += 1;
411                }
412            }
413            assert!(seen.iter().all(|&c| c == 1), "axis {axis} does not hit each voxel once");
414        }
415    }
416
417    #[test]
418    fn slice_layout_grid_keeps_the_two_in_plane_axes() {
419        let g = Grid::new(5, 7, 3, 1.0, 2.0, 3.0);
420        assert_eq!(SliceLayout::new(&g, 0).grid, Grid::new(7, 3, 1, 2.0, 3.0, 1.0));
421        assert_eq!(SliceLayout::new(&g, 1).grid, Grid::new(5, 3, 1, 1.0, 3.0, 2.0));
422        assert_eq!(SliceLayout::new(&g, 2).grid, Grid::new(5, 7, 1, 1.0, 2.0, 3.0));
423    }
424
425    #[test]
426    fn slice_layout_buffer_order_matches_the_slice_grid() {
427        // The gathered buffer is handed straight to kernels that read it through
428        // `layout.grid`, so its two in-slice extents have to be that grid's own, in that
429        // order. Transposing them is a bijection over the volume and leaves gather/scatter
430        // round-tripping, so nothing else here would notice.
431        let g = Grid::new(5, 7, 3, 1.0, 2.0, 3.0);
432        for axis in 0..3 {
433            let layout = SliceLayout::new(&g, axis);
434            assert_eq!(
435                (layout.d0, layout.d1),
436                (layout.grid.nx(), layout.grid.ny()),
437                "axis {axis}: buffer is {}x{} but its grid says {}x{}",
438                layout.d0, layout.d1, layout.grid.nx(), layout.grid.ny()
439            );
440            // and stepping one along the buffer steps one along that grid's first axis
441            assert_eq!(layout.index(0, 1) - layout.index(0, 0), match axis {
442                0 => 5,            // x stride of the y axis
443                1 => 1,            // x stride of the x axis
444                _ => 1,
445            });
446        }
447    }
448
449    #[test]
450    fn slice_layout_gather_and_scatter_round_trip() {
451        let g = Grid::new(4, 5, 3, 1.0, 1.0, 1.0);
452        let volume: Vec<f64> = (0..g.n_total()).map(|i| i as f64 * 0.25).collect();
453        for axis in 0..3 {
454            let layout = SliceLayout::new(&g, axis);
455            let mut buf = vec![0.0; layout.n_in_slice()];
456            let mut out = vec![-1.0; g.n_total()];
457            for s in 0..layout.n_slices {
458                layout.gather(&volume, s, &mut buf);
459                layout.scatter(&mut out, s, &buf);
460            }
461            assert_eq!(out, volume, "axis {axis}");
462        }
463    }
464
465    #[test]
466    fn slice_layout_gather_of_an_empty_volume_leaves_the_buffer_alone() {
467        let layout = SliceLayout::new(&Grid::new(4, 4, 2, 1.0, 1.0, 1.0), 2);
468        let mut buf = vec![7.0; layout.n_in_slice()];
469        layout.gather(&[] as &[f64], 0, &mut buf);
470        assert!(buf.iter().all(|&v| v == 7.0));
471    }
472
473    #[test]
474    #[should_panic(expected = "slice_axis must be 0, 1 or 2")]
475    fn slice_layout_rejects_a_bad_axis() {
476        SliceLayout::new(&Grid::new(2, 2, 2, 1.0, 1.0, 1.0), 3);
477    }
478
479    #[test]
480    fn test_grid_copy() {
481        let g = Grid::new(10, 20, 30, 0.5, 0.5, 1.0);
482        let g2 = g;
483        assert_eq!(g, g2);
484    }
485}