wsi_dicom_annotations/annotations/pathology_geojson/
conversion.rs1use 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 pub fn to_ann(&self) -> Result<AnnotationDocument> {
15 self.build_ann(false)
16 }
17
18 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 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}