Skip to main content

wsi_dicom_annotations/annotations/sr/
mod.rs

1mod reader;
2mod writer;
3
4use std::path::Path;
5
6use crate::{Error, Result};
7
8use super::context::DicomAnnotationContext;
9use super::derived_object::DerivedObjectProducer;
10use super::dicom_dataset::new_dicom_uid;
11use super::model::{AlgorithmIdentification, DicomCode, Point2, Point3, TrackingIdentity};
12use super::seg::SegmentationDocument;
13
14#[derive(Debug, Clone, Copy, PartialEq, Eq)]
15pub enum StructuredReportReferenceKind {
16    Point,
17    Polygon,
18    Segmentation,
19    MeasurementCoordinates,
20}
21
22#[derive(Debug, Clone, Copy, PartialEq, Eq)]
23pub enum CoordinateGraphic {
24    Point,
25    Multipoint,
26    Polyline,
27    Polygon,
28}
29
30impl CoordinateGraphic {
31    #[must_use]
32    pub const fn dicom_value(self) -> &'static str {
33        match self {
34            Self::Point => "POINT",
35            Self::Multipoint => "MULTIPOINT",
36            Self::Polyline => "POLYLINE",
37            Self::Polygon => "POLYGON",
38        }
39    }
40}
41
42#[derive(Debug, Clone, PartialEq)]
43pub struct SpatialCoordinates {
44    pub(crate) graphic: CoordinateGraphic,
45    pub(crate) points: Vec<Point3>,
46    pub(crate) frame_of_reference_uid: String,
47}
48
49impl SpatialCoordinates {
50    #[must_use]
51    pub const fn graphic(&self) -> CoordinateGraphic {
52        self.graphic
53    }
54
55    #[must_use]
56    pub fn points(&self) -> &[Point3] {
57        &self.points
58    }
59
60    #[must_use]
61    pub fn frame_of_reference_uid(&self) -> &str {
62        &self.frame_of_reference_uid
63    }
64}
65
66#[derive(Debug, Clone, PartialEq)]
67pub(crate) struct SegmentationReference {
68    pub(crate) sop_instance_uid: String,
69    pub(crate) series_instance_uid: String,
70    pub(crate) segment_number: u16,
71    pub(crate) frame_numbers: Vec<u32>,
72}
73
74#[derive(Debug, Clone, PartialEq)]
75pub(crate) enum RegionReference {
76    Coordinates(SpatialCoordinates),
77    Segmentation(SegmentationReference),
78    MeasurementCoordinates,
79}
80
81#[derive(Debug, Clone, PartialEq)]
82pub struct StructuredReportMeasurement {
83    pub(crate) concept: DicomCode,
84    pub(crate) unit: DicomCode,
85    pub(crate) value: f64,
86    pub(crate) coordinates: Vec<SpatialCoordinates>,
87}
88
89impl StructuredReportMeasurement {
90    #[must_use]
91    pub fn concept(&self) -> &DicomCode {
92        &self.concept
93    }
94
95    #[must_use]
96    pub fn unit(&self) -> &DicomCode {
97        &self.unit
98    }
99
100    #[must_use]
101    pub const fn value(&self) -> f64 {
102        self.value
103    }
104
105    #[must_use]
106    pub fn coordinates(&self) -> &[SpatialCoordinates] {
107        &self.coordinates
108    }
109}
110
111#[derive(Debug, Clone, PartialEq)]
112pub struct StructuredReportQualitativeEvaluation {
113    pub(crate) concept: DicomCode,
114    pub(crate) value: DicomCode,
115}
116
117#[derive(Debug, Clone, PartialEq, Eq)]
118pub struct MeasurementReportSemantics {
119    report_title: DicomCode,
120    procedures_reported: Vec<DicomCode>,
121    length_concept: DicomCode,
122    length_unit: DicomCode,
123}
124
125impl MeasurementReportSemantics {
126    pub fn new(
127        report_title: DicomCode,
128        procedures_reported: Vec<DicomCode>,
129        length_concept: DicomCode,
130        length_unit: DicomCode,
131    ) -> Result<Self> {
132        if procedures_reported.is_empty() {
133            return Err(Error::InvalidInput(
134                "measurement report semantics require at least one procedure".into(),
135            ));
136        }
137        Ok(Self {
138            report_title,
139            procedures_reported,
140            length_concept,
141            length_unit,
142        })
143    }
144
145    #[must_use]
146    pub fn pathology_v1() -> Self {
147        Self::new(
148            DicomCode::new("126000", "DCM", "Imaging Measurement Report")
149                .expect("fixed report title code is valid"),
150            vec![DicomCode::new("252416005", "SCT", "Histopathology test")
151                .expect("fixed pathology procedure code is valid")],
152            DicomCode::new("410668003", "SCT", "Length").expect("fixed length code is valid"),
153            DicomCode::new("mm", "UCUM", "mm").expect("fixed millimeter code is valid"),
154        )
155        .expect("fixed pathology measurement semantics are valid")
156    }
157
158    #[must_use]
159    pub fn report_title(&self) -> &DicomCode {
160        &self.report_title
161    }
162
163    #[must_use]
164    pub fn procedures_reported(&self) -> &[DicomCode] {
165        &self.procedures_reported
166    }
167
168    #[must_use]
169    pub fn length_concept(&self) -> &DicomCode {
170        &self.length_concept
171    }
172
173    #[must_use]
174    pub fn length_unit(&self) -> &DicomCode {
175        &self.length_unit
176    }
177}
178
179#[derive(Debug, Clone, PartialEq)]
180pub struct LinearMeasurementSpec {
181    tracking: TrackingIdentity,
182    finding_category: DicomCode,
183    finding_type: DicomCode,
184    finding_sites: Vec<DicomCode>,
185    endpoints: [Point2; 2],
186}
187
188impl LinearMeasurementSpec {
189    pub fn new(
190        tracking: TrackingIdentity,
191        finding_category: DicomCode,
192        finding_type: DicomCode,
193        start: Point2,
194        end: Point2,
195    ) -> Result<Self> {
196        if !start.x.is_finite() || !start.y.is_finite() || !end.x.is_finite() || !end.y.is_finite()
197        {
198            return Err(Error::InvalidInput(
199                "linear measurement endpoints must be finite".into(),
200            ));
201        }
202        if start == end {
203            return Err(Error::InvalidInput(
204                "linear measurement requires distinct endpoints".into(),
205            ));
206        }
207        Ok(Self {
208            tracking,
209            finding_category,
210            finding_type,
211            finding_sites: Vec::new(),
212            endpoints: [start, end],
213        })
214    }
215
216    #[must_use]
217    pub fn with_finding_sites(mut self, finding_sites: Vec<DicomCode>) -> Self {
218        self.finding_sites = finding_sites;
219        self
220    }
221
222    #[must_use]
223    pub fn tracking(&self) -> &TrackingIdentity {
224        &self.tracking
225    }
226
227    #[must_use]
228    pub fn finding_category(&self) -> &DicomCode {
229        &self.finding_category
230    }
231
232    #[must_use]
233    pub fn finding_type(&self) -> &DicomCode {
234        &self.finding_type
235    }
236
237    #[must_use]
238    pub fn finding_sites(&self) -> &[DicomCode] {
239        &self.finding_sites
240    }
241
242    #[must_use]
243    pub const fn endpoints(&self) -> [Point2; 2] {
244        self.endpoints
245    }
246}
247
248impl StructuredReportQualitativeEvaluation {
249    #[must_use]
250    pub fn concept(&self) -> &DicomCode {
251        &self.concept
252    }
253
254    #[must_use]
255    pub fn value(&self) -> &DicomCode {
256        &self.value
257    }
258}
259
260#[derive(Debug, Clone, PartialEq)]
261pub struct StructuredReportMeasurementGroup {
262    pub(crate) template_id: &'static str,
263    pub(crate) tracking_id: String,
264    pub(crate) tracking_uid: String,
265    pub(crate) finding_category: DicomCode,
266    pub(crate) finding_type: DicomCode,
267    pub(crate) algorithms: Vec<AlgorithmIdentification>,
268    pub(crate) finding_sites: Vec<DicomCode>,
269    pub(crate) reference: RegionReference,
270    pub(crate) measurements: Vec<StructuredReportMeasurement>,
271    pub(crate) qualitative_evaluations: Vec<StructuredReportQualitativeEvaluation>,
272}
273
274impl StructuredReportMeasurementGroup {
275    #[must_use]
276    pub fn tracking_id(&self) -> &str {
277        &self.tracking_id
278    }
279
280    #[must_use]
281    pub fn tracking_uid(&self) -> &str {
282        &self.tracking_uid
283    }
284
285    #[must_use]
286    pub fn finding_category(&self) -> &DicomCode {
287        &self.finding_category
288    }
289
290    #[must_use]
291    pub fn finding_type(&self) -> &DicomCode {
292        &self.finding_type
293    }
294
295    #[must_use]
296    pub fn algorithms(&self) -> &[AlgorithmIdentification] {
297        &self.algorithms
298    }
299
300    #[must_use]
301    pub fn finding_sites(&self) -> &[DicomCode] {
302        &self.finding_sites
303    }
304
305    #[must_use]
306    pub fn region_coordinates(&self) -> Option<&SpatialCoordinates> {
307        match &self.reference {
308            RegionReference::Coordinates(coordinates) => Some(coordinates),
309            RegionReference::Segmentation(_) | RegionReference::MeasurementCoordinates => None,
310        }
311    }
312
313    #[must_use]
314    pub const fn reference_kind(&self) -> StructuredReportReferenceKind {
315        match &self.reference {
316            RegionReference::Coordinates(coordinates) => match coordinates.graphic {
317                CoordinateGraphic::Point => StructuredReportReferenceKind::Point,
318                CoordinateGraphic::Polygon => StructuredReportReferenceKind::Polygon,
319                CoordinateGraphic::Multipoint | CoordinateGraphic::Polyline => {
320                    StructuredReportReferenceKind::MeasurementCoordinates
321                }
322            },
323            RegionReference::Segmentation(_) => StructuredReportReferenceKind::Segmentation,
324            RegionReference::MeasurementCoordinates => {
325                StructuredReportReferenceKind::MeasurementCoordinates
326            }
327        }
328    }
329
330    #[must_use]
331    pub fn referenced_segmentation_uid(&self) -> Option<&str> {
332        match &self.reference {
333            RegionReference::Segmentation(reference) => Some(&reference.sop_instance_uid),
334            _ => None,
335        }
336    }
337
338    #[must_use]
339    pub const fn referenced_segment_number(&self) -> Option<u16> {
340        match &self.reference {
341            RegionReference::Segmentation(reference) => Some(reference.segment_number),
342            _ => None,
343        }
344    }
345
346    #[must_use]
347    pub fn measurements(&self) -> &[StructuredReportMeasurement] {
348        &self.measurements
349    }
350
351    #[must_use]
352    pub fn qualitative_evaluations(&self) -> &[StructuredReportQualitativeEvaluation] {
353        &self.qualitative_evaluations
354    }
355}
356
357#[derive(Debug, Clone, PartialEq)]
358pub struct StructuredReportDocument {
359    pub(crate) source: DicomAnnotationContext,
360    pub(crate) sop_instance_uid: String,
361    pub(crate) series_instance_uid: String,
362    pub(crate) device_observer_uid: String,
363    pub(crate) producer: DerivedObjectProducer,
364    pub(crate) report_title: DicomCode,
365    pub(crate) procedures_reported: Vec<DicomCode>,
366    pub(crate) groups: Vec<StructuredReportMeasurementGroup>,
367    pub(crate) completion_flag: String,
368    pub(crate) verification_flag: String,
369    pub(crate) preliminary_flag: String,
370}
371
372impl StructuredReportDocument {
373    pub(crate) fn new(
374        source: DicomAnnotationContext,
375        report_title: DicomCode,
376        procedures_reported: Vec<DicomCode>,
377        groups: Vec<StructuredReportMeasurementGroup>,
378    ) -> Result<Self> {
379        if procedures_reported.is_empty() || groups.is_empty() {
380            return Err(Error::InvalidInput(
381                "TID 1500 requires a procedure reported and at least one measurement group".into(),
382            ));
383        }
384        if source.frame_of_reference_uid().is_none() {
385            return Err(Error::InvalidInput(
386                "Comprehensive 3D SR coordinates require a source Frame of Reference UID".into(),
387            ));
388        }
389        Ok(Self {
390            source,
391            sop_instance_uid: new_dicom_uid(),
392            series_instance_uid: new_dicom_uid(),
393            device_observer_uid: new_dicom_uid(),
394            producer: DerivedObjectProducer::library_default(9301, "WSI measurement reports"),
395            report_title,
396            procedures_reported,
397            groups,
398            completion_flag: "COMPLETE".into(),
399            verification_flag: "UNVERIFIED".into(),
400            preliminary_flag: "PRELIMINARY".into(),
401        })
402    }
403
404    pub fn from_linear_measurements(
405        source: DicomAnnotationContext,
406        semantics: &MeasurementReportSemantics,
407        measurements: &[LinearMeasurementSpec],
408    ) -> Result<Self> {
409        if measurements.is_empty() {
410            return Err(Error::InvalidInput(
411                "measurement report requires at least one linear measurement".into(),
412            ));
413        }
414        let frame_of_reference_uid = source
415            .frame_of_reference_uid()
416            .ok_or_else(|| {
417                Error::InvalidInput(
418                    "Comprehensive 3D SR coordinates require a source Frame of Reference UID"
419                        .into(),
420                )
421            })?
422            .to_string();
423        let groups = measurements
424            .iter()
425            .map(|spec| {
426                let endpoints = spec.endpoints();
427                let points = endpoints
428                    .into_iter()
429                    .map(|point| source.pixel_to_slide_coordinate(point.x, point.y))
430                    .collect::<Result<Vec<_>>>()?;
431                let delta = Point3::new(
432                    points[1].x - points[0].x,
433                    points[1].y - points[0].y,
434                    points[1].z - points[0].z,
435                );
436                let value = (delta.x * delta.x + delta.y * delta.y + delta.z * delta.z).sqrt();
437                if !value.is_finite() || value <= 0.0 {
438                    return Err(Error::InvalidInput(
439                        "linear measurement has no positive physical length".into(),
440                    ));
441                }
442                let coordinates = SpatialCoordinates {
443                    graphic: CoordinateGraphic::Polyline,
444                    points,
445                    frame_of_reference_uid: frame_of_reference_uid.clone(),
446                };
447                Ok(StructuredReportMeasurementGroup {
448                    template_id: "1501",
449                    tracking_id: spec.tracking.id().to_string(),
450                    tracking_uid: spec.tracking.uid().to_string(),
451                    finding_category: spec.finding_category.clone(),
452                    finding_type: spec.finding_type.clone(),
453                    algorithms: Vec::new(),
454                    finding_sites: spec.finding_sites.clone(),
455                    reference: RegionReference::MeasurementCoordinates,
456                    measurements: vec![StructuredReportMeasurement {
457                        concept: semantics.length_concept.clone(),
458                        unit: semantics.length_unit.clone(),
459                        value,
460                        coordinates: vec![coordinates],
461                    }],
462                    qualitative_evaluations: Vec::new(),
463                })
464            })
465            .collect::<Result<Vec<_>>>()?;
466        Self::new(
467            source,
468            semantics.report_title.clone(),
469            semantics.procedures_reported.clone(),
470            groups,
471        )
472    }
473
474    /// Replaces the neutral library identity with caller-owned producer metadata.
475    #[must_use]
476    pub fn with_producer(mut self, producer: DerivedObjectProducer) -> Self {
477        self.producer = producer;
478        self
479    }
480
481    #[must_use]
482    pub fn producer(&self) -> &DerivedObjectProducer {
483        &self.producer
484    }
485
486    #[must_use]
487    pub fn source(&self) -> &DicomAnnotationContext {
488        &self.source
489    }
490
491    pub fn read_sr(
492        path: impl AsRef<Path>,
493        source: &DicomAnnotationContext,
494        segmentation: Option<&SegmentationDocument>,
495    ) -> Result<Self> {
496        reader::read_sr(path.as_ref(), source, segmentation)
497    }
498
499    pub fn write_sr(&self, path: impl AsRef<Path>) -> Result<()> {
500        writer::write_sr(self, path.as_ref())
501    }
502
503    #[must_use]
504    pub fn sop_instance_uid(&self) -> &str {
505        &self.sop_instance_uid
506    }
507
508    #[must_use]
509    pub fn series_instance_uid(&self) -> &str {
510        &self.series_instance_uid
511    }
512
513    #[must_use]
514    pub fn groups(&self) -> &[StructuredReportMeasurementGroup] {
515        &self.groups
516    }
517
518    #[must_use]
519    pub fn report_title(&self) -> &DicomCode {
520        &self.report_title
521    }
522
523    #[must_use]
524    pub fn procedures_reported(&self) -> &[DicomCode] {
525        &self.procedures_reported
526    }
527
528    #[must_use]
529    pub fn completion_flag(&self) -> &str {
530        &self.completion_flag
531    }
532
533    #[must_use]
534    pub fn verification_flag(&self) -> &str {
535        &self.verification_flag
536    }
537
538    #[must_use]
539    pub fn preliminary_flag(&self) -> &str {
540        &self.preliminary_flag
541    }
542}