dicom_viewer/dcm/annotation.rs
1//! User annotations + measurements, persisted to a sidecar JSON.
2//!
3//! Coordinates are stored in **displayed image pixels** (i.e. post the
4//! decimation that `load_raw` applies). Conversion to real-world
5//! millimetres uses `Instance::pixel_spacing` scaled by
6//! `RawImage::display_scale`.
7
8use anyhow::Result;
9use serde::{Deserialize, Serialize};
10use std::collections::BTreeMap;
11use std::fs;
12use std::path::Path;
13
14/// A single user annotation on a DICOM instance.
15///
16/// All point coordinates are in **displayed image pixels** — i.e. they
17/// index into [`RawImage::values`](crate::dcm::pixel::RawImage::values)
18/// directly. To convert to millimetres, multiply by
19/// `Instance::pixel_spacing × RawImage::display_scale` (see
20/// [`crate::dcm::roi::length_label`]).
21#[derive(Debug, Clone, Serialize, Deserialize)]
22#[serde(tag = "kind")]
23pub enum Annotation {
24 /// Two-point distance measurement.
25 Length {
26 /// First endpoint.
27 p1: [f32; 2],
28 /// Second endpoint.
29 p2: [f32; 2],
30 },
31 /// Three-point angle: rays from `v` to `p1` and `p2`.
32 Angle {
33 /// One ray endpoint.
34 p1: [f32; 2],
35 /// Vertex of the angle.
36 v: [f32; 2],
37 /// Other ray endpoint.
38 p2: [f32; 2],
39 },
40 /// Axis-aligned rectangular ROI.
41 Rect {
42 /// One corner.
43 p1: [f32; 2],
44 /// Opposite corner.
45 p2: [f32; 2],
46 },
47 /// Ellipse inscribed in the rectangle spanned by `p1` and `p2`.
48 Ellipse {
49 /// One corner of the bounding rectangle.
50 p1: [f32; 2],
51 /// Opposite corner of the bounding rectangle.
52 p2: [f32; 2],
53 },
54}
55
56impl Annotation {
57 /// Short human-readable label for the annotation kind.
58 pub fn kind_label(&self) -> &'static str {
59 match self {
60 Self::Length { .. } => "Length",
61 Self::Angle { .. } => "Angle",
62 Self::Rect { .. } => "Rect ROI",
63 Self::Ellipse { .. } => "Ellipse ROI",
64 }
65 }
66}
67
68/// All annotations on disk, keyed by SOP Instance UID.
69///
70/// Saved as pretty JSON to `<data_dir>/annotations.json` on every push,
71/// clear, or undo (the viewer favours safety over write-batching here —
72/// these files are tiny).
73#[derive(Debug, Clone, Default, Serialize, Deserialize)]
74pub struct AnnotationStore {
75 /// SOP Instance UID → annotation list (in insertion order).
76 pub by_instance: BTreeMap<String, Vec<Annotation>>,
77}
78
79impl AnnotationStore {
80 /// Load from a JSON sidecar. Missing or malformed files yield
81 /// [`Self::default`] — annotations are an optional layer, not a hard
82 /// requirement.
83 pub fn load(path: &Path) -> Self {
84 match fs::read_to_string(path) {
85 Ok(text) => serde_json::from_str(&text).unwrap_or_default(),
86 Err(_) => Self::default(),
87 }
88 }
89 /// Atomically(-ish) write the store to `path` as pretty JSON, creating
90 /// the parent directory if needed.
91 pub fn save(&self, path: &Path) -> Result<()> {
92 if let Some(p) = path.parent() {
93 fs::create_dir_all(p).ok();
94 }
95 let text = serde_json::to_string_pretty(self)?;
96 fs::write(path, text)?;
97 Ok(())
98 }
99 /// Borrow the annotation list for `uid`, or an empty slice if absent.
100 pub fn for_instance(&self, uid: &str) -> &[Annotation] {
101 self.by_instance
102 .get(uid)
103 .map(|v| v.as_slice())
104 .unwrap_or(&[])
105 }
106 /// Append `ann` to the list for `uid`.
107 pub fn push(&mut self, uid: &str, ann: Annotation) {
108 self.by_instance
109 .entry(uid.to_string())
110 .or_default()
111 .push(ann);
112 }
113 /// Drop every annotation on `uid`.
114 pub fn clear_instance(&mut self, uid: &str) {
115 self.by_instance.remove(uid);
116 }
117 /// Pop the most recently pushed annotation on `uid`. Removes the
118 /// per-instance entry entirely if the list becomes empty.
119 pub fn pop_last(&mut self, uid: &str) {
120 if let Some(v) = self.by_instance.get_mut(uid) {
121 v.pop();
122 if v.is_empty() {
123 self.by_instance.remove(uid);
124 }
125 }
126 }
127}