cube_helix/
lib.rs

1//! Dave Green's 'cubehelix' colour scheme.  
2//! See cubehelix homepage at:  
3//! https://www.mrao.cam.ac.uk/~dag/CUBEHELIX/  
4//! Calculation from:  
5//! http://astron-soc.in/bulletin/11June/289392011.pdf  
6//! # Examples
7//! ```
8//! use cube_helix::CubeHelix;
9//! # fn main() {
10//! // Get default values
11//! let ch: CubeHelix = Default::default();
12//! // Returns color white: (255,255,255)
13//! let colors = ch.get_color(1.0);
14//! # }
15//! ```
16
17use std::f64::consts::PI;
18
19// RGB perceived intensity const
20/// Red perceived intensity cons
21const RPIC: (f64, f64) = (-0.14861, 1.78277);
22/// Green perceived intensity cons
23const GPIC: (f64, f64) = (-0.29227, -0.90649);
24/// Blue perceived intensity cons
25const BPIC: (f64, f64) = (1.97294, 0.0);
26
27/// # Examples
28/// Using default values:
29/// ```
30/// use cube_helix::CubeHelix;
31/// # fn main() {
32/// // Get default values
33/// let ch: CubeHelix = Default::default();
34/// // Returns color white: (255,255,255)
35/// let color = ch.get_color(1.0);
36/// assert_eq!(color.0, 255);
37/// assert_eq!(color.1, 255);
38/// assert_eq!(color.2, 255);
39/// # }
40/// ```
41/// Using defaults partially:
42/// ```
43/// use cube_helix::CubeHelix;
44/// // Override 'start' and 'rotation' values
45/// let ch = CubeHelix { start: 0.2, rotations: 1.5, ..Default::default() };
46/// // Returns color black: (0,0,0)
47/// let color = ch.get_color(0.0);
48/// assert_eq!(color.0, 0);
49/// assert_eq!(color.1, 0);
50/// assert_eq!(color.2, 0);
51/// ```
52#[derive(Debug)]
53pub struct CubeHelix {
54    /// Gamma factor.
55    pub gamma: f64,
56    /// Starting angle: 0.5 -> purple
57    pub start: f64,
58    /// Rotations (1.5 : R -> G -> B -> R).  
59    /// Negative value will 'spin' in opposite direction.
60    pub rotations: f64,
61    /// Value from 0..1 to control saturation.
62    /// Zero value is completelly grayscale.
63    pub saturation: f64,
64    /// Lowest value in value range. This value represents black.
65    pub min: f64,
66    /// Highest value in value range. This value represents white.
67    pub max: f64,
68}
69
70/// Default values to produce the same gradient as D.A Green's paper in Figure 1.  
71/// gamma: 1.0  
72/// start: 0.5  
73/// rotations: -1.5  
74/// saturation: 1.0  
75/// min: 0.0  
76/// max: 1.0
77impl Default for CubeHelix {
78    fn default() -> CubeHelix {
79        CubeHelix {
80            gamma: 1.0,
81            start: 0.5,
82            rotations: -1.5,
83            saturation: 1.0,
84            min: 0.0,
85            max: 1.0,
86        }
87    }
88}
89
90/// Adds color calculation to CubeHelix struct
91impl CubeHelix {
92    /// Calculates CubeHelix color for given value.  
93    /// Value must be in the min..max range.
94    /// Returns a tuple with three values: (red: u8, green: u8, blue: u8).
95    /// # Examples
96    /// ```
97    /// use cube_helix::CubeHelix;
98    /// # fn main() {
99    /// // Use range 0..100 - defalts otherwise
100    /// let ch = CubeHelix { min: 0.0, max: 100.0, ..Default::default() };
101    /// // Get color in the middle. Returns color: (174,97,158)
102    /// let color = ch.get_color(50.0);
103    /// assert_eq!(color.0, 174);
104    /// assert_eq!(color.1, 97);
105    /// assert_eq!(color.2, 158);
106    /// # }
107    /// ```
108    pub fn get_color(&self, value: f64) -> (u8, u8, u8) {
109        let rgb: (f64, f64, f64) = calc(self, value);
110        ((rgb.0 * 255.0) as u8, (rgb.1 * 255.0) as u8, (rgb.2 * 255.0) as u8)
111    }
112}
113
114/// Use cubehelix color calculation without CubeHelix struct.  
115/// Returns a tuple with three values (red: u8, green: u8, blue: u8)  
116/// # Examples
117/// ```
118/// use cube_helix::color;
119/// # fn main() {
120/// // Returns color (181, 104, 101)
121/// let color = color(1.0, 0.2, -1.5, 1.0, 0.0, 1.0, 0.5);
122/// assert_eq!(color.0, 181);
123/// assert_eq!(color.1, 104);
124/// assert_eq!(color.2, 101);
125/// # }
126/// ```
127pub fn color(
128    gamma: f64,
129    start: f64,
130    rotations: f64,
131    saturation: f64,
132    min: f64,
133    max: f64,
134    value: f64,
135) -> (u8, u8, u8) {
136    let rgb: (f64, f64, f64) = calc(
137        &CubeHelix {gamma, start, rotations, saturation, min, max},
138        value,
139    );
140    ((rgb.0 * 255.0) as u8, (rgb.1 * 255.0) as u8, (rgb.2 * 255.0) as u8)
141}
142
143fn calc(cube_helix: &CubeHelix, value: f64) -> (f64, f64, f64) {
144    // normalize value to min-max range
145    let x: f64 = normalize(value, cube_helix.min, cube_helix.max);
146    // Apply gamma factor to emphasise low or high intensity values
147    let lambda: f64 = x * cube_helix.gamma;
148    // Calculate amplitude of deviation
149    let amplitude: f64 = amplitude(lambda, cube_helix.saturation);
150    // Calculate angle of deviation
151    let phi: f64 = phi(cube_helix, value);
152
153    // Calculate rgb values
154    let red: f64 = color_calc(lambda, amplitude, phi, RPIC);
155    let green: f64 = color_calc(lambda, amplitude, phi, GPIC);
156    let blue: f64 = color_calc(lambda, amplitude, phi, BPIC);
157
158    (red, green, blue)
159}
160
161// lambda: given value normalized and gamma corrected
162//         (value between 0-1 via normalized)
163// amplitude: deviation of amplitude in the black and white line
164// phi: deviation of angle in the black and white diagonal line
165// color_const: perceived intesity constant tuple for red, green or blue
166fn color_calc(lambda: f64, amplitude: f64, phi: f64, color_const: (f64, f64)) -> f64 {
167    lambda + amplitude * (color_const.0 * phi.cos() + color_const.1 * phi.sin())
168}
169
170// phi: deviation of angle in the black and white diagonal line
171// φ = 2π(s/3 + rλ)
172fn phi(cube_helix: &CubeHelix, value: f64) -> f64 {
173    2.0 * PI * (cube_helix.start / 3.0 + cube_helix.rotations * value)
174}
175
176// amplitude: deviation of amplitude in the black and white line
177//
178// Calculate amplitude and angle of deviation from the black
179// to white diagonal in the plane of constant
180// perceived intensity.
181// a = hλ(1 − λ)/2
182fn amplitude(lambda: f64, saturation: f64) -> f64 {
183    saturation * lambda * (1.0 - lambda) / 2.0
184}
185
186// Normalize value in the range of min..max to 0..1
187fn normalize(value: f64, min: f64, max: f64) -> f64 {
188    if value < min || value > max {
189        panic!("Value: {:?} not in range: {:?} - {:?}", value, min, max);
190    }
191    if min > max {
192        panic!("Incorrect range: min > max");
193    }
194    if (min - max).signum() == 0.0 {
195        panic!("Incorrect range: {:?} - {:?}", min, max);
196    }
197    (value - min) / (max - min)
198}
199
200//Unit tests for functions and structs in this file.
201#[cfg(test)]
202mod cube_helix_tests {
203    use super::*;
204
205    // Struct tests
206    #[test]
207    fn defaults() {
208        let ch: CubeHelix = Default::default();
209        assert_eq!(ch.gamma, 1.0);
210        assert_eq!(ch.start, 0.5);
211        assert_eq!(ch.rotations, -1.5);
212        assert_eq!(ch.saturation, 1.0);
213        assert_eq!(ch.min, 0.0);
214        assert_eq!(ch.max, 1.0);
215    }
216
217    #[test]
218    fn partial_defaults() {
219        let ch = CubeHelix {
220            start: 0.2,
221            rotations: 1.5,
222            ..Default::default()
223        };
224        assert_eq!(ch.gamma, 1.0);
225        assert_eq!(ch.start, 0.2);
226        assert_eq!(ch.rotations, 1.5);
227        assert_eq!(ch.saturation, 1.0);
228        assert_eq!(ch.min, 0.0);
229        assert_eq!(ch.max, 1.0);
230    }
231
232    #[test]
233    fn odd_start_and_rotations() {
234        let ch = CubeHelix {
235            start: 77.2,
236            rotations: -21.5,
237            ..Default::default()
238        };
239        let color = ch.get_color(0.3);
240        assert_eq!(color.0, 124);
241        assert_eq!(color.1, 54);
242        assert_eq!(color.2, 65);
243 
244    }
245
246    // Color call tests
247    #[test]
248    fn partial_fn_call() {
249        let color = color(1.0, 0.2, -1.5, 1.0, 0.0, 1.0, 0.5);
250        assert_eq!(color.0, 181);
251        assert_eq!(color.1, 104);
252        assert_eq!(color.2, 101);
253    }
254
255    #[test]
256    fn max_is_white() {
257        let ch: CubeHelix = Default::default();
258        let colors = ch.get_color(1.0);
259        assert_eq!(colors.0, 255);
260        assert_eq!(colors.1, 255);
261        assert_eq!(colors.2, 255);
262    }
263
264    #[test]
265    fn min_is_black() {
266        let ch: CubeHelix = Default::default();
267        let colors = ch.get_color(0.0);
268        assert_eq!(colors.0, 0);
269        assert_eq!(colors.1, 0);
270        assert_eq!(colors.2, 0);
271    }
272
273    // Tests for normalize
274    #[test]
275    fn normalize_with_defaults() {
276        assert_eq!(normalize(0.5, 0.0, 1.0), 0.5);
277        assert_eq!(normalize(0.2, 0.0, 1.0), 0.2);
278        assert_eq!(normalize(0.0, 0.0, 1.0), 0.0);
279        assert_eq!(normalize(1.0, 0.0, 1.0), 1.0);
280    }
281
282    // Panic: if user tries to normalize value that is not in his min-max range,
283    // the given rgb value would most likely be unwanted.
284    #[test]
285    #[should_panic]
286    fn normalize_with_value_not_in_range() {
287        assert_eq!(normalize(-0.5, 0.0, 1.0), 666.0);
288    }
289
290    #[test]
291    fn normalize_with_negative_to_positive_range() {
292        assert_eq!(normalize(0.0, -0.5, 0.5), 0.5);
293        assert_eq!(normalize(0.5, -0.5, 0.5), 1.0);
294        assert_eq!(normalize(-0.5, -0.5, 0.5), 0.0);
295        assert_eq!(normalize(0.2, -0.5, 0.5), 0.7);
296    }
297
298    // It is better to panic rather than have implicit behaviour.
299    // If min and max value are the same: division with zero value would occur
300    // Possible implicit behaviour could be just to return value 1.0.
301    #[test]
302    #[should_panic]
303    fn normalize_with_incorrect_range() {
304        assert_eq!(normalize(1.1, 1.1, 1.1), 0.0);
305    }
306}