Skip to main content

dicomview_core/
metadata.rs

1//! DICOM geometry and per-frame metadata extraction.
2
3use dicom_toolkit_data::{DataSet, Value};
4use dicom_toolkit_dict::{tags, Tag};
5use dicom_toolkit_image::PixelRepresentation;
6use glam::{DMat3, DVec3, UVec3};
7use thiserror::Error;
8
9/// DICOM tag `(0028,0030)` Pixel Spacing.
10pub const PIXEL_SPACING: Tag = Tag::new(0x0028, 0x0030);
11
12/// DICOM tag `(0018,0050)` Slice Thickness.
13pub const SLICE_THICKNESS: Tag = Tag::new(0x0018, 0x0050);
14
15/// Errors raised while extracting metadata from a DICOM dataset.
16#[derive(Debug, Error, PartialEq)]
17pub enum MetadataError {
18    /// A required dataset attribute was missing.
19    #[error("missing mandatory attribute: {name}")]
20    MissingAttribute {
21        /// Human-readable attribute name.
22        name: &'static str,
23    },
24}
25
26/// Geometry needed to allocate or build a 3D volume.
27#[derive(Debug, Clone, Copy, PartialEq)]
28pub struct VolumeGeometry {
29    /// Volume dimensions in voxels.
30    pub dimensions: UVec3,
31    /// Voxel spacing in world units, typically millimetres.
32    pub spacing: DVec3,
33    /// World-space origin of voxel `(0, 0, 0)`.
34    pub origin: DVec3,
35    /// Orientation matrix whose columns are the volume axes.
36    pub direction: DMat3,
37}
38
39impl VolumeGeometry {
40    /// Returns the number of scalar voxels in the full volume.
41    #[must_use]
42    pub fn voxel_count(self) -> usize {
43        (self.dimensions.x as usize) * (self.dimensions.y as usize) * (self.dimensions.z as usize)
44    }
45
46    /// Returns the number of voxels contained in one Z slice.
47    #[must_use]
48    pub fn slice_len(self) -> usize {
49        (self.dimensions.x as usize) * (self.dimensions.y as usize)
50    }
51}
52
53/// Metadata extracted for one decoded image frame.
54#[derive(Debug, Clone, PartialEq)]
55pub struct FrameMetadata {
56    /// Zero-based frame index inside the source DICOM object.
57    pub frame_index: u32,
58    /// Number of rows in the frame.
59    pub rows: u16,
60    /// Number of columns in the frame.
61    pub columns: u16,
62    /// Number of frames in the source DICOM object.
63    pub number_of_frames: u32,
64    /// Samples per pixel, usually `1` for grayscale volumes.
65    pub samples_per_pixel: u16,
66    /// Bits allocated per sample.
67    pub bits_allocated: u16,
68    /// Bits stored per sample.
69    pub bits_stored: u16,
70    /// Highest stored bit index.
71    pub high_bit: u16,
72    /// Pixel sign convention.
73    pub pixel_representation: PixelRepresentation,
74    /// Instance Number `(0020,0013)` if present.
75    pub instance_number: i32,
76    /// Pixel spacing as `(row_spacing, column_spacing)`.
77    pub pixel_spacing: Option<(f64, f64)>,
78    /// Slice thickness if present.
79    pub slice_thickness: Option<f64>,
80    /// Image Position (Patient) if present.
81    pub image_position: Option<DVec3>,
82    /// Image Orientation (Patient) as `(row_direction, column_direction)`.
83    pub image_orientation: Option<(DVec3, DVec3)>,
84    /// Optional display window center.
85    pub window_center: Option<f64>,
86    /// Optional display window width.
87    pub window_width: Option<f64>,
88    /// Modality LUT intercept.
89    pub rescale_intercept: f64,
90    /// Modality LUT slope.
91    pub rescale_slope: f64,
92    /// SOP Instance UID if present.
93    pub sop_instance_uid: Option<String>,
94    /// Transfer Syntax UID from the file meta information.
95    pub transfer_syntax_uid: String,
96}
97
98impl FrameMetadata {
99    /// Returns a best-effort 3x3 direction matrix for the frame.
100    #[must_use]
101    pub fn direction(&self) -> DMat3 {
102        self.image_orientation
103            .map(|(row, col)| {
104                let normal = row.cross(col).normalize_or_zero();
105                if normal.length_squared() > 0.0 {
106                    DMat3::from_cols(row, col, normal)
107                } else {
108                    DMat3::IDENTITY
109                }
110            })
111            .unwrap_or(DMat3::IDENTITY)
112    }
113
114    /// Returns the frame normal when orientation is available.
115    #[must_use]
116    pub fn slice_normal(&self) -> Option<DVec3> {
117        self.image_orientation.and_then(|(row, col)| {
118            let normal = row.cross(col).normalize_or_zero();
119            (normal.length_squared() > 0.0).then_some(normal)
120        })
121    }
122
123    /// Returns in-plane spacing in `(x, y)` volume order.
124    #[must_use]
125    pub fn spacing_xy(&self) -> DVec3 {
126        let (row, col) = self.pixel_spacing.unwrap_or((1.0, 1.0));
127        DVec3::new(col, row, self.slice_thickness.unwrap_or(1.0))
128    }
129
130    /// Returns the expected pixel count for one decoded frame.
131    #[must_use]
132    pub fn voxel_count(&self) -> usize {
133        (self.rows as usize) * (self.columns as usize) * (self.samples_per_pixel as usize)
134    }
135}
136
137/// Extracts per-frame metadata from a parsed DICOM dataset.
138pub fn extract_frame_metadata(
139    dataset: &DataSet,
140    transfer_syntax_uid: impl Into<String>,
141    frame_index: u32,
142) -> Result<FrameMetadata, MetadataError> {
143    let rows = dataset
144        .get_u16(tags::ROWS)
145        .ok_or(MetadataError::MissingAttribute {
146            name: "Rows (0028,0010)",
147        })?;
148    let columns = dataset
149        .get_u16(tags::COLUMNS)
150        .ok_or(MetadataError::MissingAttribute {
151            name: "Columns (0028,0011)",
152        })?;
153    let bits_allocated =
154        dataset
155            .get_u16(tags::BITS_ALLOCATED)
156            .ok_or(MetadataError::MissingAttribute {
157                name: "BitsAllocated (0028,0100)",
158            })?;
159    let samples_per_pixel = dataset.get_u16(tags::SAMPLES_PER_PIXEL).unwrap_or(1);
160    let bits_stored = dataset.get_u16(tags::BITS_STORED).unwrap_or(bits_allocated);
161    let high_bit = dataset
162        .get_u16(tags::HIGH_BIT)
163        .unwrap_or(bits_stored.saturating_sub(1));
164    let pixel_representation = match dataset.get_u16(tags::PIXEL_REPRESENTATION).unwrap_or(0) {
165        1 => PixelRepresentation::Signed,
166        _ => PixelRepresentation::Unsigned,
167    };
168
169    Ok(FrameMetadata {
170        frame_index,
171        rows,
172        columns,
173        number_of_frames: number_of_frames(dataset),
174        samples_per_pixel,
175        bits_allocated,
176        bits_stored,
177        high_bit,
178        pixel_representation,
179        instance_number: dataset.get_i32(tags::INSTANCE_NUMBER).unwrap_or(0),
180        pixel_spacing: decimal_pair(dataset, PIXEL_SPACING),
181        slice_thickness: dataset
182            .get_f64(SLICE_THICKNESS)
183            .or_else(|| dataset.get_string(SLICE_THICKNESS).and_then(parse_decimal)),
184        image_position: decimal_vector3(dataset, tags::IMAGE_POSITION_PATIENT),
185        image_orientation: orientation_pair(dataset, tags::IMAGE_ORIENTATION_PATIENT),
186        window_center: decimal_value(dataset, tags::WINDOW_CENTER),
187        window_width: decimal_value(dataset, tags::WINDOW_WIDTH),
188        rescale_intercept: decimal_value(dataset, tags::RESCALE_INTERCEPT).unwrap_or(0.0),
189        rescale_slope: decimal_value(dataset, tags::RESCALE_SLOPE).unwrap_or(1.0),
190        sop_instance_uid: dataset
191            .get_string(tags::SOP_INSTANCE_UID)
192            .map(std::string::ToString::to_string),
193        transfer_syntax_uid: transfer_syntax_uid.into(),
194    })
195}
196
197fn number_of_frames(dataset: &DataSet) -> u32 {
198    dataset
199        .get(tags::NUMBER_OF_FRAMES)
200        .and_then(|elem| match &elem.value {
201            dicom_toolkit_data::Value::Ints(values) => {
202                values.first().copied().map(|n| n.max(1) as u32)
203            }
204            dicom_toolkit_data::Value::Strings(values) => values
205                .first()
206                .and_then(|value| value.trim().parse::<u32>().ok()),
207            dicom_toolkit_data::Value::U16(values) => values.first().copied().map(u32::from),
208            dicom_toolkit_data::Value::U32(values) => values.first().copied(),
209            _ => None,
210        })
211        .unwrap_or(1)
212}
213
214fn decimal_value(dataset: &DataSet, tag: Tag) -> Option<f64> {
215    decimal_values(dataset, tag).into_iter().next()
216}
217
218fn parse_decimal(value: &str) -> Option<f64> {
219    value.trim().parse::<f64>().ok()
220}
221
222fn decimal_values(dataset: &DataSet, tag: Tag) -> Vec<f64> {
223    let Some(element) = dataset.get(tag) else {
224        return Vec::new();
225    };
226    match &element.value {
227        Value::Decimals(values) => values.clone(),
228        Value::F64(values) => values.clone(),
229        Value::F32(values) => values.iter().map(|&value| value as f64).collect(),
230        Value::Strings(values) => values
231            .iter()
232            .flat_map(|value| value.split('\\'))
233            .filter_map(parse_decimal)
234            .collect(),
235        Value::U16(values) => values.iter().map(|&value| value as f64).collect(),
236        Value::U32(values) => values.iter().map(|&value| value as f64).collect(),
237        Value::I32(values) => values.iter().map(|&value| value as f64).collect(),
238        _ => Vec::new(),
239    }
240}
241
242fn decimal_pair(dataset: &DataSet, tag: Tag) -> Option<(f64, f64)> {
243    let parts = decimal_values(dataset, tag);
244    (parts.len() >= 2).then(|| (parts[0], parts[1]))
245}
246
247fn decimal_vector3(dataset: &DataSet, tag: Tag) -> Option<DVec3> {
248    let parts = decimal_values(dataset, tag);
249    (parts.len() >= 3).then(|| DVec3::new(parts[0], parts[1], parts[2]))
250}
251
252fn orientation_pair(dataset: &DataSet, tag: Tag) -> Option<(DVec3, DVec3)> {
253    let parts = decimal_values(dataset, tag);
254    (parts.len() >= 6).then(|| {
255        (
256            DVec3::new(parts[0], parts[1], parts[2]).normalize_or_zero(),
257            DVec3::new(parts[3], parts[4], parts[5]).normalize_or_zero(),
258        )
259    })
260}
261
262#[cfg(test)]
263mod tests {
264    use super::*;
265    use dicom_toolkit_data::DataSet;
266    use dicom_toolkit_dict::Vr;
267
268    fn dataset_with_geometry() -> DataSet {
269        let mut ds = DataSet::new();
270        ds.set_u16(tags::ROWS, 4);
271        ds.set_u16(tags::COLUMNS, 3);
272        ds.set_u16(tags::SAMPLES_PER_PIXEL, 1);
273        ds.set_u16(tags::BITS_ALLOCATED, 16);
274        ds.set_u16(tags::BITS_STORED, 12);
275        ds.set_u16(tags::HIGH_BIT, 11);
276        ds.set_u16(tags::PIXEL_REPRESENTATION, 1);
277        ds.set_string(PIXEL_SPACING, Vr::DS, "0.7\\0.8");
278        ds.set_string(tags::IMAGE_POSITION_PATIENT, Vr::DS, "1\\2\\3");
279        ds.set_string(tags::IMAGE_ORIENTATION_PATIENT, Vr::DS, "1\\0\\0\\0\\1\\0");
280        ds.set_string(tags::WINDOW_CENTER, Vr::DS, "40");
281        ds.set_string(tags::WINDOW_WIDTH, Vr::DS, "400");
282        ds.set_string(tags::SOP_INSTANCE_UID, Vr::UI, "1.2.3");
283        ds
284    }
285
286    #[test]
287    fn extracts_frame_metadata() {
288        let metadata = extract_frame_metadata(&dataset_with_geometry(), "1.2.840.10008.1.2.1", 0)
289            .expect("metadata");
290        assert_eq!(metadata.rows, 4);
291        assert_eq!(metadata.columns, 3);
292        assert_eq!(metadata.bits_allocated, 16);
293        assert_eq!(metadata.spacing_xy(), DVec3::new(0.8, 0.7, 1.0));
294        assert_eq!(metadata.image_position, Some(DVec3::new(1.0, 2.0, 3.0)));
295        assert_eq!(metadata.sop_instance_uid.as_deref(), Some("1.2.3"));
296    }
297
298    #[test]
299    fn direction_defaults_to_identity_without_orientation() {
300        let mut metadata =
301            extract_frame_metadata(&dataset_with_geometry(), "1.2.840.10008.1.2.1", 0).unwrap();
302        metadata.image_orientation = None;
303        assert_eq!(metadata.direction(), DMat3::IDENTITY);
304        assert_eq!(metadata.slice_normal(), None);
305    }
306}