207 行
7.0 KiB
Python
207 行
7.0 KiB
Python
# SPDX-FileCopyrightText: 2022 n9k <https://git.076.ne.jp/ninya9k>
|
|
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
|
|
import re
|
|
import random
|
|
from math import inf
|
|
|
|
class NotAColor(Exception):
|
|
pass
|
|
|
|
RE_COLOR = re.compile(
|
|
r'^#(?P<red>[0-9a-fA-F]{2})(?P<green>[0-9a-fA-F]{2})(?P<blue>[0-9a-fA-F]{2})$'
|
|
)
|
|
|
|
def color_to_colour(color):
|
|
match = RE_COLOR.match(color)
|
|
if not match:
|
|
raise NotAColor
|
|
return (
|
|
int(match.group('red'), 16),
|
|
int(match.group('green'), 16),
|
|
int(match.group('blue'), 16),
|
|
)
|
|
|
|
def colour_to_color(colour):
|
|
red, green, blue = colour
|
|
return f'#{red:02x}{green:02x}{blue:02x}'
|
|
|
|
def dot(a, b):
|
|
'''
|
|
Dot product.
|
|
'''
|
|
return sum(i * j for i, j in zip(a, b, strict=True))
|
|
|
|
def _sc_to_tc(sc):
|
|
'''
|
|
The transformation on [0,1] (from an s-component to a t-component)
|
|
defined at https://www.w3.org/TR/WCAG21/#dfn-relative-luminance.
|
|
'''
|
|
if sc < 0.03928:
|
|
tc = sc / 12.92
|
|
else:
|
|
tc = pow((sc + 0.055) / 1.055, 2.4)
|
|
return tc
|
|
|
|
def _tc_to_sc(tc):
|
|
'''
|
|
Almost-inverse of _sc_to_tc.
|
|
|
|
The function _sc_to_tc is not injective (because of the discontinuity at
|
|
sc=0.03928), thus it has no true inverse. In this implementation, whenever
|
|
for a given `tc` there are two distinct values of `sc` such that
|
|
sc_to_tc(`sc`)=`tc`, the smaller sc is chosen. (The smaller one is less
|
|
expensive to compute).
|
|
'''
|
|
sc = tc * 12.92
|
|
if sc >= 0.03928:
|
|
sc = pow(tc, 1 / 2.4) * 1.055 - 0.055
|
|
return sc
|
|
|
|
def get_relative_luminance(colour):
|
|
'''
|
|
Take a colour and return its relative luminance.
|
|
|
|
https://www.w3.org/TR/WCAG21/#dfn-relative-luminance
|
|
'''
|
|
s = map(lambda sc: sc / 255, colour)
|
|
t = map(_sc_to_tc, s)
|
|
return dot((0.2126, 0.7152, 0.0722), t)
|
|
|
|
def get_colour(t):
|
|
'''
|
|
Take a 3-tuple of channels `t` and return an approximation of a colour
|
|
that when fed into get_relative_luminance would internally cause the
|
|
the variable named "t" to have a value equal to `t`.
|
|
'''
|
|
s = map(_tc_to_sc, t)
|
|
colour = map(lambda sc: round(sc * 255), s)
|
|
return tuple(colour)
|
|
|
|
def get_contrast(bg, fg):
|
|
'''
|
|
Return the contrast ratio between two colours `bg` and `fg`.
|
|
|
|
https://www.w3.org/TR/WCAG21/#dfn-contrast-ratio
|
|
'''
|
|
lumas = (
|
|
get_relative_luminance(bg),
|
|
get_relative_luminance(fg),
|
|
)
|
|
return (max(lumas) + 0.05) / (min(lumas) + 0.05)
|
|
|
|
def generate_colour(seed, bg, min_contrast=4.5, max_contrast=inf, lighter=True):
|
|
'''
|
|
Generate a random colour with a contrast to `bg` in a given interval.
|
|
|
|
This works by generating an intermediate 3-tuple `t` and transforming it
|
|
into the returned colour. Channels of `t` are uniformly distributed, but no
|
|
characteristics of the returned colour are guaranteed to be chosen uniformly
|
|
from the space of possible values.
|
|
|
|
If `lighter` is true, the returned colour is forced to have a higher
|
|
relative luminance than `bg`. This is fine if `bg` is dark; if `bg` is not
|
|
dark, the space of possible returned colours will be a lot smaller (and
|
|
might be empty). If `lighter` is false, the returned colour is forced to
|
|
have a lower relative luminance than `bg`.
|
|
|
|
It's simple to calculate the maximum possible contrast between `bg` and any
|
|
other colour. (The minimum contrast is always 1.)
|
|
|
|
>>> bg = (0x23, 0x23, 0x27)
|
|
>>> luma = get_relative_luminance(bg)
|
|
>>> (luma + 0.05) / 0.05 # maximum contrast for colours with smaller luma
|
|
1.3411743495243844
|
|
>>> 1.05 / (luma + 0.05) # maximum contrast for colours with greater luma
|
|
15.657919499763137
|
|
|
|
There are contrast intervals for which the space of possible returned
|
|
colours is empty. For example a contrast greater than 21 is always
|
|
impossible, but the exact upper bound depends on `bg`. The desired relative
|
|
luminance of the returned colour must exist in the interval [0,1]. The
|
|
formula for desired luma is given below. This is for one particular
|
|
contrast but the same formula can be used twice (once with `min_contrast` and
|
|
once with `max_contrast`) to get a range of desired lumas.
|
|
|
|
>>> bg_luma = get_relative_luminance(bg)
|
|
>>> desired_luma = (
|
|
... contrast * (bg_luma + 0.05) - 0.05
|
|
... if lighter else
|
|
... (bg_luma + 0.05) / contrast - 0.05
|
|
... )
|
|
>>> 0 <= desired_luma <= 1
|
|
True
|
|
'''
|
|
r = random.Random(seed)
|
|
|
|
if lighter:
|
|
min_desired_luma = min_contrast * (get_relative_luminance(bg) + 0.05) - 0.05
|
|
max_desired_luma = max_contrast * (get_relative_luminance(bg) + 0.05) - 0.05
|
|
else:
|
|
min_desired_luma = (get_relative_luminance(bg) + 0.05) / max_contrast - 0.05
|
|
max_desired_luma = (get_relative_luminance(bg) + 0.05) / min_contrast - 0.05
|
|
|
|
V = (0.2126, 0.7152, 0.0722)
|
|
indices = [0, 1, 2]
|
|
r.shuffle(indices)
|
|
i, j, k = indices
|
|
|
|
# V[i] * ti + V[j] * 0 + V[k] * 0 <= max_desired_luma
|
|
# V[i] * ti + V[j] * 1 + V[k] * 1 >= min_desired_luma
|
|
ti_upper = (max_desired_luma - V[j] * 0 - V[k] * 0) / V[i]
|
|
ti_lower = (min_desired_luma - V[j] * 1 - V[k] * 1) / V[i]
|
|
ti = r.uniform(max(0, ti_lower), min(1, ti_upper))
|
|
|
|
# V[i] * ti + V[j] * tj + V[k] * 0 <= max_desired_luma
|
|
# V[i] * ti + V[j] * tj + V[k] * 1 >= min_desired_luma
|
|
tj_upper = (max_desired_luma - V[i] * ti - V[k] * 0) / V[j]
|
|
tj_lower = (min_desired_luma - V[i] * ti - V[k] * 1) / V[j]
|
|
tj = r.uniform(max(0, tj_lower), min(1, tj_upper))
|
|
|
|
# V[i] * ti + V[j] * tj + V[k] * tk <= max_desired_luma
|
|
# V[i] * ti + V[j] * tj + V[k] * tk >= min_desired_luma
|
|
tk_upper = (max_desired_luma - V[i] * ti - V[j] * tj) / V[k]
|
|
tk_lower = (min_desired_luma - V[i] * ti - V[j] * tj) / V[k]
|
|
tk = r.uniform(max(0, tk_lower), min(1, tk_upper))
|
|
|
|
t = [None, None, None]
|
|
t[i], t[j], t[k] = ti, tj, tk
|
|
|
|
s = map(_tc_to_sc, t)
|
|
colour = map(lambda sc: round(sc * 255), s)
|
|
return tuple(colour)
|
|
|
|
def get_maximum_contrast(bg, lighter=True):
|
|
'''
|
|
Return the maximum possible contrast between `bg` and any other lighter
|
|
or darker colour.
|
|
|
|
If `lighter` is true, restrict to the set of colours whose relative
|
|
luminance is greater than `bg`'s.
|
|
|
|
If `lighter` is false, restrict to the set of colours whose relative
|
|
luminance is greater than `bg`'s.
|
|
'''
|
|
luma = get_relative_luminance(bg)
|
|
if lighter:
|
|
max_contrast = 1.05 / (luma + 0.05)
|
|
else:
|
|
max_contrast = (luma + 0.05) / 0.05
|
|
return max_contrast
|
|
|
|
def generate_maximum_contrast_colour(seed, bg, proportion_of_max=31/32):
|
|
max_lighter_contrast = get_maximum_contrast(bg, lighter=True)
|
|
max_darker_contrast = get_maximum_contrast(bg, lighter=False)
|
|
|
|
max_contrast = max(max_lighter_contrast, max_darker_contrast)
|
|
practical_max_contrast = max_contrast * proportion_of_max
|
|
colour = generate_colour(
|
|
seed,
|
|
bg,
|
|
min_contrast=practical_max_contrast,
|
|
max_contrast=practical_max_contrast,
|
|
lighter=max_lighter_contrast > max_darker_contrast,
|
|
)
|
|
|
|
return colour
|