Skip to main content

wsi_dicom_annotations/annotations/
ann.rs

1mod geometry_codec;
2mod read;
3mod write;
4
5#[cfg(test)]
6#[path = "ann_validation_tests.rs"]
7mod validation_tests;
8
9use std::collections::BTreeSet;
10use std::path::Path;
11
12use crate::{Error, Result};
13
14use super::context::DicomAnnotationContext;
15use super::derived_object::DerivedObjectProducer;
16use super::dicom_dataset::new_dicom_uid;
17use super::model::{AnnotationGeometry, AnnotationGroup, InteroperabilityDiagnostic, Point2};
18
19const MAX_ANN_FILE_BYTES: u64 = 512 * 1024 * 1024;
20const MAX_ANNOTATION_GROUPS: usize = 10_000;
21const MAX_COORDINATE_VALUES: usize = 32 * 1024 * 1024;
22
23#[derive(Debug, Clone, PartialEq)]
24pub struct AnnotationDocument {
25    source: DicomAnnotationContext,
26    sop_instance_uid: String,
27    series_instance_uid: String,
28    predecessor_sop_instance_uid: Option<String>,
29    coordinate_type: String,
30    pixel_origin_interpretation: Option<String>,
31    references_source_image: bool,
32    referenced_frame_number: Option<u32>,
33    volume_frame_of_reference_present: bool,
34    volume_container_identifier_present: bool,
35    content_label: String,
36    content_description: String,
37    content_creator_name: Option<String>,
38    producer: DerivedObjectProducer,
39    groups: Vec<AnnotationGroup>,
40    diagnostics: Vec<InteroperabilityDiagnostic>,
41}
42
43impl AnnotationDocument {
44    pub fn new(source: DicomAnnotationContext, groups: Vec<AnnotationGroup>) -> Result<Self> {
45        validate_new_2d_groups(&source, &groups)?;
46        let volume_frame_of_reference_present = source.frame_of_reference_uid().is_some();
47        let volume_container_identifier_present = source.container_identifier().is_some();
48        let document = Self {
49            source,
50            sop_instance_uid: new_dicom_uid(),
51            series_instance_uid: new_dicom_uid(),
52            predecessor_sop_instance_uid: None,
53            coordinate_type: "2D".into(),
54            pixel_origin_interpretation: Some("VOLUME".into()),
55            references_source_image: true,
56            referenced_frame_number: None,
57            volume_frame_of_reference_present,
58            volume_container_identifier_present,
59            content_label: "WSI_ANNOTATION".into(),
60            content_description: "WSI vector annotations".into(),
61            content_creator_name: None,
62            producer: DerivedObjectProducer::library_default(9101, "WSI annotations"),
63            groups,
64            diagnostics: Vec::new(),
65        };
66        document.validate()?;
67        Ok(document)
68    }
69
70    pub fn read_ann(path: impl AsRef<Path>, source: &DicomAnnotationContext) -> Result<Self> {
71        read::read_ann(path.as_ref(), source)
72    }
73
74    pub fn write_ann(&self, path: impl AsRef<Path>) -> Result<()> {
75        self.write_ann_with_loss_policy(path, false)
76    }
77
78    pub fn write_ann_with_loss_policy(
79        &self,
80        path: impl AsRef<Path>,
81        allow_lossy: bool,
82    ) -> Result<()> {
83        write::write_ann(self, path.as_ref(), allow_lossy)
84    }
85
86    #[must_use]
87    pub fn revised(&self) -> Self {
88        let mut document = self.clone();
89        document.sop_instance_uid = new_dicom_uid();
90        document.predecessor_sop_instance_uid = Some(self.sop_instance_uid.clone());
91        document
92    }
93
94    pub fn revised_with_groups(&self, groups: Vec<AnnotationGroup>) -> Result<Self> {
95        validate_new_2d_groups(&self.source, &groups)?;
96        let mut document = self.revised();
97        document.groups = groups;
98        document.validate()?;
99        Ok(document)
100    }
101
102    /// Replaces the neutral library identity with caller-owned producer metadata.
103    #[must_use]
104    pub fn with_producer(mut self, producer: DerivedObjectProducer) -> Self {
105        self.producer = producer;
106        self
107    }
108
109    #[must_use]
110    pub fn producer(&self) -> &DerivedObjectProducer {
111        &self.producer
112    }
113
114    /// Atomically replaces all groups after validating group and document invariants.
115    pub fn replace_groups(&mut self, groups: Vec<AnnotationGroup>) -> Result<()> {
116        validate_new_2d_groups(&self.source, &groups)?;
117        let mut candidate = self.clone();
118        candidate.groups = groups;
119        candidate.validate()?;
120        self.groups = candidate.groups;
121        Ok(())
122    }
123
124    /// Atomically replaces one group after validating group and document invariants.
125    pub fn replace_group(&mut self, index: usize, group: AnnotationGroup) -> Result<()> {
126        let mut groups = self.groups.clone();
127        let group_count = groups.len();
128        let destination = groups.get_mut(index).ok_or_else(|| {
129            Error::InvalidInput(format!(
130                "annotation group index {index} is outside the {}-group document",
131                group_count
132            ))
133        })?;
134        *destination = group;
135        self.replace_groups(groups)
136    }
137
138    #[must_use]
139    pub fn source(&self) -> &DicomAnnotationContext {
140        &self.source
141    }
142
143    #[must_use]
144    pub fn sop_instance_uid(&self) -> &str {
145        &self.sop_instance_uid
146    }
147
148    #[must_use]
149    pub fn series_instance_uid(&self) -> &str {
150        &self.series_instance_uid
151    }
152
153    #[must_use]
154    pub fn predecessor_sop_instance_uid(&self) -> Option<&str> {
155        self.predecessor_sop_instance_uid.as_deref()
156    }
157
158    #[must_use]
159    pub fn supports_2d_volume_editing(&self) -> bool {
160        self.coordinate_type == "2D"
161            && self.pixel_origin_interpretation.as_deref() == Some("VOLUME")
162    }
163
164    #[must_use]
165    pub fn coordinate_type(&self) -> &str {
166        &self.coordinate_type
167    }
168
169    #[must_use]
170    pub fn pixel_origin_interpretation(&self) -> Option<&str> {
171        self.pixel_origin_interpretation.as_deref()
172    }
173
174    #[must_use]
175    pub const fn referenced_frame_number(&self) -> Option<u32> {
176        self.referenced_frame_number
177    }
178
179    #[must_use]
180    pub fn content_label(&self) -> &str {
181        &self.content_label
182    }
183
184    #[must_use]
185    pub fn content_description(&self) -> &str {
186        &self.content_description
187    }
188
189    #[must_use]
190    pub fn content_creator_name(&self) -> Option<&str> {
191        self.content_creator_name.as_deref()
192    }
193
194    pub fn canonical_level0_pixel(
195        &self,
196        canonical_source: &DicomAnnotationContext,
197        x: f64,
198        y: f64,
199        z: Option<f64>,
200    ) -> Result<Point2> {
201        if self.source.frame_of_reference_uid().is_none()
202            || self.source.frame_of_reference_uid() != canonical_source.frame_of_reference_uid()
203        {
204            return Err(Error::InvalidInput(
205                "annotation source and canonical source do not share a Frame of Reference UID"
206                    .into(),
207            ));
208        }
209        let canonical_pixel = if self.coordinate_type == "3D" {
210            canonical_source.slide_coordinate_to_pixel3(
211                x,
212                y,
213                z.ok_or_else(|| {
214                    Error::InvalidInput("3D ANN canonicalization requires a Z coordinate".into())
215                })?,
216            )?
217        } else {
218            let source_pixel = match self.pixel_origin_interpretation.as_deref() {
219                Some("VOLUME") => Point2::new(x, y),
220                Some("FRAME") => self.source.frame_coordinate_to_total_pixel(
221                    self.referenced_frame_number.ok_or_else(|| {
222                        Error::InvalidInput("FRAME-relative ANN has no referenced frame".into())
223                    })?,
224                    x,
225                    y,
226                )?,
227                value => {
228                    return Err(Error::Unsupported(format!(
229                        "cannot canonicalize Pixel Origin Interpretation {value:?}"
230                    )))
231                }
232            };
233            let slide = self
234                .source
235                .pixel_to_slide_coordinate(source_pixel.x, source_pixel.y)?;
236            canonical_source.slide_coordinate_to_pixel3(slide.x, slide.y, slide.z)?
237        };
238        canonical_source.validate_point(canonical_pixel.x, canonical_pixel.y)?;
239        Ok(canonical_pixel)
240    }
241
242    #[must_use]
243    pub fn groups(&self) -> &[AnnotationGroup] {
244        &self.groups
245    }
246
247    #[must_use]
248    pub fn diagnostics(&self) -> &[InteroperabilityDiagnostic] {
249        &self.diagnostics
250    }
251
252    fn ensure_roundtrip_safe(&self, allow_lossy: bool) -> Result<()> {
253        if allow_lossy {
254            return Ok(());
255        }
256        let blocking = self
257            .diagnostics
258            .iter()
259            .filter(|diagnostic| diagnostic.blocks_roundtrip())
260            .map(InteroperabilityDiagnostic::code)
261            .collect::<Vec<_>>();
262        if blocking.is_empty() {
263            Ok(())
264        } else {
265            Err(Error::Unsupported(format!(
266                "ANN rewrite would lose semantics ({}); pass the explicit lossy override to continue",
267                blocking.join(", ")
268            )))
269        }
270    }
271
272    fn validate(&self) -> Result<()> {
273        if self.groups.is_empty() || self.groups.len() > MAX_ANNOTATION_GROUPS {
274            return Err(Error::InvalidInput(format!(
275                "an ANN document must contain 1..={MAX_ANNOTATION_GROUPS} groups"
276            )));
277        }
278        let mut coordinate_values = 0_usize;
279        let mut group_uids = BTreeSet::new();
280        for group in &self.groups {
281            group.validate()?;
282            if !group_uids.insert(group.uid()) {
283                return Err(Error::InvalidInput(format!(
284                    "duplicate Annotation Group UID {}",
285                    group.uid()
286                )));
287            }
288            match group.geometry() {
289                AnnotationGeometry::Points(points) => {
290                    for point in points {
291                        self.source.validate_point(point.x, point.y)?;
292                    }
293                    coordinate_values = coordinate_values.saturating_add(points.len() * 2);
294                }
295                AnnotationGeometry::Polygons(polygons) => {
296                    for polygon in polygons {
297                        for point in polygon {
298                            self.source.validate_point(point.x, point.y)?;
299                        }
300                        coordinate_values =
301                            coordinate_values.saturating_add(polygon.len().saturating_mul(2));
302                    }
303                }
304                AnnotationGeometry::ReadOnly {
305                    coordinates,
306                    coordinate_dimensions,
307                    ..
308                } => {
309                    if self.coordinate_type == "2D" {
310                        if *coordinate_dimensions != 2 {
311                            return Err(Error::InvalidInput(
312                                "2D ANN geometry does not contain coordinate pairs".into(),
313                            ));
314                        }
315                        for point in coordinates.as_chunks::<2>().0 {
316                            match self.pixel_origin_interpretation.as_deref() {
317                                Some("VOLUME") => self.source.validate_point(point[0], point[1])?,
318                                Some("FRAME") => {
319                                    self.source.frame_coordinate_to_total_pixel(
320                                        self.referenced_frame_number.ok_or_else(|| {
321                                            Error::InvalidInput(
322                                                "FRAME-relative ANN has no referenced frame".into(),
323                                            )
324                                        })?,
325                                        point[0],
326                                        point[1],
327                                    )?;
328                                }
329                                _ => {
330                                    return Err(Error::InvalidInput(
331                                        "2D ANN has no valid Pixel Origin Interpretation".into(),
332                                    ))
333                                }
334                            }
335                        }
336                    } else {
337                        match *coordinate_dimensions {
338                            2 if group.common_z_coordinates().is_empty() => {
339                                return Err(Error::InvalidInput(
340                                    "3D ANN coordinate pairs require Common Z Coordinate Value"
341                                        .into(),
342                                ));
343                            }
344                            3 if !group.common_z_coordinates().is_empty() => {
345                                return Err(Error::InvalidInput(
346                                    "3D ANN coordinate triplets cannot also contain Common Z Coordinate Value"
347                                        .into(),
348                                ));
349                            }
350                            2 | 3 => {}
351                            _ => {
352                                return Err(Error::InvalidInput(
353                                    "3D ANN geometry must contain coordinate pairs or triplets"
354                                        .into(),
355                                ))
356                            }
357                        }
358                    }
359                    coordinate_values = coordinate_values.saturating_add(coordinates.len());
360                }
361            }
362        }
363        if coordinate_values > MAX_COORDINATE_VALUES {
364            return Err(Error::InvalidInput(format!(
365                "ANN contains {coordinate_values} coordinate values, exceeding the {MAX_COORDINATE_VALUES}-value limit"
366            )));
367        }
368        Ok(())
369    }
370}
371
372fn validate_new_2d_groups(
373    source: &DicomAnnotationContext,
374    groups: &[AnnotationGroup],
375) -> Result<()> {
376    for group in groups {
377        if !group.applies_to_all_z_planes() || !group.common_z_coordinates().is_empty() {
378            return Err(Error::Unsupported(format!(
379                "2D ANN group {:?} cannot carry Z-plane applicability",
380                group.label()
381            )));
382        }
383        for path in group.referenced_optical_paths() {
384            if !source.optical_path_identifiers().contains(path) {
385                return Err(Error::InvalidInput(format!(
386                    "annotation group {:?} references optical path {path:?}, which is not declared by the source WSI",
387                    group.label()
388                )));
389            }
390        }
391    }
392    Ok(())
393}