pub fn luminance(rgb: (u8, u8, u8)) -> f64 {
(0.299 * rgb.0 as f64 + 0.587 * rgb.1 as f64 + 0.114 * rgb.2 as f64) / 255.0
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Palette {
Viridis,
Magma,
Inferno,
Plasma,
OkabeIto,
}
impl Palette {
pub fn from_name(s: &str) -> Option<Palette> {
Some(match s {
"viridis" => Palette::Viridis,
"magma" => Palette::Magma,
"inferno" => Palette::Inferno,
"plasma" => Palette::Plasma,
"okabe-ito" => Palette::OkabeIto,
_ => return None,
})
}
pub fn name(&self) -> &'static str {
match self {
Palette::Viridis => "viridis",
Palette::Magma => "magma",
Palette::Inferno => "inferno",
Palette::Plasma => "plasma",
Palette::OkabeIto => "okabe-ito",
}
}
fn stops(&self) -> &'static [(u8, u8, u8)] {
match self {
Palette::Viridis => &[
(68, 1, 84),
(59, 82, 139),
(33, 145, 140),
(94, 201, 98),
(253, 231, 37),
],
Palette::Magma => &[
(0, 0, 4),
(81, 18, 124),
(183, 55, 121),
(252, 138, 101),
(252, 253, 191),
],
Palette::Inferno => &[
(0, 0, 4),
(87, 16, 110),
(188, 54, 62),
(248, 142, 24),
(252, 255, 164),
],
Palette::Plasma => &[
(13, 8, 135),
(126, 3, 168),
(203, 70, 121),
(248, 149, 64),
(240, 249, 33),
],
Palette::OkabeIto => &[
(230, 159, 0),
(86, 180, 233),
(0, 158, 115),
(240, 228, 66),
(0, 114, 178),
(213, 94, 0),
(204, 121, 167),
(0, 0, 0),
],
}
}
pub fn sample(&self, t: f64) -> (u8, u8, u8) {
let stops = self.stops();
let t = t.clamp(0.0, 1.0);
if stops.len() == 1 {
return stops[0];
}
let scaled = t * (stops.len() - 1) as f64;
let i = scaled.floor() as usize;
let i = i.min(stops.len() - 2);
let f = scaled - i as f64;
let (r0, g0, b0) = stops[i];
let (r1, g1, b1) = stops[i + 1];
let lerp = |a: u8, b: u8| {
(a as f64 + (b as f64 - a as f64) * f)
.round()
.clamp(0.0, 255.0) as u8
};
(lerp(r0, r1), lerp(g0, g1), lerp(b0, b1))
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Deficiency {
Protanopia,
Deuteranopia,
Tritanopia,
}
impl Deficiency {
pub fn from_name(s: &str) -> Option<Deficiency> {
Some(match s {
"protanopia" => Deficiency::Protanopia,
"deuteranopia" => Deficiency::Deuteranopia,
"tritanopia" => Deficiency::Tritanopia,
_ => return None,
})
}
pub fn name(&self) -> &'static str {
match self {
Deficiency::Protanopia => "protanopia",
Deficiency::Deuteranopia => "deuteranopia",
Deficiency::Tritanopia => "tritanopia",
}
}
}
pub fn daltonize(rgb: (u8, u8, u8), d: Deficiency) -> (u8, u8, u8) {
let (r, g, b) = (rgb.0 as f64, rgb.1 as f64, rgb.2 as f64);
let l = 17.8824 * r + 43.5161 * g + 4.11935 * b;
let m = 3.45565 * r + 27.1554 * g + 3.86714 * b;
let s = 0.0299566 * r + 0.184309 * g + 1.46709 * b;
let (sl, sm, ss) = match d {
Deficiency::Protanopia => (2.02344 * m - 2.52581 * s, m, s),
Deficiency::Deuteranopia => (l, 0.494207 * l + 1.24827 * s, s),
Deficiency::Tritanopia => (l, m, -0.395913 * l + 0.801109 * m),
};
let sr = 0.080954 * sl - 0.130504 * sm + 0.116722 * ss;
let sb = -0.000365294 * sl - 0.00412163 * sm + 0.693511 * ss;
let er = r - sr;
let eb = b - sb;
let (or, og, ob) = match d {
Deficiency::Protanopia | Deficiency::Deuteranopia => (r, g + 0.7 * er, b + 0.7 * er),
Deficiency::Tritanopia => (r + 0.7 * eb, g + 0.7 * eb, b),
};
(
or.clamp(0.0, 255.0) as u8,
og.clamp(0.0, 255.0) as u8,
ob.clamp(0.0, 255.0) as u8,
)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum ColorAssist {
#[default]
None,
Remap(Palette),
Daltonize(Deficiency),
}
impl ColorAssist {
pub fn from_cli(palette: Option<&str>, colorblind: Option<&str>) -> Option<ColorAssist> {
if let Some(name) = palette {
Palette::from_name(name).map(ColorAssist::Remap)
} else if let Some(name) = colorblind {
Deficiency::from_name(name).map(ColorAssist::Daltonize)
} else {
None
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn luminance_white_is_one_black_is_zero() {
assert!((luminance((255, 255, 255)) - 1.0).abs() < 1e-12);
assert!(luminance((0, 0, 0)).abs() < 1e-12);
}
#[test]
fn luminance_is_in_unit_interval() {
for &(r, g, b) in &[(255, 0, 0), (0, 255, 0), (0, 0, 255), (123, 45, 200)] {
let l = luminance((r, g, b));
assert!(
(0.0..=1.0).contains(&l),
"{l} out of range for ({r},{g},{b})"
);
}
}
#[test]
fn palette_from_name_round_trips_and_rejects_garbage() {
use Palette::*;
assert_eq!(Palette::from_name("viridis"), Some(Viridis));
assert_eq!(Palette::from_name("magma"), Some(Magma));
assert_eq!(Palette::from_name("inferno"), Some(Inferno));
assert_eq!(Palette::from_name("plasma"), Some(Plasma));
assert_eq!(Palette::from_name("okabe-ito"), Some(OkabeIto));
assert_eq!(Palette::from_name("Viridis"), None); assert_eq!(Palette::from_name("nope"), None);
}
#[test]
fn palette_name_round_trips() {
for &p in &[
Palette::Viridis,
Palette::Magma,
Palette::Inferno,
Palette::Plasma,
Palette::OkabeIto,
] {
let n = p.name();
assert_eq!(Palette::from_name(n), Some(p));
}
}
#[test]
fn palette_endpoints_differ_and_clamp() {
for &p in &[
Palette::Viridis,
Palette::Magma,
Palette::Inferno,
Palette::Plasma,
Palette::OkabeIto,
] {
let lo = Palette::sample(&p, 0.0);
let hi = Palette::sample(&p, 1.0);
assert_ne!(lo, hi, "{p:?} has no range");
assert_eq!(Palette::sample(&p, -0.5), lo);
assert_eq!(Palette::sample(&p, 1.5), hi);
}
}
#[test]
fn perceptual_palettes_have_monotonic_luma() {
for &p in &[
Palette::Viridis,
Palette::Magma,
Palette::Inferno,
Palette::Plasma,
] {
let mut last = -1.0;
for i in 0..=8 {
let t = i as f64 / 8.0;
let l = luminance(Palette::sample(&p, t));
assert!(l + 1e-9 >= last, "{p:?} luma not monotonic at t={t}");
last = l;
}
}
}
#[test]
fn deficiency_from_name_round_trips() {
use Deficiency::*;
assert_eq!(Deficiency::from_name("protanopia"), Some(Protanopia));
assert_eq!(Deficiency::from_name("deuteranopia"), Some(Deuteranopia));
assert_eq!(Deficiency::from_name("tritanopia"), Some(Tritanopia));
assert_eq!(Deficiency::from_name("xyz"), None);
for &d in &[Protanopia, Deuteranopia, Tritanopia] {
assert_eq!(Deficiency::from_name(d.name()), Some(d));
}
}
#[test]
fn daltonize_stays_in_gamut() {
let inputs = [
(255, 0, 0),
(0, 255, 0),
(0, 0, 255),
(255, 255, 255),
(0, 0, 0),
];
for &rgb in &inputs {
for &d in &[
Deficiency::Protanopia,
Deficiency::Deuteranopia,
Deficiency::Tritanopia,
] {
let (r, g, b) = daltonize(rgb, d);
assert!((0..=255).contains(&r) && (0..=255).contains(&g) && (0..=255).contains(&b));
}
}
}
#[test]
fn daltonize_actually_shifts_affected_colors() {
assert_ne!(
daltonize((255, 0, 0), Deficiency::Deuteranopia),
(255, 0, 0)
);
assert_ne!(daltonize((255, 0, 0), Deficiency::Protanopia), (255, 0, 0));
assert_ne!(
daltonize((0, 100, 255), Deficiency::Tritanopia),
(0, 100, 255)
);
}
#[test]
fn daltonize_keeps_red_and_green_distinct_under_deuteranopia() {
let r = daltonize((255, 0, 0), Deficiency::Deuteranopia);
let g = daltonize((0, 255, 0), Deficiency::Deuteranopia);
assert_ne!(r, g, "daltonization must not collapse red and green");
}
#[test]
fn color_assist_from_cli_resolves() {
use ColorAssist::*;
assert_eq!(
ColorAssist::from_cli(Some("viridis"), Option::None),
Some(Remap(Palette::Viridis))
);
assert_eq!(
ColorAssist::from_cli(Option::None, Some("deuteranopia")),
Some(Daltonize(Deficiency::Deuteranopia))
);
assert_eq!(
ColorAssist::from_cli(Option::None, Option::None),
Option::None
);
assert_eq!(
ColorAssist::from_cli(Some("bogus"), Option::None),
Option::None
);
assert_eq!(
ColorAssist::from_cli(Some("magma"), Some("tritanopia")),
Some(Remap(Palette::Magma))
);
}
}