Skip to main content

wsi_dicom/
annotation_export.rs

1//! QuPath GeoJSON conversion tied to a completed VL WSI export.
2
3use std::fs;
4use std::io::Read;
5use std::path::{Path, PathBuf};
6
7use serde::Serialize;
8use sha2::{Digest, Sha256};
9use wsi_dicom_annotations::{
10    DiagnosticDisposition, DiagnosticSeverity, DicomAnnotationContext, DicomBundlePublication,
11    PathologyAnnotationSet, PathologyCoordinateSpace, PathologyDicomDocuments,
12    PathologyDicomTarget, PathologyDocumentWriteError,
13};
14
15use crate::{Error, ExportReport, InstanceReport};
16
17const MAX_GEOJSON_BYTES: u64 = 64 * 1024 * 1024;
18const MAX_MAPPING_BYTES: u64 = 4 * 1024 * 1024;
19const ANN_STORAGE_UID: &str = "1.2.840.10008.5.1.4.1.1.91.1";
20const SEG_STORAGE_UID: &str = "1.2.840.10008.5.1.4.1.1.66.4";
21const COMPREHENSIVE_3D_SR_STORAGE_UID: &str = "1.2.840.10008.5.1.4.1.1.88.34";
22
23/// Coordinate convention used by a QuPath GeoJSON annotation export.
24#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize)]
25#[serde(rename_all = "kebab-case")]
26pub enum AnnotationCoordinateSpace {
27    /// Coordinates are pixels in the highest-resolution source image.
28    #[default]
29    Level0Pixels,
30    /// Coordinates are pixels in the selected DICOM WSI instance.
31    SourcePixels,
32    /// Coordinates are millimetres in the DICOM slide coordinate system.
33    SlideMillimeters,
34}
35
36impl From<AnnotationCoordinateSpace> for PathologyCoordinateSpace {
37    fn from(value: AnnotationCoordinateSpace) -> Self {
38        match value {
39            AnnotationCoordinateSpace::Level0Pixels => Self::Level0Pixels,
40            AnnotationCoordinateSpace::SourcePixels => Self::SourcePixels,
41            AnnotationCoordinateSpace::SlideMillimeters => Self::SlideMillimeters,
42        }
43    }
44}
45
46/// DICOM derived-object representation requested for mapped QuPath content.
47#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
48#[serde(rename_all = "lowercase")]
49pub enum AnnotationTarget {
50    /// Microscopy Bulk Simple Annotations for points and simple polygons.
51    Ann,
52    /// DICOM Segmentation for mask-style regions, components, and holes.
53    Seg,
54    /// Comprehensive 3D SR for mapped measurements and coded evaluations.
55    Sr,
56}
57
58impl AnnotationTarget {
59    const fn pathology_target(self) -> PathologyDicomTarget {
60        match self {
61            Self::Ann => PathologyDicomTarget::Ann,
62            Self::Seg => PathologyDicomTarget::Seg,
63            Self::Sr => PathologyDicomTarget::Sr,
64        }
65    }
66
67    const fn label(self) -> &'static str {
68        match self {
69            Self::Ann => "ann",
70            Self::Seg => "seg",
71            Self::Sr => "sr",
72        }
73    }
74
75    const fn file_name(self) -> &'static str {
76        match self {
77            Self::Ann => "ann.dcm",
78            Self::Seg => "seg.dcm",
79            Self::Sr => "sr.dcm",
80        }
81    }
82
83    const fn sop_class_uid(self) -> &'static str {
84        match self {
85            Self::Ann => ANN_STORAGE_UID,
86            Self::Seg => SEG_STORAGE_UID,
87            Self::Sr => COMPREHENSIVE_3D_SR_STORAGE_UID,
88        }
89    }
90}
91
92/// Inputs and semantic choices for converting one QuPath GeoJSON export.
93#[derive(Debug, Clone, PartialEq, Eq)]
94pub struct QuPathAnnotationOptions {
95    /// QuPath GeoJSON file to convert.
96    pub geojson_path: PathBuf,
97    /// Explicit mapping from QuPath classes and properties to coded DICOM concepts.
98    pub mapping_path: PathBuf,
99    /// Coordinate convention used in the GeoJSON file.
100    pub coordinate_space: AnnotationCoordinateSpace,
101    /// Derived DICOM object types to create; at least one unique target is required.
102    pub targets: Vec<AnnotationTarget>,
103    /// Permit only the lossy normalizations explicitly supported by the mapping converter.
104    pub allow_lossy: bool,
105}
106
107impl QuPathAnnotationOptions {
108    /// Create options using the usual QuPath level-zero pixel convention.
109    #[must_use]
110    pub fn new(
111        geojson_path: impl Into<PathBuf>,
112        mapping_path: impl Into<PathBuf>,
113        targets: Vec<AnnotationTarget>,
114    ) -> Self {
115        Self {
116            geojson_path: geojson_path.into(),
117            mapping_path: mapping_path.into(),
118            coordinate_space: AnnotationCoordinateSpace::Level0Pixels,
119            targets,
120            allow_lossy: false,
121        }
122    }
123
124    pub(crate) fn prepare(&self) -> Result<PreparedQuPathAnnotations, Error> {
125        validate_targets(&self.targets)?;
126        Ok(PreparedQuPathAnnotations {
127            options: self.clone(),
128            geojson: read_bounded_file(&self.geojson_path, MAX_GEOJSON_BYTES, "QuPath GeoJSON")?,
129            mapping: read_bounded_file(
130                &self.mapping_path,
131                MAX_MAPPING_BYTES,
132                "annotation mapping",
133            )?,
134        })
135    }
136}
137
138/// Structured diagnostic emitted while normalizing profiled annotation content.
139#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
140pub struct AnnotationDiagnosticReport {
141    /// Stable diagnostic code.
142    pub code: String,
143    /// `info`, `warning`, or `error`.
144    pub severity: &'static str,
145    /// JSON path or semantic location associated with the diagnostic.
146    pub path: String,
147    /// `normalized`, `would_drop`, or `unsupported`.
148    pub disposition: &'static str,
149    /// Human-readable explanation.
150    pub message: String,
151}
152
153/// One verified DICOM annotation object created by the combined conversion.
154#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
155pub struct AnnotationInstanceReport {
156    /// DICOM representation written for this object.
157    pub target: AnnotationTarget,
158    /// Published DICOM file path.
159    pub path: PathBuf,
160    /// SOP Class UID of the derived object.
161    pub sop_class_uid: &'static str,
162    /// SOP Instance UID assigned to this export.
163    pub sop_instance_uid: String,
164    /// Series Instance UID assigned to this export.
165    pub series_instance_uid: String,
166}
167
168/// Verified annotation sidecars produced for one WSI conversion.
169#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
170pub struct AnnotationExportReport {
171    /// Atomically published sidecar directory.
172    pub output_dir: PathBuf,
173    /// Unique level-zero VL WSI instance referenced by every sidecar.
174    pub source_wsi: PathBuf,
175    /// Number of profiled GeoJSON features converted.
176    pub feature_count: usize,
177    /// SHA-256 of normalized annotation semantics and source-space geometry.
178    pub semantic_sha256: String,
179    /// SHA-256 of the exact QuPath GeoJSON input bytes.
180    pub geojson_sha256: String,
181    /// SHA-256 of the exact mapping input bytes.
182    pub mapping_sha256: String,
183    /// Explicit coordinate convention used for conversion.
184    pub coordinate_space: AnnotationCoordinateSpace,
185    /// Normalization and interoperability diagnostics.
186    pub diagnostics: Vec<AnnotationDiagnosticReport>,
187    /// Verified DICOM annotation objects.
188    pub instances: Vec<AnnotationInstanceReport>,
189}
190
191pub(crate) struct PreparedQuPathAnnotations {
192    options: QuPathAnnotationOptions,
193    geojson: Vec<u8>,
194    mapping: Vec<u8>,
195}
196
197impl PreparedQuPathAnnotations {
198    pub(crate) fn export_for(&self, wsi: &ExportReport) -> Result<AnnotationExportReport, Error> {
199        let source_instance = select_annotation_source(wsi, self.options.coordinate_space)?;
200        let source =
201            DicomAnnotationContext::from_source(&source_instance.path).map_err(annotation_error)?;
202        let annotations = PathologyAnnotationSet::from_json(
203            &self.geojson,
204            &self.mapping,
205            &source,
206            &source,
207            self.options.coordinate_space.into(),
208            self.options.allow_lossy,
209        )
210        .map_err(annotation_error)?;
211        let targets = self
212            .options
213            .targets
214            .iter()
215            .copied()
216            .map(AnnotationTarget::pathology_target)
217            .collect::<Vec<_>>();
218        let documents =
219            PathologyDicomDocuments::build(&annotations, &targets).map_err(annotation_error)?;
220        let output_dir = wsi.output_dir.join("annotations");
221        let protected_inputs = [
222            source_instance.path.as_path(),
223            self.options.geojson_path.as_path(),
224            self.options.mapping_path.as_path(),
225        ];
226        let publication = DicomBundlePublication::new(&output_dir, &protected_inputs)
227            .map_err(publication_error)?;
228        let mut instances = Vec::with_capacity(self.options.targets.len());
229        for target in self.options.targets.iter().copied() {
230            let staged = publication.staging_path().join(target.file_name());
231            documents
232                .write_and_verify(target.pathology_target(), &staged, &source)
233                .map_err(document_write_error)?;
234            let (sop_instance_uid, series_instance_uid) = document_identity(&documents, target)?;
235            instances.push(AnnotationInstanceReport {
236                target,
237                path: output_dir.join(target.file_name()),
238                sop_class_uid: target.sop_class_uid(),
239                sop_instance_uid: sop_instance_uid.to_string(),
240                series_instance_uid: series_instance_uid.to_string(),
241            });
242        }
243        let report = AnnotationExportReport {
244            output_dir: output_dir.clone(),
245            source_wsi: source_instance.path.clone(),
246            feature_count: annotations.feature_count(),
247            semantic_sha256: annotations.semantic_sha256(),
248            geojson_sha256: sha256(&self.geojson),
249            mapping_sha256: sha256(&self.mapping),
250            coordinate_space: self.options.coordinate_space,
251            diagnostics: annotations
252                .diagnostics()
253                .iter()
254                .map(diagnostic_report)
255                .collect(),
256            instances,
257        };
258        let manifest = publication.staging_path().join("manifest.json");
259        let bytes = serde_json::to_vec_pretty(&report).map_err(|source| Error::JsonSerialize {
260            message: source.to_string(),
261        })?;
262        fs::write(&manifest, bytes).map_err(|source| Error::Io {
263            path: manifest.clone(),
264            source,
265        })?;
266        publication
267            .sync_staged_file(&manifest)
268            .map_err(publication_error)?;
269        publication.publish().map_err(publication_error)?;
270        Ok(report)
271    }
272}
273
274/// Convert profiled QuPath GeoJSON into verified sidecars for an existing WSI export report.
275///
276/// This lower-level form is useful when the WSI conversion was run separately. Most callers
277/// should use [`crate::Export::run_with_qupath_annotations`] to snapshot annotation inputs before
278/// starting the WSI export.
279pub fn export_qupath_annotations(
280    wsi: &ExportReport,
281    options: &QuPathAnnotationOptions,
282) -> Result<AnnotationExportReport, Error> {
283    options.prepare()?.export_for(wsi)
284}
285
286fn validate_targets(targets: &[AnnotationTarget]) -> Result<(), Error> {
287    if targets.is_empty() {
288        return Err(Error::InvalidOptions {
289            reason: "QuPath annotation conversion requires at least one annotation target".into(),
290        });
291    }
292    if targets
293        .iter()
294        .enumerate()
295        .any(|(index, target)| targets[..index].contains(target))
296    {
297        return Err(Error::InvalidOptions {
298            reason: "QuPath annotation targets must be unique".into(),
299        });
300    }
301    Ok(())
302}
303
304fn select_annotation_source(
305    report: &ExportReport,
306    coordinate_space: AnnotationCoordinateSpace,
307) -> Result<&InstanceReport, Error> {
308    let level_zero = report
309        .instances
310        .iter()
311        .filter(|instance| instance.level == 0)
312        .collect::<Vec<_>>();
313    if level_zero.len() == 1 {
314        return Ok(level_zero[0]);
315    }
316    if level_zero.is_empty()
317        && coordinate_space != AnnotationCoordinateSpace::Level0Pixels
318        && report.instances.len() == 1
319    {
320        return Ok(&report.instances[0]);
321    }
322    Err(Error::InvalidOptions {
323        reason: format!(
324            "QuPath annotation conversion requires one unambiguous level-zero WSI instance; export produced {}",
325            level_zero.len()
326        ),
327    })
328}
329
330fn read_bounded_file(path: &Path, maximum_bytes: u64, description: &str) -> Result<Vec<u8>, Error> {
331    let metadata = fs::metadata(path).map_err(|source| Error::Io {
332        path: path.to_path_buf(),
333        source,
334    })?;
335    if !metadata.is_file() || metadata.len() > maximum_bytes {
336        return Err(Error::InvalidOptions {
337            reason: format!(
338                "{description} {} must be a file no larger than {maximum_bytes} bytes",
339                path.display()
340            ),
341        });
342    }
343    let capacity = usize::try_from(metadata.len()).map_err(|_| Error::InvalidOptions {
344        reason: format!("{description} length does not fit this platform"),
345    })?;
346    let mut bytes = Vec::with_capacity(capacity);
347    fs::File::open(path)
348        .map_err(|source| Error::Io {
349            path: path.to_path_buf(),
350            source,
351        })?
352        .take(maximum_bytes + 1)
353        .read_to_end(&mut bytes)
354        .map_err(|source| Error::Io {
355            path: path.to_path_buf(),
356            source,
357        })?;
358    if bytes.len() as u64 > maximum_bytes {
359        return Err(Error::InvalidOptions {
360            reason: format!("{description} changed while being read or exceeds its size limit"),
361        });
362    }
363    Ok(bytes)
364}
365
366fn document_identity(
367    documents: &PathologyDicomDocuments,
368    target: AnnotationTarget,
369) -> Result<(&str, &str), Error> {
370    match target {
371        AnnotationTarget::Ann => documents
372            .ann()
373            .map(|document| (document.sop_instance_uid(), document.series_instance_uid())),
374        AnnotationTarget::Seg => documents
375            .seg()
376            .map(|document| (document.sop_instance_uid(), document.series_instance_uid())),
377        AnnotationTarget::Sr => documents
378            .sr()
379            .map(|document| (document.sop_instance_uid(), document.series_instance_uid())),
380    }
381    .ok_or_else(|| Error::Annotation {
382        reason: format!("{} document was not constructed", target.label()),
383    })
384}
385
386fn diagnostic_report(
387    diagnostic: &wsi_dicom_annotations::InteroperabilityDiagnostic,
388) -> AnnotationDiagnosticReport {
389    AnnotationDiagnosticReport {
390        code: diagnostic.code().to_string(),
391        severity: match diagnostic.severity() {
392            DiagnosticSeverity::Info => "info",
393            DiagnosticSeverity::Warning => "warning",
394            DiagnosticSeverity::Error => "error",
395        },
396        path: diagnostic.path().to_string(),
397        disposition: match diagnostic.disposition() {
398            DiagnosticDisposition::Normalized => "normalized",
399            DiagnosticDisposition::WouldDrop => "would_drop",
400            DiagnosticDisposition::Unsupported => "unsupported",
401        },
402        message: diagnostic.message().to_string(),
403    }
404}
405
406fn document_write_error(error: PathologyDocumentWriteError) -> Error {
407    Error::Annotation {
408        reason: error.to_string(),
409    }
410}
411
412fn annotation_error(error: wsi_dicom_annotations::Error) -> Error {
413    Error::Annotation {
414        reason: error.to_string(),
415    }
416}
417
418fn publication_error(error: wsi_dicom_annotations::DicomPublicationError) -> Error {
419    Error::Annotation {
420        reason: format!("{}: {error}", error.code()),
421    }
422}
423
424fn sha256(bytes: &[u8]) -> String {
425    format!("{:x}", Sha256::digest(bytes))
426}
427
428#[cfg(test)]
429#[path = "annotation_export/tests.rs"]
430mod tests;