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}