Skip to main content

dicomview_core/
volume_assembly.rs

1//! Volume construction from decoded DICOM frames.
2
3use crate::dicom_decode::DecodedFrame;
4use crate::metadata::{FrameMetadata, VolumeGeometry};
5use glam::{DMat3, DVec3, UVec3};
6use std::cmp::Ordering;
7use thiserror::Error;
8use volren_core::{Volume, VolumeError};
9
10/// Errors raised while assembling a 3D volume from decoded frames.
11#[derive(Debug, Error)]
12pub enum VolumeAssemblyError {
13    /// No decoded frames were supplied.
14    #[error("no decoded frames were provided")]
15    EmptyFrames,
16    /// A frame has incompatible geometry for the target volume.
17    #[error("frame {frame_index} has inconsistent geometry: {reason}")]
18    InconsistentGeometry {
19        /// Index of the inconsistent frame.
20        frame_index: usize,
21        /// Human-readable mismatch description.
22        reason: String,
23    },
24    /// One frame contains the wrong number of grayscale voxels.
25    #[error("frame {frame_index} has {actual} voxels, expected {expected}")]
26    PixelCountMismatch {
27        /// Zero-based frame index.
28        frame_index: usize,
29        /// Expected pixel count.
30        expected: usize,
31        /// Actual decoded pixel count.
32        actual: usize,
33    },
34    /// The final volume buffer was invalid.
35    #[error(transparent)]
36    Volume(#[from] VolumeError),
37}
38
39/// Assembles a grayscale `Volume<i16>` from decoded DICOM frames.
40pub fn assemble_volume_from_frames(
41    frames: &[DecodedFrame],
42) -> Result<Volume<i16>, VolumeAssemblyError> {
43    let geometry = derive_volume_geometry_from_frames(frames)?;
44    let sorted_frames = sort_frames_for_volume(frames);
45    let slice_len = geometry.slice_len();
46    let mut slices = Vec::with_capacity(sorted_frames.len());
47
48    for (index, frame) in sorted_frames.iter().enumerate() {
49        if frame.pixels.len() != slice_len {
50            return Err(VolumeAssemblyError::PixelCountMismatch {
51                frame_index: index,
52                expected: slice_len,
53                actual: frame.pixels.len(),
54            });
55        }
56        slices.push(frame.pixels.as_slice());
57    }
58
59    Volume::from_slices(
60        &slices,
61        geometry.dimensions.x,
62        geometry.dimensions.y,
63        geometry.spacing,
64        geometry.origin,
65        geometry.direction,
66    )
67    .map_err(VolumeAssemblyError::from)
68}
69
70/// Derives volume geometry from a homogeneous set of decoded frames.
71pub fn derive_volume_geometry_from_frames(
72    frames: &[DecodedFrame],
73) -> Result<VolumeGeometry, VolumeAssemblyError> {
74    let first = frames.first().ok_or(VolumeAssemblyError::EmptyFrames)?;
75    validate_frame_set(frames, &first.metadata)?;
76    let sorted_frames = sort_frames_for_volume(frames);
77    let spacing = extract_spacing(&sorted_frames);
78    let origin = sorted_frames
79        .first()
80        .and_then(|frame| frame.metadata.image_position)
81        .unwrap_or(DVec3::ZERO);
82    let direction = sorted_frames
83        .first()
84        .map(|frame| frame.metadata.direction())
85        .unwrap_or(DMat3::IDENTITY);
86
87    Ok(VolumeGeometry {
88        dimensions: UVec3::new(
89            first.metadata.columns as u32,
90            first.metadata.rows as u32,
91            sorted_frames.len() as u32,
92        ),
93        spacing,
94        origin,
95        direction,
96    })
97}
98
99fn validate_frame_set(
100    frames: &[DecodedFrame],
101    first: &FrameMetadata,
102) -> Result<(), VolumeAssemblyError> {
103    for (index, frame) in frames.iter().enumerate() {
104        if frame.metadata.rows != first.rows || frame.metadata.columns != first.columns {
105            return Err(VolumeAssemblyError::InconsistentGeometry {
106                frame_index: index,
107                reason: format!(
108                    "expected {}x{}, got {}x{}",
109                    first.columns, first.rows, frame.metadata.columns, frame.metadata.rows
110                ),
111            });
112        }
113        if frame.metadata.samples_per_pixel != first.samples_per_pixel {
114            return Err(VolumeAssemblyError::InconsistentGeometry {
115                frame_index: index,
116                reason: format!(
117                    "expected samples_per_pixel={}, got {}",
118                    first.samples_per_pixel, frame.metadata.samples_per_pixel
119                ),
120            });
121        }
122        if frame.metadata.image_orientation != first.image_orientation
123            && frame.metadata.image_orientation.is_some()
124            && first.image_orientation.is_some()
125        {
126            return Err(VolumeAssemblyError::InconsistentGeometry {
127                frame_index: index,
128                reason: "image orientation differs across frames".to_string(),
129            });
130        }
131    }
132    Ok(())
133}
134
135fn sort_frames_for_volume<'a>(frames: &'a [DecodedFrame]) -> Vec<&'a DecodedFrame> {
136    let mut sorted: Vec<&DecodedFrame> = frames.iter().collect();
137    if sorted.len() <= 1 {
138        return sorted;
139    }
140
141    let Some(reference_normal) = reference_slice_normal(&sorted) else {
142        sorted.sort_by(|left, right| compare_frames_fallback(left, right));
143        return sorted;
144    };
145
146    let reference_origin = sorted
147        .iter()
148        .find_map(|frame| frame.metadata.image_position)
149        .unwrap_or(DVec3::ZERO);
150
151    sorted.sort_by(|left, right| {
152        compare_frames_by_geometry(left, right, reference_origin, reference_normal)
153            .then_with(|| compare_frames_fallback(left, right))
154    });
155    sorted
156}
157
158fn reference_slice_normal(frames: &[&DecodedFrame]) -> Option<DVec3> {
159    let candidate = frames
160        .iter()
161        .find_map(|frame| frame.metadata.slice_normal())?;
162    frames
163        .iter()
164        .all(|frame| {
165            let Some(normal) = frame.metadata.slice_normal() else {
166                return false;
167            };
168            normal.dot(candidate).abs() > 0.999
169        })
170        .then_some(candidate)
171}
172
173fn compare_frames_by_geometry(
174    left: &DecodedFrame,
175    right: &DecodedFrame,
176    reference_origin: DVec3,
177    reference_normal: DVec3,
178) -> Ordering {
179    match (left.metadata.image_position, right.metadata.image_position) {
180        (Some(left_pos), Some(right_pos)) => {
181            let left_distance = reference_normal.dot(left_pos - reference_origin);
182            let right_distance = reference_normal.dot(right_pos - reference_origin);
183            left_distance
184                .partial_cmp(&right_distance)
185                .unwrap_or(Ordering::Equal)
186        }
187        _ => compare_frames_fallback(left, right),
188    }
189}
190
191fn compare_frames_fallback(left: &DecodedFrame, right: &DecodedFrame) -> Ordering {
192    left.metadata
193        .instance_number
194        .cmp(&right.metadata.instance_number)
195        .then_with(|| {
196            left.metadata
197                .sop_instance_uid
198                .as_deref()
199                .unwrap_or("")
200                .cmp(right.metadata.sop_instance_uid.as_deref().unwrap_or(""))
201        })
202        .then_with(|| left.metadata.frame_index.cmp(&right.metadata.frame_index))
203}
204
205fn extract_spacing(frames: &[&DecodedFrame]) -> DVec3 {
206    let first = &frames[0].metadata;
207    let pixel_spacing = first.pixel_spacing.unwrap_or((1.0, 1.0));
208    let slice_spacing = if frames.len() >= 2 {
209        projected_slice_spacing(frames)
210            .or(first.slice_thickness)
211            .unwrap_or(1.0)
212    } else {
213        first.slice_thickness.unwrap_or(1.0)
214    };
215
216    DVec3::new(pixel_spacing.1, pixel_spacing.0, slice_spacing)
217}
218
219fn projected_slice_spacing(frames: &[&DecodedFrame]) -> Option<f64> {
220    let normal = reference_slice_normal(frames)?;
221    let first = frames.first()?.metadata.image_position?;
222    let second = frames.get(1)?.metadata.image_position?;
223    Some(normal.dot(second - first).abs())
224}
225
226#[cfg(test)]
227mod tests {
228    use super::*;
229    use crate::metadata::FrameMetadata;
230    use dicom_toolkit_image::PixelRepresentation;
231    use volren_core::VolumeInfo;
232
233    fn frame(
234        instance_number: i32,
235        z: f64,
236        pixels: [i16; 4],
237        orientation: Option<(DVec3, DVec3)>,
238    ) -> DecodedFrame {
239        DecodedFrame {
240            metadata: FrameMetadata {
241                frame_index: 0,
242                rows: 2,
243                columns: 2,
244                number_of_frames: 1,
245                samples_per_pixel: 1,
246                bits_allocated: 16,
247                bits_stored: 16,
248                high_bit: 15,
249                pixel_representation: PixelRepresentation::Signed,
250                instance_number,
251                pixel_spacing: Some((0.6, 0.8)),
252                slice_thickness: Some(1.5),
253                image_position: Some(DVec3::new(0.0, 0.0, z)),
254                image_orientation: orientation,
255                window_center: None,
256                window_width: None,
257                rescale_intercept: 0.0,
258                rescale_slope: 1.0,
259                sop_instance_uid: Some(format!("1.2.3.{instance_number}")),
260                transfer_syntax_uid: "1.2.840.10008.1.2.1".to_string(),
261            },
262            pixels: pixels.to_vec(),
263        }
264    }
265
266    #[test]
267    fn assembles_sorted_volume() {
268        let orientation = Some((DVec3::X, DVec3::Y));
269        let frames = vec![
270            frame(2, 2.0, [5, 6, 7, 8], orientation),
271            frame(1, 1.0, [1, 2, 3, 4], orientation),
272            frame(3, 3.0, [9, 10, 11, 12], orientation),
273        ];
274
275        let volume = assemble_volume_from_frames(&frames).expect("volume");
276        assert_eq!(volume.dimensions(), UVec3::new(2, 2, 3));
277        assert_eq!(volume.spacing(), DVec3::new(0.8, 0.6, 1.0));
278        assert_eq!(volume.get(0, 0, 0), Some(1));
279        assert_eq!(volume.get(1, 1, 2), Some(12));
280    }
281
282    #[test]
283    fn falls_back_to_instance_number_sorting() {
284        let mut frames = vec![
285            frame(3, 0.0, [9, 10, 11, 12], None),
286            frame(1, 0.0, [1, 2, 3, 4], None),
287            frame(2, 0.0, [5, 6, 7, 8], None),
288        ];
289        for decoded in &mut frames {
290            decoded.metadata.image_position = None;
291        }
292
293        let volume = assemble_volume_from_frames(&frames).expect("volume");
294        assert_eq!(volume.get(0, 0, 0), Some(1));
295        assert_eq!(volume.get(0, 0, 1), Some(5));
296        assert_eq!(volume.get(0, 0, 2), Some(9));
297    }
298
299    #[test]
300    fn rejects_inconsistent_geometry() {
301        let orientation = Some((DVec3::X, DVec3::Y));
302        let mut frames = vec![
303            frame(1, 1.0, [1, 2, 3, 4], orientation),
304            frame(2, 2.0, [5, 6, 7, 8], orientation),
305        ];
306        frames[1].metadata.columns = 3;
307
308        let err = assemble_volume_from_frames(&frames).unwrap_err();
309        assert!(matches!(
310            err,
311            VolumeAssemblyError::InconsistentGeometry { .. }
312        ));
313    }
314}