wsi_dicom_annotations/annotations/
ann.rs1mod 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 #[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 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 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}