Skip to main content

wsi_dicom/
metadata.rs

1use serde::{Deserialize, Serialize};
2
3use crate::WsiDicomError;
4
5/// Metadata accepted by the DICOM writer after strict JSON or FHIR mapping.
6#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
7pub struct DicomMetadata {
8    pub patient_name: Option<String>,
9    pub patient_id: Option<String>,
10    pub patient_birth_date: Option<String>,
11    pub patient_sex: Option<String>,
12    pub accession_number: Option<String>,
13    pub study_instance_uid: Option<String>,
14    pub study_id: Option<String>,
15    pub study_date: Option<String>,
16    pub study_time: Option<String>,
17    pub study_description: Option<String>,
18    pub referring_physician_name: Option<String>,
19    pub laterality: Option<String>,
20    pub manufacturer: Option<String>,
21    pub manufacturer_model_name: Option<String>,
22    pub device_serial_number: Option<String>,
23    pub software_versions: Option<String>,
24    pub content_date: Option<String>,
25    pub content_time: Option<String>,
26    pub acquisition_date_time: Option<String>,
27    pub container_identifier: Option<String>,
28    pub specimen_identifier: Option<String>,
29    pub specimen_description: Option<String>,
30    pub imaged_volume_depth_mm: Option<f64>,
31    pub focus_method: Option<String>,
32}
33
34impl DicomMetadata {
35    pub fn research_placeholder() -> Self {
36        Self {
37            patient_name: Some("RESEARCH^PLACEHOLDER".into()),
38            patient_id: Some("RESEARCH".into()),
39            patient_birth_date: Some(String::new()),
40            patient_sex: Some(String::new()),
41            accession_number: Some("RESEARCH".into()),
42            study_id: Some("1".into()),
43            study_date: Some("19700101".into()),
44            study_time: Some("000000".into()),
45            study_description: Some("Research placeholder WSI export".into()),
46            referring_physician_name: Some(String::new()),
47            laterality: Some(String::new()),
48            manufacturer: Some("wsi-dicom".into()),
49            manufacturer_model_name: Some("wsi-dicom".into()),
50            device_serial_number: Some("RESEARCH".into()),
51            software_versions: Some(env!("CARGO_PKG_VERSION").into()),
52            content_date: Some("19700101".into()),
53            content_time: Some("000000".into()),
54            acquisition_date_time: Some("19700101000000".into()),
55            container_identifier: Some("RESEARCH-CONTAINER".into()),
56            specimen_identifier: Some("RESEARCH-SPECIMEN".into()),
57            specimen_description: Some("Research placeholder specimen".into()),
58            imaged_volume_depth_mm: Some(0.001),
59            focus_method: Some("AUTO".into()),
60            study_instance_uid: None,
61        }
62    }
63
64    pub fn from_fhir_r4_bundle(value: &serde_json::Value) -> Result<Self, WsiDicomError> {
65        let mut metadata = Self::default();
66        let resources = fhir_resources(value)?;
67        for resource in resources {
68            match resource
69                .get("resourceType")
70                .and_then(serde_json::Value::as_str)
71            {
72                Some("Patient") => map_fhir_patient(resource, &mut metadata),
73                Some("Specimen") => map_fhir_specimen(resource, &mut metadata),
74                Some("ServiceRequest") => map_fhir_service_request(resource, &mut metadata),
75                Some("DiagnosticReport") => map_fhir_diagnostic_report(resource, &mut metadata),
76                _ => {}
77            }
78        }
79        metadata.validate_strict()?;
80        Ok(metadata)
81    }
82
83    pub fn validate_strict(&self) -> Result<(), WsiDicomError> {
84        if self.patient_id.as_deref().unwrap_or_default().is_empty() {
85            return Err(WsiDicomError::Metadata {
86                reason: "strict metadata requires patient_id".into(),
87            });
88        }
89        if self.patient_name.as_deref().unwrap_or_default().is_empty() {
90            return Err(WsiDicomError::Metadata {
91                reason: "strict metadata requires patient_name".into(),
92            });
93        }
94        Ok(())
95    }
96}
97
98/// Source of metadata for the DICOM export request.
99#[derive(Debug, Clone, PartialEq)]
100pub enum MetadataSource {
101    Strict(Box<DicomMetadata>),
102    ResearchPlaceholder,
103    FhirR4Bundle(serde_json::Value),
104}
105
106impl MetadataSource {
107    pub(crate) fn resolve(&self) -> Result<DicomMetadata, WsiDicomError> {
108        match self {
109            Self::Strict(metadata) => {
110                metadata.validate_strict()?;
111                Ok(metadata.as_ref().clone())
112            }
113            Self::ResearchPlaceholder => Ok(DicomMetadata::research_placeholder()),
114            Self::FhirR4Bundle(bundle) => DicomMetadata::from_fhir_r4_bundle(bundle),
115        }
116    }
117}
118
119fn fhir_resources(value: &serde_json::Value) -> Result<Vec<&serde_json::Value>, WsiDicomError> {
120    match value
121        .get("resourceType")
122        .and_then(serde_json::Value::as_str)
123    {
124        Some("Bundle") => Ok(value
125            .get("entry")
126            .and_then(serde_json::Value::as_array)
127            .ok_or_else(|| WsiDicomError::Metadata {
128                reason: "FHIR Bundle is missing entry array".into(),
129            })?
130            .iter()
131            .filter_map(|entry| entry.get("resource"))
132            .collect()),
133        Some(_) => Ok(vec![value]),
134        None => Err(WsiDicomError::Metadata {
135            reason: "FHIR JSON is missing resourceType".into(),
136        }),
137    }
138}
139
140fn map_fhir_patient(resource: &serde_json::Value, metadata: &mut DicomMetadata) {
141    metadata.patient_id = first_identifier(resource).or_else(|| json_string(resource, "/id"));
142    metadata.patient_name = resource
143        .get("name")
144        .and_then(serde_json::Value::as_array)
145        .and_then(|names| names.first())
146        .and_then(fhir_human_name_to_pn);
147    metadata.patient_birth_date =
148        json_string(resource, "/birthDate").map(|date| date.replace('-', ""));
149    metadata.patient_sex =
150        json_string(resource, "/gender").and_then(|gender| match gender.as_str() {
151            "male" => Some("M".to_string()),
152            "female" => Some("F".to_string()),
153            "other" => Some("O".to_string()),
154            "unknown" => Some("U".to_string()),
155            _ => None,
156        });
157}
158
159fn map_fhir_specimen(resource: &serde_json::Value, metadata: &mut DicomMetadata) {
160    metadata.specimen_identifier = json_string(resource, "/accessionIdentifier/value")
161        .or_else(|| first_identifier(resource))
162        .or_else(|| json_string(resource, "/id"));
163    if metadata.container_identifier.is_none() {
164        metadata.container_identifier = metadata.specimen_identifier.clone();
165    }
166    metadata.specimen_description = json_string(resource, "/type/text");
167}
168
169fn map_fhir_service_request(resource: &serde_json::Value, metadata: &mut DicomMetadata) {
170    metadata.accession_number = first_identifier(resource)
171        .or_else(|| json_string(resource, "/requisition/value"))
172        .or_else(|| json_string(resource, "/id"));
173    if metadata.study_description.is_none() {
174        metadata.study_description = json_string(resource, "/code/text");
175    }
176}
177
178fn map_fhir_diagnostic_report(resource: &serde_json::Value, metadata: &mut DicomMetadata) {
179    if metadata.study_id.is_none() {
180        metadata.study_id = first_identifier(resource).or_else(|| json_string(resource, "/id"));
181    }
182    metadata.study_description = json_string(resource, "/code/text");
183}
184
185fn first_identifier(resource: &serde_json::Value) -> Option<String> {
186    resource
187        .get("identifier")
188        .and_then(serde_json::Value::as_array)
189        .and_then(|ids| ids.first())
190        .and_then(|id| json_string(id, "/value"))
191}
192
193fn fhir_human_name_to_pn(name: &serde_json::Value) -> Option<String> {
194    let family = name.get("family").and_then(serde_json::Value::as_str)?;
195    let given = name
196        .get("given")
197        .and_then(serde_json::Value::as_array)
198        .map(|values| {
199            values
200                .iter()
201                .filter_map(serde_json::Value::as_str)
202                .collect::<Vec<_>>()
203                .join(" ")
204        })
205        .unwrap_or_default();
206    if given.is_empty() {
207        Some(family.to_string())
208    } else {
209        Some(format!("{family}^{given}"))
210    }
211}
212
213fn json_string(value: &serde_json::Value, pointer: &str) -> Option<String> {
214    value
215        .pointer(pointer)
216        .and_then(serde_json::Value::as_str)
217        .filter(|s| !s.is_empty())
218        .map(ToOwned::to_owned)
219}