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}