scarlet/
colormap.rs

1//! This module defines a generalized trait, [`ColorMap`], for a colormap—a
2//! mapping of the numbers between 0 and 1 to colors in a continuous way—and
3//! provides some common ones used in programs like MATLAB and in data
4//! visualization everywhere.
5
6use color::{Color, RGBColor};
7use colorpoint::ColorPoint;
8use coord::Coord;
9use matplotlib_cmaps;
10use std::iter::Iterator;
11
12/// A trait that models a colormap, a continuous mapping of the numbers between 0 and 1 to
13/// colors. Any color output format is supported, but it must be consistent.
14pub trait ColorMap<T: Color + Sized> {
15    /// Maps a given number between 0 and 1 to a given output `Color`. This should never fail or panic
16    /// except for NaN and similar: there should be some Color that marks out-of-range data.
17    fn transform_single(&self, color: f64) -> T;
18    /// Maps a given collection of numbers between 0 and 1 to an iterator of `Color`s. Does not evaluate
19    /// lazily, because the colormap could have some sort of state that changes between iterations otherwise.
20    fn transform<U: IntoIterator<Item = f64>>(&self, inputs: U) -> Vec<T> {
21        // TODO: make to work on references?
22        inputs
23            .into_iter()
24            .map(|x| self.transform_single(x))
25            .collect()
26    }
27}
28
29/// A struct that describes different transformations of the numbers between 0 and 1 to themselves,
30/// used for controlling the linearity or nonlinearity of gradients.
31#[derive(Debug, PartialEq, Clone)]
32pub enum NormalizeMapping {
33    /// A normal linear mapping: each number maps to itself.
34    Linear,
35    /// A cube root mapping: 1/8 would map to 1/2, for example. This has the effect of emphasizing the
36    /// differences in the low end of the range, which is useful for some data like sound intensity
37    /// that isn't perceived linearly.
38    Cbrt,
39    /// A generic mapping, taking as a value any function or closure that maps the integers from 0-1
40    /// to the same range. This should never fail.
41    Generic(fn(f64) -> f64),
42}
43
44impl NormalizeMapping {
45    /// Performs the given mapping on an input number, with undefined behavior or panics if the given
46    /// number is outside of the range (0, 1). Given an input between 0 and 1, should always output
47    /// another number in the same range.
48    pub fn normalize(&self, x: f64) -> f64 {
49        match *self {
50            NormalizeMapping::Linear => x,
51            NormalizeMapping::Cbrt => x.cbrt(),
52            NormalizeMapping::Generic(func) => func(x),
53        }
54    }
55}
56
57/// A gradient colormap: a continuous, evenly-spaced shift between two colors A and B such that 0 maps
58/// to A, 1 maps to B, and any number in between maps to a weighted mix of them in a given
59/// coordinate space. Uses the gradient functions in the [`ColorPoint`] trait to complete this.
60/// Out-of-range values are simply clamped to the correct range: calling this on negative numbers
61/// will return A, and calling this on numbers larger than 1 will return B.
62#[derive(Debug, Clone)]
63pub struct GradientColorMap<T: ColorPoint> {
64    /// The start of the gradient. Calling this colormap on 0 or any negative number returns this color.
65    pub start: T,
66    /// The end of the gradient. Calling this colormap on 1 or any larger number returns this color.
67    pub end: T,
68    /// Any additional added nonlinearity imposed on the gradient: for example, a cube root mapping
69    /// emphasizes differences in the low end of the range.
70    pub normalization: NormalizeMapping,
71    /// Any desired padding: offsets introduced that artificially shift the limits of the
72    /// range. Expressed as `(new_min, new_max)`, where both are floats and `new_min < new_max`. For
73    /// example, having padding of `(1/8, 1)` would remove the lower eighth of the color map while
74    /// keeping the overall map smooth and continuous. Padding of `(0., 1.)` is the default and normal
75    /// behavior.
76    pub padding: (f64, f64),
77}
78
79impl<T: ColorPoint> GradientColorMap<T> {
80    /// Constructs a new linear [`GradientColorMap`], without padding, from two colors.
81    pub fn new_linear(start: T, end: T) -> GradientColorMap<T> {
82        GradientColorMap {
83            start,
84            end,
85            normalization: NormalizeMapping::Linear,
86            padding: (0., 1.),
87        }
88    }
89    /// Constructs a new cube root [`GradientColorMap`], without padding, from two colors.
90    pub fn new_cbrt(start: T, end: T) -> GradientColorMap<T> {
91        GradientColorMap {
92            start,
93            end,
94            normalization: NormalizeMapping::Cbrt,
95            padding: (0., 1.),
96        }
97    }
98}
99
100impl<T: ColorPoint> ColorMap<T> for GradientColorMap<T> {
101    fn transform_single(&self, x: f64) -> T {
102        // clamp between 0 and 1 beforehand
103        let clamped = if x < 0. {
104            0.
105        } else if x > 1. {
106            1.
107        } else {
108            x
109        };
110        self.start
111            .padded_gradient(&self.end, self.padding.0, self.padding.1)(
112            self.normalization.normalize(clamped),
113        )
114    }
115}
116
117/// A colormap that linearly interpolates between a given series of values in an equally-spaced
118/// progression. This is modeled off of the `matplotlib` Python library's `ListedColormap`, and is
119/// only used to provide reference implementations of the standard matplotlib colormaps. Clamps values
120/// outside of 0 to 1.
121#[derive(Debug, Clone)]
122pub struct ListedColorMap {
123    /// The list of values, as a vector of `[f64]` arrays that provide equally-spaced RGB values.
124    pub vals: Vec<[f64; 3]>,
125}
126
127impl<T: ColorPoint> ColorMap<T> for ListedColorMap {
128    /// Linearly interpolates by first finding the two colors on either boundary, and then using a
129    /// simple linear gradient. There's no need to instantiate every single Color, because the vast
130    /// majority of them aren't important for one computation.
131    fn transform_single(&self, x: f64) -> T {
132        let clamped = if x < 0. {
133            0.
134        } else if x > 1. {
135            1.
136        } else {
137            x
138        };
139        // TODO: keeping every Color in memory might be more efficient for large-scale
140        // transformation; if it's a performance issue, try and fix
141
142        // now find the two values that bound the clamped x
143        // get the index as a floating point: the integers on either side bound it
144        // we subtract 1 because 0-n is n+1 numbers, not n
145        // otherwise, 1 would map out of range
146        let float_ind = clamped * (self.vals.len() as f64 - 1.);
147        let ind1 = float_ind.floor() as usize;
148        let ind2 = float_ind.ceil() as usize;
149        if ind1 == ind2 {
150            // x is exactly on the boundary, no interpolation needed
151            let arr = self.vals[ind1]; // guaranteed to be in range
152            RGBColor::from(Coord {
153                x: arr[0],
154                y: arr[1],
155                z: arr[2],
156            })
157            .convert()
158        } else {
159            // interpolate
160            let arr1 = self.vals[ind1];
161            let arr2 = self.vals[ind2];
162            let coord1 = Coord {
163                x: arr1[0],
164                y: arr1[1],
165                z: arr1[2],
166            };
167            let coord2 = Coord {
168                x: arr2[0],
169                y: arr2[1],
170                z: arr2[2],
171            };
172            // now interpolate and convert to the desired type
173            let rgb: RGBColor = coord2.weighted_midpoint(&coord1, clamped).into();
174            rgb.convert()
175        }
176    }
177}
178
179// now just constructors
180impl ListedColorMap {
181    // TODO: In the future, I'd like to remove this weird array type bound if possible
182    /// Initializes a ListedColorMap from an iterator of arrays [R, G, B].
183    pub fn new<T: Iterator<Item = [f64; 3]>>(vals: T) -> ListedColorMap {
184        ListedColorMap {
185            vals: vals.collect(),
186        }
187    }
188    /// Initializes a viridis colormap, a pleasing blue-green-yellow colormap that is perceptually
189    /// uniform with respect to luminance, found in Python's `matplotlib` as the default
190    /// colormap.
191    pub fn viridis() -> ListedColorMap {
192        let vals = matplotlib_cmaps::VIRIDIS_DATA.to_vec();
193        ListedColorMap { vals }
194    }
195    /// Initializes a magma colormap, a pleasing blue-purple-red-yellow map that is perceptually
196    /// uniform with respect to luminance, found in Python's `matplotlib.`
197    pub fn magma() -> ListedColorMap {
198        let vals = matplotlib_cmaps::MAGMA_DATA.to_vec();
199        ListedColorMap { vals }
200    }
201    /// Initializes an inferno colormap, a pleasing blue-purple-red-yellow map similar to magma, but
202    /// with a slight shift towards red and yellow, that is perceptually uniform with respect to
203    /// luminance, found in Python's `matplotlib.`
204    pub fn inferno() -> ListedColorMap {
205        let vals = matplotlib_cmaps::INFERNO_DATA.to_vec();
206        ListedColorMap { vals }
207    }
208    /// Initializes a plasma colormap, a pleasing blue-purple-red-yellow map that is perceptually
209    /// uniform with respect to luminance, found in Python's `matplotlib.` It eschews the really dark
210    /// blue found in inferno and magma, instead starting at a fairly bright blue.
211    pub fn plasma() -> ListedColorMap {
212        let vals = matplotlib_cmaps::PLASMA_DATA.to_vec();
213        ListedColorMap { vals }
214    }
215    /// Initializes a cividis colormap, a pleasing shades of blue-yellow map that is perceptually
216    /// uniform with respect to luminance, found in Python's `matplotlib.`
217    pub fn cividis() -> ListedColorMap {
218        let vals = matplotlib_cmaps::CIVIDIS_DATA.to_vec();
219        ListedColorMap { vals }
220    }
221    /// Initializes a turbo colormap, a pleasing blue-green-red map that is perceptually
222    /// uniform with respect to luminance, found in Python's `matplotlib.`
223    pub fn turbo() -> ListedColorMap {
224        let vals = matplotlib_cmaps::TURBO_DATA.to_vec();
225        ListedColorMap { vals }
226    }
227    /// "circle" is a constant-brightness, perceptually uniform cyclic rainbow map
228    /// going from magenta through blue, green and red back to magenta.
229    pub fn circle() -> ListedColorMap {
230        let vals = matplotlib_cmaps::CIRCLE_DATA.to_vec();
231        ListedColorMap { vals }
232    }
233    /// "bluered" is a diverging colormap going from dark magenta/blue/cyan to yellow/red/dark purple,
234    /// analogously to "RdBu_r" but with higher contrast and more uniform gradient. It is suitable for
235    /// plotting velocity maps (blue/redshifted) and is similar to "breeze" and "mist" in this respect,
236    /// but has (nearly) white as the central color instead of green.
237    /// It is also cyclic (same colors at endpoints).
238    pub fn bluered() -> ListedColorMap {
239        let vals = matplotlib_cmaps::BLUERED_DATA.to_vec();
240        ListedColorMap { vals }
241    }
242    /// "breeze" is a better-balanced version of "jet", with diverging luminosity profile,
243    /// going from dark blue to bright green in the center and then back to dark red.
244    /// It is nearly perceptually uniform, unlike the original jet map.
245    pub fn breeze() -> ListedColorMap {
246        let vals = matplotlib_cmaps::BREEZE_DATA.to_vec();
247        ListedColorMap { vals }
248    }
249    /// "mist" is another replacement for "jet" or "rainbow" maps, which differs from "breeze" by
250    /// having smaller dynamical range in brightness. The red and blue endpoints are darker than
251    /// the green center, but not as dark as in "breeze", while the center is not as bright.
252    pub fn mist() -> ListedColorMap {
253        let vals = matplotlib_cmaps::MIST_DATA.to_vec();
254        ListedColorMap { vals }
255    }
256    /// "earth" is a rainbow-like colormap with increasing luminosity, going from black through
257    //  dark blue, medium green in the middle and light red/orange to white.
258    // # It is nearly perceptually uniform, monotonic in luminosity, and is suitable for
259    // # plotting nearly anything, especially velocity maps (blue/redshifted).
260    // # It resembles "gist_earth" (but with more vivid colors) or MATLAB's "parula".
261    pub fn earth() -> ListedColorMap {
262        let vals = matplotlib_cmaps::EARTH_DATA.to_vec();
263        ListedColorMap { vals }
264    }
265    /// "hell" is a slightly tuned version of "inferno", with the main difference that it goes to
266    // # pure white at the bright end (starts from black, then dark blue/purple, red in the middle,
267    // # yellow and white). It is fully perceptually uniform and monotonic in luminosity.
268    pub fn hell() -> ListedColorMap {
269        let vals = matplotlib_cmaps::HELL_DATA.to_vec();
270        ListedColorMap { vals }
271    }
272}
273
274#[cfg(test)]
275mod tests {
276    #[allow(unused_imports)]
277    use super::*;
278    use color::RGBColor;
279
280    #[test]
281    fn test_linear_gradient() {
282        let red = RGBColor::from_hex_code("#ff0000").unwrap();
283        let blue = RGBColor::from_hex_code("#0000ff").unwrap();
284        let cmap = GradientColorMap::new_linear(red, blue);
285        let vals = vec![-0.2, 0., 1. / 15., 1. / 5., 4. / 5., 1., 100.];
286        let cols = cmap.transform(vals);
287        let strs = vec![
288            "#FF0000", "#FF0000", "#EE0011", "#CC0033", "#3300CC", "#0000FF", "#0000FF",
289        ];
290        for (i, col) in cols.into_iter().enumerate() {
291            assert_eq!(col.to_string(), strs[i]);
292        }
293    }
294    #[test]
295    fn test_cbrt_gradient() {
296        let red = RGBColor::from_hex_code("#CC0000").unwrap();
297        let blue = RGBColor::from_hex_code("#0000CC").unwrap();
298        let cmap = GradientColorMap::new_cbrt(red, blue);
299        let vals = vec![-0.2, 0., 1. / 27., 1. / 8., 8. / 27., 1., 100.];
300        let cols = cmap.transform(vals);
301        let strs = vec![
302            "#CC0000", "#CC0000", "#880044", "#660066", "#440088", "#0000CC", "#0000CC",
303        ];
304        for (i, col) in cols.into_iter().enumerate() {
305            assert_eq!(col.to_string(), strs[i]);
306        }
307    }
308    #[test]
309    fn test_padding() {
310        let red = RGBColor::from_hex_code("#CC0000").unwrap();
311        let blue = RGBColor::from_hex_code("#0000CC").unwrap();
312        let mut cmap = GradientColorMap::new_cbrt(red, blue);
313        cmap.padding = (0.25, 0.75);
314        // essentially, start and end are now #990033 and #330099
315        let vals = vec![-0.2, 0., 1. / 27., 1. / 8., 8. / 27., 1., 100.];
316        let cols = cmap.transform(vals);
317        let strs = vec![
318            "#990033", "#990033", "#770055", "#660066", "#550077", "#330099", "#330099",
319        ];
320        for (i, col) in cols.into_iter().enumerate() {
321            assert_eq!(col.to_string(), strs[i]);
322        }
323    }
324    #[test]
325    fn test_mpl_colormaps() {
326        let viridis = ListedColorMap::viridis();
327        let magma = ListedColorMap::magma();
328        let inferno = ListedColorMap::inferno();
329        let plasma = ListedColorMap::plasma();
330        let vals = vec![-0.2, 0., 0.2, 0.4, 0.6, 0.8, 1., 100.];
331        // these values were taken using matplotlib
332        let viridis_colors = [
333            [0.267004, 0.004874, 0.329415],
334            [0.267004, 0.004874, 0.329415],
335            [0.253935, 0.265254, 0.529983],
336            [0.163625, 0.471133, 0.558148],
337            [0.134692, 0.658636, 0.517649],
338            [0.477504, 0.821444, 0.318195],
339            [0.993248, 0.906157, 0.143936],
340            [0.993248, 0.906157, 0.143936],
341        ];
342        let magma_colors = [
343            [1.46200000e-03, 4.66000000e-04, 1.38660000e-02],
344            [1.46200000e-03, 4.66000000e-04, 1.38660000e-02],
345            [2.32077000e-01, 5.98890000e-02, 4.37695000e-01],
346            [5.50287000e-01, 1.61158000e-01, 5.05719000e-01],
347            [8.68793000e-01, 2.87728000e-01, 4.09303000e-01],
348            [9.94738000e-01, 6.24350000e-01, 4.27397000e-01],
349            [9.87053000e-01, 9.91438000e-01, 7.49504000e-01],
350            [9.87053000e-01, 9.91438000e-01, 7.49504000e-01],
351        ];
352        let plasma_colors = [
353            [5.03830000e-02, 2.98030000e-02, 5.27975000e-01],
354            [5.03830000e-02, 2.98030000e-02, 5.27975000e-01],
355            [4.17642000e-01, 5.64000000e-04, 6.58390000e-01],
356            [6.92840000e-01, 1.65141000e-01, 5.64522000e-01],
357            [8.81443000e-01, 3.92529000e-01, 3.83229000e-01],
358            [9.88260000e-01, 6.52325000e-01, 2.11364000e-01],
359            [9.40015000e-01, 9.75158000e-01, 1.31326000e-01],
360            [9.40015000e-01, 9.75158000e-01, 1.31326000e-01],
361        ];
362        let inferno_colors = [
363            [1.46200000e-03, 4.66000000e-04, 1.38660000e-02],
364            [1.46200000e-03, 4.66000000e-04, 1.38660000e-02],
365            [2.58234000e-01, 3.85710000e-02, 4.06485000e-01],
366            [5.78304000e-01, 1.48039000e-01, 4.04411000e-01],
367            [8.65006000e-01, 3.16822000e-01, 2.26055000e-01],
368            [9.87622000e-01, 6.45320000e-01, 3.98860000e-02],
369            [9.88362000e-01, 9.98364000e-01, 6.44924000e-01],
370            [9.88362000e-01, 9.98364000e-01, 6.44924000e-01],
371        ];
372        let colors = vec![viridis_colors, magma_colors, inferno_colors, plasma_colors];
373        let cmaps = vec![viridis, magma, inferno, plasma];
374        for (colors, cmap) in colors.iter().zip(cmaps.iter()) {
375            for (ref_arr, test_color) in colors.iter().zip(cmap.transform(vals.clone()).iter()) {
376                let ref_color = RGBColor {
377                    r: ref_arr[0],
378                    g: ref_arr[1],
379                    b: ref_arr[2],
380                };
381                let deref_test_color: RGBColor = *test_color;
382                assert_eq!(deref_test_color.to_string(), ref_color.to_string());
383            }
384        }
385    }
386}