Skip to main content

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}