Skip to main content

wsi_dicom_annotations/annotations/pathology_geojson/
mod.rs

1mod conversion;
2mod documents;
3mod feature;
4mod geometry;
5mod mapping;
6mod preview;
7mod sr_conversion;
8
9use crate::{Error, Result};
10use sha2::{Digest, Sha256};
11
12use super::context::DicomAnnotationContext;
13use super::model::InteroperabilityDiagnostic;
14use super::semantic_digest::{finish, update_text};
15pub use documents::{PathologyDicomDocuments, PathologyDicomTarget, PathologyDocumentWriteError};
16use feature::{parse_feature_collection, ProfiledFeature};
17use mapping::{Mapping, StructuredReportMapping};
18pub use preview::{
19    PathologyPreview, PathologyPreviewFeature, PathologyPreviewGeometry, PathologyPreviewPolygon,
20};
21
22const MAX_GEOJSON_BYTES: usize = 64 * 1024 * 1024;
23const MAX_MAPPING_BYTES: usize = 4 * 1024 * 1024;
24
25#[derive(Debug, Clone, Copy, PartialEq, Eq)]
26pub enum PathologyCoordinateSpace {
27    Level0Pixels,
28    SourcePixels,
29    SlideMillimeters,
30}
31
32impl PathologyCoordinateSpace {
33    pub(crate) const fn label(self) -> &'static str {
34        match self {
35            Self::Level0Pixels => "level0-pixels",
36            Self::SourcePixels => "source-pixels",
37            Self::SlideMillimeters => "slide-mm",
38        }
39    }
40}
41
42#[derive(Debug, Clone, Copy, PartialEq, Eq)]
43pub enum PathologyGeometryKind {
44    Point,
45    MultiPoint,
46    LineString,
47    MultiLineString,
48    Polygon,
49    MultiPolygon,
50}
51
52#[derive(Debug)]
53pub struct PathologyAnnotationSet {
54    source: DicomAnnotationContext,
55    features: Vec<ProfiledFeature>,
56    diagnostics: Vec<InteroperabilityDiagnostic>,
57    sr_mapping: Option<StructuredReportMapping>,
58}
59
60impl PathologyAnnotationSet {
61    /// Parse and validate pathology-profiled GeoJSON and its explicit DICOM mapping.
62    ///
63    /// Coordinates are transformed into the referenced source WSI's Total Pixel
64    /// Matrix coordinate system. `canonical_source` is used only when the declared
65    /// coordinate space is level-zero pixels.
66    pub fn from_json(
67        geojson: &[u8],
68        mapping: &[u8],
69        source: &DicomAnnotationContext,
70        canonical_source: &DicomAnnotationContext,
71        coordinate_space: PathologyCoordinateSpace,
72        allow_lossy: bool,
73    ) -> Result<Self> {
74        if geojson.len() > MAX_GEOJSON_BYTES {
75            return Err(Error::InvalidInput(format!(
76                "GeoJSON input exceeds the {MAX_GEOJSON_BYTES}-byte limit"
77            )));
78        }
79        if mapping.len() > MAX_MAPPING_BYTES {
80            return Err(Error::InvalidInput(format!(
81                "mapping profile exceeds the {MAX_MAPPING_BYTES}-byte limit"
82            )));
83        }
84        validate_coordinate_contexts(source, canonical_source, coordinate_space)?;
85        let mapping = Mapping::from_json(mapping)?;
86        let (features, diagnostics) = parse_feature_collection(
87            geojson,
88            &mapping,
89            source,
90            canonical_source,
91            coordinate_space,
92            allow_lossy,
93        )?;
94        Ok(Self {
95            source: source.clone(),
96            features,
97            diagnostics,
98            sr_mapping: mapping.sr,
99        })
100    }
101
102    #[must_use]
103    pub fn feature_count(&self) -> usize {
104        self.features.len()
105    }
106
107    #[must_use]
108    pub fn feature_geometry_kind(&self, index: usize) -> Option<PathologyGeometryKind> {
109        self.features.get(index).map(|feature| feature.kind)
110    }
111
112    #[must_use]
113    pub fn feature_tracking_id(&self, index: usize) -> Option<&str> {
114        self.features
115            .get(index)
116            .map(|feature| feature.tracking_id.as_str())
117    }
118
119    #[must_use]
120    pub fn feature_tracking_uid(&self, index: usize) -> Option<&str> {
121        self.features
122            .get(index)
123            .map(|feature| feature.tracking_uid.as_str())
124    }
125
126    #[must_use]
127    pub fn diagnostics(&self) -> &[InteroperabilityDiagnostic] {
128        &self.diagnostics
129    }
130
131    /// Consume the normalized annotation set and transfer its geometry into a
132    /// read-only viewer preview without retaining a second full geometry copy.
133    /// Mixed feature collections and polygon interior rings remain intact, and
134    /// coordinates are in the source WSI's Total Pixel Matrix space.
135    #[must_use]
136    pub fn into_preview(self) -> PathologyPreview {
137        PathologyPreview::from_owned_features(self.features)
138    }
139
140    /// SHA-256 over the normalized profile semantics and source-space geometry.
141    #[must_use]
142    pub fn semantic_sha256(&self) -> String {
143        let mut digest = Sha256::new();
144        update_text(&mut digest, "pathology-geojson-semantics-v1");
145        update_text(&mut digest, self.source.sop_instance_uid());
146        for feature in &self.features {
147            feature.update_semantic_digest(&mut digest);
148        }
149        digest.update([u8::from(self.sr_mapping.is_some())]);
150        if let Some(mapping) = &self.sr_mapping {
151            mapping.update_digest(&mut digest);
152        }
153        finish(digest)
154    }
155}
156
157fn validate_coordinate_contexts(
158    source: &DicomAnnotationContext,
159    canonical_source: &DicomAnnotationContext,
160    coordinate_space: PathologyCoordinateSpace,
161) -> Result<()> {
162    if coordinate_space != PathologyCoordinateSpace::Level0Pixels {
163        return Ok(());
164    }
165    source.require_shared_slide_frame(canonical_source)
166}