wsi-dicom 0.7.0

Convert whole-slide imaging files to DICOM VL Whole Slide Microscopy
Documentation
use std::path::{Path, PathBuf};

use dicom_object::{FileMetaTableBuilder, InMemDicomObject};

use crate::coordinate::InstanceCoordinate;
use crate::metadata::DicomMetadata;
use crate::report::{ExportMetrics, IccProfileSource, InstanceReport};
use crate::tile::PixelProfile;
use crate::uid::DicomExportIdentity;
use crate::writer::{
    build_dicom_object, DicomObjectIdentifiers, DicomObjectParams, FrameGrid,
    LossyCompressionMetadata, PixelDataOffsetTables,
};
use crate::{Error, VL_WSI_SOP_CLASS_UID};

pub(crate) struct DicomInstanceContext {
    pub(crate) path: PathBuf,
    pub(crate) series_uid: String,
    pub(crate) sop_instance_uid: String,
    pub(crate) frame_of_reference_uid: String,
    pub(crate) pyramid_uid: String,
    pub(crate) dimension_organization_uid: String,
    pub(crate) pyramid_label: String,
    pub(crate) pixel_spacing_mm: (f64, f64),
    pub(crate) series_number: u32,
    pub(crate) coordinate: InstanceCoordinate,
}

impl DicomInstanceContext {
    pub(crate) fn new(
        identity: &DicomExportIdentity,
        output_dir: &Path,
        pixel_spacing_mm: (f64, f64),
        coordinate: InstanceCoordinate,
    ) -> Result<Self, Error> {
        Ok(Self {
            path: coordinate.output_path(output_dir),
            series_uid: identity.uid(&format!(
                "series:{}:{}:{}:{}:{}",
                coordinate.scene_idx,
                coordinate.series_idx,
                coordinate.z,
                coordinate.c,
                coordinate.t
            )),
            sop_instance_uid: identity.uid(&format!(
                "instance:{}:{}:{}:{}:{}:{}",
                coordinate.scene_idx,
                coordinate.series_idx,
                coordinate.level_idx,
                coordinate.z,
                coordinate.c,
                coordinate.t
            )),
            frame_of_reference_uid: identity.uid(&format!(
                "frame-of-reference:{}:{}",
                coordinate.scene_idx, coordinate.series_idx
            )),
            pyramid_uid: identity.uid(&format!(
                "pyramid:{}:{}:{}:{}:{}",
                coordinate.scene_idx,
                coordinate.series_idx,
                coordinate.z,
                coordinate.c,
                coordinate.t
            )),
            dimension_organization_uid: identity.uid(&format!(
                "dimension-organization:{}:{}:{}:{}:{}",
                coordinate.scene_idx,
                coordinate.series_idx,
                coordinate.z,
                coordinate.c,
                coordinate.t
            )),
            pyramid_label: format!(
                "WSI pyramid s{} ser{} z{} c{} t{}",
                coordinate.scene_idx,
                coordinate.series_idx,
                coordinate.z,
                coordinate.c,
                coordinate.t
            ),
            pixel_spacing_mm,
            series_number: coordinate.series_number()?,
            coordinate,
        })
    }

    pub(crate) fn build_dicom_object(
        &self,
        params: InstanceDicomObjectParams<'_>,
    ) -> Result<InMemDicomObject, Error> {
        build_dicom_object(DicomObjectParams {
            metadata: params.metadata,
            identifiers: DicomObjectIdentifiers {
                study_uid: params.study_uid,
                series_uid: &self.series_uid,
                sop_instance_uid: &self.sop_instance_uid,
                frame_of_reference_uid: &self.frame_of_reference_uid,
                pyramid_uid: &self.pyramid_uid,
                dimension_organization_uid: &self.dimension_organization_uid,
                pyramid_label: &self.pyramid_label,
            },
            series_number: self.series_number,
            instance_number: params.instance_number,
            level_idx: self.coordinate.level_idx,
            frame_grid: params.frame_grid,
            frame_count: params.frame_count,
            profile: params.profile,
            pixel_spacing_mm: Some(self.pixel_spacing_mm),
            pixel_data_offsets: params.pixel_data_offsets,
            icc_profile: params.icc_profile,
            lossy_compression: params.lossy_compression,
        })
    }

    pub(crate) fn file_meta(&self, transfer_syntax_uid: &'static str) -> FileMetaTableBuilder {
        FileMetaTableBuilder::new()
            .media_storage_sop_class_uid(VL_WSI_SOP_CLASS_UID)
            .media_storage_sop_instance_uid(&self.sop_instance_uid)
            .transfer_syntax(transfer_syntax_uid)
    }

    pub(crate) fn report(
        &self,
        transfer_syntax_uid: &'static str,
        frame_count: u32,
        icc_profile_source: IccProfileSource,
        metrics: ExportMetrics,
    ) -> InstanceReport {
        InstanceReport {
            path: self.path.clone(),
            sop_instance_uid: self.sop_instance_uid.clone(),
            series_instance_uid: self.series_uid.clone(),
            transfer_syntax_uid,
            icc_profile_source,
            scene: self.coordinate.scene_idx,
            series: self.coordinate.series_idx,
            level: self.coordinate.level_idx,
            z: self.coordinate.z,
            c: self.coordinate.c,
            t: self.coordinate.t,
            frame_count,
            metrics,
        }
    }
}

pub(crate) struct InstanceDicomObjectParams<'a> {
    pub(crate) metadata: &'a DicomMetadata,
    pub(crate) study_uid: &'a str,
    pub(crate) instance_number: u32,
    pub(crate) frame_grid: FrameGrid,
    pub(crate) frame_count: u32,
    pub(crate) profile: PixelProfile,
    pub(crate) pixel_data_offsets: PixelDataOffsetTables,
    pub(crate) icc_profile: Option<&'a [u8]>,
    pub(crate) lossy_compression: Option<LossyCompressionMetadata>,
}

#[cfg(test)]
mod tests {
    use std::path::PathBuf;

    use super::DicomInstanceContext;
    use crate::coordinate::InstanceCoordinate;
    use crate::options::ExportOptions;
    use crate::report::{ExportMetrics, IccProfileSource, InstanceReport};
    use crate::uid::DicomExportIdentity;

    #[test]
    fn dicom_instance_context_captures_stable_ids_paths_and_report_fields() {
        let output = PathBuf::from("/tmp/out");
        let coordinate = InstanceCoordinate::new(1, 2, 3, 4, 5, 6);
        let identity = DicomExportIdentity::from_seed("1.2.3".into(), "test-seed".into());
        let context =
            DicomInstanceContext::new(&identity, &output, (0.0005, 0.0005), coordinate).unwrap();

        assert_eq!(context.path, coordinate.output_path(&output));
        let actual_uids = [
            context.series_uid.as_str(),
            context.sop_instance_uid.as_str(),
            context.frame_of_reference_uid.as_str(),
            context.pyramid_uid.as_str(),
            context.dimension_organization_uid.as_str(),
        ];
        let expected_uids = [
            identity.uid("series:1:2:4:5:6"),
            identity.uid("instance:1:2:3:4:5:6"),
            identity.uid("frame-of-reference:1:2"),
            identity.uid("pyramid:1:2:4:5:6"),
            identity.uid("dimension-organization:1:2:4:5:6"),
        ];
        assert_eq!(actual_uids, expected_uids.each_ref().map(String::as_str));
        assert_eq!(context.pyramid_label, "WSI pyramid s1 ser2 z4 c5 t6");
        assert_eq!(context.pixel_spacing_mm, (0.0005, 0.0005));
        assert_eq!(context.series_number, 3);

        let report = context.report(
            ExportOptions::default().transfer_syntax.uid(),
            7,
            IccProfileSource::SynthesizedSrgb,
            ExportMetrics::default(),
        );
        assert_eq!(
            report,
            InstanceReport {
                path: context.path,
                sop_instance_uid: context.sop_instance_uid,
                series_instance_uid: context.series_uid,
                transfer_syntax_uid: ExportOptions::default().transfer_syntax.uid(),
                icc_profile_source: IccProfileSource::SynthesizedSrgb,
                scene: 1,
                series: 2,
                level: 3,
                z: 4,
                c: 5,
                t: 6,
                frame_count: 7,
                metrics: ExportMetrics::default(),
            }
        );
    }

    #[test]
    fn dicom_instance_context_uid_changes_when_only_timepoint_changes() {
        let output = PathBuf::from("/tmp/out");
        let identity = DicomExportIdentity::from_seed("1.2.3".into(), "test-seed".into());
        let t0 = DicomInstanceContext::new(
            &identity,
            &output,
            (0.0005, 0.0005),
            InstanceCoordinate::new(0, 0, 0, 0, 0, 0),
        )
        .unwrap();
        let t1 = DicomInstanceContext::new(
            &identity,
            &output,
            (0.0005, 0.0005),
            InstanceCoordinate::new(0, 0, 0, 0, 0, 1),
        )
        .unwrap();

        assert_ne!(t0.sop_instance_uid, t1.sop_instance_uid);
        assert_ne!(t0.path, t1.path);
    }
}