Skip to main content

wsi_dicom_annotations/annotations/pathology_geojson/
conversion.rs

1use crate::{Error, Result};
2
3use super::feature::ProfiledFeature;
4use super::geometry::ProfiledGeometry;
5use super::PathologyAnnotationSet;
6use crate::annotations::ann::AnnotationDocument;
7use crate::annotations::model::{
8    AnnotationGeometry, AnnotationGraphicType, AnnotationGroup, AnnotationMeasurement,
9};
10use crate::annotations::seg::{SegmentationDocument, SegmentationSegment};
11
12impl PathologyAnnotationSet {
13    /// Build one DICOM ANN group per source feature so feature identity remains exact.
14    pub fn to_ann(&self) -> Result<AnnotationDocument> {
15        self.build_ann(false)
16    }
17
18    /// Build ANN geometry and measurements when a companion SR preserves coded evaluations.
19    pub fn to_ann_with_companion_sr(&self) -> Result<AnnotationDocument> {
20        self.build_ann(true)
21    }
22
23    fn build_ann(&self, companion_sr: bool) -> Result<AnnotationDocument> {
24        let groups = self
25            .features
26            .iter()
27            .map(|feature| annotation_group(feature, companion_sr))
28            .collect::<Result<Vec<_>>>()?;
29        AnnotationDocument::new(self.source.clone(), groups)
30    }
31
32    /// Build a sparse binary SEG with one segment per source feature.
33    ///
34    /// `companion_sr` must be true when mapped measurements or qualitative
35    /// evaluations need the companion SR object selected by the caller.
36    pub fn to_seg(&self, companion_sr: bool) -> Result<SegmentationDocument> {
37        if !companion_sr
38            && self.features.iter().any(|feature| {
39                !feature.measurements.is_empty() || !feature.qualitative_evaluations.is_empty()
40            })
41        {
42            return Err(Error::InvalidInput(
43                "SEG cannot carry mapped measurements or conclusions without a companion SR target"
44                    .into(),
45            ));
46        }
47        let segments = self
48            .features
49            .iter()
50            .map(segmentation_segment)
51            .collect::<Result<Vec<_>>>()?;
52        SegmentationDocument::binary(self.source.clone(), segments)
53    }
54}
55
56fn annotation_group(feature: &ProfiledFeature, companion_sr: bool) -> Result<AnnotationGroup> {
57    if !companion_sr && !feature.qualitative_evaluations.is_empty() {
58        return Err(Error::InvalidInput(format!(
59            "feature {:?} has coded qualitative evaluations that require an SR target",
60            feature.tracking_id
61        )));
62    }
63    let geometry = ann_geometry(feature)?;
64    let annotation_count = geometry.annotation_count();
65    if annotation_count != 1 && !feature.measurements.is_empty() && !companion_sr {
66        return Err(Error::InvalidInput(format!(
67            "feature {:?} contains {annotation_count} ANN primitives, so its feature-level measurements require SR",
68            feature.tracking_id
69        )));
70    }
71    let measurements = if annotation_count == 1 {
72        feature
73            .measurements
74            .iter()
75            .map(|measurement| {
76                AnnotationMeasurement::new(
77                    measurement.mapping.concept.clone(),
78                    measurement.mapping.unit.clone(),
79                    vec![measurement.value],
80                )
81            })
82            .collect()
83    } else {
84        Vec::new()
85    };
86    let semantics = &feature.semantics;
87    AnnotationGroup::from_parts(
88        feature.tracking_uid.clone(),
89        feature.name.clone(),
90        String::new(),
91        semantics.finding.clone(),
92        semantics.all_optical_paths,
93        semantics.optical_paths.clone(),
94        semantics.all_z_planes,
95        semantics.z_coordinates_mm.clone(),
96        geometry,
97        measurements,
98    )
99}
100
101fn ann_geometry(feature: &ProfiledFeature) -> Result<AnnotationGeometry> {
102    match &feature.geometry {
103        ProfiledGeometry::Points(points) => Ok(AnnotationGeometry::Points(points.clone())),
104        ProfiledGeometry::Lines(lines) => polyline_geometry(lines),
105        ProfiledGeometry::Polygons(polygons) => {
106            if polygons.iter().any(|polygon| !polygon.holes.is_empty()) {
107                return Err(Error::InvalidInput(format!(
108                    "feature {:?}: ANN cannot represent polygon interior rings; select SEG",
109                    feature.tracking_id
110                )));
111            }
112            Ok(AnnotationGeometry::Polygons(
113                polygons
114                    .iter()
115                    .map(|polygon| polygon.outer.clone())
116                    .collect(),
117            ))
118        }
119    }
120}
121
122fn polyline_geometry(lines: &[Vec<crate::Point2>]) -> Result<AnnotationGeometry> {
123    let mut coordinates = Vec::new();
124    let mut primitive_point_indices = Vec::with_capacity(lines.len());
125    for line in lines {
126        primitive_point_indices.push(u32::try_from(coordinates.len() + 1).map_err(|_| {
127            Error::InvalidInput("ANN polyline coordinate index exceeds DICOM OL range".into())
128        })?);
129        coordinates.extend(line.iter().flat_map(|point| [point.x, point.y]));
130    }
131    Ok(AnnotationGeometry::ReadOnly {
132        graphic_type: AnnotationGraphicType::Polyline,
133        coordinates,
134        primitive_point_indices,
135        coordinate_dimensions: 2,
136    })
137}
138
139fn segmentation_segment(feature: &ProfiledFeature) -> Result<SegmentationSegment> {
140    let ProfiledGeometry::Polygons(polygons) = &feature.geometry else {
141        return Err(Error::InvalidInput(format!(
142            "feature {:?}: SEG accepts only Polygon and MultiPolygon geometry",
143            feature.tracking_id
144        )));
145    };
146    let semantics = &feature.semantics;
147    if !semantics.all_optical_paths || !semantics.all_z_planes {
148        return Err(Error::Unsupported(format!(
149            "feature {:?}: the current SEG writer cannot preserve restricted optical-path or Z applicability",
150            feature.tracking_id
151        )));
152    }
153    let outer_polygons = polygons
154        .iter()
155        .map(|polygon| polygon.outer.clone())
156        .collect();
157    let component_holes = polygons
158        .iter()
159        .map(|polygon| polygon.holes.clone())
160        .collect();
161    let segment = SegmentationSegment::new(
162        semantics.segment_label.clone(),
163        semantics.finding.category().clone(),
164        semantics.finding.property_type().clone(),
165        semantics.finding.recommended_display_cielab(),
166        outer_polygons,
167        Vec::new(),
168    )?
169    .with_component_holes(component_holes)?
170    .with_description(feature.name.clone())?
171    .with_finding_semantics(semantics.finding.clone())?
172    .with_tracking(feature.tracking_id.clone(), feature.tracking_uid.clone())?;
173    Ok(segment)
174}