Skip to main content

wsi_dicom_annotations/annotations/pathology_geojson/
documents.rs

1use std::path::Path;
2
3use super::PathologyAnnotationSet;
4use crate::{
5    AnnotationDocument, DerivedObjectProducer, DicomAnnotationContext, Error, Result,
6    SegmentationDocument, StructuredReportDocument,
7};
8
9#[derive(Debug, thiserror::Error)]
10pub enum PathologyDocumentWriteError {
11    #[error("selected pathology DICOM target document is missing")]
12    MissingTarget,
13    #[error("could not write staged DICOM {target}: {source}")]
14    Write {
15        target: &'static str,
16        #[source]
17        source: Error,
18    },
19    #[error("could not reread staged DICOM {target}: {source}")]
20    Verification {
21        target: &'static str,
22        #[source]
23        source: Error,
24    },
25    #[error("reread {0} semantics differ from the staged document")]
26    Mismatch(&'static str),
27}
28
29#[derive(Debug, Clone, Copy, PartialEq, Eq)]
30pub enum PathologyDicomTarget {
31    Ann,
32    Seg,
33    Sr,
34}
35
36impl PathologyDicomTarget {
37    #[must_use]
38    pub const fn label(self) -> &'static str {
39        match self {
40            Self::Ann => "ann",
41            Self::Seg => "seg",
42            Self::Sr => "sr",
43        }
44    }
45
46    #[must_use]
47    pub const fn file_name(self) -> &'static str {
48        match self {
49            Self::Ann => "ann.dcm",
50            Self::Seg => "seg.dcm",
51            Self::Sr => "sr.dcm",
52        }
53    }
54}
55
56/// A target-consistent set of pathology-derived DICOM documents.
57///
58/// Construction enforces companion-object semantics once for both the desktop
59/// viewer and the headless converter. In particular, SR may preserve semantics
60/// omitted from ANN and may reference the exact SEG built in this set.
61pub struct PathologyDicomDocuments {
62    ann: Option<AnnotationDocument>,
63    seg: Option<SegmentationDocument>,
64    sr: Option<StructuredReportDocument>,
65}
66
67impl PathologyDicomDocuments {
68    pub fn build(
69        annotations: &PathologyAnnotationSet,
70        targets: &[PathologyDicomTarget],
71    ) -> Result<Self> {
72        validate_targets(targets)?;
73        let wants_ann = targets.contains(&PathologyDicomTarget::Ann);
74        let wants_seg = targets.contains(&PathologyDicomTarget::Seg);
75        let wants_sr = targets.contains(&PathologyDicomTarget::Sr);
76        let ann = wants_ann
77            .then(|| {
78                if wants_sr {
79                    annotations.to_ann_with_companion_sr()
80                } else {
81                    annotations.to_ann()
82                }
83            })
84            .transpose()?;
85        let seg = wants_seg
86            .then(|| annotations.to_seg(wants_sr))
87            .transpose()?;
88        let sr = wants_sr
89            .then(|| annotations.to_sr(seg.as_ref()))
90            .transpose()?;
91        Ok(Self { ann, seg, sr })
92    }
93
94    /// Applies caller-owned producer metadata to every document present in the set.
95    #[must_use]
96    pub fn with_producers(
97        mut self,
98        ann: DerivedObjectProducer,
99        seg: DerivedObjectProducer,
100        sr: DerivedObjectProducer,
101    ) -> Self {
102        self.ann = self.ann.take().map(|document| document.with_producer(ann));
103        self.seg = self.seg.take().map(|document| document.with_producer(seg));
104        self.sr = self.sr.take().map(|document| document.with_producer(sr));
105        self
106    }
107
108    #[must_use]
109    pub const fn ann(&self) -> Option<&AnnotationDocument> {
110        self.ann.as_ref()
111    }
112
113    #[must_use]
114    pub const fn seg(&self) -> Option<&SegmentationDocument> {
115        self.seg.as_ref()
116    }
117
118    #[must_use]
119    pub const fn sr(&self) -> Option<&StructuredReportDocument> {
120        self.sr.as_ref()
121    }
122
123    /// Write one selected document, reread it, and compare the semantics used
124    /// by the converter. Callers should write into a private staging area.
125    pub fn write_and_verify(
126        &self,
127        target: PathologyDicomTarget,
128        path: &Path,
129        source: &DicomAnnotationContext,
130    ) -> std::result::Result<(), PathologyDocumentWriteError> {
131        match target {
132            PathologyDicomTarget::Ann => {
133                let expected = self.ann.as_ref().ok_or_else(missing_document)?;
134                expected
135                    .write_ann(path)
136                    .map_err(|source| write_error("ANN", source))?;
137                let actual = AnnotationDocument::read_ann(path, source)
138                    .map_err(|source| verification_error("ANN", source))?;
139                if actual.groups().len() != expected.groups().len()
140                    || actual.sop_instance_uid() != expected.sop_instance_uid()
141                {
142                    return Err(verification_mismatch("ANN"));
143                }
144            }
145            PathologyDicomTarget::Seg => {
146                let expected = self.seg.as_ref().ok_or_else(missing_document)?;
147                expected
148                    .write_seg(path)
149                    .map_err(|source| write_error("SEG", source))?;
150                let actual = SegmentationDocument::read_seg(path, source)
151                    .map_err(|source| verification_error("SEG", source))?;
152                if actual.segments().len() != expected.segments().len()
153                    || actual.sop_instance_uid() != expected.sop_instance_uid()
154                    || actual
155                        .binary_runs()
156                        .map_err(|source| verification_error("SEG", source))?
157                        != expected
158                            .binary_runs()
159                            .map_err(|source| verification_error("SEG", source))?
160                {
161                    return Err(verification_mismatch("SEG"));
162                }
163            }
164            PathologyDicomTarget::Sr => {
165                let expected = self.sr.as_ref().ok_or_else(missing_document)?;
166                expected
167                    .write_sr(path)
168                    .map_err(|source| write_error("SR", source))?;
169                let actual = StructuredReportDocument::read_sr(path, source, self.seg.as_ref())
170                    .map_err(|source| verification_error("SR", source))?;
171                if !sr_semantics_match(&actual, expected) {
172                    return Err(verification_mismatch("SR"));
173                }
174            }
175        }
176        Ok(())
177    }
178}
179
180fn validate_targets(targets: &[PathologyDicomTarget]) -> Result<()> {
181    if targets.is_empty() {
182        return Err(Error::InvalidInput(
183            "pathology conversion requires at least one DICOM target".into(),
184        ));
185    }
186    if targets
187        .iter()
188        .enumerate()
189        .any(|(index, target)| targets[..index].contains(target))
190    {
191        return Err(Error::InvalidInput(
192            "pathology conversion targets must be unique".into(),
193        ));
194    }
195    Ok(())
196}
197
198fn sr_semantics_match(
199    actual: &StructuredReportDocument,
200    expected: &StructuredReportDocument,
201) -> bool {
202    actual.sop_instance_uid() == expected.sop_instance_uid()
203        && actual.series_instance_uid() == expected.series_instance_uid()
204        && actual.completion_flag() == expected.completion_flag()
205        && actual.verification_flag() == expected.verification_flag()
206        && actual.preliminary_flag() == expected.preliminary_flag()
207        && actual.groups().len() == expected.groups().len()
208        && actual
209            .groups()
210            .iter()
211            .zip(expected.groups())
212            .all(|(actual, expected)| {
213                actual.tracking_id() == expected.tracking_id()
214                    && actual.tracking_uid() == expected.tracking_uid()
215                    && actual.reference_kind() == expected.reference_kind()
216                    && actual.referenced_segmentation_uid()
217                        == expected.referenced_segmentation_uid()
218                    && actual.referenced_segment_number() == expected.referenced_segment_number()
219                    && actual.measurements().len() == expected.measurements().len()
220                    && actual
221                        .measurements()
222                        .iter()
223                        .zip(expected.measurements())
224                        .all(|(actual, expected)| {
225                            actual.concept() == expected.concept()
226                                && actual.unit() == expected.unit()
227                                && actual.value() == expected.value()
228                        })
229                    && actual.qualitative_evaluations() == expected.qualitative_evaluations()
230            })
231}
232
233fn missing_document() -> PathologyDocumentWriteError {
234    PathologyDocumentWriteError::MissingTarget
235}
236
237fn write_error(target: &'static str, source: Error) -> PathologyDocumentWriteError {
238    PathologyDocumentWriteError::Write { target, source }
239}
240
241fn verification_error(target: &'static str, source: Error) -> PathologyDocumentWriteError {
242    PathologyDocumentWriteError::Verification { target, source }
243}
244
245fn verification_mismatch(target: &'static str) -> PathologyDocumentWriteError {
246    PathologyDocumentWriteError::Mismatch(target)
247}