1use 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#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize)]
25#[serde(rename_all = "kebab-case")]
26pub enum AnnotationCoordinateSpace {
27 #[default]
29 Level0Pixels,
30 SourcePixels,
32 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
48#[serde(rename_all = "lowercase")]
49pub enum AnnotationTarget {
50 Ann,
52 Seg,
54 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#[derive(Debug, Clone, PartialEq, Eq)]
94pub struct QuPathAnnotationOptions {
95 pub geojson_path: PathBuf,
97 pub mapping_path: PathBuf,
99 pub coordinate_space: AnnotationCoordinateSpace,
101 pub targets: Vec<AnnotationTarget>,
103 pub allow_lossy: bool,
105}
106
107impl QuPathAnnotationOptions {
108 #[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#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
140pub struct AnnotationDiagnosticReport {
141 pub code: String,
143 pub severity: &'static str,
145 pub path: String,
147 pub disposition: &'static str,
149 pub message: String,
151}
152
153#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
155pub struct AnnotationInstanceReport {
156 pub target: AnnotationTarget,
158 pub path: PathBuf,
160 pub sop_class_uid: &'static str,
162 pub sop_instance_uid: String,
164 pub series_instance_uid: String,
166}
167
168#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
170pub struct AnnotationExportReport {
171 pub output_dir: PathBuf,
173 pub source_wsi: PathBuf,
175 pub feature_count: usize,
177 pub semantic_sha256: String,
179 pub geojson_sha256: String,
181 pub mapping_sha256: String,
183 pub coordinate_space: AnnotationCoordinateSpace,
185 pub diagnostics: Vec<AnnotationDiagnosticReport>,
187 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
274pub 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;