Skip to main content

wsi_dicom_annotations/annotations/pathology_geojson/
sr_conversion.rs

1use 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    /// Build a Comprehensive 3D SR measurement report.
17    ///
18    /// Polygon holes and disconnected polygon components require a companion SEG
19    /// from this annotation set. Lines and multipoints are represented only as
20    /// coordinate references for mapped numeric measurements.
21    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}