Skip to main content

volren_core/
window_level.rs

1//! DICOM-compliant window/level (window centre/width) mapping.
2//!
3//! Implements the formula from **DICOM PS 3.3 §C.7.6.3.1.5**:
4//!
5//! ```text
6//! if (value - window_center + 0.5) / window_width + 0.5 ≤ 0   → y_min
7//! if (value - window_center + 0.5) / window_width + 0.5 ≥ 1   → y_max
8//! else                                                          → linear
9//! ```
10
11/// DICOM window/level parameters.
12///
13/// - `center` — the value that maps to the midpoint of the output range.
14/// - `width`  — the full span of input values that map to the full output range.
15///
16/// A width of zero is not meaningful; the caller should ensure `width > 0`.
17#[derive(Debug, Clone, Copy, PartialEq)]
18pub struct WindowLevel {
19    /// Window centre (level).
20    pub center: f64,
21    /// Window width.
22    pub width: f64,
23}
24
25impl WindowLevel {
26    /// Create from center and width.
27    ///
28    /// # Panics (debug only)
29    /// Panics in debug builds if `width <= 0`.
30    #[must_use]
31    pub fn new(center: f64, width: f64) -> Self {
32        debug_assert!(width > 0.0, "window width must be positive");
33        Self { center, width }
34    }
35
36    /// Apply the DICOM linear mapping and return a value in `[out_min, out_max]`.
37    ///
38    /// `out_min` and `out_max` are typically `0.0` and `1.0` for GPU normalisation,
39    /// or `0.0` and `255.0` for 8-bit display.
40    #[must_use]
41    pub fn apply(&self, value: f64, out_min: f64, out_max: f64) -> f64 {
42        // DICOM formula (PS 3.3 C.7.6.3.1.5)
43        let t = (value - self.center + 0.5) / self.width + 0.5;
44        let t = t.clamp(0.0, 1.0);
45        out_min + t * (out_max - out_min)
46    }
47
48    /// Normalise `value` to `[0, 1]` using the window mapping.
49    #[inline]
50    #[must_use]
51    pub fn normalise(&self, value: f64) -> f64 {
52        self.apply(value, 0.0, 1.0)
53    }
54
55    /// Return the input value that maps to `t ∈ [0, 1]`.
56    ///
57    /// This is the inverse of [`WindowLevel::normalise`].
58    #[must_use]
59    pub fn denormalise(&self, t: f64) -> f64 {
60        (t - 0.5) * self.width + self.center - 0.5
61    }
62
63    /// Adjust the center by `delta` (level change).
64    pub fn adjust_center(&mut self, delta: f64) {
65        self.center += delta;
66    }
67
68    /// Adjust the width by `factor` (window change, multiplicative).
69    ///
70    /// The width is clamped to at least 1.0 to avoid division by zero.
71    pub fn adjust_width(&mut self, factor: f64) {
72        self.width = (self.width * factor).max(1.0);
73    }
74
75    /// Derive window/level from a scalar range.
76    ///
77    /// Sets center to the midpoint and width to the full range (minimum 1.0).
78    #[must_use]
79    pub fn from_scalar_range(min: f64, max: f64) -> Self {
80        Self::new((min + max) * 0.5, (max - min).max(1.0))
81    }
82}
83
84/// Common CT window presets (Hounsfield units).
85pub mod presets {
86    use super::WindowLevel;
87
88    /// Soft tissue window (C 40, W 400).
89    pub const SOFT_TISSUE: WindowLevel = WindowLevel {
90        center: 40.0,
91        width: 400.0,
92    };
93
94    /// Lung window (C −600, W 1500).
95    pub const LUNG: WindowLevel = WindowLevel {
96        center: -600.0,
97        width: 1500.0,
98    };
99
100    /// Bone window (C 400, W 1500).
101    pub const BONE: WindowLevel = WindowLevel {
102        center: 400.0,
103        width: 1500.0,
104    };
105
106    /// Brain window (C 40, W 80).
107    pub const BRAIN: WindowLevel = WindowLevel {
108        center: 40.0,
109        width: 80.0,
110    };
111
112    /// Liver window (C 60, W 160).
113    pub const LIVER: WindowLevel = WindowLevel {
114        center: 60.0,
115        width: 160.0,
116    };
117
118    /// Abdomen window (C 60, W 400).
119    pub const ABDOMEN: WindowLevel = WindowLevel {
120        center: 60.0,
121        width: 400.0,
122    };
123}
124
125// ── Tests ─────────────────────────────────────────────────────────────────────
126
127#[cfg(test)]
128mod tests {
129    use super::*;
130    use approx::assert_abs_diff_eq;
131
132    #[test]
133    fn center_maps_to_midpoint() {
134        let wl = WindowLevel::new(40.0, 400.0);
135        // Per DICOM PS3.3 §C.7.6.3.1.5, value=(center-0.5) maps to exactly 0.5
136        assert_abs_diff_eq!(wl.normalise(39.5), 0.5, epsilon = 1e-10);
137    }
138
139    #[test]
140    fn below_window_clamps_to_zero() {
141        let wl = WindowLevel::new(40.0, 400.0);
142        assert_abs_diff_eq!(wl.normalise(-160.0 - 1.0), 0.0, epsilon = 1e-10);
143    }
144
145    #[test]
146    fn above_window_clamps_to_one() {
147        let wl = WindowLevel::new(40.0, 400.0);
148        assert_abs_diff_eq!(wl.normalise(240.0 + 1.0), 1.0, epsilon = 1e-10);
149    }
150
151    #[test]
152    fn normalise_denormalise_round_trip() {
153        let wl = WindowLevel::new(100.0, 200.0);
154        // Round-trip works for values strictly inside the window (not clamped).
155        // With c=100, w=200: unclamped range is [c-0.5-w/2, c-0.5+w/2] = [-0.5, 199.5]
156        for v in [0.0, 50.0, 100.0, 150.0, 199.0] {
157            let t = wl.normalise(v);
158            let back = wl.denormalise(t);
159            assert_abs_diff_eq!(back, v, epsilon = 1e-8);
160        }
161    }
162
163    #[test]
164    fn apply_custom_range() {
165        let wl = WindowLevel::new(0.0, 2.0);
166        // value=center-0.5 → t=0.5 exactly (DICOM spec)
167        let out = wl.apply(-0.5, 0.0, 255.0);
168        assert_abs_diff_eq!(out, 127.5, epsilon = 0.5);
169    }
170
171    #[test]
172    fn presets_are_valid() {
173        // Check sensible ranges — use let bindings to avoid const-assertion lint.
174        let st = presets::SOFT_TISSUE.width;
175        let lu = presets::LUNG.width;
176        let bo = presets::BONE.width;
177        assert!(st > 0.0);
178        assert!(lu > 0.0);
179        assert!(bo > 0.0);
180    }
181
182    #[test]
183    fn from_scalar_range() {
184        let wl = WindowLevel::from_scalar_range(100.0, 300.0);
185        assert_abs_diff_eq!(wl.center, 200.0, epsilon = 1e-10);
186        assert_abs_diff_eq!(wl.width, 200.0, epsilon = 1e-10);
187    }
188
189    #[test]
190    fn from_scalar_range_degenerate() {
191        let wl = WindowLevel::from_scalar_range(50.0, 50.0);
192        assert!(wl.width >= 1.0, "width should be at least 1.0");
193    }
194}