Skip to main content

wsi_dicom_annotations/annotations/
sidecar.rs

1use std::io::{Read, Seek, SeekFrom};
2use std::path::{Path, PathBuf};
3
4use dicom_core::Tag;
5use dicom_dictionary_std::{tags, uids};
6use dicom_object::file::ReadPreamble;
7use dicom_object::meta::FileMetaTable;
8use dicom_object::OpenFileOptions;
9
10use crate::{Error, Result};
11
12use super::context::DicomAnnotationContext;
13use super::dicom_dataset::optional_string;
14
15const MAX_SIDECAR_CANDIDATES: usize = 10_000;
16const MAX_SIDECAR_FILE_BYTES: u64 = 2 * 1024 * 1024 * 1024;
17
18#[derive(Debug, Clone, Copy, PartialEq, Eq)]
19pub enum SidecarKind {
20    Annotation,
21    BinarySegmentation,
22    FractionalSegmentation,
23    LabelMapSegmentation,
24    StructuredReport,
25}
26
27#[derive(Debug, Clone, Copy, PartialEq, Eq)]
28pub enum AnnotationObjectKind {
29    Annotation,
30    Segmentation,
31}
32
33#[derive(Debug, Clone, PartialEq, Eq)]
34pub struct SidecarMetadata {
35    path: PathBuf,
36    kind: SidecarKind,
37    sop_instance_uid: String,
38    series_instance_uid: Option<String>,
39}
40
41struct SidecarHeader {
42    sop_class_uid: String,
43    sop_instance_uid: String,
44}
45
46impl SidecarMetadata {
47    #[must_use]
48    pub fn path(&self) -> &Path {
49        &self.path
50    }
51
52    #[must_use]
53    pub const fn kind(&self) -> SidecarKind {
54        self.kind
55    }
56
57    #[must_use]
58    pub fn sop_instance_uid(&self) -> &str {
59        &self.sop_instance_uid
60    }
61
62    #[must_use]
63    pub fn series_instance_uid(&self) -> Option<&str> {
64        self.series_instance_uid.as_deref()
65    }
66}
67
68pub fn annotation_object_kind(path: impl AsRef<Path>) -> Result<AnnotationObjectKind> {
69    let header = read_sidecar_header(path.as_ref())?;
70    match header.sop_class_uid.as_str() {
71        uids::MICROSCOPY_BULK_SIMPLE_ANNOTATIONS_STORAGE => Ok(AnnotationObjectKind::Annotation),
72        uids::SEGMENTATION_STORAGE | uids::LABEL_MAP_SEGMENTATION_STORAGE => {
73            Ok(AnnotationObjectKind::Segmentation)
74        }
75        sop_class => Err(Error::Unsupported(format!(
76            "SOP Class {sop_class} is not ANN or SEG"
77        ))),
78    }
79}
80
81fn read_sidecar_header(path: &Path) -> Result<SidecarHeader> {
82    let length = std::fs::metadata(path)
83        .map_err(|source| Error::Io {
84            path: path.to_path_buf(),
85            source,
86        })?
87        .len();
88    if !(132..=MAX_SIDECAR_FILE_BYTES).contains(&length) {
89        return Err(Error::InvalidInput(format!(
90            "annotation object is {length} bytes; supported range is 132..={MAX_SIDECAR_FILE_BYTES}"
91        )));
92    }
93    let mut file = std::fs::File::open(path).map_err(|source| Error::Io {
94        path: path.to_path_buf(),
95        source,
96    })?;
97    let mut preamble = [0_u8; 132];
98    file.read_exact(&mut preamble).map_err(|source| Error::Io {
99        path: path.to_path_buf(),
100        source,
101    })?;
102    if &preamble[128..] != b"DICM" {
103        return Err(Error::InvalidInput(
104            "annotation object has no DICOM preamble".into(),
105        ));
106    }
107    file.seek(SeekFrom::Start(128))
108        .map_err(|source| Error::Io {
109            path: path.to_path_buf(),
110            source,
111        })?;
112    let meta = FileMetaTable::from_reader(&mut file).map_err(|error| {
113        Error::InvalidInput(format!("invalid annotation object file meta: {error}"))
114    })?;
115    Ok(SidecarHeader {
116        sop_class_uid: meta.media_storage_sop_class_uid().to_string(),
117        sop_instance_uid: meta.media_storage_sop_instance_uid().to_string(),
118    })
119}
120
121/// Discover local DICOM annotation sidecars without reading ANN coordinate arrays
122/// or SEG functional groups and Pixel Data.
123pub fn discover_sidecars(source: &DicomAnnotationContext) -> Result<Vec<SidecarMetadata>> {
124    let directory = source
125        .source_path()
126        .parent()
127        .unwrap_or_else(|| Path::new("."));
128    let mut candidates = Vec::new();
129    for entry in std::fs::read_dir(directory).map_err(|source_error| Error::Io {
130        path: directory.to_path_buf(),
131        source: source_error,
132    })? {
133        let entry = entry.map_err(|source_error| Error::Io {
134            path: directory.to_path_buf(),
135            source: source_error,
136        })?;
137        let path = entry.path();
138        if !path.is_file() || path == source.source_path() {
139            continue;
140        }
141        if candidates.len() >= MAX_SIDECAR_CANDIDATES {
142            return Err(Error::InvalidInput(format!(
143                "sidecar directory contains more than {MAX_SIDECAR_CANDIDATES} files"
144            )));
145        }
146        candidates.push(path);
147    }
148    candidates.sort();
149    let mut sidecars = candidates
150        .into_iter()
151        .filter_map(|path| inspect_sidecar(&path, source))
152        .collect::<Result<Vec<_>>>()?;
153    sidecars.sort_by(|a, b| a.path.cmp(&b.path));
154    Ok(sidecars)
155}
156
157fn inspect_sidecar(
158    path: &Path,
159    source: &DicomAnnotationContext,
160) -> Option<Result<SidecarMetadata>> {
161    let header = match read_sidecar_header(path) {
162        Ok(header) => header,
163        Err(_) => return None,
164    };
165    let (kind, stop_tag) =
166        if header.sop_class_uid == uids::MICROSCOPY_BULK_SIMPLE_ANNOTATIONS_STORAGE {
167            (SidecarKind::Annotation, tags::ANNOTATION_GROUP_SEQUENCE)
168        } else if header.sop_class_uid == uids::SEGMENTATION_STORAGE {
169            (
170                SidecarKind::BinarySegmentation,
171                tags::PER_FRAME_FUNCTIONAL_GROUPS_SEQUENCE,
172            )
173        } else if header.sop_class_uid == uids::LABEL_MAP_SEGMENTATION_STORAGE {
174            (
175                SidecarKind::LabelMapSegmentation,
176                tags::PER_FRAME_FUNCTIONAL_GROUPS_SEQUENCE,
177            )
178        } else if header.sop_class_uid == uids::COMPREHENSIVE3_DSR_STORAGE {
179            (SidecarKind::StructuredReport, tags::CONTENT_SEQUENCE)
180        } else {
181            return None;
182        };
183    let object = match OpenFileOptions::new()
184        .read_until(stop_tag)
185        .read_preamble(ReadPreamble::Always)
186        .open_file(path)
187    {
188        Ok(object) => object,
189        Err(_) => return None,
190    };
191    let kind = if kind == SidecarKind::BinarySegmentation
192        && optional_string(&object, tags::SEGMENTATION_TYPE).as_deref() == Some("FRACTIONAL")
193    {
194        SidecarKind::FractionalSegmentation
195    } else {
196        kind
197    };
198    if !references_source(&object, kind, source.sop_instance_uid()) {
199        return None;
200    }
201    Some(Ok(SidecarMetadata {
202        path: path.to_path_buf(),
203        kind,
204        sop_instance_uid: header.sop_instance_uid,
205        series_instance_uid: optional_string(&object, tags::SERIES_INSTANCE_UID),
206    }))
207}
208
209fn references_source(
210    object: &dicom_object::DefaultDicomObject,
211    kind: SidecarKind,
212    source_uid: &str,
213) -> bool {
214    if kind == SidecarKind::Annotation {
215        return object
216            .get(tags::REFERENCED_IMAGE_SEQUENCE)
217            .and_then(|element| element.items())
218            .into_iter()
219            .flatten()
220            .any(|item| {
221                optional_string(item, tags::REFERENCED_SOP_INSTANCE_UID).as_deref()
222                    == Some(source_uid)
223            })
224            || sequence_contains_reference(object, tags::REFERENCED_SERIES_SEQUENCE, source_uid);
225    }
226    if kind == SidecarKind::StructuredReport {
227        return evidence_contains_reference(
228            object,
229            tags::CURRENT_REQUESTED_PROCEDURE_EVIDENCE_SEQUENCE,
230            source_uid,
231        ) || evidence_contains_reference(
232            object,
233            tags::PERTINENT_OTHER_EVIDENCE_SEQUENCE,
234            source_uid,
235        );
236    }
237    sequence_contains_reference(object, tags::REFERENCED_SERIES_SEQUENCE, source_uid)
238        || shared_derivation_references_source(object, source_uid)
239}
240
241fn evidence_contains_reference(
242    object: &dicom_object::DefaultDicomObject,
243    evidence_tag: Tag,
244    source_uid: &str,
245) -> bool {
246    object
247        .get(evidence_tag)
248        .and_then(|element| element.items())
249        .into_iter()
250        .flatten()
251        .flat_map(|study| {
252            study
253                .get(tags::REFERENCED_SERIES_SEQUENCE)
254                .and_then(|element| element.items())
255                .unwrap_or_default()
256        })
257        .flat_map(|series| {
258            series
259                .get(tags::REFERENCED_SOP_SEQUENCE)
260                .and_then(|element| element.items())
261                .unwrap_or_default()
262        })
263        .any(|item| {
264            optional_string(item, tags::REFERENCED_SOP_INSTANCE_UID).as_deref() == Some(source_uid)
265        })
266}
267
268fn shared_derivation_references_source(
269    object: &dicom_object::DefaultDicomObject,
270    source_uid: &str,
271) -> bool {
272    object
273        .get(tags::SHARED_FUNCTIONAL_GROUPS_SEQUENCE)
274        .and_then(|element| element.items())
275        .into_iter()
276        .flatten()
277        .flat_map(|shared| {
278            shared
279                .get(tags::DERIVATION_IMAGE_SEQUENCE)
280                .and_then(|element| element.items())
281                .unwrap_or_default()
282        })
283        .flat_map(|derivation| {
284            derivation
285                .get(tags::SOURCE_IMAGE_SEQUENCE)
286                .and_then(|element| element.items())
287                .unwrap_or_default()
288        })
289        .any(|reference| {
290            optional_string(reference, tags::REFERENCED_SOP_INSTANCE_UID).as_deref()
291                == Some(source_uid)
292        })
293}
294
295fn sequence_contains_reference(
296    object: &dicom_object::DefaultDicomObject,
297    tag: Tag,
298    source_uid: &str,
299) -> bool {
300    object
301        .get(tag)
302        .and_then(|element| element.items())
303        .into_iter()
304        .flatten()
305        .flat_map(|series| {
306            series
307                .get(tags::REFERENCED_INSTANCE_SEQUENCE)
308                .and_then(|element| element.items())
309                .unwrap_or_default()
310        })
311        .any(|item| {
312            optional_string(item, tags::REFERENCED_SOP_INSTANCE_UID).as_deref() == Some(source_uid)
313        })
314}
315
316#[cfg(test)]
317#[path = "sidecar_tests.rs"]
318mod tests;