Skip to main content

wsi_dicom_annotations/annotations/model/
annotation.rs

1use crate::{Error, Result};
2
3use super::super::dicom_dataset::new_dicom_uid;
4use super::algorithm::{AlgorithmIdentification, GenerationType};
5use super::code::{is_valid_dicom_uid, validate_text, DicomCode, MAX_LONG_TEXT_BYTES};
6use super::finding::FindingSemantics;
7use super::geometry::{validate_points, validate_polygon, AnnotationGeometry, Point2};
8use crate::annotations::semantic_digest::deterministic_annotation_group_uid;
9
10#[derive(Debug, Clone, PartialEq)]
11pub struct AnnotationMeasurement {
12    concept: DicomCode,
13    units: DicomCode,
14    values: Vec<f64>,
15    annotation_indices: Option<Vec<u32>>,
16}
17
18impl AnnotationMeasurement {
19    #[must_use]
20    pub fn new(concept: DicomCode, units: DicomCode, values: Vec<f64>) -> Self {
21        Self {
22            concept,
23            units,
24            values,
25            annotation_indices: None,
26        }
27    }
28
29    pub fn for_annotations(
30        concept: DicomCode,
31        units: DicomCode,
32        values: Vec<f64>,
33        annotation_indices: Vec<u32>,
34    ) -> Result<Self> {
35        if values.len() != annotation_indices.len() {
36            return Err(Error::InvalidInput(format!(
37                "measurement has {} values but {} annotation indices",
38                values.len(),
39                annotation_indices.len()
40            )));
41        }
42        if annotation_indices.windows(2).any(|pair| pair[0] >= pair[1])
43            || annotation_indices.first().is_some_and(|index| *index == 0)
44        {
45            return Err(Error::InvalidInput(
46                "measurement annotation indices must be strictly increasing and one-based".into(),
47            ));
48        }
49        Ok(Self {
50            concept,
51            units,
52            values,
53            annotation_indices: Some(annotation_indices),
54        })
55    }
56
57    #[must_use]
58    pub fn concept(&self) -> &DicomCode {
59        &self.concept
60    }
61
62    #[must_use]
63    pub fn units(&self) -> &DicomCode {
64        &self.units
65    }
66
67    #[must_use]
68    pub fn values(&self) -> &[f64] {
69        &self.values
70    }
71
72    #[must_use]
73    pub fn annotation_indices(&self) -> Option<&[u32]> {
74        self.annotation_indices.as_deref()
75    }
76}
77
78#[derive(Debug, Clone, PartialEq)]
79pub struct AnnotationGroup {
80    uid: String,
81    label: String,
82    description: String,
83    finding: FindingSemantics,
84    applies_to_all_optical_paths: bool,
85    referenced_optical_paths: Vec<String>,
86    applies_to_all_z_planes: bool,
87    common_z_coordinates: Vec<f64>,
88    geometry: AnnotationGeometry,
89    measurements: Vec<AnnotationMeasurement>,
90}
91
92impl AnnotationGroup {
93    pub fn points(
94        label: impl Into<String>,
95        category: DicomCode,
96        property_type: DicomCode,
97        recommended_display_cielab: [u16; 3],
98        points: Vec<Point2>,
99    ) -> Result<Self> {
100        validate_points(points.iter().copied())?;
101        Self::from_parts(
102            new_dicom_uid(),
103            label.into(),
104            String::new(),
105            FindingSemantics::manual(category, property_type, recommended_display_cielab),
106            true,
107            Vec::new(),
108            true,
109            Vec::new(),
110            AnnotationGeometry::Points(points),
111            Vec::new(),
112        )
113    }
114
115    pub fn polygons(
116        label: impl Into<String>,
117        category: DicomCode,
118        property_type: DicomCode,
119        recommended_display_cielab: [u16; 3],
120        polygons: Vec<Vec<Point2>>,
121    ) -> Result<Self> {
122        for polygon in &polygons {
123            validate_polygon(polygon)?;
124        }
125        Self::from_parts(
126            new_dicom_uid(),
127            label.into(),
128            String::new(),
129            FindingSemantics::manual(category, property_type, recommended_display_cielab),
130            true,
131            Vec::new(),
132            true,
133            Vec::new(),
134            AnnotationGeometry::Polygons(polygons),
135            Vec::new(),
136        )
137    }
138
139    #[allow(clippy::too_many_arguments)]
140    pub(crate) fn from_parts(
141        uid: String,
142        label: String,
143        description: String,
144        finding: FindingSemantics,
145        applies_to_all_optical_paths: bool,
146        referenced_optical_paths: Vec<String>,
147        applies_to_all_z_planes: bool,
148        common_z_coordinates: Vec<f64>,
149        geometry: AnnotationGeometry,
150        measurements: Vec<AnnotationMeasurement>,
151    ) -> Result<Self> {
152        let group = Self {
153            uid,
154            label,
155            description,
156            finding,
157            applies_to_all_optical_paths,
158            referenced_optical_paths,
159            applies_to_all_z_planes,
160            common_z_coordinates,
161            geometry,
162            measurements,
163        };
164        group.validate()?;
165        Ok(group)
166    }
167
168    pub fn with_description(mut self, description: impl Into<String>) -> Result<Self> {
169        let description = description.into();
170        if description.len() > MAX_LONG_TEXT_BYTES {
171            return Err(Error::InvalidInput(format!(
172                "annotation group description exceeds {MAX_LONG_TEXT_BYTES} bytes"
173            )));
174        }
175        if description.contains('\0') {
176            return Err(Error::InvalidInput(
177                "annotation group description contains NUL".into(),
178            ));
179        }
180        self.description = description;
181        Ok(self)
182    }
183
184    /// Replaces the DICOM Annotation Group UID with a stable, caller-owned UID.
185    ///
186    /// This is useful when one tracked finding is represented by one ANN group
187    /// and its identity must remain stable across repeated exports.
188    pub fn with_uid(mut self, uid: impl Into<String>) -> Result<Self> {
189        let uid = uid.into();
190        validate_text("annotation group UID", &uid, 64)?;
191        if !super::code::is_valid_dicom_uid(&uid) {
192            return Err(Error::InvalidInput(
193                "annotation group UID is not a valid DICOM UID".into(),
194            ));
195        }
196        self.uid = uid;
197        Ok(self)
198    }
199
200    /// Derives a stable DICOM Annotation Group UID from caller identity and group semantics.
201    ///
202    /// Geometry, measurements, and the group's current UID are deliberately excluded so that
203    /// moving or remeasuring one logical group does not change its identity. Changing its label,
204    /// coded finding, generation metadata, display color, or applicability does change the UID.
205    pub fn with_deterministic_uid(
206        mut self,
207        namespace: &str,
208        scope: &str,
209        application_key: &str,
210    ) -> Result<Self> {
211        self.uid = deterministic_annotation_group_uid(&self, namespace, scope, application_key);
212        self.validate()?;
213        Ok(self)
214    }
215
216    pub fn with_generation(
217        mut self,
218        generation_type: GenerationType,
219        algorithms: Vec<AlgorithmIdentification>,
220    ) -> Result<Self> {
221        self.finding = self.finding.with_generation(generation_type, algorithms)?;
222        Ok(self)
223    }
224
225    #[must_use]
226    pub fn with_property_type_modifiers(mut self, modifiers: Vec<DicomCode>) -> Self {
227        self.finding = self.finding.with_property_type_modifiers(modifiers);
228        self
229    }
230
231    #[must_use]
232    pub fn with_anatomic_regions(mut self, regions: Vec<DicomCode>) -> Self {
233        self.finding = self.finding.with_anatomic_regions(regions);
234        self
235    }
236
237    #[must_use]
238    pub fn with_primary_anatomic_structures(mut self, structures: Vec<DicomCode>) -> Self {
239        self.finding = self.finding.with_primary_anatomic_structures(structures);
240        self
241    }
242
243    pub(crate) fn with_finding_semantics(mut self, finding: FindingSemantics) -> Result<Self> {
244        self.finding = finding;
245        self.validate()?;
246        Ok(self)
247    }
248
249    pub fn with_referenced_optical_paths(mut self, paths: Vec<String>) -> Result<Self> {
250        validate_optical_path_applicability(false, &paths)?;
251        for path in &paths {
252            validate_text("referenced optical path identifier", path, 16)?;
253        }
254        if paths
255            .iter()
256            .enumerate()
257            .any(|(index, path)| paths[index + 1..].contains(path))
258        {
259            return Err(Error::InvalidInput(
260                "referenced optical path identifiers must be unique".into(),
261            ));
262        }
263        self.applies_to_all_optical_paths = false;
264        self.referenced_optical_paths = paths;
265        Ok(self)
266    }
267
268    pub fn with_common_z_coordinates(mut self, coordinates: Vec<f64>) -> Result<Self> {
269        if coordinates.is_empty() || coordinates.iter().any(|value| !value.is_finite()) {
270            return Err(Error::InvalidInput(
271                "common Z coordinates must contain finite values".into(),
272            ));
273        }
274        self.applies_to_all_z_planes = false;
275        self.common_z_coordinates = coordinates;
276        Ok(self)
277    }
278
279    pub fn add_measurement(&mut self, measurement: AnnotationMeasurement) -> Result<()> {
280        self.measurements.push(measurement);
281        if let Err(error) = self.validate_measurements() {
282            self.measurements.pop();
283            return Err(error);
284        }
285        Ok(())
286    }
287
288    pub fn validate_geometry(&self) -> Result<()> {
289        match &self.geometry {
290            AnnotationGeometry::Points(points) => validate_points(points.iter().copied()),
291            AnnotationGeometry::Polygons(polygons) => {
292                for polygon in polygons {
293                    validate_polygon(polygon)?;
294                }
295                Ok(())
296            }
297            AnnotationGeometry::ReadOnly { .. } => Ok(()),
298        }
299    }
300
301    pub(crate) fn validate(&self) -> Result<()> {
302        validate_text("annotation group UID", &self.uid, 64)?;
303        if !is_valid_dicom_uid(&self.uid) {
304            return Err(Error::InvalidInput(
305                "annotation group UID is not a valid DICOM UID".into(),
306            ));
307        }
308        validate_text("annotation group label", &self.label, 64)?;
309        if self.description.len() > MAX_LONG_TEXT_BYTES || self.description.contains('\0') {
310            return Err(Error::InvalidInput(format!(
311                "annotation group description exceeds {MAX_LONG_TEXT_BYTES} bytes or contains NUL"
312            )));
313        }
314        self.finding.validate()?;
315        validate_optical_path_applicability(
316            self.applies_to_all_optical_paths,
317            &self.referenced_optical_paths,
318        )?;
319        for path in &self.referenced_optical_paths {
320            validate_text("referenced optical path identifier", path, 16)?;
321        }
322        if self
323            .referenced_optical_paths
324            .iter()
325            .enumerate()
326            .any(|(index, path)| self.referenced_optical_paths[index + 1..].contains(path))
327        {
328            return Err(Error::InvalidInput(
329                "referenced optical path identifiers must be unique".into(),
330            ));
331        }
332        if self
333            .common_z_coordinates
334            .iter()
335            .any(|value| !value.is_finite())
336        {
337            return Err(Error::InvalidInput(
338                "common Z coordinates must be finite".into(),
339            ));
340        }
341        if self.applies_to_all_z_planes && !self.common_z_coordinates.is_empty() {
342            return Err(Error::InvalidInput(
343                "common Z coordinates cannot be present when annotations apply to all Z planes"
344                    .into(),
345            ));
346        }
347        if self.annotation_count() == 0 {
348            return Err(Error::InvalidInput(format!(
349                "annotation group {:?} has no annotations",
350                self.label
351            )));
352        }
353        self.validate_geometry()?;
354        self.validate_measurements()
355    }
356
357    #[must_use]
358    pub fn annotation_count(&self) -> usize {
359        self.geometry.annotation_count()
360    }
361
362    fn validate_measurements(&self) -> Result<()> {
363        let count = self.geometry.annotation_count();
364        for measurement in &self.measurements {
365            if measurement.values.iter().any(|value| !value.is_finite()) {
366                return Err(Error::InvalidInput(
367                    "annotation measurements must be finite".into(),
368                ));
369            }
370            match measurement.annotation_indices() {
371                Some(indices) => {
372                    if indices.last().is_some_and(|index| *index as usize > count) {
373                        return Err(Error::InvalidInput(format!(
374                            "measurement references annotation beyond group size {count}"
375                        )));
376                    }
377                }
378                None if measurement.values.len() != count => {
379                    return Err(Error::InvalidInput(format!(
380                        "measurement has {} values for {count} annotations",
381                        measurement.values.len()
382                    )));
383                }
384                None => {}
385            }
386        }
387        Ok(())
388    }
389
390    #[must_use]
391    pub fn uid(&self) -> &str {
392        &self.uid
393    }
394
395    #[must_use]
396    pub fn label(&self) -> &str {
397        &self.label
398    }
399
400    #[must_use]
401    pub fn description(&self) -> &str {
402        &self.description
403    }
404
405    #[must_use]
406    pub const fn generation_type(&self) -> GenerationType {
407        self.finding.generation_type()
408    }
409
410    #[must_use]
411    pub fn algorithms(&self) -> &[AlgorithmIdentification] {
412        self.finding.algorithms()
413    }
414
415    #[must_use]
416    pub fn category(&self) -> &DicomCode {
417        self.finding.category()
418    }
419
420    #[must_use]
421    pub fn property_type(&self) -> &DicomCode {
422        self.finding.property_type()
423    }
424
425    #[must_use]
426    pub fn property_type_modifiers(&self) -> &[DicomCode] {
427        self.finding.property_type_modifiers()
428    }
429
430    #[must_use]
431    pub fn anatomic_regions(&self) -> &[DicomCode] {
432        self.finding.anatomic_regions()
433    }
434
435    #[must_use]
436    pub fn primary_anatomic_structures(&self) -> &[DicomCode] {
437        self.finding.primary_anatomic_structures()
438    }
439
440    #[must_use]
441    pub const fn applies_to_all_optical_paths(&self) -> bool {
442        self.applies_to_all_optical_paths
443    }
444
445    #[must_use]
446    pub fn referenced_optical_paths(&self) -> &[String] {
447        &self.referenced_optical_paths
448    }
449
450    #[must_use]
451    pub const fn applies_to_all_z_planes(&self) -> bool {
452        self.applies_to_all_z_planes
453    }
454
455    #[must_use]
456    pub fn common_z_coordinates(&self) -> &[f64] {
457        &self.common_z_coordinates
458    }
459
460    #[must_use]
461    pub const fn recommended_display_cielab(&self) -> [u16; 3] {
462        self.finding.recommended_display_cielab()
463    }
464
465    #[must_use]
466    pub fn geometry(&self) -> &AnnotationGeometry {
467        &self.geometry
468    }
469
470    #[must_use]
471    pub fn point_annotations(&self) -> Option<&[Point2]> {
472        match &self.geometry {
473            AnnotationGeometry::Points(points) => Some(points),
474            _ => None,
475        }
476    }
477
478    /// Atomically replaces point geometry after revalidating all group invariants.
479    pub fn replace_points(&mut self, points: Vec<Point2>) -> Result<()> {
480        if !matches!(self.geometry, AnnotationGeometry::Points(_)) {
481            return Err(Error::InvalidInput(
482                "point replacement requires a point annotation group".into(),
483            ));
484        }
485        let mut candidate = self.clone();
486        candidate.geometry = AnnotationGeometry::Points(points);
487        candidate.validate()?;
488        *self = candidate;
489        Ok(())
490    }
491
492    #[must_use]
493    pub fn polygon_annotations(&self) -> Option<&[Vec<Point2>]> {
494        match &self.geometry {
495            AnnotationGeometry::Polygons(polygons) => Some(polygons),
496            _ => None,
497        }
498    }
499
500    /// Atomically replaces polygon geometry after revalidating all group invariants.
501    pub fn replace_polygons(&mut self, polygons: Vec<Vec<Point2>>) -> Result<()> {
502        if !matches!(self.geometry, AnnotationGeometry::Polygons(_)) {
503            return Err(Error::InvalidInput(
504                "polygon replacement requires a polygon annotation group".into(),
505            ));
506        }
507        let mut candidate = self.clone();
508        candidate.geometry = AnnotationGeometry::Polygons(polygons);
509        candidate.validate()?;
510        *self = candidate;
511        Ok(())
512    }
513
514    #[must_use]
515    pub fn measurements(&self) -> &[AnnotationMeasurement] {
516        &self.measurements
517    }
518
519    #[must_use]
520    pub const fn editable(&self) -> bool {
521        self.geometry.editable()
522    }
523}
524
525fn validate_optical_path_applicability(all: bool, identifiers: &[String]) -> Result<()> {
526    if all == identifiers.is_empty() {
527        return Ok(());
528    }
529    if all {
530        return Err(Error::InvalidInput(
531            "referenced optical path identifiers cannot be present when applicability is all"
532                .into(),
533        ));
534    }
535    Err(Error::InvalidInput(
536        "at least one referenced optical path identifier is required".into(),
537    ))
538}
539
540#[cfg(test)]
541#[path = "annotation_tests.rs"]
542mod tests;