wsi_dicom_annotations/annotations/pathology_geojson/
sr_conversion.rs1use std::collections::BTreeMap;
2
3use crate::{Error, Point2, Result};
4
5use super::feature::ProfiledFeature;
6use super::geometry::ProfiledGeometry;
7use super::{PathologyAnnotationSet, PathologyGeometryKind};
8use crate::annotations::seg::SegmentationDocument;
9use crate::annotations::sr::{
10 CoordinateGraphic, RegionReference, SegmentationReference, SpatialCoordinates,
11 StructuredReportDocument, StructuredReportMeasurement, StructuredReportMeasurementGroup,
12 StructuredReportQualitativeEvaluation,
13};
14
15impl PathologyAnnotationSet {
16 pub fn to_sr(
22 &self,
23 segmentation: Option<&SegmentationDocument>,
24 ) -> Result<StructuredReportDocument> {
25 let mapping = self.sr_mapping.as_ref().ok_or_else(|| {
26 Error::InvalidInput("SR target requires an sr section in the mapping profile".into())
27 })?;
28 let segmentation_frames = segmentation
29 .map(|document| segmentation_frame_numbers(document, &self.source))
30 .transpose()?;
31 let groups = self
32 .features
33 .iter()
34 .map(|feature| {
35 measurement_group(
36 feature,
37 &self.source,
38 segmentation,
39 segmentation_frames.as_ref(),
40 )
41 })
42 .collect::<Result<Vec<_>>>()?;
43 StructuredReportDocument::new(
44 self.source.clone(),
45 mapping.report_title.clone(),
46 mapping.procedures_reported.clone(),
47 groups,
48 )
49 }
50}
51
52fn measurement_group(
53 feature: &ProfiledFeature,
54 source: &crate::DicomAnnotationContext,
55 segmentation: Option<&SegmentationDocument>,
56 segmentation_frames: Option<&BTreeMap<u16, Vec<u32>>>,
57) -> Result<StructuredReportMeasurementGroup> {
58 let (template_id, reference, measurement_coordinates) = match feature.kind {
59 PathologyGeometryKind::Point => (
60 "1410",
61 RegionReference::Coordinates(direct_coordinates(feature, source)?),
62 Vec::new(),
63 ),
64 PathologyGeometryKind::Polygon if polygon_is_simple(feature) => (
65 "1410",
66 RegionReference::Coordinates(direct_coordinates(feature, source)?),
67 Vec::new(),
68 ),
69 PathologyGeometryKind::Polygon | PathologyGeometryKind::MultiPolygon => (
70 "1410",
71 RegionReference::Segmentation(segmentation_reference(
72 feature,
73 segmentation,
74 segmentation_frames,
75 )?),
76 Vec::new(),
77 ),
78 PathologyGeometryKind::MultiPoint
79 | PathologyGeometryKind::LineString
80 | PathologyGeometryKind::MultiLineString => {
81 if feature.measurements.is_empty() {
82 return Err(Error::InvalidInput(format!(
83 "feature {:?}: multipoint and line geometry requires a mapped numeric measurement for lossless SR representation",
84 feature.tracking_id
85 )));
86 }
87 (
88 "1501",
89 RegionReference::MeasurementCoordinates,
90 measurement_coordinates(feature, source)?,
91 )
92 }
93 };
94 let measurements = feature
95 .measurements
96 .iter()
97 .map(|measurement| StructuredReportMeasurement {
98 concept: measurement.mapping.concept.clone(),
99 unit: measurement.mapping.unit.clone(),
100 value: measurement.value,
101 coordinates: measurement_coordinates.clone(),
102 })
103 .collect();
104 let qualitative_evaluations = feature
105 .qualitative_evaluations
106 .iter()
107 .map(|evaluation| StructuredReportQualitativeEvaluation {
108 concept: evaluation.concept.clone(),
109 value: evaluation.value.clone(),
110 })
111 .collect();
112 Ok(StructuredReportMeasurementGroup {
113 template_id,
114 tracking_id: feature.tracking_id.clone(),
115 tracking_uid: feature.tracking_uid.clone(),
116 finding_category: feature.semantics.finding.category().clone(),
117 finding_type: feature.semantics.finding.property_type().clone(),
118 algorithms: feature.semantics.finding.algorithms().to_vec(),
119 finding_sites: feature.semantics.finding.anatomic_regions().to_vec(),
120 reference,
121 measurements,
122 qualitative_evaluations,
123 })
124}
125
126fn polygon_is_simple(feature: &ProfiledFeature) -> bool {
127 matches!(
128 &feature.geometry,
129 ProfiledGeometry::Polygons(polygons)
130 if polygons.len() == 1 && polygons[0].holes.is_empty()
131 )
132}
133
134fn direct_coordinates(
135 feature: &ProfiledFeature,
136 source: &crate::DicomAnnotationContext,
137) -> Result<SpatialCoordinates> {
138 let (graphic, points) = match &feature.geometry {
139 ProfiledGeometry::Points(points) if points.len() == 1 => {
140 (CoordinateGraphic::Point, points.as_slice())
141 }
142 ProfiledGeometry::Polygons(polygons)
143 if polygons.len() == 1 && polygons[0].holes.is_empty() =>
144 {
145 (CoordinateGraphic::Polygon, polygons[0].outer.as_slice())
146 }
147 _ => {
148 return Err(Error::InvalidInput(format!(
149 "feature {:?} is not a directly representable TID 1410 region",
150 feature.tracking_id
151 )));
152 }
153 };
154 spatial_coordinates(graphic, points, source)
155}
156
157fn measurement_coordinates(
158 feature: &ProfiledFeature,
159 source: &crate::DicomAnnotationContext,
160) -> Result<Vec<SpatialCoordinates>> {
161 match &feature.geometry {
162 ProfiledGeometry::Points(points) => Ok(vec![spatial_coordinates(
163 CoordinateGraphic::Multipoint,
164 points,
165 source,
166 )?]),
167 ProfiledGeometry::Lines(lines) => lines
168 .iter()
169 .map(|line| spatial_coordinates(CoordinateGraphic::Polyline, line, source))
170 .collect(),
171 ProfiledGeometry::Polygons(_) => Err(Error::InvalidInput(
172 "polygon geometry cannot use TID 1501 measurement coordinates".into(),
173 )),
174 }
175}
176
177fn spatial_coordinates(
178 graphic: CoordinateGraphic,
179 points: &[Point2],
180 source: &crate::DicomAnnotationContext,
181) -> Result<SpatialCoordinates> {
182 let frame_of_reference_uid = source
183 .frame_of_reference_uid()
184 .ok_or_else(|| Error::InvalidInput("source WSI has no Frame of Reference UID".into()))?;
185 let points = points
186 .iter()
187 .map(|point| source.pixel_to_slide_coordinate(point.x, point.y))
188 .collect::<Result<Vec<_>>>()?;
189 Ok(SpatialCoordinates {
190 graphic,
191 points,
192 frame_of_reference_uid: frame_of_reference_uid.to_string(),
193 })
194}
195
196fn segmentation_frame_numbers(
197 segmentation: &SegmentationDocument,
198 source: &crate::DicomAnnotationContext,
199) -> Result<BTreeMap<u16, Vec<u32>>> {
200 if segmentation.source().study_instance_uid() != source.study_instance_uid()
201 || segmentation.source().sop_instance_uid() != source.sop_instance_uid()
202 || segmentation.source().frame_of_reference_uid() != source.frame_of_reference_uid()
203 {
204 return Err(Error::InvalidInput(
205 "companion SEG does not reference the profiled source WSI".into(),
206 ));
207 }
208 let mut by_segment = BTreeMap::<u16, Vec<u32>>::new();
209 for (index, frame) in segmentation.rasterized_frames()?.iter().enumerate() {
210 let frame_number = u32::try_from(index + 1)
211 .map_err(|_| Error::InvalidInput("SEG frame number exceeds u32".into()))?;
212 by_segment
213 .entry(frame.segment_number())
214 .or_default()
215 .push(frame_number);
216 }
217 Ok(by_segment)
218}
219
220fn segmentation_reference(
221 feature: &ProfiledFeature,
222 segmentation: Option<&SegmentationDocument>,
223 frames: Option<&BTreeMap<u16, Vec<u32>>>,
224) -> Result<SegmentationReference> {
225 let segmentation = segmentation.ok_or_else(|| {
226 Error::InvalidInput(format!(
227 "feature {:?}: polygon holes or disconnected components require a SEG target referenced by SR",
228 feature.tracking_id
229 ))
230 })?;
231 let (segment_index, segment) = segmentation
232 .segments()
233 .iter()
234 .enumerate()
235 .find(|(_, segment)| {
236 segment.tracking_id() == Some(feature.tracking_id.as_str())
237 && segment.tracking_uid() == Some(feature.tracking_uid.as_str())
238 })
239 .ok_or_else(|| {
240 Error::InvalidInput(format!(
241 "companion SEG has no segment matching feature {:?} tracking identity",
242 feature.tracking_id
243 ))
244 })?;
245 let segment_number = segment.source_segment_number().unwrap_or(
246 u16::try_from(segment_index + 1)
247 .map_err(|_| Error::InvalidInput("SEG segment number exceeds u16".into()))?,
248 );
249 let frame_numbers = frames
250 .and_then(|frames| frames.get(&segment_number))
251 .filter(|frames| !frames.is_empty())
252 .cloned()
253 .ok_or_else(|| {
254 Error::InvalidInput(format!(
255 "companion SEG segment {segment_number} has no rasterized frames"
256 ))
257 })?;
258 Ok(SegmentationReference {
259 sop_instance_uid: segmentation.sop_instance_uid().to_string(),
260 series_instance_uid: segmentation.series_instance_uid().to_string(),
261 segment_number,
262 frame_numbers,
263 })
264}