wsi_dicom_annotations/annotations/
sidecar.rs1use 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
121pub 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;