Colour blindness
Colours and pictures as people with colour blindness see them, at full or milder severity; chart palettes checked for colours that become alike; and text contrast (WCAG) as each of them sees it.
Version 1.0.0 · 4 functions · 2 crates
Functions
What use color_vision.qwer gives a program, with the types Quillo sees. To take only some, or under other names: use color_vision_simulate, color_vision_image from color_vision (Quillo 0.6).
color_vision_simulate(color: str, kind: str, severity: float) → stror an errorcolor ("#d62728") as seen with kind ("protanopia", "deuteranopia", "tritanopia", "achromatopsia") at severity from 0 (normal sight) to 1 (the full form; 0.6 is a typical "-anomaly"): #rrggbb.
color_vision_image(path: str, kind: str, severity: float, out_path: str) → stror an errorA picture as seen with kind at severity, saved to out_path (PNG, folders made), transparency kept: for checking maps, charts and product photos. The path.
color_vision_palette(colors: list, min_difference: float) → anyor an errorA chart's or map's colours checked for every kind of colour blindness: { normal, protanopia, deuteranopia, tritanopia, achromatopsia }, each a list of { a, b, difference } for the pairs that differ by less than min_difference (CIEDE2000; 10 is a good line for chart series, 2 is barely visible); ok when only the achromatopsia list (seeing no colour at all, which is rare) has any.
color_vision_contrast(fg: str, bg: str) → anyor an errorThe WCAG contrast of text fg on bg as people with each kind of colour blindness see it: { normal, protanopia, deuteranopia, tritanopia, achromatopsia, lowest, aa }, ratios to 2 places; aa when even the lowest is 4.5 or more (normal text).
Get it
$ quillo install color_vision # color_vision.qwer into this folder$ quillo install color_vision --example # and its example, color_vision.qweOr with curl:
$ curl -O https://quillolang.org/plugins/files/color_vision.qwer$ curl -O https://quillolang.org/plugins/files/color_vision.qwe$ curl -O https://quillolang.org/plugins/files/color_vision_sample.pngThen, in your program, all of it or only what you call:
use color_vision.qweruse color_vision_simulate, color_vision_image from color_vision // Quillo 0.6: only theseIt uses these crates, which cargo fetches the first time the plugin is built:
image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp", "gif"] }serde_json = { version = "1", features = ["preserve_order"] }Example
// A dashboard's colours checked before release: do the chart's series stay apart for colour-blind readers, does the error text stay readable, and how does the map look to them?//// quillo run color_vision.qwe then open http://localhost:8080/checkuse "color_vision.qwer"// The usual red and green for "down" and "up", and a palette that holds up better.let RISKY = ["#d62728", "#2ca02c", "#ff7f0e", "#1f77b4"]let SAFER = ["#0072b2", "#e69f00", "#009e73", "#cc79a7"]route GET /check { { red_for_deutan: color_vision_simulate("#d62728", "deuteranopia", 1.0)?, green_for_deutan: color_vision_simulate("#2ca02c", "deuteranopia", 1.0)?, mild: color_vision_simulate("#d62728", "deuteranomaly", 0.6)?, risky: color_vision_palette(RISKY, 10.0)?, safer: color_vision_palette(SAFER, 10.0)?, error_text: color_vision_contrast("#d62728", "#2ca02c")?, body_text: color_vision_contrast("#1a1a1a", "#ffffff")?.aa, }}route GET /photo/:kind { let out = color_vision_image("color_vision_sample.png", kind, 1.0, `data/color_vision/${kind}.png`)? file(out)}$ quillo run color_vision.qwe$ curl 'localhost:8080/check'$ curl 'localhost:8080/photo/deuteranopia'Source
//! Colours and pictures as people with colour blindness see them (protanopia, deuteranopia, tritanopia, and their milder forms; achromatopsia), chart palettes checked for colours that become alike, and text contrast (WCAG) as each of them sees it.//! version: 1.0.0//!//! [dependencies]//! image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp", "gif"] }//! serde_json = { version = "1", features = ["preserve_order"] }use serde_json::{json, Map, Value};// Machado, Oliveira and Fernandes (2009), at full severity, for linear RGB.const PROTAN: [[f64; 3]; 3] = [[0.152286, 1.052583, -0.204868], [0.114503, 0.786281, 0.099216], [-0.003882, -0.048116, 1.051998]];const DEUTAN: [[f64; 3]; 3] = [[0.367322, 0.860646, -0.227968], [0.280085, 0.672501, 0.047413], [-0.011820, 0.042940, 0.968881]];const TRITAN: [[f64; 3]; 3] = [[1.255528, -0.076749, -0.178779], [-0.078411, 0.930809, 0.147602], [0.004733, 0.691367, 0.303900]];const KINDS: [&str; 4] = ["protanopia", "deuteranopia", "tritanopia", "achromatopsia"];fn to_lin(c: f64) -> f64 { let c = c / 255.0; if c <= 0.04045 { c / 12.92 } else { ((c + 0.055) / 1.055).powf(2.4) } }fn to_srgb(c: f64) -> f64 { let c = c.clamp(0.0, 1.0); (if c <= 0.0031308 { c * 12.92 } else { 1.055 * c.powf(1.0 / 2.4) - 0.055 }) * 255.0 }fn parse(c: &str) -> Result<[f64; 3], String> { let h = c.trim().trim_start_matches('#'); let h = if h.len() == 3 { h.chars().flat_map(|x| [x, x]).collect::<String>() } else { h.to_string() }; let v = u32::from_str_radix(&h, 16).ok().filter(|_| h.len() == 6).ok_or_else(|| format!("`{}` is not a #rrggbb colour", c))?; Ok([(v >> 16 & 255) as f64, (v >> 8 & 255) as f64, (v & 255) as f64])}fn hex(c: [f64; 3]) -> String { format!("#{:02x}{:02x}{:02x}", c[0].round() as u8, c[1].round() as u8, c[2].round() as u8) }fn kind(k: &str) -> Result<&'static str, String> { let k = k.trim().to_lowercase(); let m = match k.as_str() { "protanopia" | "protan" | "protanomaly" => "protanopia", "deuteranopia" | "deutan" | "deuteranomaly" => "deuteranopia", "tritanopia" | "tritan" | "tritanomaly" => "tritanopia", "achromatopsia" | "monochromacy" | "gray" => "achromatopsia", _ => return Err(format!("`{}` is not one of protanopia, deuteranopia, tritanopia, achromatopsia", k)), }; Ok(m)}// A colour (0-255 sRGB) as seen with `kind` at `severity` (0 to 1).fn sim(c: [f64; 3], kind: &str, severity: f64) -> [f64; 3] { let l = [to_lin(c[0]), to_lin(c[1]), to_lin(c[2])]; let s = match kind { "achromatopsia" => { let y = 0.2126 * l[0] + 0.7152 * l[1] + 0.0722 * l[2]; [y, y, y] } _ => { let m = match kind { "protanopia" => PROTAN, "deuteranopia" => DEUTAN, _ => TRITAN }; [0, 1, 2].map(|i| m[i][0] * l[0] + m[i][1] * l[1] + m[i][2] * l[2]) } }; let mixed = [0, 1, 2].map(|i| l[i] + (s[i] - l[i]) * severity); [to_srgb(mixed[0]), to_srgb(mixed[1]), to_srgb(mixed[2])]}fn lab(c: [f64; 3]) -> [f64; 3] { let l = [to_lin(c[0]), to_lin(c[1]), to_lin(c[2])]; let x = (0.4124 * l[0] + 0.3576 * l[1] + 0.1805 * l[2]) / 0.95047; let y = 0.2126 * l[0] + 0.7152 * l[1] + 0.0722 * l[2]; let z = (0.0193 * l[0] + 0.1192 * l[1] + 0.9505 * l[2]) / 1.08883; let f = |t: f64| if t > 0.008856 { t.cbrt() } else { 7.787 * t + 16.0 / 116.0 }; [116.0 * f(y) - 16.0, 500.0 * (f(x) - f(y)), 200.0 * (f(y) - f(z))]}// CIEDE2000 colour difference: about 2 is just noticeable, under 10 is easily confused in a chart.fn de2000(a: [f64; 3], b: [f64; 3]) -> f64 { let (l1, a1, b1) = (a[0], a[1], a[2]); let (l2, a2, b2) = (b[0], b[1], b[2]); let c1 = (a1 * a1 + b1 * b1).sqrt(); let c2 = (a2 * a2 + b2 * b2).sqrt(); let cm = (c1 + c2) / 2.0; let g = 0.5 * (1.0 - (cm.powi(7) / (cm.powi(7) + 25f64.powi(7))).sqrt()); let (a1p, a2p) = (a1 * (1.0 + g), a2 * (1.0 + g)); let (c1p, c2p) = ((a1p * a1p + b1 * b1).sqrt(), (a2p * a2p + b2 * b2).sqrt()); let h = |b: f64, a: f64| { let x = b.atan2(a).to_degrees(); if x < 0.0 { x + 360.0 } else { x } }; let (h1, h2) = (h(b1, a1p), h(b2, a2p)); let dl = l2 - l1; let dc = c2p - c1p; let mut dh = h2 - h1; if c1p * c2p != 0.0 { if dh > 180.0 { dh -= 360.0 } else if dh < -180.0 { dh += 360.0 } } else { dh = 0.0 } let dhh = 2.0 * (c1p * c2p).sqrt() * (dh.to_radians() / 2.0).sin(); let lm = (l1 + l2) / 2.0; let cmp = (c1p + c2p) / 2.0; let mut hm = h1 + h2; if c1p * c2p != 0.0 { if (h1 - h2).abs() > 180.0 { hm = if h1 + h2 < 360.0 { (h1 + h2 + 360.0) / 2.0 } else { (h1 + h2 - 360.0) / 2.0 } } else { hm = (h1 + h2) / 2.0 } } let t = 1.0 - 0.17 * (hm - 30.0).to_radians().cos() + 0.24 * (2.0 * hm).to_radians().cos() + 0.32 * (3.0 * hm + 6.0).to_radians().cos() - 0.20 * (4.0 * hm - 63.0).to_radians().cos(); let sl = 1.0 + 0.015 * (lm - 50.0).powi(2) / (20.0 + (lm - 50.0).powi(2)).sqrt(); let sc = 1.0 + 0.045 * cmp; let sh = 1.0 + 0.015 * cmp * t; let rt = -2.0 * (cmp.powi(7) / (cmp.powi(7) + 25f64.powi(7))).sqrt() * (60.0 * (-(((hm - 275.0) / 25.0).powi(2))).exp()).to_radians().sin(); ((dl / sl).powi(2) + (dc / sc).powi(2) + (dhh / sh).powi(2) + rt * (dc / sc) * (dhh / sh)).sqrt()}fn luminance(c: [f64; 3]) -> f64 { 0.2126 * to_lin(c[0]) + 0.7152 * to_lin(c[1]) + 0.0722 * to_lin(c[2]) }fn ratio(a: [f64; 3], b: [f64; 3]) -> f64 { let (x, y) = (luminance(a), luminance(b)); (x.max(y) + 0.05) / (x.min(y) + 0.05) }/// `color` (`"#d62728"`) as seen with `kind` (`"protanopia"`, `"deuteranopia"`, `"tritanopia"`, `"achromatopsia"`) at `severity` from 0 (normal sight) to 1 (the full form; 0.6 is a typical "-anomaly"): `#rrggbb`.pub fn color_vision_simulate(color: &str, kind: &str, severity: f64) -> Result<String, String> { Ok(hex(sim(parse(color)?, self::kind(kind)?, severity.clamp(0.0, 1.0))))}/// A picture as seen with `kind` at `severity`, saved to `out_path` (PNG, folders made), transparency kept: for checking maps, charts and product photos. The path.pub fn color_vision_image(path: &str, kind: &str, severity: f64, out_path: &str) -> Result<String, String> { let k = self::kind(kind)?; let mut img = image::open(path).map_err(|e| format!("{}: {}", path, e))?.to_rgba8(); // The same colours come back again and again: remember them. let mut cache: std::collections::HashMap<[u8; 3], [u8; 3]> = std::collections::HashMap::new(); for p in img.pixels_mut() { let key = [p[0], p[1], p[2]]; let out = *cache.entry(key).or_insert_with(|| { let s = sim([p[0] as f64, p[1] as f64, p[2] as f64], k, severity.clamp(0.0, 1.0)); [s[0].round() as u8, s[1].round() as u8, s[2].round() as u8] }); p[0] = out[0]; p[1] = out[1]; p[2] = out[2]; } if let Some(d) = std::path::Path::new(out_path).parent().filter(|d| !d.as_os_str().is_empty()) { std::fs::create_dir_all(d).map_err(|e| e.to_string())?; } img.save_with_format(out_path, image::ImageFormat::Png).map_err(|e| format!("{}: {}", out_path, e))?; Ok(out_path.to_string())}/// A chart's or map's colours checked for every kind of colour blindness: `{ normal, protanopia, deuteranopia, tritanopia, achromatopsia }`, each a list of `{ a, b, difference }` for the pairs that differ by less than `min_difference` (CIEDE2000; 10 is a good line for chart series, 2 is barely visible); `ok` when only the achromatopsia list (seeing no colour at all, which is rare) has any.pub fn color_vision_palette(colors: Vec<String>, min_difference: f64) -> Result<Value, String> { let cs: Vec<[f64; 3]> = colors.iter().map(|c| parse(c)).collect::<Result<_, _>>()?; let mut m = Map::new(); let mut ok = true; for k in std::iter::once("normal").chain(KINDS) { let seen: Vec<[f64; 3]> = cs.iter().map(|&c| if k == "normal" { c } else { sim(c, k, 1.0) }).collect(); let mut pairs = Vec::new(); for i in 0..seen.len() { for j in (i + 1)..seen.len() { let d = de2000(lab(seen[i]), lab(seen[j])); if d < min_difference { pairs.push(json!({ "a": colors[i], "b": colors[j], "difference": (d * 10.0).round() / 10.0 })) } } } if !pairs.is_empty() && k != "achromatopsia" { ok = false } m.insert(k.to_string(), Value::Array(pairs)); } m.insert("ok".into(), json!(ok)); Ok(Value::Object(m))}/// The WCAG contrast of text `fg` on `bg` as people with each kind of colour blindness see it: `{ normal, protanopia, deuteranopia, tritanopia, achromatopsia, lowest, aa }`, ratios to 2 places; `aa` when even the lowest is 4.5 or more (normal text).pub fn color_vision_contrast(fg: &str, bg: &str) -> Result<Value, String> { let (f, b) = (parse(fg)?, parse(bg)?); let mut m = Map::new(); let r2 = |x: f64| (x * 100.0).round() / 100.0; let mut lowest = ratio(f, b); m.insert("normal".into(), json!(r2(lowest))); for k in KINDS { let r = ratio(sim(f, k, 1.0), sim(b, k, 1.0)); lowest = lowest.min(r); m.insert(k.into(), json!(r2(r))); } m.insert("lowest".into(), json!(r2(lowest))); m.insert("aa".into(), json!(lowest >= 4.5)); Ok(Value::Object(m))}Related
color.qwer · 1.0.0Colours in any CSS form, contrast checked against WCAG AA and AAA, readable text on any colour, and mixes.6 views · 6 downloadsColour names color_names.qwer · 1.0.0The nearest of the 148 CSS colour names to any colour, names back to hex, and the full list: for labels, filters and alt text.8 views · 9 downloadsGradients gradient.qwer · 1.0.0Colour scales and gradients (viridis, magma, blues, red-blue and more, or your own stops) as hex lists, a colour per value, CSS or SVG.6 views · 6 downloadsHeatmaps heatmap.qwer · 1.0.0Heatmaps as PNG: clicks or sightings blurred into a density map over a screenshot or a plain background, and tables of numbers as coloured grids.9 views · 9 downloadsPalette palette.qwer · 1.0.0A picture's main colours as hex, how much of it each covers, and a readable text colour on each, for themes and matching cards.7 views · 8 downloadsSparklines sparkline.qwer · 1.0.0Tiny inline charts from a list of numbers: SVG line and bar sparklines for table cells and sentences, and the same in text (▁▃▆█).6 views · 8 downloadsThread 0 posts
On Colour blindness: questions, tips, corrections, and code that uses it.
No posts yet. The first one opens the thread.