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}