wsi-dicom-annotations 0.2.1

DICOM WSI ANN, SEG, SR, and Parametric Map interoperability
Documentation
use std::collections::BTreeMap;

use serde::Deserialize;
use sha2::{Digest, Sha256};

use crate::{Error, Result};

use super::super::model::{DicomCode, FindingSemantics, GenerationType};
use super::super::profile::{profile_codes, ProfileAlgorithm, ProfileCode};
use crate::annotations::json::validate_unique_object_keys;
use crate::annotations::semantic_digest::{update_algorithm, update_code, update_text};

#[derive(Debug, Clone)]
pub(super) struct LabelSemantics {
    pub(super) finding: FindingSemantics,
    pub(super) all_optical_paths: bool,
    pub(super) optical_paths: Vec<String>,
    pub(super) all_z_planes: bool,
    pub(super) z_coordinates_mm: Vec<f64>,
    pub(super) segment_label: String,
}

impl LabelSemantics {
    pub(super) fn update_digest(&self, digest: &mut Sha256) {
        update_code(digest, self.finding.category());
        update_code(digest, self.finding.property_type());
        for codes in [
            self.finding.property_type_modifiers(),
            self.finding.anatomic_regions(),
            self.finding.primary_anatomic_structures(),
        ] {
            digest.update((codes.len() as u64).to_le_bytes());
            for code in codes {
                update_code(digest, code);
            }
        }
        digest.update([self.finding.generation_type() as u8]);
        for algorithm in self.finding.algorithms() {
            update_algorithm(digest, algorithm);
        }
        digest.update([u8::from(self.all_optical_paths)]);
        for path in &self.optical_paths {
            update_text(digest, path);
        }
        digest.update([u8::from(self.all_z_planes)]);
        for coordinate in &self.z_coordinates_mm {
            digest.update(coordinate.to_bits().to_le_bytes());
        }
        for component in self.finding.recommended_display_cielab() {
            digest.update(component.to_le_bytes());
        }
        update_text(digest, &self.segment_label);
    }
}

#[derive(Debug, Clone)]
pub(super) struct MeasurementMapping {
    pub(super) concept: DicomCode,
    pub(super) unit: DicomCode,
}

#[derive(Debug, Clone)]
pub(super) struct QualitativeMapping {
    pub(super) concept: DicomCode,
    pub(super) values: BTreeMap<String, DicomCode>,
}

#[derive(Debug, Clone)]
pub(super) struct StructuredReportMapping {
    pub(super) report_title: DicomCode,
    pub(super) procedures_reported: Vec<DicomCode>,
}

impl StructuredReportMapping {
    pub(super) fn update_digest(&self, digest: &mut Sha256) {
        update_code(digest, &self.report_title);
        digest.update((self.procedures_reported.len() as u64).to_le_bytes());
        for procedure in &self.procedures_reported {
            update_code(digest, procedure);
        }
    }
}

#[derive(Debug)]
pub(super) struct Mapping {
    pub(super) labels: BTreeMap<String, LabelSemantics>,
    pub(super) measurements: BTreeMap<String, MeasurementMapping>,
    pub(super) qualitative_evaluations: BTreeMap<String, QualitativeMapping>,
    pub(super) sr: Option<StructuredReportMapping>,
}

impl Mapping {
    pub(super) fn from_json(json: &[u8]) -> Result<Self> {
        validate_unique_object_keys(json, "mapping profile")?;
        let raw: RawMapping = serde_json::from_slice(json).map_err(|error| {
            Error::InvalidInput(format!("mapping profile is not valid JSON: {error}"))
        })?;
        if raw.schema_version != 1 {
            return Err(Error::Unsupported(format!(
                "mapping schema version {} is not supported",
                raw.schema_version
            )));
        }
        if raw.labels.is_empty() {
            return Err(Error::InvalidInput(
                "mapping profile must define at least one label".into(),
            ));
        }
        let labels = raw
            .labels
            .into_iter()
            .map(|(source, label)| {
                if source.trim().is_empty() {
                    return Err(Error::InvalidInput(
                        "mapping label source classification cannot be empty".into(),
                    ));
                }
                Ok((source, label.into_semantics()?))
            })
            .collect::<Result<_>>()?;
        let measurements = raw
            .measurements
            .into_iter()
            .map(|(name, mapping)| {
                validate_mapping_key("measurement", &name)?;
                Ok((
                    name,
                    MeasurementMapping {
                        concept: mapping.concept.into_code()?,
                        unit: mapping.unit.into_code()?,
                    },
                ))
            })
            .collect::<Result<_>>()?;
        let qualitative_evaluations = raw
            .qualitative_evaluations
            .into_iter()
            .map(|(key, mapping)| {
                validate_mapping_key("qualitative evaluation", &key)?;
                if mapping.values.is_empty() {
                    return Err(Error::InvalidInput(format!(
                        "qualitative evaluation {key:?} has no source-value mappings"
                    )));
                }
                let values = mapping
                    .values
                    .into_iter()
                    .map(|(value, code)| {
                        validate_mapping_key("qualitative source value", &value)?;
                        Ok((value, code.into_code()?))
                    })
                    .collect::<Result<_>>()?;
                Ok((
                    key,
                    QualitativeMapping {
                        concept: mapping.concept.into_code()?,
                        values,
                    },
                ))
            })
            .collect::<Result<_>>()?;
        let sr = raw.sr.map(RawStructuredReport::into_mapping).transpose()?;
        Ok(Self {
            labels,
            measurements,
            qualitative_evaluations,
            sr,
        })
    }
}

fn validate_mapping_key(kind: &str, value: &str) -> Result<()> {
    if value.trim().is_empty() || value.contains('\0') {
        return Err(Error::InvalidInput(format!(
            "{kind} mapping key cannot be empty or contain NUL"
        )));
    }
    Ok(())
}

#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct RawMapping {
    schema_version: u32,
    labels: BTreeMap<String, RawLabel>,
    #[serde(default)]
    measurements: BTreeMap<String, RawMeasurement>,
    #[serde(default)]
    qualitative_evaluations: BTreeMap<String, RawQualitative>,
    #[serde(default)]
    sr: Option<RawStructuredReport>,
}

#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct RawLabel {
    category: ProfileCode,
    property_type: ProfileCode,
    #[serde(default)]
    property_type_modifiers: Vec<ProfileCode>,
    #[serde(default)]
    anatomic_regions: Vec<ProfileCode>,
    #[serde(default)]
    primary_anatomic_structures: Vec<ProfileCode>,
    generation_type: RawGenerationType,
    #[serde(default)]
    algorithm: Option<ProfileAlgorithm>,
    #[serde(default)]
    applicability: RawApplicability,
    recommended_display_cielab: [u16; 3],
    segment_label: String,
}

impl RawLabel {
    fn into_semantics(self) -> Result<LabelSemantics> {
        validate_mapping_key("segment label", &self.segment_label)?;
        if self.segment_label.len() > 64 || self.segment_label.contains('\\') {
            return Err(Error::InvalidInput(
                "segment label must fit DICOM LO and contain no value separator".into(),
            ));
        }
        let generation_type = self.generation_type.into();
        let algorithms = self
            .algorithm
            .map(ProfileAlgorithm::into_algorithm)
            .transpose()?
            .into_iter()
            .collect::<Vec<_>>();
        self.applicability.validate()?;
        Ok(LabelSemantics {
            finding: FindingSemantics::new(
                generation_type,
                algorithms,
                self.category.into_code()?,
                self.property_type.into_code()?,
                profile_codes(self.property_type_modifiers)?,
                profile_codes(self.anatomic_regions)?,
                profile_codes(self.primary_anatomic_structures)?,
                self.recommended_display_cielab,
            )?,
            all_optical_paths: self.applicability.all_optical_paths,
            optical_paths: self.applicability.optical_paths,
            all_z_planes: self.applicability.all_z_planes,
            z_coordinates_mm: self.applicability.z_coordinates_mm,
            segment_label: self.segment_label,
        })
    }
}

#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct RawApplicability {
    #[serde(default = "default_true")]
    all_optical_paths: bool,
    #[serde(default)]
    optical_paths: Vec<String>,
    #[serde(default = "default_true")]
    all_z_planes: bool,
    #[serde(default)]
    z_coordinates_mm: Vec<f64>,
}

impl Default for RawApplicability {
    fn default() -> Self {
        Self {
            all_optical_paths: true,
            optical_paths: Vec::new(),
            all_z_planes: true,
            z_coordinates_mm: Vec::new(),
        }
    }
}

impl RawApplicability {
    fn validate(&self) -> Result<()> {
        if self.all_optical_paths != self.optical_paths.is_empty() {
            return Err(Error::InvalidInput(
                "optical-path applicability must be either all or a nonempty explicit list".into(),
            ));
        }
        if self.all_z_planes != self.z_coordinates_mm.is_empty() {
            return Err(Error::InvalidInput(
                "Z applicability must be either all or a nonempty explicit coordinate list".into(),
            ));
        }
        if self
            .z_coordinates_mm
            .iter()
            .any(|coordinate| !coordinate.is_finite())
        {
            return Err(Error::InvalidInput(
                "Z applicability coordinates must be finite".into(),
            ));
        }
        Ok(())
    }
}

const fn default_true() -> bool {
    true
}

#[derive(Deserialize)]
#[serde(rename_all = "UPPERCASE")]
enum RawGenerationType {
    Manual,
    Semiautomatic,
    Automatic,
}

impl From<RawGenerationType> for GenerationType {
    fn from(value: RawGenerationType) -> Self {
        match value {
            RawGenerationType::Manual => Self::Manual,
            RawGenerationType::Semiautomatic => Self::Semiautomatic,
            RawGenerationType::Automatic => Self::Automatic,
        }
    }
}

#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct RawMeasurement {
    concept: ProfileCode,
    unit: ProfileCode,
}

#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct RawQualitative {
    concept: ProfileCode,
    values: BTreeMap<String, ProfileCode>,
}

#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct RawStructuredReport {
    report_title: ProfileCode,
    procedures_reported: Vec<ProfileCode>,
}

impl RawStructuredReport {
    fn into_mapping(self) -> Result<StructuredReportMapping> {
        if self.procedures_reported.is_empty() {
            return Err(Error::InvalidInput(
                "SR mapping requires at least one coded procedure reported".into(),
            ));
        }
        Ok(StructuredReportMapping {
            report_title: self.report_title.into_code()?,
            procedures_reported: profile_codes(self.procedures_reported)?,
        })
    }
}