Skip to main content

dicom_viewer/dcm/
pixel.rs

1//! Pixel-data decode + CPU windowing.
2//!
3//! `load_raw` decodes a DICOM file once into rescaled f32 values plus
4//! metadata about default window and photometric inversion. `render_rgba`
5//! then turns those values into RGBA8 with a window/level applied — fast
6//! enough to call on every drag delta for interactive W/L.
7
8use anyhow::{Context, Result};
9use dicom::object::open_file;
10use dicom_pixeldata::{
11    ConvertOptions, ModalityLutOption, PhotometricInterpretation, PixelDecoder, VoiLutOption,
12};
13use std::path::Path;
14
15/// Cap interactive display at this many pixels per side. Anything larger is
16/// decimated at load time so window/level dragging stays smooth on MG-sized
17/// images. Non-diagnostic viewer — full-resolution display is not a goal.
18const MAX_DISPLAY_DIM: u32 = 2048;
19
20/// Which VOI window to apply when calling [`decode_to_rgba`].
21#[derive(Debug, Clone, Copy)]
22pub enum WindowSetting {
23    /// Use the file's default window if present, otherwise full range.
24    Auto,
25    /// Caller-supplied window centre / width.
26    Manual {
27        /// Window centre in rescaled units (HU on CT).
28        center: f64,
29        /// Window width in rescaled units.
30        width: f64,
31    },
32}
33
34/// RGBA8 buffer returned by [`decode_to_rgba`].
35pub struct DecodedFrame {
36    /// Frame width in pixels (after decimation, if any).
37    pub width: u32,
38    /// Frame height in pixels (after decimation, if any).
39    pub height: u32,
40    /// RGBA8 bytes, row-major, length = `width * height * 4`.
41    pub rgba: Vec<u8>,
42}
43
44/// Rescaled monochrome pixel values, ready for interactive windowing.
45///
46/// Produced by [`load_raw`]. The modality LUT (rescale slope/intercept)
47/// has already been applied — for CT this means [`Self::values`] are in
48/// Hounsfield Units. VOI windowing is *not* applied; that happens cheaply
49/// on every drag delta via [`render_rgba`].
50///
51/// Images larger than 2048 px per side are decimated at load time;
52/// [`Self::display_scale`] records the factor so measurements can convert
53/// back to original-pixel units.
54pub struct RawImage {
55    /// Image width in pixels (after decimation).
56    pub width: u32,
57    /// Image height in pixels (after decimation).
58    pub height: u32,
59    /// Pixel values, row-major. Length = `width * height`. Already in
60    /// modality units (HU on CT).
61    pub values: Vec<f32>,
62    /// `(center, width)` from `WindowCenter`/`WindowWidth` if present.
63    pub default_window: Option<(f64, f64)>,
64    /// Minimum finite value in [`Self::values`].
65    pub min_val: f32,
66    /// Maximum finite value in [`Self::values`].
67    pub max_val: f32,
68    /// `true` for `MONOCHROME1` — display inversion is needed.
69    pub photometric_invert: bool,
70    /// Decimation factor applied at load time. `1` = full resolution.
71    /// Used to scale measurements back to original-pixel units.
72    pub display_scale: u32,
73}
74
75/// Decode the pixel data of a DICOM file into a [`RawImage`].
76///
77/// Applies the modality LUT (rescale slope/intercept) but *not* the VOI
78/// LUT. Decimates oversized images so that the longest side is at most
79/// 2048 pixels (see `MAX_DISPLAY_DIM`).
80///
81/// # Errors
82/// Returns an error when the file can't be opened, pixel data can't be
83/// decoded, or the layout isn't supported (e.g. RGB / multi-sample inputs
84/// the viewer doesn't yet handle).
85pub fn load_raw(path: &Path) -> Result<RawImage> {
86    let obj = open_file(path).with_context(|| format!("open {}", path.display()))?;
87    let decoded = obj
88        .decode_pixel_data()
89        .with_context(|| format!("decode pixels {}", path.display()))?;
90
91    let opts = ConvertOptions::new()
92        .with_modality_lut(ModalityLutOption::Default)
93        .with_voi_lut(VoiLutOption::Identity);
94    let vals: Vec<f32> = decoded
95        .to_vec_with_options(&opts)
96        .with_context(|| format!("to_vec f32 {}", path.display()))?;
97
98    let invert = matches!(
99        decoded.photometric_interpretation(),
100        PhotometricInterpretation::Monochrome1
101    );
102
103    let default_window = decoded
104        .window()
105        .ok()
106        .flatten()
107        .and_then(|ws| ws.first().map(|w| (w.center, w.width)));
108
109    let mut raw = RawImage {
110        width: decoded.columns(),
111        height: decoded.rows(),
112        values: vals,
113        default_window,
114        min_val: 0.0,
115        max_val: 1.0,
116        photometric_invert: invert,
117        display_scale: 1,
118    };
119
120    // Downsample oversized images.
121    let max_side = raw.width.max(raw.height);
122    if max_side > MAX_DISPLAY_DIM {
123        let scale = max_side.div_ceil(MAX_DISPLAY_DIM);
124        raw.display_scale = scale;
125        let new_w = (raw.width / scale).max(1);
126        let new_h = (raw.height / scale).max(1);
127        let mut new_vals = Vec::with_capacity((new_w * new_h) as usize);
128        for y in 0..new_h {
129            for x in 0..new_w {
130                let sx = (x * scale) as usize;
131                let sy = (y * scale) as usize;
132                let idx = sy * (raw.width as usize) + sx;
133                new_vals.push(raw.values[idx]);
134            }
135        }
136        raw.width = new_w;
137        raw.height = new_h;
138        raw.values = new_vals;
139    }
140
141    let (mut mn, mut mx) = (f32::INFINITY, f32::NEG_INFINITY);
142    for &v in &raw.values {
143        if v.is_finite() {
144            if v < mn {
145                mn = v;
146            }
147            if v > mx {
148                mx = v;
149            }
150        }
151    }
152    if !mn.is_finite() {
153        mn = 0.0;
154    }
155    if !mx.is_finite() {
156        mx = mn + 1.0;
157    }
158    raw.min_val = mn;
159    raw.max_val = mx;
160
161    // Sanity check — keeps the rest of the app from panicking on
162    // mismatched buffer sizes (e.g. RGB or multi-sample inputs we don't
163    // handle yet).
164    let expected = (raw.width as usize)
165        .checked_mul(raw.height as usize)
166        .unwrap_or(0);
167    if raw.width == 0 || raw.height == 0 || expected == 0 || raw.values.len() != expected {
168        anyhow::bail!(
169            "unsupported pixel layout: {}x{}, {} values",
170            raw.width,
171            raw.height,
172            raw.values.len()
173        );
174    }
175    Ok(raw)
176}
177
178/// Apply a window/level and convert a [`RawImage`] to RGBA8.
179///
180/// The output is grey-on-grey (R = G = B = level) with full alpha. Cheap
181/// enough to call on every drag delta for interactive W/L. The effective
182/// inversion is `raw.photometric_invert XOR user_invert`, so toggling
183/// "invert" on a `MONOCHROME1` image cancels back to natural display.
184pub fn render_rgba(raw: &RawImage, center: f64, width: f64, user_invert: bool) -> Vec<u8> {
185    let w = width.max(1e-3);
186    let lo = center - w / 2.0;
187    let inv_w = 255.0 / w;
188    let invert = raw.photometric_invert ^ user_invert;
189    let n = raw.values.len();
190    let mut out = vec![0u8; n * 4];
191    for (i, &v) in raw.values.iter().enumerate() {
192        let g = ((v as f64 - lo) * inv_w).round().clamp(0.0, 255.0);
193        let mut gi = g as u8;
194        if invert {
195            gi = 255 - gi;
196        }
197        let o = i * 4;
198        out[o] = gi;
199        out[o + 1] = gi;
200        out[o + 2] = gi;
201        out[o + 3] = 255;
202    }
203    out
204}
205
206/// Pick a sensible default window for `raw`.
207///
208/// Returns the DICOM file's default window if present, otherwise a
209/// centred full-range window over `[min_val, max_val]`.
210pub fn auto_window(raw: &RawImage) -> (f64, f64) {
211    raw.default_window.unwrap_or_else(|| {
212        let c = ((raw.min_val + raw.max_val) as f64) / 2.0;
213        let w = (raw.max_val - raw.min_val).max(1.0) as f64;
214        (c, w)
215    })
216}
217
218/// Convenience: load + render in one shot. Used by tests.
219pub fn decode_to_rgba(path: &Path, window: WindowSetting) -> Result<DecodedFrame> {
220    let raw = load_raw(path)?;
221    let (center, width) = match window {
222        WindowSetting::Auto => auto_window(&raw),
223        WindowSetting::Manual { center, width } => (center, width),
224    };
225    let rgba = render_rgba(&raw, center, width, false);
226    Ok(DecodedFrame {
227        width: raw.width,
228        height: raw.height,
229        rgba,
230    })
231}