fluxplot 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- fluxplot/__init__.py +115 -0
- fluxplot/_fieldmap.py +97 -0
- fluxplot/_mesh_reduce.py +54 -0
- fluxplot/_scene3d_size.py +95 -0
- fluxplot/_viewer/THIRD-PARTY.txt +23 -0
- fluxplot/_viewer/flux-model3d-viewer.min.js +4221 -0
- fluxplot/_viewer/stamp.json +4 -0
- fluxplot/api.py +1196 -0
- fluxplot/autotag.py +164 -0
- fluxplot/base.mplstyle +0 -0
- fluxplot/brackets.py +242 -0
- fluxplot/canonical_json.py +23 -0
- fluxplot/capture.py +150 -0
- fluxplot/colorcheck.py +285 -0
- fluxplot/colors.py +727 -0
- fluxplot/colorscale.py +477 -0
- fluxplot/data.py +178 -0
- fluxplot/definitions/colormaps.json +1639 -0
- fluxplot/definitions/flexoki.tokens.json +2571 -0
- fluxplot/definitions/palettes.json +2547 -0
- fluxplot/descriptors.py +87 -0
- fluxplot/fields.py +611 -0
- fluxplot/fits.py +240 -0
- fluxplot/glb.py +84 -0
- fluxplot/ids.py +173 -0
- fluxplot/images.py +362 -0
- fluxplot/integrity.py +27 -0
- fluxplot/manifest.py +788 -0
- fluxplot/mesh3d.py +376 -0
- fluxplot/panels.py +284 -0
- fluxplot/postprocess.py +638 -0
- fluxplot/presets.py +66 -0
- fluxplot/provenance.py +177 -0
- fluxplot/raster.py +295 -0
- fluxplot/recipe.py +178 -0
- fluxplot/render.py +66 -0
- fluxplot/roles.py +147 -0
- fluxplot/scene3d.py +386 -0
- fluxplot/scene3d_manifest.py +112 -0
- fluxplot/scene3d_viewer.py +633 -0
- fluxplot/schemas/.gitkeep +0 -0
- fluxplot/schemas/manifest.schema.json +2479 -0
- fluxplot/schemas/recipe.schema.json +179 -0
- fluxplot/schemas/scene3d.schema.json +461 -0
- fluxplot/seaborn_adapters.py +323 -0
- fluxplot/signature_fluxplots/__init__.py +18 -0
- fluxplot/signature_fluxplots/_colour.py +412 -0
- fluxplot/signature_fluxplots/fluxbox.py +433 -0
- fluxplot/signature_fluxplots/glowbar.py +769 -0
- fluxplot/signature_fluxplots/hexmatrix.py +927 -0
- fluxplot/stats/__init__.py +63 -0
- fluxplot/stats/_common.py +196 -0
- fluxplot/stats/multi_group.py +443 -0
- fluxplot/stats/paired.py +209 -0
- fluxplot/stats/two_group.py +149 -0
- fluxplot/style.py +469 -0
- fluxplot/surface.py +487 -0
- fluxplot/surface3d.py +197 -0
- fluxplot/tagger.py +561 -0
- fluxplot/version.py +19 -0
- fluxplot-0.1.0.dist-info/METADATA +1199 -0
- fluxplot-0.1.0.dist-info/RECORD +65 -0
- fluxplot-0.1.0.dist-info/WHEEL +4 -0
- fluxplot-0.1.0.dist-info/licenses/LICENSE +21 -0
- fluxplot-0.1.0.dist-info/licenses/THIRD_PARTY_NOTICES.md +472 -0
fluxplot/colorcheck.py
ADDED
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
"""Accessibility lint for colours — colour-vision deficiency, greyscale, contrast, colormap uniformity.
|
|
2
|
+
|
|
3
|
+
A plot's colours are chosen for the sighted author's screen. This module asks what happens to them
|
|
4
|
+
for the reader: **which pairs collapse** under the three common colour-vision deficiencies (Machado,
|
|
5
|
+
Oliveira & Fernandes 2009: 3×3 matrices in linear sRGB, embedded here at every published severity —
|
|
6
|
+
no extra dependency), **which vanish in greyscale** (a photocopied paper, a black-and-white print),
|
|
7
|
+
whether marks and text **contrast** enough with the ground (WCAG 2), and whether a colormap is
|
|
8
|
+
**perceptually uniform** (even ΔE steps, lightness running one way for a sequential map). Distances
|
|
9
|
+
are CIEDE2000 (``delta_e``) — the perceptual metric the CAM02-UCS helpers of the signature plots
|
|
10
|
+
approximate for their shade spacing.
|
|
11
|
+
|
|
12
|
+
:func:`check_palette` and :func:`check_colormap` return :class:`Finding` records; :func:`check_figure`
|
|
13
|
+
gathers a figure's series colours and text inks (the B2 colour records) and checks them against
|
|
14
|
+
the axes background. ``fp.save(..., lint="warn"|"error")`` runs it on the way out, puts the
|
|
15
|
+
findings in ``SaveResult.warnings`` and in the manifest's ``quality.color``.
|
|
16
|
+
"""
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
from dataclasses import asdict, dataclass
|
|
20
|
+
from typing import List, Optional
|
|
21
|
+
|
|
22
|
+
import numpy as np
|
|
23
|
+
|
|
24
|
+
# ---------------------------------------------------------------------------------------------
|
|
25
|
+
# Machado, Oliveira & Fernandes (2009), "A Physiologically-based Model for Simulation of Color
|
|
26
|
+
# Vision Deficiency": the linear-sRGB matrices at severity 0.0 … 1.0 in steps of 0.1. Between
|
|
27
|
+
# published steps the matrices are interpolated linearly, as the paper's own tables are used.
|
|
28
|
+
# ---------------------------------------------------------------------------------------------
|
|
29
|
+
CVD_KINDS = ("protanomaly", "deuteranomaly", "tritanomaly")
|
|
30
|
+
_MACHADO = {
|
|
31
|
+
"protanomaly": [[[1.0, 0.0, -0.0], [0.0, 1.0, 0.0], [-0.0, -0.0, 1.0]], [[0.856167, 0.182038, -0.038205], [0.029342, 0.955115, 0.015544], [-0.00288, -0.001563, 1.004443]], [[0.734766, 0.334872, -0.069637], [0.05184, 0.919198, 0.028963], [-0.004928, -0.004209, 1.009137]], [[0.630323, 0.465641, -0.095964], [0.069181, 0.890046, 0.040773], [-0.006308, -0.007724, 1.014032]], [[0.539009, 0.579343, -0.118352], [0.082546, 0.866121, 0.051332], [-0.007136, -0.011959, 1.019095]], [[0.458064, 0.679578, -0.137642], [0.092785, 0.846313, 0.060902], [-0.007494, -0.016807, 1.024301]], [[0.38545, 0.769005, -0.154455], [0.100526, 0.829802, 0.069673], [-0.007442, -0.02219, 1.029632]], [[0.319627, 0.849633, -0.169261], [0.106241, 0.815969, 0.07779], [-0.007025, -0.028051, 1.035076]], [[0.259411, 0.923008, -0.18242], [0.110296, 0.80434, 0.085364], [-0.006276, -0.034346, 1.040622]], [[0.203876, 0.990338, -0.194214], [0.112975, 0.794542, 0.092483], [-0.005222, -0.041043, 1.046265]], [[0.152286, 1.052583, -0.204868], [0.114503, 0.786281, 0.099216], [-0.003882, -0.048116, 1.051998]]],
|
|
32
|
+
"deuteranomaly": [[[1.0, 0.0, -0.0], [0.0, 1.0, 0.0], [-0.0, 0.0, 1.0]], [[0.866435, 0.177704, -0.044139], [0.049567, 0.939063, 0.01137], [-0.003453, 0.007233, 0.99622]], [[0.760729, 0.319078, -0.079807], [0.090568, 0.889315, 0.020117], [-0.006027, 0.013325, 0.992702]], [[0.675425, 0.43385, -0.109275], [0.125303, 0.847755, 0.026942], [-0.00795, 0.018572, 0.989378]], [[0.605511, 0.52856, -0.134071], [0.155318, 0.812366, 0.032316], [-0.009376, 0.023176, 0.9862]], [[0.547494, 0.607765, -0.155259], [0.181692, 0.781742, 0.036566], [-0.01041, 0.027275, 0.983136]], [[0.498864, 0.674741, -0.173604], [0.205199, 0.754872, 0.039929], [-0.011131, 0.030969, 0.980162]], [[0.457771, 0.731899, -0.18967], [0.226409, 0.731012, 0.042579], [-0.011595, 0.034333, 0.977261]], [[0.422823, 0.781057, -0.203881], [0.245752, 0.709602, 0.044646], [-0.011843, 0.037423, 0.974421]], [[0.392952, 0.82361, -0.216562], [0.263559, 0.69021, 0.046232], [-0.01191, 0.040281, 0.97163]], [[0.367322, 0.860646, -0.227968], [0.280085, 0.672501, 0.047413], [-0.01182, 0.04294, 0.968881]]],
|
|
33
|
+
"tritanomaly": [[[1.0, 0.0, -0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]], [[0.92667, 0.092514, -0.019184], [0.021191, 0.964503, 0.014306], [0.008437, 0.054813, 0.93675]], [[0.89572, 0.13333, -0.02905], [0.029997, 0.9454, 0.024603], [0.013027, 0.104707, 0.882266]], [[0.905871, 0.127791, -0.033662], [0.026856, 0.941251, 0.031893], [0.01341, 0.148296, 0.838294]], [[0.948035, 0.08949, -0.037526], [0.014364, 0.946792, 0.038844], [0.010853, 0.193991, 0.795156]], [[1.017277, 0.027029, -0.044306], [-0.006113, 0.958479, 0.047634], [0.006379, 0.248708, 0.744913]], [[1.104996, -0.046633, -0.058363], [-0.032137, 0.971635, 0.060503], [0.001336, 0.317922, 0.680742]], [[1.193214, -0.109812, -0.083402], [-0.058496, 0.97941, 0.079086], [-0.002346, 0.403492, 0.598854]], [[1.257728, -0.139648, -0.118081], [-0.078003, 0.975409, 0.102594], [-0.003316, 0.501214, 0.502102]], [[1.278864, -0.125333, -0.153531], [-0.084748, 0.957674, 0.127074], [-0.000989, 0.601151, 0.399838]], [[1.255528, -0.076749, -0.178779], [-0.078411, 0.930809, 0.147602], [0.004733, 0.691367, 0.3039]]],
|
|
34
|
+
}
|
|
35
|
+
_ALIASES = {"protanopia": "protanomaly", "deuteranopia": "deuteranomaly", "tritanopia": "tritanomaly",
|
|
36
|
+
"protan": "protanomaly", "deutan": "deuteranomaly", "tritan": "tritanomaly"}
|
|
37
|
+
|
|
38
|
+
#: Default thresholds of :func:`check_palette` / :func:`check_colormap`.
|
|
39
|
+
THRESHOLDS = {
|
|
40
|
+
"cvd-confusable": 10.0, # ΔE2000 between two palette colours under a simulation
|
|
41
|
+
"greyscale-confusable": 10.0, # ΔL* between two palette colours in greyscale
|
|
42
|
+
"low-contrast-mark": 3.0, # WCAG ratio of a mark against the ground
|
|
43
|
+
"low-contrast-text": 4.5, # WCAG ratio of text against the ground
|
|
44
|
+
"non-uniform": 0.35, # coefficient of variation of a colormap's ΔE steps
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def machado_matrix(kind: str, severity: float = 1.0) -> np.ndarray:
|
|
49
|
+
"""The 3×3 linear-sRGB matrix for ``kind`` at ``severity`` (0 = normal vision, 1 = dichromacy)."""
|
|
50
|
+
kind = _ALIASES.get(kind, kind)
|
|
51
|
+
if kind not in _MACHADO:
|
|
52
|
+
raise ValueError(f"unknown colour-vision deficiency {kind!r}; use one of {CVD_KINDS}")
|
|
53
|
+
if not 0.0 <= severity <= 1.0:
|
|
54
|
+
raise ValueError("severity must lie in [0, 1]")
|
|
55
|
+
table = np.asarray(_MACHADO[kind])
|
|
56
|
+
pos = severity * 10.0
|
|
57
|
+
lo = int(np.floor(pos))
|
|
58
|
+
hi = min(lo + 1, 10)
|
|
59
|
+
t = pos - lo
|
|
60
|
+
return (1 - t) * table[lo] + t * table[hi]
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
# ---------------------------------------------------------------------------------------------
|
|
64
|
+
# colour spaces
|
|
65
|
+
# ---------------------------------------------------------------------------------------------
|
|
66
|
+
def _rgb(colors) -> np.ndarray:
|
|
67
|
+
from matplotlib.colors import to_rgba_array
|
|
68
|
+
return to_rgba_array(list(colors) if not isinstance(colors, str) else [colors])[:, :3]
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _linear(rgb: np.ndarray) -> np.ndarray:
|
|
72
|
+
rgb = np.asarray(rgb, dtype=float)
|
|
73
|
+
return np.where(rgb <= 0.04045, rgb / 12.92, ((rgb + 0.055) / 1.055) ** 2.4)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _encode(lin: np.ndarray) -> np.ndarray:
|
|
77
|
+
lin = np.clip(np.asarray(lin, dtype=float), 0.0, 1.0)
|
|
78
|
+
return np.where(lin <= 0.0031308, lin * 12.92, 1.055 * np.power(lin, 1 / 2.4) - 0.055)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def lab(colors) -> np.ndarray:
|
|
82
|
+
"""sRGB colours (anything matplotlib accepts, or an (N, 3) array in [0, 1]) → CIELAB (D65)."""
|
|
83
|
+
from .signature_fluxplots._colour import lab as _lab
|
|
84
|
+
return _lab(_rgb(colors) if not isinstance(colors, np.ndarray) or colors.ndim != 2 else colors)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def simulate(colors, kind: str, severity: float = 1.0) -> np.ndarray:
|
|
88
|
+
"""What ``colors`` look like to a viewer with ``kind`` colour-vision deficiency, as sRGB in
|
|
89
|
+
[0, 1] (Machado et al. 2009, applied in linear sRGB). ``kind`` is ``protanomaly`` /
|
|
90
|
+
``deuteranomaly`` / ``tritanomaly`` (or the ``-opia`` names); ``severity`` 1.0 is dichromacy."""
|
|
91
|
+
rgb = _rgb(colors) if not (isinstance(colors, np.ndarray) and colors.ndim == 2) else np.asarray(colors)[:, :3]
|
|
92
|
+
lin = _linear(rgb) @ machado_matrix(kind, severity).T
|
|
93
|
+
return _encode(lin)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def greyscale(colors) -> np.ndarray:
|
|
97
|
+
"""The colours' lightness only (L* as an sRGB grey), the way a black-and-white print shows them."""
|
|
98
|
+
L = lab(colors)[:, 0]
|
|
99
|
+
fy = (L + 16) / 116
|
|
100
|
+
y = np.where(fy ** 3 > 216 / 24389, fy ** 3, (116 * fy - 16) * 27 / 24389)
|
|
101
|
+
grey = _encode(y)
|
|
102
|
+
return np.stack([grey, grey, grey], axis=-1)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def delta_e(a, b) -> float:
|
|
106
|
+
"""CIEDE2000 colour difference between two colours (Sharma, Wu & Dalal 2005 formulation)."""
|
|
107
|
+
L1, a1, b1 = lab(a)[0]
|
|
108
|
+
L2, a2, b2 = lab(b)[0]
|
|
109
|
+
return float(_ciede2000(np.array([[L1, a1, b1]]), np.array([[L2, a2, b2]]))[0])
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _ciede2000(lab1: np.ndarray, lab2: np.ndarray) -> np.ndarray:
|
|
113
|
+
L1, a1, b1 = lab1[:, 0], lab1[:, 1], lab1[:, 2]
|
|
114
|
+
L2, a2, b2 = lab2[:, 0], lab2[:, 1], lab2[:, 2]
|
|
115
|
+
kL = kC = kH = 1.0
|
|
116
|
+
C1, C2 = np.hypot(a1, b1), np.hypot(a2, b2)
|
|
117
|
+
Cbar = (C1 + C2) / 2
|
|
118
|
+
G = 0.5 * (1 - np.sqrt(Cbar ** 7 / (Cbar ** 7 + 25.0 ** 7)))
|
|
119
|
+
a1p, a2p = (1 + G) * a1, (1 + G) * a2
|
|
120
|
+
C1p, C2p = np.hypot(a1p, b1), np.hypot(a2p, b2)
|
|
121
|
+
h1p = np.where((a1p == 0) & (b1 == 0), 0.0, np.degrees(np.arctan2(b1, a1p)) % 360)
|
|
122
|
+
h2p = np.where((a2p == 0) & (b2 == 0), 0.0, np.degrees(np.arctan2(b2, a2p)) % 360)
|
|
123
|
+
dLp = L2 - L1
|
|
124
|
+
dCp = C2p - C1p
|
|
125
|
+
dh = h2p - h1p
|
|
126
|
+
dhp = np.where(C1p * C2p == 0, 0.0, np.where(np.abs(dh) <= 180, dh, np.where(dh > 180, dh - 360, dh + 360)))
|
|
127
|
+
dHp = 2 * np.sqrt(C1p * C2p) * np.sin(np.radians(dhp / 2))
|
|
128
|
+
Lbp = (L1 + L2) / 2
|
|
129
|
+
Cbp = (C1p + C2p) / 2
|
|
130
|
+
hsum = h1p + h2p
|
|
131
|
+
hbp = np.where(C1p * C2p == 0, hsum,
|
|
132
|
+
np.where(np.abs(h1p - h2p) <= 180, hsum / 2,
|
|
133
|
+
np.where(hsum < 360, (hsum + 360) / 2, (hsum - 360) / 2)))
|
|
134
|
+
T = (1 - 0.17 * np.cos(np.radians(hbp - 30)) + 0.24 * np.cos(np.radians(2 * hbp))
|
|
135
|
+
+ 0.32 * np.cos(np.radians(3 * hbp + 6)) - 0.20 * np.cos(np.radians(4 * hbp - 63)))
|
|
136
|
+
dtheta = 30 * np.exp(-(((hbp - 275) / 25) ** 2))
|
|
137
|
+
RC = 2 * np.sqrt(Cbp ** 7 / (Cbp ** 7 + 25.0 ** 7))
|
|
138
|
+
SL = 1 + 0.015 * (Lbp - 50) ** 2 / np.sqrt(20 + (Lbp - 50) ** 2)
|
|
139
|
+
SC = 1 + 0.045 * Cbp
|
|
140
|
+
SH = 1 + 0.015 * Cbp * T
|
|
141
|
+
RT = -np.sin(np.radians(2 * dtheta)) * RC
|
|
142
|
+
return np.sqrt((dLp / (kL * SL)) ** 2 + (dCp / (kC * SC)) ** 2 + (dHp / (kH * SH)) ** 2
|
|
143
|
+
+ RT * (dCp / (kC * SC)) * (dHp / (kH * SH)))
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _luminance(colors) -> np.ndarray:
|
|
147
|
+
lin = _linear(_rgb(colors) if not (isinstance(colors, np.ndarray) and colors.ndim == 2) else colors)
|
|
148
|
+
return lin @ np.array([0.2126, 0.7152, 0.0722])
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def contrast(fg, bg) -> float:
|
|
152
|
+
"""WCAG 2 contrast ratio between a foreground and a background colour (1 … 21)."""
|
|
153
|
+
lf, lb = float(_luminance(fg)[0]), float(_luminance(bg)[0])
|
|
154
|
+
hi, lo = max(lf, lb), min(lf, lb)
|
|
155
|
+
return (hi + 0.05) / (lo + 0.05)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
# ---------------------------------------------------------------------------------------------
|
|
159
|
+
# findings
|
|
160
|
+
# ---------------------------------------------------------------------------------------------
|
|
161
|
+
@dataclass
|
|
162
|
+
class Finding:
|
|
163
|
+
"""One accessibility finding: what ``kind`` of problem, between which colours (``a``, ``b``;
|
|
164
|
+
``b`` is the ground for a contrast finding), the measured ``value`` against the ``threshold``
|
|
165
|
+
it failed, and a sentence a reader can act on."""
|
|
166
|
+
|
|
167
|
+
kind: str
|
|
168
|
+
a: str
|
|
169
|
+
b: Optional[str]
|
|
170
|
+
value: float
|
|
171
|
+
threshold: float
|
|
172
|
+
message: str
|
|
173
|
+
|
|
174
|
+
def as_dict(self) -> dict:
|
|
175
|
+
d = asdict(self)
|
|
176
|
+
d["value"] = round(float(d["value"]), 3)
|
|
177
|
+
return d
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _hex(c) -> str:
|
|
181
|
+
from matplotlib.colors import to_hex
|
|
182
|
+
return to_hex(c)
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def check_palette(colors, bg="#ffffff", *, text=None, names=None, thresholds=None) -> List[Finding]:
|
|
186
|
+
"""Lint a set of mark colours against one another and their ground.
|
|
187
|
+
|
|
188
|
+
- every pair closer than ΔE 10 under any of the three deficiencies → ``cvd-confusable``;
|
|
189
|
+
- every pair whose lightness differs by less than 10 L* → ``greyscale-confusable``;
|
|
190
|
+
- a mark colour with WCAG contrast < 3:1 against ``bg`` → ``low-contrast-mark``;
|
|
191
|
+
- a ``text`` colour with contrast < 4.5:1 against ``bg`` → ``low-contrast-text``.
|
|
192
|
+
``names`` label the colours in the messages (default: their hex).
|
|
193
|
+
"""
|
|
194
|
+
th = {**THRESHOLDS, **(thresholds or {})}
|
|
195
|
+
colors = list(colors)
|
|
196
|
+
names = list(names) if names is not None else [_hex(c) for c in colors]
|
|
197
|
+
findings: List[Finding] = []
|
|
198
|
+
rgb = _rgb(colors)
|
|
199
|
+
n = len(colors)
|
|
200
|
+
for kind in CVD_KINDS:
|
|
201
|
+
sim = simulate(rgb, kind, 1.0)
|
|
202
|
+
labs = lab(sim)
|
|
203
|
+
for i in range(n):
|
|
204
|
+
for j in range(i + 1, n):
|
|
205
|
+
d = float(_ciede2000(labs[i:i + 1], labs[j:j + 1])[0])
|
|
206
|
+
if d < th["cvd-confusable"]:
|
|
207
|
+
findings.append(Finding("cvd-confusable", _hex(colors[i]), _hex(colors[j]), d, th["cvd-confusable"],
|
|
208
|
+
f"{names[i]} and {names[j]} are near-identical to a {kind[:-3]}ope (ΔE {d:.1f} < {th['cvd-confusable']:g})"))
|
|
209
|
+
L = lab(rgb)[:, 0]
|
|
210
|
+
for i in range(n):
|
|
211
|
+
for j in range(i + 1, n):
|
|
212
|
+
d = float(abs(L[i] - L[j]))
|
|
213
|
+
if d < th["greyscale-confusable"]:
|
|
214
|
+
findings.append(Finding("greyscale-confusable", _hex(colors[i]), _hex(colors[j]), d, th["greyscale-confusable"],
|
|
215
|
+
f"{names[i]} and {names[j]} merge in greyscale (ΔL* {d:.1f} < {th['greyscale-confusable']:g})"))
|
|
216
|
+
for c, name in zip(colors, names):
|
|
217
|
+
ratio = contrast(c, bg)
|
|
218
|
+
if ratio < th["low-contrast-mark"]:
|
|
219
|
+
findings.append(Finding("low-contrast-mark", _hex(c), _hex(bg), ratio, th["low-contrast-mark"],
|
|
220
|
+
f"{name} has {ratio:.2f}:1 contrast against the ground (needs {th['low-contrast-mark']:g}:1)"))
|
|
221
|
+
for c in (text or []):
|
|
222
|
+
ratio = contrast(c, bg)
|
|
223
|
+
if ratio < th["low-contrast-text"]:
|
|
224
|
+
findings.append(Finding("low-contrast-text", _hex(c), _hex(bg), ratio, th["low-contrast-text"],
|
|
225
|
+
f"text in {_hex(c)} has {ratio:.2f}:1 contrast against the ground (needs {th['low-contrast-text']:g}:1)"))
|
|
226
|
+
return findings
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def check_colormap(cmap, *, sequential=None, n=256, thresholds=None) -> List[Finding]:
|
|
230
|
+
"""Lint a colormap: ``non-uniform`` when the ΔE2000 steps between neighbouring samples vary
|
|
231
|
+
by more than a 0.35 coefficient of variation, ``non-monotone`` when a sequential map's
|
|
232
|
+
lightness does not run one way (``sequential`` defaults to the map's shipped type)."""
|
|
233
|
+
from matplotlib.colors import Colormap
|
|
234
|
+
from .colors import maps
|
|
235
|
+
th = {**THRESHOLDS, **(thresholds or {})}
|
|
236
|
+
cm = cmap if isinstance(cmap, Colormap) else maps._resolve_any(cmap)
|
|
237
|
+
if sequential is None:
|
|
238
|
+
try:
|
|
239
|
+
sequential = maps.info(cm.name).get("type") == "sequential"
|
|
240
|
+
except Exception:
|
|
241
|
+
sequential = False
|
|
242
|
+
rgba = cm(np.linspace(0, 1, n))
|
|
243
|
+
labs = lab(rgba[:, :3])
|
|
244
|
+
steps = _ciede2000(labs[:-1], labs[1:])
|
|
245
|
+
findings: List[Finding] = []
|
|
246
|
+
cv = float(np.std(steps) / np.mean(steps)) if np.mean(steps) > 0 else 0.0
|
|
247
|
+
if cv > th["non-uniform"]:
|
|
248
|
+
findings.append(Finding("non-uniform", cm.name, None, cv, th["non-uniform"],
|
|
249
|
+
f"{cm.name} is not perceptually uniform (ΔE step variation {cv:.2f} > {th['non-uniform']:g})"))
|
|
250
|
+
if sequential:
|
|
251
|
+
dL = np.diff(labs[:, 0])
|
|
252
|
+
tol = 0.5
|
|
253
|
+
if not (np.all(dL >= -tol) or np.all(dL <= tol)):
|
|
254
|
+
reversals = int(np.sum(np.sign(dL[np.abs(dL) > tol])[1:] != np.sign(dL[np.abs(dL) > tol])[:-1]))
|
|
255
|
+
findings.append(Finding("non-monotone", cm.name, None, float(reversals), 0.0,
|
|
256
|
+
f"{cm.name} is sequential but its lightness reverses direction ({reversals}×)"))
|
|
257
|
+
return findings
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def check_figure(fig, *, thresholds=None) -> List[Finding]:
|
|
261
|
+
"""Lint a figure: its tagged series' primary colours (the B2 colour records) against one another
|
|
262
|
+
and the axes background, and its text inks against the same ground."""
|
|
263
|
+
from matplotlib.colors import to_hex
|
|
264
|
+
from . import tagger
|
|
265
|
+
from .data import primary_paint
|
|
266
|
+
reg = tagger.registry_for(fig)
|
|
267
|
+
colours, names, seen = [], [], set()
|
|
268
|
+
for m in reg.marks:
|
|
269
|
+
if m.series is None or m.role not in ("line", "point", "bar", "area", "box"):
|
|
270
|
+
continue
|
|
271
|
+
paint = primary_paint(m)
|
|
272
|
+
if not paint or paint.get("hex") in (None, "varies") or m.series in seen:
|
|
273
|
+
continue
|
|
274
|
+
seen.add(m.series)
|
|
275
|
+
colours.append(paint["hex"])
|
|
276
|
+
names.append(f"series {m.series!r}")
|
|
277
|
+
if not fig.axes:
|
|
278
|
+
return []
|
|
279
|
+
ax = next((a for a in fig.axes if a.get_label() != "<colorbar>"), fig.axes[0])
|
|
280
|
+
bg = ax.get_facecolor()
|
|
281
|
+
if bg[3] == 0:
|
|
282
|
+
bg = fig.get_facecolor()
|
|
283
|
+
texts = {to_hex(t.get_color()) for t in list(ax.texts) + [ax.title, ax.xaxis.label, ax.yaxis.label] if t.get_text()}
|
|
284
|
+
texts |= {to_hex(t.get_color()) for t in ax.get_xticklabels() + ax.get_yticklabels() if t.get_text()}
|
|
285
|
+
return check_palette(colours, to_hex(bg), text=sorted(texts), names=names, thresholds=thresholds)
|