wsi_dicom_annotations/annotations/scheme/
model.rs1use 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}