Skip to content

Commit

Permalink
feat(cvd): support colorblind mode with daltonization (#281)
Browse files Browse the repository at this point in the history
This change adds support for a colorblind or color vision deficiency
(cvd) mode. This mode implements a daltonization algorithm that shifts
color to ranges that cvd people can see which helps create contrast with
colors.
  • Loading branch information
EdenEast committed Jan 23, 2023
1 parent 97e66df commit f093297
Show file tree
Hide file tree
Showing 6 changed files with 438 additions and 6 deletions.
57 changes: 57 additions & 0 deletions doc/nightfox.txt
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ Table of Contents *nightfox-table-of-contents*
- Group |nightfox-group|
- Module |nightfox-module|
- Color |nightfox-color|
- Colorblind |nightfox-colorblind|
- Compile |nightfox-compile|
- Interactive |nightfox-interactive|

Expand Down Expand Up @@ -297,6 +298,27 @@ inverse {table} `inverse` is a table that contains a
foureground and background colors.


*nightfox-colorblind*

colorblind {table} `colorblind` stores configuration
information for nightfox’s `color
vision deficiency` (cdv) mode. This
contains the following table:


>
colorblind = {
enable = false, -- Enable colorblind support
simulate_only = false, -- Only show simulated colorblind colors and not diff shifted
severity = {
protan = 0, -- Severity [0,1] for protan (red)
deutan = 0, -- Severity [0,1] for deutan (green)
tritan = 0, -- Severity [0,1] for tritan (blue)
},
},
<


*nightfox-modules*

modules {table} `modules` store configuration
Expand Down Expand Up @@ -704,6 +726,41 @@ color:rotate({value}) Returns a new `Color` with {value} added
wrapped back to `0`.


COLORBLIND *nightfox-colorblind*

For individuals with `color vision deficiency` (cvd), nightfox has implemented
a `colorblind` mode to help enhance color contrast. This can be enabled with
this option `colorblind.enable`.

There are three types of `color vision deficiency` (cvd)

│ Cone │ Type │Week (trichromacy)│Missing (Dichromacy)│
│L / Red │Protan│Protanomaly │Protanopia │
│M / Green│Deutan│Deuteranomaly │Deuteranopia │
│S / Blue │Tritan│Tritanomaly │Tritanopia │


The severity of `protan`, `deutan`, and `tritan` can be set individually.
Severity is a value ranging from `0` to `1`. where `1` is full `dichromacy`
(missing cone type).

**Example:**

>
require("nightfox").setup({
options = {
colorblind = {
enable = true,
severity = {
protan = 0.3,
deutan = 0.6,
},
},
},
})
<


COMPILE *nightfox-compile*

Nightfox is a highly customizable and configurable colorscheme. This does
Expand Down
9 changes: 9 additions & 0 deletions lua/nightfox/config.lua
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,15 @@ local defaults = {
terminal_colors = true,
dim_inactive = false,
module_default = true,
colorblind = {
enable = false,
simulate_only = false,
severity = {
protan = 0,
deutan = 0,
tritan = 0,
},
},
styles = {
comments = "NONE",
conditionals = "NONE",
Expand Down
205 changes: 205 additions & 0 deletions lua/nightfox/lib/colorblind.lua
Original file line number Diff line number Diff line change
@@ -0,0 +1,205 @@
local bop = bit or bit32 or require("nightfox.lib.vim.bit")
local fmt = string.format

local linear_rgb_to_lms = {
{ 0.17882, 0.43516, 0.04119 },
{ 0.03456, 0.27155, 0.03867 },
{ 0.00030, 0.00184, 0.01466 },
}

local lms_to_linear_rgb = {
{ 8.09444, -13.05043, 11.67206 },
{ -1.02485, 5.40193, -11.36147 },
{ -0.03653, -0.41216, 69.35132 },
}

---Convert color hex string to srgb value [0-255]
---@param str string
---@return table
local function from_str(str)
local s = str:lower():match("#?([a-f0-9]+)")
local n = tonumber(s, 16)
return {
bop.band(bop.rshift(n, 16), 0xff),
bop.band(bop.rshift(n, 8), 0xff),
bop.band(n, 0xff),
}
end

local function to_str(c)
return fmt("#%02x%02x%02x", c[1], c[2], c[3])
end

local function round_uint8(n)
return math.max(math.min(math.floor(n + 0.5), 0xff), 0)
end

local function clamp(v, min, max)
min, max = min or 0, max or 1
return math.max(math.min(v, max), min)
end

local function apply(t, f)
local r = {}
for i = 1, #t do
r[i] = f(t[i])
end
return r
end

local function mul(c, m)
return {
m[1][1] * c[1] + m[1][2] * c[2] + m[1][3] * c[3],
m[2][1] * c[1] + m[2][2] * c[2] + m[2][3] * c[3],
m[3][1] * c[1] + m[3][2] * c[2] + m[3][3] * c[3],
}
end

-- stylua: ignore
local function srgb_to_lrgb(c)
return apply(c, function(x)
x = x / 0xff
if x <= 0 then return 0 end
if x >= 1 then return 1 end
if x < 0.04045 then return x / 12.92 end
return math.pow((x + 0.055) / 1.055, 2.4)
end
)
end

-- stylua: ignore
local function lrgb_to_srgb(c)
return apply(c, function(a)
local f = function(x)
if x <= 0 then return 0 end
if x >= 1 then return 1 end
if x < 0.0031308 then return x * 12.92 end
return math.pow(x, 1.0 / 2.4) * 1.055 - 0.055
end
return round_uint8(f(a) * 0xff)
end)
end

local function lms_to_lrgb(lms)
return mul(lms, lms_to_linear_rgb)
end

local function lrgb_to_lms(lrgb)
return mul(lrgb, linear_rgb_to_lms)
end

local function apply_protan(lms, severity)
-- Viénot 1999.
lms[1] = (1 - severity) * lms[1] + severity * (2.02344 * lms[2] - 2.52580 * lms[3])
end

local function apply_deutan(lms, severity)
-- Viénot 1999.
lms[2] = (1 - severity) * lms[2] + severity * (0.49421 * lms[1] + 1.24827 * lms[3])
end

local function apply_tritan(lms, severity)
-- Brettel 1997.
-- Check which plane.
if (lms[1] * 0.34478 - lms[2] * 0.65518) >= 0 then
-- Plane 1 for tritanopia
lms[3] = (1 - severity) * lms[3] + severity * (-0.00257 * lms[1] + 0.05366 * lms[2])
else
-- Plane 2 for tritanopia
lms[3] = (1 - severity) * lms[3] + severity * (-0.06011 * lms[1] + 0.16299 * lms[2])
end
end

local function simulate(lms, kinds)
kinds = kinds or {}
local x = { lms[1], lms[2], lms[3] }

-- stylua: ignore start
if kinds.protan and kinds.protan > 0 then apply_protan(x, clamp(kinds.protan)) end
if kinds.deutan and kinds.deutan > 0 then apply_deutan(x, clamp(kinds.deutan)) end
if kinds.tritan and kinds.tritan > 0 then apply_tritan(x, clamp(kinds.tritan)) end
-- stylua: ignore end

return x
end

local function apply_difference_lrgb(orig_lrgb, sim_lrgb)
-- [0.0, 0.0, 0.0]
-- [0.7, 1.0, 0.0]
-- [0.7, 0.0, 1.0]

local e = {
orig_lrgb[1] - sim_lrgb[1],
orig_lrgb[2] - sim_lrgb[2],
orig_lrgb[3] - sim_lrgb[3],
}

return {
orig_lrgb[1],
orig_lrgb[2] + 0.7 * e[1] + 1 * e[2] + 0 * e[3],
orig_lrgb[3] + 0.7 * e[1] + 0 * e[2] + 1 * e[3],
}
end

local M = {}

---@class CBKinds
---@field protan number [0-1]
---@field deutan number [0-1]
---@field tritan number [0-1]

---Simulate colorblindness based on the different severities
---@param color string hex color
---@param opts CBKinds
function M.simulate(color, opts)
local lms = lrgb_to_lms(srgb_to_lrgb(from_str(color)))
local sim = simulate(lms, opts)
return to_str(lrgb_to_srgb(lms_to_lrgb(sim)))
end

---Apply Dantonize algorithm
---@param color string hex color
---@param opts CBKinds
function M.daltonize(color, opts)
local lrgb = srgb_to_lrgb(from_str(color))
local lms = lrgb_to_lms(lrgb)
local sim = lms_to_lrgb(simulate(lms, opts))
-- P({ color = color, lrgb = lrgb_to_srgb(lrgb), sim = lrgb_to_srgb(sim) })
local diff = apply_difference_lrgb(lrgb, sim)
return to_str(lrgb_to_srgb(diff))
end

-- Keeping This here as by test case and what I was supposed to get
--
-- local srgb = from_str("#63cdcf")
-- print(fmt(" srgb: r = %s, g = %s, b = %s", srgb[1], srgb[2], srgb[3]))
-- local lrgb = srgb_to_lrgb(srgb)
-- print(fmt(" lrgb: r = %s, g = %s, b = %s", lrgb[1], lrgb[2], lrgb[3]))
-- local lms = lrgb_to_lms(lrgb)
-- print(fmt(" lms: l = %s, m = %s, s = %s", lms[1], lms[2], lms[3]))
-- local sim = simulate(lms, { protan = 1 })
-- print(fmt(" sim: l = %s, m = %s, s = %s", sim[1], sim[2], sim[3]))
-- local slrgb = lms_to_lrgb(sim)
-- print(fmt("slrgb: r = %s, g = %s, b = %s", slrgb[1], slrgb[2], slrgb[3]))
-- local dlrgb = apply_difference_lrgb(lrgb, slrgb)
-- print(fmt("dlrgb: r = %s, g = %s, b = %s", dlrgb[1], dlrgb[2], dlrgb[3]))
-- print(to_str(lrgb_to_srgb(dlrgb)))
--
-- Expected values
-- srgb: r = 99, g = 205, b = 207
-- lrgb: r = 0.124772, g = 0.610496, b = 0.623960
-- lms: l = 0.313676, m = 0.194221, s = 0.010314
-- sim: l = 0.366942, m = 0.194221, s = 0.010314
-- slrgb: r = 0.555917, g = 0.555921, b = 0.621852
-- dlrgb: r = 0.124772, g = 0.363269, b = 0.324267

return M

-- References:
-- HUGE thanks to Nicolas Burrus and his DaltonLens project and website. This has been an
-- invaluable resource in trying to understand colorblindness and cvd. His posts on understanding
-- lms based cvd simulators helped make this topic comprehensible.
-- https://daltonlens.org/
-- https://daltonlens.org/understanding-cvd-simulation/
-- https://github.com/DaltonLens/DaltonLens-Python
-- https://github.com/DaltonLens/DaltonLens
31 changes: 31 additions & 0 deletions lua/nightfox/palette.lua
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
local collect = require("nightfox.lib.collect")
local config = require("nightfox.config")

--#region Types

Expand Down Expand Up @@ -64,17 +65,46 @@ local function override(color, ovr)
return color
end

local function apply_daltonization(palette)
local cb = require("nightfox.lib.colorblind")
local severity = config.options.colorblind.severity
local severities = { protan = severity.protan, deutan = severity.deutan, tritan = severity.tritan }
local f = config.options.colorblind.simulate_only and cb.simulate or cb.daltonize

local function inner(p)
local t = type(p)
if t == "string" then
return f(p, severities)
elseif t == "table" then
local r = {}
for k, v in pairs(p) do
r[k] = inner(v)
end
return setmetatable(r, getmetatable(p))
else
return p
end
end

return inner(palette)
end

function M.load(name)
local ovr = require("nightfox.override").palettes

local function apply_ovr(key, palette)
return ovr[key] and override(palette, ovr[key]) or palette
end

local cb_func = config.options.colorblind.enable and apply_daltonization or function(x)
return x
end

if name then
local valid = collect.contains(M.foxes, name)
local raw = valid and require("nightfox.palette." .. name) or require("nightfox.palette.nightfox")
local palette = raw.palette
palette = cb_func(palette)
palette = apply_ovr("all", palette)
palette = apply_ovr(name, palette)
palette.meta = raw.meta
Expand All @@ -85,6 +115,7 @@ function M.load(name)
for _, mod in ipairs(M.foxes) do
local raw = require("nightfox.palette." .. mod)
local palette = raw.palette
palette = cb_func(palette)
palette = apply_ovr("all", palette)
palette = apply_ovr(mod, palette)
palette.meta = raw.meta
Expand Down
Loading

0 comments on commit f093297

Please sign in to comment.