Skip to main content

volren_core/transfer_function/
two_d.rs

1//! Two-dimensional transfer function: `(scalar, gradient)` to RGBA.
2
3/// An axis-aligned region in `(scalar, gradient)` space.
4///
5/// Regions are composited in insertion order using source-over alpha blending.
6#[derive(Debug, Clone, Copy, PartialEq)]
7pub struct TransferFunction2DRegion {
8    /// Inclusive scalar range covered by the region.
9    pub scalar_range: [f64; 2],
10    /// Inclusive gradient-magnitude range covered by the region.
11    pub gradient_range: [f64; 2],
12    /// RGBA colour produced when the sample falls inside the region.
13    pub rgba: [f64; 4],
14}
15
16impl TransferFunction2DRegion {
17    /// Create a new region.
18    #[must_use]
19    pub fn new(scalar_range: [f64; 2], gradient_range: [f64; 2], rgba: [f64; 4]) -> Self {
20        Self {
21            scalar_range,
22            gradient_range,
23            rgba,
24        }
25    }
26
27    /// `true` if `(scalar, gradient)` lies inside the region.
28    #[must_use]
29    pub fn contains(&self, scalar: f64, gradient: f64) -> bool {
30        scalar >= self.scalar_range[0]
31            && scalar <= self.scalar_range[1]
32            && gradient >= self.gradient_range[0]
33            && gradient <= self.gradient_range[1]
34    }
35}
36
37/// A 2D transfer function mapping `(scalar, gradient magnitude)` to RGBA.
38///
39/// This is a practical building block for feature classification: for example,
40/// highlight bone-like CT values only when the gradient magnitude is high.
41#[derive(Debug, Clone, Default)]
42pub struct TransferFunction2D {
43    regions: Vec<TransferFunction2DRegion>,
44    background: [f64; 4],
45}
46
47impl TransferFunction2D {
48    /// Create an empty 2D transfer function with transparent background.
49    #[must_use]
50    pub fn new() -> Self {
51        Self::default()
52    }
53
54    /// Set the fallback RGBA returned when no region matches.
55    #[must_use]
56    pub fn with_background(mut self, rgba: [f64; 4]) -> Self {
57        self.background = rgba;
58        self
59    }
60
61    /// Add a region.
62    pub fn add_region(&mut self, region: TransferFunction2DRegion) {
63        self.regions.push(region);
64    }
65
66    /// Remove the region at `index`, if it exists.
67    pub fn remove_region(&mut self, index: usize) -> Option<TransferFunction2DRegion> {
68        if index < self.regions.len() {
69            Some(self.regions.remove(index))
70        } else {
71            None
72        }
73    }
74
75    /// Borrow all configured regions.
76    #[must_use]
77    pub fn regions(&self) -> &[TransferFunction2DRegion] {
78        &self.regions
79    }
80
81    /// Evaluate the transfer function.
82    ///
83    /// Matching regions are composited in insertion order.
84    #[must_use]
85    pub fn evaluate(&self, scalar: f64, gradient: f64) -> [f64; 4] {
86        self.regions
87            .iter()
88            .filter(|region| region.contains(scalar, gradient))
89            .fold(self.background, |dst, region| alpha_over(dst, region.rgba))
90    }
91}
92
93fn alpha_over(dst: [f64; 4], src: [f64; 4]) -> [f64; 4] {
94    let src_a = src[3].clamp(0.0, 1.0);
95    let dst_a = dst[3].clamp(0.0, 1.0);
96    let out_a = src_a + dst_a * (1.0 - src_a);
97    if out_a <= f64::EPSILON {
98        return [0.0, 0.0, 0.0, 0.0];
99    }
100
101    let blend_channel =
102        |src_c: f64, dst_c: f64| (src_c * src_a + dst_c * dst_a * (1.0 - src_a)) / out_a;
103
104    [
105        blend_channel(src[0], dst[0]),
106        blend_channel(src[1], dst[1]),
107        blend_channel(src[2], dst[2]),
108        out_a,
109    ]
110}
111
112#[cfg(test)]
113mod tests {
114    use super::*;
115    use approx::assert_abs_diff_eq;
116
117    #[test]
118    fn empty_returns_background() {
119        let tf = TransferFunction2D::new().with_background([0.1, 0.2, 0.3, 0.4]);
120        assert_eq!(tf.evaluate(1.0, 2.0), [0.1, 0.2, 0.3, 0.4]);
121    }
122
123    #[test]
124    fn matching_region_returns_rgba() {
125        let mut tf = TransferFunction2D::new();
126        tf.add_region(TransferFunction2DRegion::new(
127            [100.0, 200.0],
128            [0.0, 1.0],
129            [1.0, 0.9, 0.8, 0.7],
130        ));
131        let rgba = tf.evaluate(150.0, 0.5);
132        assert_abs_diff_eq!(rgba[0], 1.0, epsilon = 1e-12);
133        assert_abs_diff_eq!(rgba[1], 0.9, epsilon = 1e-12);
134        assert_abs_diff_eq!(rgba[2], 0.8, epsilon = 1e-12);
135        assert_abs_diff_eq!(rgba[3], 0.7, epsilon = 1e-12);
136    }
137
138    #[test]
139    fn non_matching_region_ignored() {
140        let mut tf = TransferFunction2D::new();
141        tf.add_region(TransferFunction2DRegion::new(
142            [100.0, 200.0],
143            [2.0, 3.0],
144            [1.0, 0.0, 0.0, 1.0],
145        ));
146        assert_eq!(tf.evaluate(150.0, 0.5), [0.0, 0.0, 0.0, 0.0]);
147    }
148
149    #[test]
150    fn overlapping_regions_alpha_composite() {
151        let mut tf = TransferFunction2D::new();
152        tf.add_region(TransferFunction2DRegion::new(
153            [0.0, 10.0],
154            [0.0, 10.0],
155            [1.0, 0.0, 0.0, 0.5],
156        ));
157        tf.add_region(TransferFunction2DRegion::new(
158            [0.0, 10.0],
159            [0.0, 10.0],
160            [0.0, 0.0, 1.0, 0.5],
161        ));
162
163        let rgba = tf.evaluate(5.0, 5.0);
164        assert_abs_diff_eq!(rgba[3], 0.75, epsilon = 1e-12);
165    }
166}