Skip to main content

wsi_dicom_annotations/annotations/scheme/
model.rs

1use std::collections::HashSet;
2
3use serde::{Deserialize, Serialize};
4use sha2::{Digest, Sha256};
5
6use super::builtins::{builtin_general_pathology, builtin_tumor_compatibility};
7use super::color::srgb_to_dicom_cielab;
8use super::concept_key::{annotation_class_concept_key, code_identity, AnnotationClassConceptKey};
9use super::{MAX_CLASSES, MAX_FINDING_SITES, SCHEME_SCHEMA_VERSION};
10use crate::annotations::model::DicomCode;
11use crate::annotations::semantic_digest::{finish, update_code, update_text};
12use crate::{Error, Result};
13
14#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
15#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
16pub enum AnnotationClassGeometry {
17    Region,
18    Point,
19}
20
21impl AnnotationClassGeometry {
22    #[must_use]
23    pub const fn label(self) -> &'static str {
24        match self {
25            Self::Region => "Region",
26            Self::Point => "Point",
27        }
28    }
29}
30
31#[derive(Debug, Clone, PartialEq, Eq)]
32pub struct AnnotationClass {
33    pub(super) id: String,
34    pub(super) label: String,
35    pub(super) geometry: AnnotationClassGeometry,
36    pub(super) display_color: [u8; 3],
37    pub(super) category: DicomCode,
38    pub(super) property_type: DicomCode,
39    pub(super) property_type_modifiers: Vec<DicomCode>,
40    pub(super) concept_key: AnnotationClassConceptKey,
41}
42
43impl AnnotationClass {
44    pub(super) fn new(
45        id: String,
46        label: String,
47        geometry: AnnotationClassGeometry,
48        display_color: [u8; 3],
49        category: DicomCode,
50        property_type: DicomCode,
51        property_type_modifiers: Vec<DicomCode>,
52    ) -> Result<Self> {
53        validate_identifier("class id", &id, 64)?;
54        validate_display_text("class label", &label, 128)?;
55        if property_type_modifiers.len() > 32 {
56            return Err(Error::InvalidInput(
57                "an annotation class cannot define more than 32 property modifiers".into(),
58            ));
59        }
60        let concept_key = annotation_class_concept_key(
61            geometry,
62            &category,
63            &property_type,
64            &property_type_modifiers,
65        );
66        Ok(Self {
67            id,
68            label,
69            geometry,
70            display_color,
71            category,
72            property_type,
73            property_type_modifiers,
74            concept_key,
75        })
76    }
77
78    #[must_use]
79    pub fn id(&self) -> &str {
80        &self.id
81    }
82
83    #[must_use]
84    pub fn label(&self) -> &str {
85        &self.label
86    }
87
88    #[must_use]
89    pub const fn geometry(&self) -> AnnotationClassGeometry {
90        self.geometry
91    }
92
93    #[must_use]
94    pub const fn display_color(&self) -> [u8; 3] {
95        self.display_color
96    }
97
98    #[must_use]
99    pub fn recommended_display_cielab(&self) -> [u16; 3] {
100        srgb_to_dicom_cielab(self.display_color)
101    }
102
103    #[must_use]
104    pub fn category(&self) -> &DicomCode {
105        &self.category
106    }
107
108    #[must_use]
109    pub fn property_type(&self) -> &DicomCode {
110        &self.property_type
111    }
112
113    #[must_use]
114    pub fn property_type_modifiers(&self) -> &[DicomCode] {
115        &self.property_type_modifiers
116    }
117
118    #[must_use]
119    pub fn concept_key(&self) -> &AnnotationClassConceptKey {
120        &self.concept_key
121    }
122}
123
124#[derive(Debug, Clone, PartialEq, Eq)]
125pub struct AnnotationScheme {
126    pub(super) schema_version: u32,
127    pub(super) id: String,
128    pub(super) version: u32,
129    pub(super) display_name: String,
130    pub(super) classes: Vec<AnnotationClass>,
131    pub(super) finding_sites: Vec<DicomCode>,
132    pub(super) content_digest: String,
133}
134
135impl AnnotationScheme {
136    pub(super) fn new(
137        id: String,
138        version: u32,
139        display_name: String,
140        classes: Vec<AnnotationClass>,
141        finding_sites: Vec<DicomCode>,
142    ) -> Result<Self> {
143        validate_identifier("scheme id", &id, 128)?;
144        if version == 0 {
145            return Err(Error::InvalidInput(
146                "annotation scheme version must be positive".into(),
147            ));
148        }
149        validate_display_text("scheme display name", &display_name, 128)?;
150        if classes.is_empty() || classes.len() > MAX_CLASSES {
151            return Err(Error::InvalidInput(format!(
152                "annotation scheme must define 1..={MAX_CLASSES} classes"
153            )));
154        }
155        if finding_sites.len() > MAX_FINDING_SITES {
156            return Err(Error::InvalidInput(format!(
157                "annotation scheme cannot define more than {MAX_FINDING_SITES} finding sites"
158            )));
159        }
160
161        let mut class_ids = HashSet::with_capacity(classes.len());
162        let mut concepts = HashSet::with_capacity(classes.len());
163        for class in &classes {
164            if !class_ids.insert(class.id.clone()) {
165                return Err(Error::InvalidInput(format!(
166                    "annotation scheme contains duplicate class id {:?}",
167                    class.id
168                )));
169            }
170            if !concepts.insert(class.concept_key.clone()) {
171                return Err(Error::InvalidInput(format!(
172                    "annotation scheme contains duplicate class concept {}",
173                    class.concept_key
174                )));
175            }
176        }
177
178        let mut finding_site_keys = HashSet::with_capacity(finding_sites.len());
179        for site in &finding_sites {
180            if !finding_site_keys.insert(code_identity(site)) {
181                return Err(Error::InvalidInput(
182                    "annotation scheme contains duplicate finding-site concepts".into(),
183                ));
184            }
185        }
186
187        let mut scheme = Self {
188            schema_version: SCHEME_SCHEMA_VERSION,
189            id,
190            version,
191            display_name,
192            classes,
193            finding_sites,
194            content_digest: String::new(),
195        };
196        scheme.content_digest = scheme.calculate_content_digest();
197        Ok(scheme)
198    }
199
200    #[must_use]
201    pub fn general_pathology_v1() -> Self {
202        builtin_general_pathology().expect("the built-in General Pathology scheme is valid")
203    }
204
205    #[must_use]
206    pub fn tumor_mask_compatibility_v1() -> Self {
207        builtin_tumor_compatibility().expect("the built-in tumor compatibility scheme is valid")
208    }
209
210    #[must_use]
211    pub const fn schema_version(&self) -> u32 {
212        self.schema_version
213    }
214
215    #[must_use]
216    pub fn id(&self) -> &str {
217        &self.id
218    }
219
220    #[must_use]
221    pub const fn version(&self) -> u32 {
222        self.version
223    }
224
225    #[must_use]
226    pub fn display_name(&self) -> &str {
227        &self.display_name
228    }
229
230    #[must_use]
231    pub fn classes(&self) -> &[AnnotationClass] {
232        &self.classes
233    }
234
235    #[must_use]
236    pub fn finding_sites(&self) -> &[DicomCode] {
237        &self.finding_sites
238    }
239
240    #[must_use]
241    pub fn content_digest(&self) -> &str {
242        &self.content_digest
243    }
244
245    #[must_use]
246    pub fn class(&self, id: &str) -> Option<&AnnotationClass> {
247        self.classes.iter().find(|class| class.id == id)
248    }
249
250    #[must_use]
251    pub fn class_for_concept(
252        &self,
253        concept: &AnnotationClassConceptKey,
254    ) -> Option<&AnnotationClass> {
255        self.classes
256            .iter()
257            .find(|class| class.concept_key == *concept)
258    }
259
260    fn calculate_content_digest(&self) -> String {
261        let mut digest = Sha256::new();
262        digest.update(b"frames-annotation-scheme-v1\0");
263        digest.update(self.schema_version.to_le_bytes());
264        update_text(&mut digest, &self.id);
265        digest.update(self.version.to_le_bytes());
266        update_text(&mut digest, &self.display_name);
267        digest.update((self.classes.len() as u64).to_le_bytes());
268        for class in &self.classes {
269            update_text(&mut digest, &class.id);
270            update_text(&mut digest, &class.label);
271            digest.update([match class.geometry {
272                AnnotationClassGeometry::Region => 0,
273                AnnotationClassGeometry::Point => 1,
274            }]);
275            digest.update(class.display_color);
276            update_code(&mut digest, &class.category);
277            update_code(&mut digest, &class.property_type);
278            digest.update((class.property_type_modifiers.len() as u64).to_le_bytes());
279            for modifier in &class.property_type_modifiers {
280                update_code(&mut digest, modifier);
281            }
282        }
283        digest.update((self.finding_sites.len() as u64).to_le_bytes());
284        for site in &self.finding_sites {
285            update_code(&mut digest, site);
286        }
287        finish(digest)
288    }
289}
290
291fn validate_identifier(name: &str, value: &str, max_bytes: usize) -> Result<()> {
292    if value.is_empty()
293        || value.len() > max_bytes
294        || !value
295            .bytes()
296            .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-'))
297    {
298        return Err(Error::InvalidInput(format!(
299            "{name} must be 1..={max_bytes} ASCII letters, digits, dots, underscores, or hyphens"
300        )));
301    }
302    Ok(())
303}
304
305fn validate_display_text(name: &str, value: &str, max_bytes: usize) -> Result<()> {
306    if value.trim().is_empty()
307        || value.len() > max_bytes
308        || value.chars().any(char::is_control)
309        || value.contains(['\\', '\0'])
310    {
311        return Err(Error::InvalidInput(format!(
312            "{name} must be nonempty, at most {max_bytes} bytes, and contain no control characters"
313        )));
314    }
315    Ok(())
316}