Skip to main content

wsi_dicom_annotations/annotations/parametric_map/
profile.rs

1use std::collections::BTreeSet;
2
3use serde::Deserialize;
4
5use crate::{Error, Result};
6
7use super::super::json::validate_unique_object_keys;
8use super::super::model::{AlgorithmIdentification, DicomCode};
9use super::super::profile::{ProfileAlgorithm, ProfileCode};
10
11#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
12#[serde(rename_all = "kebab-case")]
13pub enum RasterInputFormat {
14    Tiff,
15    Npy,
16    Zarr,
17    TiledManifest,
18}
19
20#[derive(Debug, Clone, PartialEq, Eq)]
21pub enum RasterChannelSelection {
22    Auto,
23    Index(usize),
24    Name(String),
25    All,
26}
27
28#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
29#[serde(rename_all = "kebab-case")]
30pub(crate) enum RasterDType {
31    Float32,
32    Float64,
33    Int8,
34    Int16,
35    Int32,
36    Int64,
37    Uint8,
38    Uint16,
39    Uint32,
40    Uint64,
41}
42
43impl RasterDType {
44    pub(crate) const fn is_integer(self) -> bool {
45        !matches!(self, Self::Float32 | Self::Float64)
46    }
47}
48
49#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
50#[serde(rename_all = "lowercase")]
51pub(crate) enum RasterAxis {
52    Y,
53    X,
54    Channel,
55}
56
57#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
58#[serde(rename_all = "kebab-case")]
59pub(crate) enum RasterCoordinateSpace {
60    Level0Pixels,
61    SourcePixels,
62    SlideMm,
63}
64
65#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
66#[serde(rename_all = "lowercase")]
67pub(crate) enum RasterOutputPrecision {
68    Float32,
69    Float64,
70}
71
72#[derive(Debug, Clone, Copy, PartialEq, Deserialize)]
73#[serde(untagged)]
74pub(crate) enum IntegerSentinel {
75    Signed(i64),
76    Unsigned(u64),
77}
78
79#[derive(Debug, Clone, PartialEq)]
80pub(crate) struct IntegerScaling {
81    pub(crate) slope: f64,
82    pub(crate) intercept: f64,
83    pub(crate) missing_sentinel: IntegerSentinel,
84    pub(crate) output_precision: RasterOutputPrecision,
85}
86
87#[derive(Debug, Clone, PartialEq)]
88pub(crate) struct RasterChannel {
89    pub(crate) name: String,
90    pub(crate) quantity: DicomCode,
91    pub(crate) unit: DicomCode,
92}
93
94#[derive(Debug, Clone)]
95pub struct RasterProfile {
96    pub(crate) input_format: RasterInputFormat,
97    pub(crate) dtype: RasterDType,
98    pub(crate) axes: Vec<RasterAxis>,
99    pub(crate) grid_origin: GridOrigin,
100    pub(crate) sample_spacing: SampleSpacing,
101    pub(crate) coordinate_space: RasterCoordinateSpace,
102    pub(crate) channels: Vec<RasterChannel>,
103    pub(crate) algorithm: AlgorithmIdentification,
104    pub(crate) integer_scaling: Option<IntegerScaling>,
105    pub(crate) zarr_array_path: Option<String>,
106}
107
108impl RasterProfile {
109    pub fn from_json(json: &[u8]) -> Result<Self> {
110        validate_unique_object_keys(json, "raster profile")?;
111        let raw: RawRasterProfile = serde_json::from_slice(json).map_err(|error| {
112            Error::InvalidInput(format!("raster profile is not valid JSON: {error}"))
113        })?;
114        raw.validate_and_build()
115    }
116
117    #[must_use]
118    pub const fn input_format(&self) -> RasterInputFormat {
119        self.input_format
120    }
121
122    #[must_use]
123    pub fn channel_count(&self) -> usize {
124        self.channels.len()
125    }
126
127    #[must_use]
128    pub fn channel_name(&self, index: usize) -> Option<&str> {
129        self.channels
130            .get(index)
131            .map(|channel| channel.name.as_str())
132    }
133
134    pub fn select_channels(&self, selection: RasterChannelSelection) -> Result<Vec<usize>> {
135        if self.channels.len() == 1 {
136            return match selection {
137                RasterChannelSelection::Auto => Ok(vec![0]),
138                _ => Err(Error::InvalidInput(
139                    "single-channel raster inputs do not accept a channel option".into(),
140                )),
141            };
142        }
143        match selection {
144            RasterChannelSelection::Auto => Err(Error::InvalidInput(
145                "multichannel raster input requires exactly one channel or all channels".into(),
146            )),
147            RasterChannelSelection::Index(index) if index < self.channels.len() => Ok(vec![index]),
148            RasterChannelSelection::Index(index) => Err(Error::InvalidInput(format!(
149                "channel index {index} is outside 0..{}",
150                self.channels.len()
151            ))),
152            RasterChannelSelection::Name(name) => self
153                .channels
154                .iter()
155                .position(|channel| channel.name == name)
156                .map(|index| vec![index])
157                .ok_or_else(|| {
158                    Error::InvalidInput(format!("channel name {name:?} is not declared"))
159                }),
160            RasterChannelSelection::All => Ok((0..self.channels.len()).collect()),
161        }
162    }
163
164    pub(crate) fn axis_index(&self, axis: RasterAxis) -> Option<usize> {
165        self.axes.iter().position(|candidate| *candidate == axis)
166    }
167}
168
169#[derive(Debug, Deserialize)]
170#[serde(deny_unknown_fields)]
171struct RawRasterProfile {
172    schema_version: u32,
173    input_format: RasterInputFormat,
174    dtype: RasterDType,
175    axes: Vec<RasterAxis>,
176    grid_origin: GridOrigin,
177    sample_spacing: SampleSpacing,
178    coordinate_space: RasterCoordinateSpace,
179    channels: Vec<RawRasterChannel>,
180    algorithm: ProfileAlgorithm,
181    #[serde(default)]
182    integer_scaling: Option<RawIntegerScaling>,
183    #[serde(default)]
184    zarr_array_path: Option<String>,
185}
186
187impl RawRasterProfile {
188    fn validate_and_build(self) -> Result<RasterProfile> {
189        if self.schema_version != 1 {
190            return Err(Error::Unsupported(format!(
191                "raster profile schema version {} is not supported",
192                self.schema_version
193            )));
194        }
195        validate_axes(&self.axes)?;
196        self.grid_origin.validate(self.coordinate_space)?;
197        self.sample_spacing.validate()?;
198        let channels = build_channels(self.channels)?;
199        let integer_scaling = self
200            .integer_scaling
201            .map(RawIntegerScaling::build)
202            .transpose()?;
203        if self.dtype.is_integer() != integer_scaling.is_some() {
204            return Err(Error::InvalidInput(
205                "integer dtypes require integer_scaling, which is forbidden for float dtypes"
206                    .into(),
207            ));
208        }
209        validate_zarr_path(self.input_format, self.zarr_array_path.as_deref())?;
210        Ok(RasterProfile {
211            input_format: self.input_format,
212            dtype: self.dtype,
213            axes: self.axes,
214            grid_origin: self.grid_origin,
215            sample_spacing: self.sample_spacing,
216            coordinate_space: self.coordinate_space,
217            channels,
218            algorithm: self.algorithm.into_algorithm()?,
219            integer_scaling,
220            zarr_array_path: self.zarr_array_path,
221        })
222    }
223}
224
225fn validate_axes(axes: &[RasterAxis]) -> Result<()> {
226    if !matches!(axes.len(), 2 | 3)
227        || axes.iter().filter(|axis| **axis == RasterAxis::Y).count() != 1
228        || axes.iter().filter(|axis| **axis == RasterAxis::X).count() != 1
229        || axes
230            .iter()
231            .filter(|axis| **axis == RasterAxis::Channel)
232            .count()
233            != axes.len() - 2
234    {
235        return Err(Error::InvalidInput(
236            "axes must contain y and x exactly once, plus at most one channel axis".into(),
237        ));
238    }
239    Ok(())
240}
241
242fn build_channels(raw: Vec<RawRasterChannel>) -> Result<Vec<RasterChannel>> {
243    if raw.is_empty() {
244        return Err(Error::InvalidInput(
245            "raster profile must declare at least one channel".into(),
246        ));
247    }
248    let mut names = BTreeSet::new();
249    raw.into_iter()
250        .map(|channel| {
251            if channel.name.trim().is_empty()
252                || channel.name.contains('\0')
253                || !names.insert(channel.name.clone())
254            {
255                return Err(Error::InvalidInput(
256                    "raster channel names must be nonempty, NUL-free, and unique".into(),
257                ));
258            }
259            Ok(RasterChannel {
260                name: channel.name,
261                quantity: channel.quantity.into_code()?,
262                unit: channel.unit.into_code()?,
263            })
264        })
265        .collect()
266}
267
268fn validate_zarr_path(format: RasterInputFormat, path: Option<&str>) -> Result<()> {
269    match (format, path) {
270        (RasterInputFormat::Zarr, Some(path))
271            if !path.trim_matches('/').is_empty()
272                && !path.split('/').any(|part| matches!(part, "" | "." | "..")) =>
273        {
274            Ok(())
275        }
276        (RasterInputFormat::Zarr, _) => Err(Error::InvalidInput(
277            "zarr input requires an explicit local zarr_array_path".into(),
278        )),
279        (_, None) => Ok(()),
280        (_, Some(_)) => Err(Error::InvalidInput(
281            "zarr_array_path is valid only for zarr input".into(),
282        )),
283    }
284}
285
286#[derive(Debug, Clone, Copy, PartialEq, Deserialize)]
287#[serde(deny_unknown_fields)]
288pub(crate) struct GridOrigin {
289    pub(crate) x: f64,
290    pub(crate) y: f64,
291    #[serde(default)]
292    pub(crate) z: Option<f64>,
293}
294
295impl GridOrigin {
296    fn validate(self, coordinate_space: RasterCoordinateSpace) -> Result<()> {
297        if !self.x.is_finite() || !self.y.is_finite() || self.z.is_some_and(|z| !z.is_finite()) {
298            return Err(Error::InvalidInput(
299                "grid origin coordinates must be finite".into(),
300            ));
301        }
302        if (coordinate_space == RasterCoordinateSpace::SlideMm) != self.z.is_some() {
303            return Err(Error::InvalidInput(
304                "slide-mm grid origin requires z; pixel coordinate origins must omit z".into(),
305            ));
306        }
307        Ok(())
308    }
309}
310
311#[derive(Debug, Clone, Copy, PartialEq, Deserialize)]
312#[serde(deny_unknown_fields)]
313pub(crate) struct SampleSpacing {
314    pub(crate) x: f64,
315    pub(crate) y: f64,
316}
317
318impl SampleSpacing {
319    fn validate(self) -> Result<()> {
320        if !self.x.is_finite() || !self.y.is_finite() || self.x <= 0.0 || self.y <= 0.0 {
321            return Err(Error::InvalidInput(
322                "sample spacing must contain positive finite x and y values".into(),
323            ));
324        }
325        Ok(())
326    }
327}
328
329#[derive(Debug, Deserialize)]
330#[serde(deny_unknown_fields)]
331struct RawRasterChannel {
332    name: String,
333    quantity: ProfileCode,
334    unit: ProfileCode,
335}
336
337#[derive(Debug, Deserialize)]
338#[serde(deny_unknown_fields)]
339struct RawIntegerScaling {
340    slope: f64,
341    intercept: f64,
342    missing_sentinel: IntegerSentinel,
343    output_precision: RasterOutputPrecision,
344}
345
346impl RawIntegerScaling {
347    fn build(self) -> Result<IntegerScaling> {
348        if !self.slope.is_finite() || !self.intercept.is_finite() || self.slope == 0.0 {
349            return Err(Error::InvalidInput(
350                "integer scaling slope must be nonzero and slope/intercept must be finite".into(),
351            ));
352        }
353        Ok(IntegerScaling {
354            slope: self.slope,
355            intercept: self.intercept,
356            missing_sentinel: self.missing_sentinel,
357            output_precision: self.output_precision,
358        })
359    }
360}