Skip to main content

scirs2_ndimage/
slice_processing.rs

1//! Multi-Planar Reconstruction and Volume Rendering
2//!
3//! This module provides functions for extracting 2D slices from 3D volumetric
4//! data at arbitrary orientations (axial, coronal, sagittal, and oblique), plus
5//! intensity projection techniques (MIP and AIP) and a simple pre-integrated
6//! transfer function for direct volume rendering.
7//!
8//! # Coordinate Convention
9//!
10//! All arrays follow the (depth/z, height/y, width/x) axis convention used
11//! throughout `scirs2-ndimage`.  Indices in the slice extraction functions
12//! therefore correspond to:
13//!
14//! * **axial** – constant z (depth) slice → output shape `(height, width)`
15//! * **coronal** – constant y (height) slice → output shape `(depth, width)`
16//! * **sagittal** – constant x (width) slice → output shape `(depth, height)`
17//!
18//! # References
19//!
20//! - Engel, Hadwiger, Kniss & Rezk-Salama (2006), "Real-Time Volume Graphics",
21//!   AK Peters.
22//! - Levoy (1988), "Display of Surfaces from Volume Data", IEEE CG&A 8(3):29–37.
23
24use crate::error::{NdimageError, NdimageResult};
25use scirs2_core::ndarray::{Array2, Array3};
26
27// ---------------------------------------------------------------------------
28// Trilinear interpolation helper
29// ---------------------------------------------------------------------------
30
31/// Sample `volume` at a continuous position `(z, y, x)` using trilinear
32/// interpolation with clamp-to-edge border handling.
33fn trilinear_sample(volume: &Array3<f64>, z: f64, y: f64, x: f64) -> f64 {
34    let shape = volume.shape();
35    let (sz, sy, sx) = (shape[0] as f64, shape[1] as f64, shape[2] as f64);
36
37    // Clamp to valid range.
38    let z = z.clamp(0.0, sz - 1.0);
39    let y = y.clamp(0.0, sy - 1.0);
40    let x = x.clamp(0.0, sx - 1.0);
41
42    let z0 = z.floor() as usize;
43    let y0 = y.floor() as usize;
44    let x0 = x.floor() as usize;
45    let z1 = (z0 + 1).min(shape[0] - 1);
46    let y1 = (y0 + 1).min(shape[1] - 1);
47    let x1 = (x0 + 1).min(shape[2] - 1);
48
49    let tz = z - z.floor();
50    let ty = y - y.floor();
51    let tx = x - x.floor();
52
53    let v000 = volume[[z0, y0, x0]];
54    let v001 = volume[[z0, y0, x1]];
55    let v010 = volume[[z0, y1, x0]];
56    let v011 = volume[[z0, y1, x1]];
57    let v100 = volume[[z1, y0, x0]];
58    let v101 = volume[[z1, y0, x1]];
59    let v110 = volume[[z1, y1, x0]];
60    let v111 = volume[[z1, y1, x1]];
61
62    let c00 = v000 * (1.0 - tx) + v001 * tx;
63    let c01 = v010 * (1.0 - tx) + v011 * tx;
64    let c10 = v100 * (1.0 - tx) + v101 * tx;
65    let c11 = v110 * (1.0 - tx) + v111 * tx;
66
67    let c0 = c00 * (1.0 - ty) + c01 * ty;
68    let c1 = c10 * (1.0 - ty) + c11 * ty;
69
70    c0 * (1.0 - tz) + c1 * tz
71}
72
73// ---------------------------------------------------------------------------
74// Axial / Coronal / Sagittal reformats
75// ---------------------------------------------------------------------------
76
77/// Extract an axial (constant-z) slice from a 3D volume.
78///
79/// Returns a 2D array of shape `(height, width)`.
80///
81/// # Arguments
82///
83/// * `volume` – 3D array `(depth, height, width)`.
84/// * `slice_idx` – Depth index to extract (0 ≤ `slice_idx` < depth).
85///
86/// # Errors
87///
88/// Returns `NdimageError::InvalidInput` when `slice_idx` is out of range.
89pub fn reformat_axial(volume: &Array3<f64>, slice_idx: usize) -> NdimageResult<Array2<f64>> {
90    let shape = volume.shape();
91    let (sz, sy, sx) = (shape[0], shape[1], shape[2]);
92    if slice_idx >= sz {
93        return Err(NdimageError::InvalidInput(format!(
94            "slice_idx {slice_idx} is out of range (depth = {sz})"
95        )));
96    }
97    let mut out = Array2::zeros((sy, sx));
98    for iy in 0..sy {
99        for ix in 0..sx {
100            out[[iy, ix]] = volume[[slice_idx, iy, ix]];
101        }
102    }
103    Ok(out)
104}
105
106/// Extract a coronal (constant-y) slice from a 3D volume.
107///
108/// Returns a 2D array of shape `(depth, width)`.
109///
110/// # Arguments
111///
112/// * `volume` – 3D array `(depth, height, width)`.
113/// * `slice_idx` – Height index to extract (0 ≤ `slice_idx` < height).
114///
115/// # Errors
116///
117/// Returns `NdimageError::InvalidInput` when `slice_idx` is out of range.
118pub fn reformat_coronal(volume: &Array3<f64>, slice_idx: usize) -> NdimageResult<Array2<f64>> {
119    let shape = volume.shape();
120    let (sz, sy, sx) = (shape[0], shape[1], shape[2]);
121    if slice_idx >= sy {
122        return Err(NdimageError::InvalidInput(format!(
123            "slice_idx {slice_idx} is out of range (height = {sy})"
124        )));
125    }
126    let mut out = Array2::zeros((sz, sx));
127    for iz in 0..sz {
128        for ix in 0..sx {
129            out[[iz, ix]] = volume[[iz, slice_idx, ix]];
130        }
131    }
132    Ok(out)
133}
134
135/// Extract a sagittal (constant-x) slice from a 3D volume.
136///
137/// Returns a 2D array of shape `(depth, height)`.
138///
139/// # Arguments
140///
141/// * `volume` – 3D array `(depth, height, width)`.
142/// * `slice_idx` – Width index to extract (0 ≤ `slice_idx` < width).
143///
144/// # Errors
145///
146/// Returns `NdimageError::InvalidInput` when `slice_idx` is out of range.
147pub fn reformat_sagittal(volume: &Array3<f64>, slice_idx: usize) -> NdimageResult<Array2<f64>> {
148    let shape = volume.shape();
149    let (sz, sy, sx) = (shape[0], shape[1], shape[2]);
150    if slice_idx >= sx {
151        return Err(NdimageError::InvalidInput(format!(
152            "slice_idx {slice_idx} is out of range (width = {sx})"
153        )));
154    }
155    let mut out = Array2::zeros((sz, sy));
156    for iz in 0..sz {
157        for iy in 0..sy {
158            out[[iz, iy]] = volume[[iz, iy, slice_idx]];
159        }
160    }
161    Ok(out)
162}
163
164// ---------------------------------------------------------------------------
165// Oblique slice
166// ---------------------------------------------------------------------------
167
168/// Sample an oblique plane defined by a centre point and a normal vector.
169///
170/// Two orthogonal basis vectors `u` and `v` are constructed from the normal
171/// using a stable algorithm (Frisvad 2012).  The output image is of shape
172/// `(size, size)` and the sample grid is centred at `point` with step
173/// `spacing` in both `u` and `v` directions.  Trilinear interpolation is
174/// used to sub-voxel precision.
175///
176/// # Arguments
177///
178/// * `volume` – Source 3D volume `(depth, height, width)`.
179/// * `point` – Centre of the output plane in voxel coordinates `(z, y, x)`.
180/// * `normal` – Normal vector of the plane (need not be normalised).
181/// * `size` – Number of pixels along each axis of the output image.
182/// * `spacing` – Physical spacing between adjacent output pixels in voxels.
183///
184/// # Errors
185///
186/// * `NdimageError::InvalidInput` if `size` is 0 or `normal` is the zero
187///   vector.
188pub fn oblique_slice(
189    volume: &Array3<f64>,
190    point: (f64, f64, f64),
191    normal: (f64, f64, f64),
192    size: usize,
193    spacing: f64,
194) -> NdimageResult<Array2<f64>> {
195    if size == 0 {
196        return Err(NdimageError::InvalidInput(
197            "size must be greater than 0".to_string(),
198        ));
199    }
200    let (nz, ny, nx) = normal;
201    let len = (nz * nz + ny * ny + nx * nx).sqrt();
202    if len < 1e-12 {
203        return Err(NdimageError::InvalidInput(
204            "normal vector must be non-zero".to_string(),
205        ));
206    }
207    // Normalise.
208    let (nz, ny, nx) = (nz / len, ny / len, nx / len);
209
210    // Frisvad 2012 ONB construction — avoids singularity at (0,0,-1).
211    let (uz, uy, ux, vz, vy, vx) = if nz < -1.0 + 1e-9 {
212        // Special case: normal ≈ (0,0,-1).
213        (0.0_f64, -1.0_f64, 0.0_f64, -1.0_f64, 0.0_f64, 0.0_f64)
214    } else {
215        let a = 1.0 / (1.0 + nz);
216        let b = -nx * ny * a;
217        // u = (1 - nx²·a, b, -nx)
218        let ux = 1.0 - nx * nx * a;
219        let uy = b;
220        let uz = -nx;
221        // v = (b, 1 - ny²·a, -ny)
222        let vx = b;
223        let vy = 1.0 - ny * ny * a;
224        let vz = -ny;
225        (uz, uy, ux, vz, vy, vx)
226    };
227
228    let half = (size as f64 - 1.0) * 0.5;
229    let mut out = Array2::zeros((size, size));
230
231    for row in 0..size {
232        let sv = (row as f64 - half) * spacing;
233        for col in 0..size {
234            let su = (col as f64 - half) * spacing;
235            let z = point.0 + su * uz + sv * vz;
236            let y = point.1 + su * uy + sv * vy;
237            let x = point.2 + su * ux + sv * vx;
238            out[[row, col]] = trilinear_sample(volume, z, y, x);
239        }
240    }
241
242    Ok(out)
243}
244
245// ---------------------------------------------------------------------------
246// Intensity projections
247// ---------------------------------------------------------------------------
248
249/// Compute the Maximum Intensity Projection (MIP) along a given axis.
250///
251/// For each ray parallel to `axis`, the output pixel is the maximum value
252/// encountered.
253///
254/// # Arguments
255///
256/// * `volume` – 3D array `(depth, height, width)`.
257/// * `axis` – Projection axis: 0 = depth, 1 = height, 2 = width.
258///
259/// # Errors
260///
261/// Returns `NdimageError::InvalidInput` for an unknown axis or empty volume.
262pub fn maximum_intensity_projection(
263    volume: &Array3<f64>,
264    axis: usize,
265) -> NdimageResult<Array2<f64>> {
266    let shape = volume.shape();
267    let (sz, sy, sx) = (shape[0], shape[1], shape[2]);
268    if sz == 0 || sy == 0 || sx == 0 {
269        return Err(NdimageError::InvalidInput(
270            "Volume must be non-empty".to_string(),
271        ));
272    }
273    match axis {
274        0 => {
275            // Project along z → output (y, x)
276            let mut out = Array2::from_elem((sy, sx), f64::NEG_INFINITY);
277            for iz in 0..sz {
278                for iy in 0..sy {
279                    for ix in 0..sx {
280                        let v = volume[[iz, iy, ix]];
281                        if v > out[[iy, ix]] {
282                            out[[iy, ix]] = v;
283                        }
284                    }
285                }
286            }
287            Ok(out)
288        }
289        1 => {
290            // Project along y → output (z, x)
291            let mut out = Array2::from_elem((sz, sx), f64::NEG_INFINITY);
292            for iz in 0..sz {
293                for iy in 0..sy {
294                    for ix in 0..sx {
295                        let v = volume[[iz, iy, ix]];
296                        if v > out[[iz, ix]] {
297                            out[[iz, ix]] = v;
298                        }
299                    }
300                }
301            }
302            Ok(out)
303        }
304        2 => {
305            // Project along x → output (z, y)
306            let mut out = Array2::from_elem((sz, sy), f64::NEG_INFINITY);
307            for iz in 0..sz {
308                for iy in 0..sy {
309                    for ix in 0..sx {
310                        let v = volume[[iz, iy, ix]];
311                        if v > out[[iz, iy]] {
312                            out[[iz, iy]] = v;
313                        }
314                    }
315                }
316            }
317            Ok(out)
318        }
319        other => Err(NdimageError::InvalidInput(format!(
320            "axis {other} is out of range; must be 0, 1, or 2"
321        ))),
322    }
323}
324
325/// Compute the Average Intensity Projection (AIP) along a given axis.
326///
327/// For each ray parallel to `axis`, the output pixel is the arithmetic mean
328/// of all values along the ray.
329///
330/// # Arguments
331///
332/// * `volume` – 3D array `(depth, height, width)`.
333/// * `axis` – Projection axis: 0 = depth, 1 = height, 2 = width.
334///
335/// # Errors
336///
337/// Returns `NdimageError::InvalidInput` for an unknown axis or empty volume.
338pub fn average_intensity_projection(
339    volume: &Array3<f64>,
340    axis: usize,
341) -> NdimageResult<Array2<f64>> {
342    let shape = volume.shape();
343    let (sz, sy, sx) = (shape[0], shape[1], shape[2]);
344    if sz == 0 || sy == 0 || sx == 0 {
345        return Err(NdimageError::InvalidInput(
346            "Volume must be non-empty".to_string(),
347        ));
348    }
349    match axis {
350        0 => {
351            let mut out = Array2::zeros((sy, sx));
352            for iz in 0..sz {
353                for iy in 0..sy {
354                    for ix in 0..sx {
355                        out[[iy, ix]] += volume[[iz, iy, ix]];
356                    }
357                }
358            }
359            out.mapv_inplace(|v| v / sz as f64);
360            Ok(out)
361        }
362        1 => {
363            let mut out = Array2::zeros((sz, sx));
364            for iz in 0..sz {
365                for iy in 0..sy {
366                    for ix in 0..sx {
367                        out[[iz, ix]] += volume[[iz, iy, ix]];
368                    }
369                }
370            }
371            out.mapv_inplace(|v| v / sy as f64);
372            Ok(out)
373        }
374        2 => {
375            let mut out = Array2::zeros((sz, sy));
376            for iz in 0..sz {
377                for iy in 0..sy {
378                    for ix in 0..sx {
379                        out[[iz, iy]] += volume[[iz, iy, ix]];
380                    }
381                }
382            }
383            out.mapv_inplace(|v| v / sx as f64);
384            Ok(out)
385        }
386        other => Err(NdimageError::InvalidInput(format!(
387            "axis {other} is out of range; must be 0, 1, or 2"
388        ))),
389    }
390}
391
392// ---------------------------------------------------------------------------
393// Volume rendering transfer function
394// ---------------------------------------------------------------------------
395
396/// A simple pre-integrated transfer function for direct volume rendering.
397///
398/// Maps scalar density values to RGBA colour.  The mapping is designed to
399/// mimic a typical medical volume rendering preset:
400///
401/// | Density range | Colour              | Interpretation         |
402/// |---------------|---------------------|------------------------|
403/// | 0–0.1        | transparent black   | air / background       |
404/// | 0.1–0.3      | semi-transparent blue | soft tissue          |
405/// | 0.3–0.6      | semi-opaque orange  | dense soft tissue      |
406/// | 0.6–0.9      | opaque white-yellow | bone                   |
407/// | 0.9–1.0      | fully opaque white  | very dense material    |
408///
409/// Density values outside `[0, 1]` are clamped to the nearest boundary.
410///
411/// # Returns
412///
413/// A tuple `(R, G, B, A)` where each component is in `[0, 1]`.
414pub fn volume_rendering_transfer_function(density: f64) -> (f64, f64, f64, f64) {
415    let d = density.clamp(0.0, 1.0);
416
417    // Piecewise linear segments over density.
418    if d < 0.1 {
419        // Air: fully transparent.
420        let t = d / 0.1;
421        (0.0, 0.0, t * 0.1, t * 0.05)
422    } else if d < 0.3 {
423        // Soft tissue: blue-ish, semi-transparent.
424        let t = (d - 0.1) / 0.2;
425        let alpha = 0.05 + t * 0.25; // 0.05 → 0.30
426        let r = 0.1 * t;
427        let g = 0.2 * t;
428        let b = 0.5 + t * 0.3; // 0.5 → 0.8
429        (r, g, b, alpha)
430    } else if d < 0.6 {
431        // Dense soft tissue: transitions from blue to orange.
432        let t = (d - 0.3) / 0.3;
433        let alpha = 0.30 + t * 0.40; // 0.30 → 0.70
434        let r = 0.1 + t * 0.8; // 0.1 → 0.9
435        let g = 0.2 + t * 0.4; // 0.2 → 0.6
436        let b = 0.8 - t * 0.7; // 0.8 → 0.1
437        (r, g, b, alpha)
438    } else if d < 0.9 {
439        // Bone: opaque white-yellow.
440        let t = (d - 0.6) / 0.3;
441        let alpha = 0.70 + t * 0.25; // 0.70 → 0.95
442        let r = 0.9 + t * 0.1; // 0.9 → 1.0
443        let g = 0.6 + t * 0.35; // 0.6 → 0.95
444        let b = 0.1 + t * 0.85; // 0.1 → 0.95
445        (r, g, b, alpha)
446    } else {
447        // Very dense: fully opaque white.
448        let t = (d - 0.9) / 0.1;
449        let alpha = 0.95 + t * 0.05;
450        (1.0, 0.95 + t * 0.05, 0.95 + t * 0.05, alpha)
451    }
452}
453
454// ---------------------------------------------------------------------------
455// Tests
456// ---------------------------------------------------------------------------
457
458#[cfg(test)]
459mod tests {
460    use super::*;
461    use scirs2_core::ndarray::Array3;
462
463    fn small_vol() -> Array3<f64> {
464        // 4 × 3 × 5 volume filled with voxel index as value.
465        Array3::from_shape_fn((4, 3, 5), |(z, y, x)| (z * 100 + y * 10 + x) as f64)
466    }
467
468    #[test]
469    fn test_reformat_axial_shape() {
470        let vol = small_vol();
471        let slice = reformat_axial(&vol, 2).expect("reformat_axial failed");
472        assert_eq!(slice.shape(), &[3, 5]);
473        // Check a known value: z=2, y=1, x=3 → 213.
474        assert_eq!(slice[[1, 3]], 213.0);
475    }
476
477    #[test]
478    fn test_reformat_axial_oob() {
479        let vol = small_vol();
480        assert!(reformat_axial(&vol, 4).is_err());
481    }
482
483    #[test]
484    fn test_reformat_coronal_shape() {
485        let vol = small_vol();
486        let slice = reformat_coronal(&vol, 1).expect("reformat_coronal failed");
487        assert_eq!(slice.shape(), &[4, 5]);
488        // z=0, y=1 → 10 + x; at x=2 → 12.
489        assert_eq!(slice[[0, 2]], 12.0);
490    }
491
492    #[test]
493    fn test_reformat_sagittal_shape() {
494        let vol = small_vol();
495        let slice = reformat_sagittal(&vol, 3).expect("reformat_sagittal failed");
496        assert_eq!(slice.shape(), &[4, 3]);
497        // z=1, y=2, x=3 → 123.
498        assert_eq!(slice[[1, 2]], 123.0);
499    }
500
501    #[test]
502    fn test_oblique_slice_axial_equivalent() {
503        // An oblique slice with normal (1,0,0) through the centre of a uniform
504        // volume should produce a constant-value image.
505        let vol = Array3::from_elem((8, 8, 8), 42.0_f64);
506        let centre = (4.0, 4.0, 4.0);
507        let normal = (1.0, 0.0, 0.0);
508        let slice = oblique_slice(&vol, centre, normal, 4, 1.0).expect("oblique_slice failed");
509        for v in slice.iter() {
510            assert!((*v - 42.0).abs() < 1e-9, "Expected 42.0 got {v}");
511        }
512    }
513
514    #[test]
515    fn test_mip_axis0_shape() {
516        let vol = small_vol(); // 4×3×5
517        let mip = maximum_intensity_projection(&vol, 0).expect("MIP failed");
518        assert_eq!(mip.shape(), &[3, 5]);
519        // Maximum along z for (y=0, x=0) should be z=3 → 300.
520        assert_eq!(mip[[0, 0]], 300.0);
521    }
522
523    #[test]
524    fn test_aip_axis1_shape() {
525        let vol = small_vol(); // 4×3×5
526        let aip = average_intensity_projection(&vol, 1).expect("AIP failed");
527        assert_eq!(aip.shape(), &[4, 5]);
528    }
529
530    #[test]
531    fn test_mip_invalid_axis() {
532        let vol = small_vol();
533        assert!(maximum_intensity_projection(&vol, 3).is_err());
534    }
535
536    #[test]
537    fn test_transfer_function_ranges() {
538        // All outputs must be in [0,1].
539        for i in 0..=100 {
540            let d = i as f64 / 100.0;
541            let (r, g, b, a) = volume_rendering_transfer_function(d);
542            for &c in &[r, g, b, a] {
543                assert!(
544                    (0.0..=1.0).contains(&c),
545                    "component {c} out of [0,1] at density {d}"
546                );
547            }
548        }
549        // Boundary clamping.
550        let (_, _, _, a_neg) = volume_rendering_transfer_function(-1.0);
551        let (_, _, _, a_over) = volume_rendering_transfer_function(2.0);
552        assert!(a_neg.is_finite());
553        assert!((a_over - 1.0).abs() < 1e-9);
554    }
555
556    #[test]
557    fn test_trilinear_sample_exact() {
558        let vol = small_vol();
559        // Exact integer position should equal the array value.
560        for z in 0_usize..4 {
561            for y in 0_usize..3 {
562                for x in 0_usize..5 {
563                    let expected = vol[[z, y, x]];
564                    let got = trilinear_sample(&vol, z as f64, y as f64, x as f64);
565                    assert!(
566                        (got - expected).abs() < 1e-9,
567                        "trilinear mismatch at ({z},{y},{x}): expected {expected}, got {got}"
568                    );
569                }
570            }
571        }
572    }
573}