wsi_dicom_annotations/annotations/model/
annotation.rs1use crate::{Error, Result};
2
3use super::super::dicom_dataset::new_dicom_uid;
4use super::algorithm::{AlgorithmIdentification, GenerationType};
5use super::code::{is_valid_dicom_uid, validate_text, DicomCode, MAX_LONG_TEXT_BYTES};
6use super::finding::FindingSemantics;
7use super::geometry::{validate_points, validate_polygon, AnnotationGeometry, Point2};
8use crate::annotations::semantic_digest::deterministic_annotation_group_uid;
9
10#[derive(Debug, Clone, PartialEq)]
11pub struct AnnotationMeasurement {
12 concept: DicomCode,
13 units: DicomCode,
14 values: Vec<f64>,
15 annotation_indices: Option<Vec<u32>>,
16}
17
18impl AnnotationMeasurement {
19 #[must_use]
20 pub fn new(concept: DicomCode, units: DicomCode, values: Vec<f64>) -> Self {
21 Self {
22 concept,
23 units,
24 values,
25 annotation_indices: None,
26 }
27 }
28
29 pub fn for_annotations(
30 concept: DicomCode,
31 units: DicomCode,
32 values: Vec<f64>,
33 annotation_indices: Vec<u32>,
34 ) -> Result<Self> {
35 if values.len() != annotation_indices.len() {
36 return Err(Error::InvalidInput(format!(
37 "measurement has {} values but {} annotation indices",
38 values.len(),
39 annotation_indices.len()
40 )));
41 }
42 if annotation_indices.windows(2).any(|pair| pair[0] >= pair[1])
43 || annotation_indices.first().is_some_and(|index| *index == 0)
44 {
45 return Err(Error::InvalidInput(
46 "measurement annotation indices must be strictly increasing and one-based".into(),
47 ));
48 }
49 Ok(Self {
50 concept,
51 units,
52 values,
53 annotation_indices: Some(annotation_indices),
54 })
55 }
56
57 #[must_use]
58 pub fn concept(&self) -> &DicomCode {
59 &self.concept
60 }
61
62 #[must_use]
63 pub fn units(&self) -> &DicomCode {
64 &self.units
65 }
66
67 #[must_use]
68 pub fn values(&self) -> &[f64] {
69 &self.values
70 }
71
72 #[must_use]
73 pub fn annotation_indices(&self) -> Option<&[u32]> {
74 self.annotation_indices.as_deref()
75 }
76}
77
78#[derive(Debug, Clone, PartialEq)]
79pub struct AnnotationGroup {
80 uid: String,
81 label: String,
82 description: String,
83 finding: FindingSemantics,
84 applies_to_all_optical_paths: bool,
85 referenced_optical_paths: Vec<String>,
86 applies_to_all_z_planes: bool,
87 common_z_coordinates: Vec<f64>,
88 geometry: AnnotationGeometry,
89 measurements: Vec<AnnotationMeasurement>,
90}
91
92impl AnnotationGroup {
93 pub fn points(
94 label: impl Into<String>,
95 category: DicomCode,
96 property_type: DicomCode,
97 recommended_display_cielab: [u16; 3],
98 points: Vec<Point2>,
99 ) -> Result<Self> {
100 validate_points(points.iter().copied())?;
101 Self::from_parts(
102 new_dicom_uid(),
103 label.into(),
104 String::new(),
105 FindingSemantics::manual(category, property_type, recommended_display_cielab),
106 true,
107 Vec::new(),
108 true,
109 Vec::new(),
110 AnnotationGeometry::Points(points),
111 Vec::new(),
112 )
113 }
114
115 pub fn polygons(
116 label: impl Into<String>,
117 category: DicomCode,
118 property_type: DicomCode,
119 recommended_display_cielab: [u16; 3],
120 polygons: Vec<Vec<Point2>>,
121 ) -> Result<Self> {
122 for polygon in &polygons {
123 validate_polygon(polygon)?;
124 }
125 Self::from_parts(
126 new_dicom_uid(),
127 label.into(),
128 String::new(),
129 FindingSemantics::manual(category, property_type, recommended_display_cielab),
130 true,
131 Vec::new(),
132 true,
133 Vec::new(),
134 AnnotationGeometry::Polygons(polygons),
135 Vec::new(),
136 )
137 }
138
139 #[allow(clippy::too_many_arguments)]
140 pub(crate) fn from_parts(
141 uid: String,
142 label: String,
143 description: String,
144 finding: FindingSemantics,
145 applies_to_all_optical_paths: bool,
146 referenced_optical_paths: Vec<String>,
147 applies_to_all_z_planes: bool,
148 common_z_coordinates: Vec<f64>,
149 geometry: AnnotationGeometry,
150 measurements: Vec<AnnotationMeasurement>,
151 ) -> Result<Self> {
152 let group = Self {
153 uid,
154 label,
155 description,
156 finding,
157 applies_to_all_optical_paths,
158 referenced_optical_paths,
159 applies_to_all_z_planes,
160 common_z_coordinates,
161 geometry,
162 measurements,
163 };
164 group.validate()?;
165 Ok(group)
166 }
167
168 pub fn with_description(mut self, description: impl Into<String>) -> Result<Self> {
169 let description = description.into();
170 if description.len() > MAX_LONG_TEXT_BYTES {
171 return Err(Error::InvalidInput(format!(
172 "annotation group description exceeds {MAX_LONG_TEXT_BYTES} bytes"
173 )));
174 }
175 if description.contains('\0') {
176 return Err(Error::InvalidInput(
177 "annotation group description contains NUL".into(),
178 ));
179 }
180 self.description = description;
181 Ok(self)
182 }
183
184 pub fn with_uid(mut self, uid: impl Into<String>) -> Result<Self> {
189 let uid = uid.into();
190 validate_text("annotation group UID", &uid, 64)?;
191 if !super::code::is_valid_dicom_uid(&uid) {
192 return Err(Error::InvalidInput(
193 "annotation group UID is not a valid DICOM UID".into(),
194 ));
195 }
196 self.uid = uid;
197 Ok(self)
198 }
199
200 pub fn with_deterministic_uid(
206 mut self,
207 namespace: &str,
208 scope: &str,
209 application_key: &str,
210 ) -> Result<Self> {
211 self.uid = deterministic_annotation_group_uid(&self, namespace, scope, application_key);
212 self.validate()?;
213 Ok(self)
214 }
215
216 pub fn with_generation(
217 mut self,
218 generation_type: GenerationType,
219 algorithms: Vec<AlgorithmIdentification>,
220 ) -> Result<Self> {
221 self.finding = self.finding.with_generation(generation_type, algorithms)?;
222 Ok(self)
223 }
224
225 #[must_use]
226 pub fn with_property_type_modifiers(mut self, modifiers: Vec<DicomCode>) -> Self {
227 self.finding = self.finding.with_property_type_modifiers(modifiers);
228 self
229 }
230
231 #[must_use]
232 pub fn with_anatomic_regions(mut self, regions: Vec<DicomCode>) -> Self {
233 self.finding = self.finding.with_anatomic_regions(regions);
234 self
235 }
236
237 #[must_use]
238 pub fn with_primary_anatomic_structures(mut self, structures: Vec<DicomCode>) -> Self {
239 self.finding = self.finding.with_primary_anatomic_structures(structures);
240 self
241 }
242
243 pub(crate) fn with_finding_semantics(mut self, finding: FindingSemantics) -> Result<Self> {
244 self.finding = finding;
245 self.validate()?;
246 Ok(self)
247 }
248
249 pub fn with_referenced_optical_paths(mut self, paths: Vec<String>) -> Result<Self> {
250 validate_optical_path_applicability(false, &paths)?;
251 for path in &paths {
252 validate_text("referenced optical path identifier", path, 16)?;
253 }
254 if paths
255 .iter()
256 .enumerate()
257 .any(|(index, path)| paths[index + 1..].contains(path))
258 {
259 return Err(Error::InvalidInput(
260 "referenced optical path identifiers must be unique".into(),
261 ));
262 }
263 self.applies_to_all_optical_paths = false;
264 self.referenced_optical_paths = paths;
265 Ok(self)
266 }
267
268 pub fn with_common_z_coordinates(mut self, coordinates: Vec<f64>) -> Result<Self> {
269 if coordinates.is_empty() || coordinates.iter().any(|value| !value.is_finite()) {
270 return Err(Error::InvalidInput(
271 "common Z coordinates must contain finite values".into(),
272 ));
273 }
274 self.applies_to_all_z_planes = false;
275 self.common_z_coordinates = coordinates;
276 Ok(self)
277 }
278
279 pub fn add_measurement(&mut self, measurement: AnnotationMeasurement) -> Result<()> {
280 self.measurements.push(measurement);
281 if let Err(error) = self.validate_measurements() {
282 self.measurements.pop();
283 return Err(error);
284 }
285 Ok(())
286 }
287
288 pub fn validate_geometry(&self) -> Result<()> {
289 match &self.geometry {
290 AnnotationGeometry::Points(points) => validate_points(points.iter().copied()),
291 AnnotationGeometry::Polygons(polygons) => {
292 for polygon in polygons {
293 validate_polygon(polygon)?;
294 }
295 Ok(())
296 }
297 AnnotationGeometry::ReadOnly { .. } => Ok(()),
298 }
299 }
300
301 pub(crate) fn validate(&self) -> Result<()> {
302 validate_text("annotation group UID", &self.uid, 64)?;
303 if !is_valid_dicom_uid(&self.uid) {
304 return Err(Error::InvalidInput(
305 "annotation group UID is not a valid DICOM UID".into(),
306 ));
307 }
308 validate_text("annotation group label", &self.label, 64)?;
309 if self.description.len() > MAX_LONG_TEXT_BYTES || self.description.contains('\0') {
310 return Err(Error::InvalidInput(format!(
311 "annotation group description exceeds {MAX_LONG_TEXT_BYTES} bytes or contains NUL"
312 )));
313 }
314 self.finding.validate()?;
315 validate_optical_path_applicability(
316 self.applies_to_all_optical_paths,
317 &self.referenced_optical_paths,
318 )?;
319 for path in &self.referenced_optical_paths {
320 validate_text("referenced optical path identifier", path, 16)?;
321 }
322 if self
323 .referenced_optical_paths
324 .iter()
325 .enumerate()
326 .any(|(index, path)| self.referenced_optical_paths[index + 1..].contains(path))
327 {
328 return Err(Error::InvalidInput(
329 "referenced optical path identifiers must be unique".into(),
330 ));
331 }
332 if self
333 .common_z_coordinates
334 .iter()
335 .any(|value| !value.is_finite())
336 {
337 return Err(Error::InvalidInput(
338 "common Z coordinates must be finite".into(),
339 ));
340 }
341 if self.applies_to_all_z_planes && !self.common_z_coordinates.is_empty() {
342 return Err(Error::InvalidInput(
343 "common Z coordinates cannot be present when annotations apply to all Z planes"
344 .into(),
345 ));
346 }
347 if self.annotation_count() == 0 {
348 return Err(Error::InvalidInput(format!(
349 "annotation group {:?} has no annotations",
350 self.label
351 )));
352 }
353 self.validate_geometry()?;
354 self.validate_measurements()
355 }
356
357 #[must_use]
358 pub fn annotation_count(&self) -> usize {
359 self.geometry.annotation_count()
360 }
361
362 fn validate_measurements(&self) -> Result<()> {
363 let count = self.geometry.annotation_count();
364 for measurement in &self.measurements {
365 if measurement.values.iter().any(|value| !value.is_finite()) {
366 return Err(Error::InvalidInput(
367 "annotation measurements must be finite".into(),
368 ));
369 }
370 match measurement.annotation_indices() {
371 Some(indices) => {
372 if indices.last().is_some_and(|index| *index as usize > count) {
373 return Err(Error::InvalidInput(format!(
374 "measurement references annotation beyond group size {count}"
375 )));
376 }
377 }
378 None if measurement.values.len() != count => {
379 return Err(Error::InvalidInput(format!(
380 "measurement has {} values for {count} annotations",
381 measurement.values.len()
382 )));
383 }
384 None => {}
385 }
386 }
387 Ok(())
388 }
389
390 #[must_use]
391 pub fn uid(&self) -> &str {
392 &self.uid
393 }
394
395 #[must_use]
396 pub fn label(&self) -> &str {
397 &self.label
398 }
399
400 #[must_use]
401 pub fn description(&self) -> &str {
402 &self.description
403 }
404
405 #[must_use]
406 pub const fn generation_type(&self) -> GenerationType {
407 self.finding.generation_type()
408 }
409
410 #[must_use]
411 pub fn algorithms(&self) -> &[AlgorithmIdentification] {
412 self.finding.algorithms()
413 }
414
415 #[must_use]
416 pub fn category(&self) -> &DicomCode {
417 self.finding.category()
418 }
419
420 #[must_use]
421 pub fn property_type(&self) -> &DicomCode {
422 self.finding.property_type()
423 }
424
425 #[must_use]
426 pub fn property_type_modifiers(&self) -> &[DicomCode] {
427 self.finding.property_type_modifiers()
428 }
429
430 #[must_use]
431 pub fn anatomic_regions(&self) -> &[DicomCode] {
432 self.finding.anatomic_regions()
433 }
434
435 #[must_use]
436 pub fn primary_anatomic_structures(&self) -> &[DicomCode] {
437 self.finding.primary_anatomic_structures()
438 }
439
440 #[must_use]
441 pub const fn applies_to_all_optical_paths(&self) -> bool {
442 self.applies_to_all_optical_paths
443 }
444
445 #[must_use]
446 pub fn referenced_optical_paths(&self) -> &[String] {
447 &self.referenced_optical_paths
448 }
449
450 #[must_use]
451 pub const fn applies_to_all_z_planes(&self) -> bool {
452 self.applies_to_all_z_planes
453 }
454
455 #[must_use]
456 pub fn common_z_coordinates(&self) -> &[f64] {
457 &self.common_z_coordinates
458 }
459
460 #[must_use]
461 pub const fn recommended_display_cielab(&self) -> [u16; 3] {
462 self.finding.recommended_display_cielab()
463 }
464
465 #[must_use]
466 pub fn geometry(&self) -> &AnnotationGeometry {
467 &self.geometry
468 }
469
470 #[must_use]
471 pub fn point_annotations(&self) -> Option<&[Point2]> {
472 match &self.geometry {
473 AnnotationGeometry::Points(points) => Some(points),
474 _ => None,
475 }
476 }
477
478 pub fn replace_points(&mut self, points: Vec<Point2>) -> Result<()> {
480 if !matches!(self.geometry, AnnotationGeometry::Points(_)) {
481 return Err(Error::InvalidInput(
482 "point replacement requires a point annotation group".into(),
483 ));
484 }
485 let mut candidate = self.clone();
486 candidate.geometry = AnnotationGeometry::Points(points);
487 candidate.validate()?;
488 *self = candidate;
489 Ok(())
490 }
491
492 #[must_use]
493 pub fn polygon_annotations(&self) -> Option<&[Vec<Point2>]> {
494 match &self.geometry {
495 AnnotationGeometry::Polygons(polygons) => Some(polygons),
496 _ => None,
497 }
498 }
499
500 pub fn replace_polygons(&mut self, polygons: Vec<Vec<Point2>>) -> Result<()> {
502 if !matches!(self.geometry, AnnotationGeometry::Polygons(_)) {
503 return Err(Error::InvalidInput(
504 "polygon replacement requires a polygon annotation group".into(),
505 ));
506 }
507 let mut candidate = self.clone();
508 candidate.geometry = AnnotationGeometry::Polygons(polygons);
509 candidate.validate()?;
510 *self = candidate;
511 Ok(())
512 }
513
514 #[must_use]
515 pub fn measurements(&self) -> &[AnnotationMeasurement] {
516 &self.measurements
517 }
518
519 #[must_use]
520 pub const fn editable(&self) -> bool {
521 self.geometry.editable()
522 }
523}
524
525fn validate_optical_path_applicability(all: bool, identifiers: &[String]) -> Result<()> {
526 if all == identifiers.is_empty() {
527 return Ok(());
528 }
529 if all {
530 return Err(Error::InvalidInput(
531 "referenced optical path identifiers cannot be present when applicability is all"
532 .into(),
533 ));
534 }
535 Err(Error::InvalidInput(
536 "at least one referenced optical path identifier is required".into(),
537 ))
538}
539
540#[cfg(test)]
541#[path = "annotation_tests.rs"]
542mod tests;