Skip to main content

wsi_annotation_tests/
conversion_matrix.rs

1use std::fs;
2use std::path::{Path, PathBuf};
3
4use serde::Serialize;
5use serde_json::Value;
6
7use crate::probe::{ProbeObservation, ViewerProbe};
8use crate::results::sha256_file;
9use crate::shim::{FixtureSet, ReferenceShim};
10
11mod expected;
12mod geojson;
13mod inputs;
14mod observation;
15mod parametric_map;
16
17#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
18pub enum ConversionMatrixKind {
19    #[serde(rename = "ann-seg")]
20    AnnSeg,
21    #[serde(rename = "sr")]
22    Sr,
23    #[serde(rename = "pm")]
24    Pm,
25}
26
27#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
28#[serde(rename_all = "lowercase")]
29pub enum ConversionTarget {
30    Ann,
31    Seg,
32    Sr,
33    Pm,
34}
35
36impl ConversionTarget {
37    const fn matrix(self) -> ConversionMatrixKind {
38        match self {
39            Self::Ann | Self::Seg => ConversionMatrixKind::AnnSeg,
40            Self::Sr => ConversionMatrixKind::Sr,
41            Self::Pm => ConversionMatrixKind::Pm,
42        }
43    }
44}
45
46#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
47#[serde(rename_all = "lowercase")]
48pub enum ConversionStatus {
49    Passed,
50    Failed,
51}
52
53impl ConversionStatus {
54    #[must_use]
55    pub const fn is_passed(self) -> bool {
56        matches!(self, Self::Passed)
57    }
58}
59
60#[derive(Debug, Serialize)]
61pub struct ConversionObservation {
62    pub matrix: ConversionMatrixKind,
63    pub case_id: String,
64    pub target: ConversionTarget,
65    pub status: ConversionStatus,
66    pub highdicom_readable: bool,
67    pub output_paths: Vec<PathBuf>,
68    pub report: Value,
69    pub normalized: Value,
70    pub command: Vec<String>,
71    /// Legacy aggregate; zero when unavailable. Failed rows retain nullable measurements in `report`.
72    pub runtime_ms: f64,
73    pub peak_rss_bytes: u64,
74    pub peak_tracked_heap_bytes: u64,
75    pub message: String,
76}
77
78#[derive(Debug, Serialize)]
79pub struct ConversionMatrixResult {
80    pub observations: Vec<ConversionObservation>,
81}
82
83impl ConversionMatrixResult {
84    #[must_use]
85    pub fn is_ok(&self) -> bool {
86        !self.observations.is_empty()
87            && self
88                .observations
89                .iter()
90                .all(|observation| observation.status.is_passed())
91    }
92}
93
94/// Run independent `GeoJSON`, SR, and Parametric Map conversion matrices.
95///
96/// # Errors
97///
98/// Returns an error when the immutable input area cannot be prepared. Individual
99/// converter or oracle failures are retained as failed observations.
100pub fn run_conversion_matrices(
101    fixtures: &FixtureSet,
102    reference: &ReferenceShim,
103    probe: &ViewerProbe,
104    output_directory: &Path,
105) -> Result<ConversionMatrixResult, String> {
106    fs::create_dir(output_directory).map_err(|error| {
107        format!(
108            "could not create conversion matrix directory {}: {error}",
109            output_directory.display()
110        )
111    })?;
112    let inputs = inputs::prepare(&output_directory.join("inputs"))?;
113    let mut observations = Vec::with_capacity(6);
114    observations.extend(geojson::run_direct(
115        fixtures,
116        reference,
117        probe,
118        output_directory,
119        &inputs,
120    ));
121    observations.extend(geojson::run_seg_reference(
122        fixtures,
123        reference,
124        probe,
125        output_directory,
126        &inputs,
127    ));
128    observations.extend(parametric_map::run_single(
129        fixtures,
130        reference,
131        probe,
132        output_directory,
133        &inputs,
134    ));
135    observations.extend(parametric_map::run_concatenation(
136        fixtures,
137        reference,
138        probe,
139        output_directory,
140        &inputs,
141    ));
142    Ok(ConversionMatrixResult { observations })
143}
144
145pub(super) fn verify_report_outputs(
146    observation: &ProbeObservation,
147    target: &str,
148    expected_paths: &[PathBuf],
149) -> Result<(), String> {
150    let outputs = observation.report["outputs"]
151        .as_array()
152        .ok_or_else(|| "conversion report outputs must be an array".to_owned())?
153        .iter()
154        .filter(|output| output["target"].as_str() == Some(target))
155        .collect::<Vec<_>>();
156    if outputs.len() != expected_paths.len() {
157        return Err(format!(
158            "conversion report listed {} {target} outputs; expected {}",
159            outputs.len(),
160            expected_paths.len()
161        ));
162    }
163    for (output, path) in outputs.into_iter().zip(expected_paths) {
164        if !path.is_file() {
165            return Err(format!("conversion output is missing: {}", path.display()));
166        }
167        let canonical = path
168            .canonicalize()
169            .map_err(|error| format!("could not canonicalize {}: {error}", path.display()))?;
170        if output["path"].as_str() != Some(canonical.to_string_lossy().as_ref()) {
171            return Err(format!(
172                "conversion report path does not match {}",
173                canonical.display()
174            ));
175        }
176        let bytes = fs::metadata(path)
177            .map_err(|error| format!("could not stat {}: {error}", path.display()))?
178            .len();
179        if output["bytes"].as_u64() != Some(bytes) {
180            return Err(format!(
181                "conversion report byte count differs for {}",
182                path.display()
183            ));
184        }
185        let checksum = sha256_file(path).map_err(|error| error.to_string())?;
186        if output["sha256"].as_str() != Some(checksum.as_str()) {
187            return Err(format!(
188                "conversion report checksum differs for {}",
189                path.display()
190            ));
191        }
192    }
193    Ok(())
194}