palettize 0.0.1__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.
- palettize/__init__.py +157 -0
- palettize/analysis.py +372 -0
- palettize/cli.py +991 -0
- palettize/core.py +755 -0
- palettize/exceptions.py +35 -0
- palettize/exporters/__init__.py +190 -0
- palettize/exporters/_base.py +138 -0
- palettize/exporters/_options.py +191 -0
- palettize/exporters/css.py +123 -0
- palettize/exporters/gdal.py +118 -0
- palettize/exporters/gee.py +110 -0
- palettize/exporters/gimp.py +86 -0
- palettize/exporters/json.py +112 -0
- palettize/exporters/mapgl.py +70 -0
- palettize/exporters/observable_plot.py +113 -0
- palettize/exporters/plaintext.py +163 -0
- palettize/exporters/qgis.py +152 -0
- palettize/exporters/sld.py +226 -0
- palettize/exporters/svg.py +107 -0
- palettize/exporters/titiler.py +66 -0
- palettize/presets.py +237 -0
- palettize/py.typed +0 -0
- palettize/render.py +209 -0
- palettize/scaling.py +349 -0
- palettize-0.0.1.dist-info/METADATA +225 -0
- palettize-0.0.1.dist-info/RECORD +30 -0
- palettize-0.0.1.dist-info/WHEEL +5 -0
- palettize-0.0.1.dist-info/entry_points.txt +2 -0
- palettize-0.0.1.dist-info/licenses/LICENSE +7 -0
- palettize-0.0.1.dist-info/top_level.txt +1 -0
palettize/__init__.py
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
"""Palettize: Python utility and CLI tool for generating and exporting colormaps.
|
|
2
|
+
|
|
3
|
+
This module exposes a small, stable programmatic API so Palettize can be used
|
|
4
|
+
directly from Python scripts and notebooks (in addition to the CLI).
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from importlib.metadata import PackageNotFoundError as _PackageNotFoundError
|
|
10
|
+
from importlib.metadata import version as _pkg_version
|
|
11
|
+
|
|
12
|
+
from .analysis import ColormapAnalysis, analyze, simulate_cvd
|
|
13
|
+
from .core import (
|
|
14
|
+
SCHEMA_VERSION,
|
|
15
|
+
Colormap,
|
|
16
|
+
ColorStop,
|
|
17
|
+
InputColor,
|
|
18
|
+
format_color,
|
|
19
|
+
parse_input_color,
|
|
20
|
+
sample_positions,
|
|
21
|
+
)
|
|
22
|
+
from .exceptions import (
|
|
23
|
+
ColormapFileError,
|
|
24
|
+
ExporterOptionError,
|
|
25
|
+
InvalidColorError,
|
|
26
|
+
PalettizeError,
|
|
27
|
+
PresetNotFoundError,
|
|
28
|
+
)
|
|
29
|
+
from .exporters import (
|
|
30
|
+
BaseExporter,
|
|
31
|
+
Opt,
|
|
32
|
+
OptionSpec,
|
|
33
|
+
get_exporter,
|
|
34
|
+
list_available_exporters,
|
|
35
|
+
load_plugin_exporters,
|
|
36
|
+
register_exporter,
|
|
37
|
+
)
|
|
38
|
+
from .presets import (
|
|
39
|
+
PresetInfo,
|
|
40
|
+
get_preset_info,
|
|
41
|
+
list_available_presets,
|
|
42
|
+
list_categories,
|
|
43
|
+
load_preset_data,
|
|
44
|
+
search_presets,
|
|
45
|
+
)
|
|
46
|
+
from .render import png_bytes, svg_document, terminal_swatch, write_image
|
|
47
|
+
from .scaling import (
|
|
48
|
+
ScalingFunction,
|
|
49
|
+
get_linear_scaler,
|
|
50
|
+
get_log_scaler,
|
|
51
|
+
get_power_scaler,
|
|
52
|
+
get_scaler_by_name,
|
|
53
|
+
get_sqrt_scaler,
|
|
54
|
+
get_symlog_scaler,
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
try:
|
|
58
|
+
__version__ = _pkg_version("palettize")
|
|
59
|
+
except _PackageNotFoundError: # running from a source tree without an install
|
|
60
|
+
__version__ = "0.0.0.dev0"
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def create_colormap(
|
|
64
|
+
*,
|
|
65
|
+
preset: str | None = None,
|
|
66
|
+
colors: list[InputColor] | None = None,
|
|
67
|
+
interpolation_space: str = "oklch",
|
|
68
|
+
name: str | None = None,
|
|
69
|
+
cut_start: float = 0.0,
|
|
70
|
+
cut_end: float = 1.0,
|
|
71
|
+
) -> Colormap:
|
|
72
|
+
"""Create a `Colormap` either from a preset name or a list of colors.
|
|
73
|
+
|
|
74
|
+
Exactly one of `preset` or `colors` must be provided.
|
|
75
|
+
|
|
76
|
+
Args:
|
|
77
|
+
preset: Name of a preset palette (e.g., "viridis" or "custom/grayscale").
|
|
78
|
+
colors: List of colors (hex, tuples, named strings, ColorAide `Color`, or dicts).
|
|
79
|
+
interpolation_space: Color space used for interpolation (default "oklch").
|
|
80
|
+
name: Optional name for the resulting colormap.
|
|
81
|
+
cut_start: Start of the sub-segment of the colormap to use (0-1).
|
|
82
|
+
cut_end: End of the sub-segment of the colormap to use (0-1).
|
|
83
|
+
|
|
84
|
+
Returns:
|
|
85
|
+
A `Colormap` instance.
|
|
86
|
+
|
|
87
|
+
Raises:
|
|
88
|
+
ValueError: If neither or both of `preset` and `colors` are provided, or
|
|
89
|
+
if parameters are otherwise invalid.
|
|
90
|
+
"""
|
|
91
|
+
if (preset is None and not colors) or (preset is not None and colors):
|
|
92
|
+
raise ValueError("Provide exactly one of `preset` or `colors`.")
|
|
93
|
+
|
|
94
|
+
if preset is not None:
|
|
95
|
+
cmap = Colormap.from_preset(
|
|
96
|
+
preset_name=preset,
|
|
97
|
+
interpolation_space=interpolation_space,
|
|
98
|
+
cut_start=cut_start,
|
|
99
|
+
cut_end=cut_end,
|
|
100
|
+
)
|
|
101
|
+
else:
|
|
102
|
+
assert colors is not None
|
|
103
|
+
cmap = Colormap.from_list(
|
|
104
|
+
colors=colors,
|
|
105
|
+
interpolation_space=interpolation_space,
|
|
106
|
+
cut_start=cut_start,
|
|
107
|
+
cut_end=cut_end,
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
if name:
|
|
111
|
+
cmap.name = name
|
|
112
|
+
return cmap
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
__all__ = (
|
|
116
|
+
"SCHEMA_VERSION",
|
|
117
|
+
"BaseExporter",
|
|
118
|
+
"ColorStop",
|
|
119
|
+
"Colormap",
|
|
120
|
+
"ColormapAnalysis",
|
|
121
|
+
"ColormapFileError",
|
|
122
|
+
"ExporterOptionError",
|
|
123
|
+
"InputColor",
|
|
124
|
+
"InvalidColorError",
|
|
125
|
+
"Opt",
|
|
126
|
+
"OptionSpec",
|
|
127
|
+
"PalettizeError",
|
|
128
|
+
"PresetInfo",
|
|
129
|
+
"PresetNotFoundError",
|
|
130
|
+
"ScalingFunction",
|
|
131
|
+
"__version__",
|
|
132
|
+
"analyze",
|
|
133
|
+
"create_colormap",
|
|
134
|
+
"format_color",
|
|
135
|
+
"get_exporter",
|
|
136
|
+
"get_linear_scaler",
|
|
137
|
+
"get_log_scaler",
|
|
138
|
+
"get_power_scaler",
|
|
139
|
+
"get_preset_info",
|
|
140
|
+
"get_scaler_by_name",
|
|
141
|
+
"get_sqrt_scaler",
|
|
142
|
+
"get_symlog_scaler",
|
|
143
|
+
"list_available_exporters",
|
|
144
|
+
"list_available_presets",
|
|
145
|
+
"list_categories",
|
|
146
|
+
"load_plugin_exporters",
|
|
147
|
+
"load_preset_data",
|
|
148
|
+
"parse_input_color",
|
|
149
|
+
"png_bytes",
|
|
150
|
+
"register_exporter",
|
|
151
|
+
"sample_positions",
|
|
152
|
+
"search_presets",
|
|
153
|
+
"simulate_cvd",
|
|
154
|
+
"svg_document",
|
|
155
|
+
"terminal_swatch",
|
|
156
|
+
"write_image",
|
|
157
|
+
)
|
palettize/analysis.py
ADDED
|
@@ -0,0 +1,372 @@
|
|
|
1
|
+
"""Perceptual analysis of colormaps.
|
|
2
|
+
|
|
3
|
+
Answers the questions that decide whether a colormap is safe to publish:
|
|
4
|
+
does lightness increase steadily, are perceptual steps evenly sized, and does
|
|
5
|
+
the map survive the common forms of color vision deficiency?
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import statistics
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
from itertools import pairwise
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
from coloraide import Color
|
|
16
|
+
|
|
17
|
+
from .core import Colormap, sample_positions
|
|
18
|
+
|
|
19
|
+
#: Color vision deficiency filters, as (key, human-readable label).
|
|
20
|
+
CVD_TYPES: tuple[tuple[str, str], ...] = (
|
|
21
|
+
("protan", "Protanopia (red-blind)"),
|
|
22
|
+
("deutan", "Deuteranopia (green-blind)"),
|
|
23
|
+
("tritan", "Tritanopia (blue-blind)"),
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
#: Default number of samples taken when analyzing a colormap.
|
|
27
|
+
DEFAULT_SAMPLES = 32
|
|
28
|
+
|
|
29
|
+
#: A colormap whose consecutive steps vary by less than this coefficient of
|
|
30
|
+
#: variation reads as perceptually even.
|
|
31
|
+
UNIFORMITY_GOOD_CV = 0.25
|
|
32
|
+
|
|
33
|
+
#: A CVD simulation that preserves at least this fraction of every original step
|
|
34
|
+
#: keeps the colormap readable. Expressed as a ratio rather than an absolute
|
|
35
|
+
#: delta-E so the verdict does not shift with the number of samples taken.
|
|
36
|
+
CVD_MIN_RETENTION = 0.3
|
|
37
|
+
|
|
38
|
+
#: Steps smaller than this in the original are treated as already-identical and
|
|
39
|
+
#: excluded from retention ratios, which would otherwise divide by ~zero.
|
|
40
|
+
_NEGLIGIBLE_DE = 0.1
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _mean(values: list[float]) -> float:
|
|
44
|
+
return statistics.fmean(values) if values else 0.0
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass(frozen=True)
|
|
48
|
+
class LightnessReport:
|
|
49
|
+
"""How lightness behaves across the colormap."""
|
|
50
|
+
|
|
51
|
+
values: list[float]
|
|
52
|
+
"""Oklab lightness (0-100) at each sample position."""
|
|
53
|
+
|
|
54
|
+
shape: str
|
|
55
|
+
"""``"sequential"`` (monotonic), ``"diverging"`` (one turning point), or
|
|
56
|
+
``"erratic"`` (lightness wanders)."""
|
|
57
|
+
|
|
58
|
+
is_monotonic: bool
|
|
59
|
+
"""Whether lightness only ever increases, or only ever decreases."""
|
|
60
|
+
|
|
61
|
+
direction: str
|
|
62
|
+
"""``"ascending"``, ``"descending"``, or ``"non-monotonic"``."""
|
|
63
|
+
|
|
64
|
+
span: float
|
|
65
|
+
"""Difference between the lightest and darkest sample."""
|
|
66
|
+
|
|
67
|
+
max_reversal: float
|
|
68
|
+
"""Largest step that runs against the overall direction. 0 when monotonic."""
|
|
69
|
+
|
|
70
|
+
turning_points: int
|
|
71
|
+
"""How many times lightness changes direction."""
|
|
72
|
+
|
|
73
|
+
def to_dict(self) -> dict[str, Any]:
|
|
74
|
+
return {
|
|
75
|
+
"values": [round(v, 2) for v in self.values],
|
|
76
|
+
"shape": self.shape,
|
|
77
|
+
"is_monotonic": self.is_monotonic,
|
|
78
|
+
"direction": self.direction,
|
|
79
|
+
"span": round(self.span, 2),
|
|
80
|
+
"max_reversal": round(self.max_reversal, 2),
|
|
81
|
+
"turning_points": self.turning_points,
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@dataclass(frozen=True)
|
|
86
|
+
class UniformityReport:
|
|
87
|
+
"""How evenly perceptual difference is spread along the colormap."""
|
|
88
|
+
|
|
89
|
+
deltas: list[float]
|
|
90
|
+
"""Delta-E 2000 between each consecutive pair of samples."""
|
|
91
|
+
|
|
92
|
+
mean: float
|
|
93
|
+
minimum: float
|
|
94
|
+
maximum: float
|
|
95
|
+
|
|
96
|
+
coefficient_of_variation: float
|
|
97
|
+
"""Standard deviation over the mean. Lower is more uniform."""
|
|
98
|
+
|
|
99
|
+
is_uniform: bool
|
|
100
|
+
"""Whether the coefficient of variation is within :data:`UNIFORMITY_GOOD_CV`."""
|
|
101
|
+
|
|
102
|
+
def to_dict(self) -> dict[str, Any]:
|
|
103
|
+
return {
|
|
104
|
+
"mean": round(self.mean, 3),
|
|
105
|
+
"min": round(self.minimum, 3),
|
|
106
|
+
"max": round(self.maximum, 3),
|
|
107
|
+
"coefficient_of_variation": round(self.coefficient_of_variation, 3),
|
|
108
|
+
"is_uniform": self.is_uniform,
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
@dataclass(frozen=True)
|
|
113
|
+
class CVDReport:
|
|
114
|
+
"""How the colormap holds up under one form of color vision deficiency."""
|
|
115
|
+
|
|
116
|
+
key: str
|
|
117
|
+
label: str
|
|
118
|
+
|
|
119
|
+
colors: list[str] = field(repr=False)
|
|
120
|
+
"""Simulated hex colors at each sample position."""
|
|
121
|
+
|
|
122
|
+
retained_contrast: float
|
|
123
|
+
"""Simulated mean step size as a fraction of the original. 1.0 loses nothing."""
|
|
124
|
+
|
|
125
|
+
worst_retention: float
|
|
126
|
+
"""The single worst-preserved step, as a fraction of its original size.
|
|
127
|
+
This is what makes a region of the map turn to mush."""
|
|
128
|
+
|
|
129
|
+
worst_position: float
|
|
130
|
+
"""Where in ``[0, 1]`` that worst-preserved step occurs."""
|
|
131
|
+
|
|
132
|
+
is_distinguishable: bool
|
|
133
|
+
"""Whether every step keeps at least :data:`CVD_MIN_RETENTION` of its size."""
|
|
134
|
+
|
|
135
|
+
def to_dict(self) -> dict[str, Any]:
|
|
136
|
+
return {
|
|
137
|
+
"label": self.label,
|
|
138
|
+
"retained_contrast": round(self.retained_contrast, 3),
|
|
139
|
+
"worst_retention": round(self.worst_retention, 3),
|
|
140
|
+
"worst_position": round(self.worst_position, 3),
|
|
141
|
+
"is_distinguishable": self.is_distinguishable,
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
@dataclass(frozen=True)
|
|
146
|
+
class ColormapAnalysis:
|
|
147
|
+
"""The complete perceptual profile of a colormap."""
|
|
148
|
+
|
|
149
|
+
name: str
|
|
150
|
+
samples: int
|
|
151
|
+
positions: list[float] = field(repr=False)
|
|
152
|
+
colors: list[str] = field(repr=False)
|
|
153
|
+
lightness: LightnessReport
|
|
154
|
+
uniformity: UniformityReport
|
|
155
|
+
cvd: list[CVDReport]
|
|
156
|
+
|
|
157
|
+
@property
|
|
158
|
+
def warnings(self) -> list[str]:
|
|
159
|
+
"""Plain-language descriptions of every problem found. Empty means clean.
|
|
160
|
+
|
|
161
|
+
A diverging lightness profile is reported as a note rather than a
|
|
162
|
+
warning: it is the intended shape for a diverging colormap.
|
|
163
|
+
"""
|
|
164
|
+
issues: list[str] = []
|
|
165
|
+
if self.lightness.shape == "erratic":
|
|
166
|
+
issues.append(
|
|
167
|
+
f"Lightness wanders ({self.lightness.turning_points} direction changes, "
|
|
168
|
+
f"largest reversal {self.lightness.max_reversal:.1f}), so the map "
|
|
169
|
+
f"implies an ordering its brightness does not follow. Features will "
|
|
170
|
+
f"appear where the data has none."
|
|
171
|
+
)
|
|
172
|
+
if not self.uniformity.is_uniform:
|
|
173
|
+
issues.append(
|
|
174
|
+
f"Perceptual steps are uneven (variation "
|
|
175
|
+
f"{self.uniformity.coefficient_of_variation:.2f}), so equal data "
|
|
176
|
+
f"differences will not look equally different."
|
|
177
|
+
)
|
|
178
|
+
for report in self.cvd:
|
|
179
|
+
if not report.is_distinguishable:
|
|
180
|
+
issues.append(
|
|
181
|
+
f"{report.label}: near position "
|
|
182
|
+
f"{report.worst_position:.2f} the map keeps only "
|
|
183
|
+
f"{report.worst_retention:.0%} of its original contrast, so that "
|
|
184
|
+
f"region reads as a flat band."
|
|
185
|
+
)
|
|
186
|
+
return issues
|
|
187
|
+
|
|
188
|
+
@property
|
|
189
|
+
def notes(self) -> list[str]:
|
|
190
|
+
"""Observations that are worth surfacing but are not problems."""
|
|
191
|
+
remarks: list[str] = []
|
|
192
|
+
if self.lightness.shape == "diverging":
|
|
193
|
+
remarks.append(
|
|
194
|
+
"Lightness peaks in the middle and falls away on both sides — the "
|
|
195
|
+
"expected shape for a diverging colormap, but wrong for sequential data."
|
|
196
|
+
)
|
|
197
|
+
if self.lightness.span < 20:
|
|
198
|
+
remarks.append(
|
|
199
|
+
f"Lightness only spans {self.lightness.span:.0f} of 100, so the map "
|
|
200
|
+
f"relies on hue alone and will not survive grayscale printing."
|
|
201
|
+
)
|
|
202
|
+
return remarks
|
|
203
|
+
|
|
204
|
+
def to_dict(self) -> dict[str, Any]:
|
|
205
|
+
"""A JSON-serializable summary of the analysis."""
|
|
206
|
+
return {
|
|
207
|
+
"name": self.name,
|
|
208
|
+
"samples": self.samples,
|
|
209
|
+
"colors": self.colors,
|
|
210
|
+
"lightness": self.lightness.to_dict(),
|
|
211
|
+
"uniformity": self.uniformity.to_dict(),
|
|
212
|
+
"cvd": {report.key: report.to_dict() for report in self.cvd},
|
|
213
|
+
"warnings": self.warnings,
|
|
214
|
+
"notes": self.notes,
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def _analyze_lightness(colors: list[Color]) -> LightnessReport:
|
|
219
|
+
values = [c.convert("oklab")["lightness"] * 100 for c in colors]
|
|
220
|
+
diffs = [b - a for a, b in pairwise(values)]
|
|
221
|
+
|
|
222
|
+
# Ignore steps too small to see; float noise would otherwise read as a
|
|
223
|
+
# direction change and make every smooth colormap look erratic.
|
|
224
|
+
significant = [d for d in diffs if abs(d) > 0.5]
|
|
225
|
+
turning_points = sum(1 for a, b in pairwise(significant) if (a > 0) != (b > 0))
|
|
226
|
+
|
|
227
|
+
is_monotonic = turning_points == 0
|
|
228
|
+
if is_monotonic:
|
|
229
|
+
direction = "ascending" if values[-1] >= values[0] else "descending"
|
|
230
|
+
max_reversal = 0.0
|
|
231
|
+
else:
|
|
232
|
+
direction = "non-monotonic"
|
|
233
|
+
going_up = values[-1] >= values[0]
|
|
234
|
+
max_reversal = max(max(-d if going_up else d for d in diffs), 0.0)
|
|
235
|
+
|
|
236
|
+
if is_monotonic:
|
|
237
|
+
shape = "sequential"
|
|
238
|
+
elif turning_points == 1:
|
|
239
|
+
shape = "diverging"
|
|
240
|
+
else:
|
|
241
|
+
shape = "erratic"
|
|
242
|
+
|
|
243
|
+
return LightnessReport(
|
|
244
|
+
values=values,
|
|
245
|
+
shape=shape,
|
|
246
|
+
is_monotonic=is_monotonic,
|
|
247
|
+
direction=direction,
|
|
248
|
+
span=max(values) - min(values),
|
|
249
|
+
max_reversal=max_reversal,
|
|
250
|
+
turning_points=turning_points,
|
|
251
|
+
)
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def _consecutive_deltas(colors: list[Color]) -> list[float]:
|
|
255
|
+
"""Delta-E 2000 between each consecutive pair of colors."""
|
|
256
|
+
return [a.delta_e(b, method="2000") for a, b in pairwise(colors)]
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def _analyze_uniformity(colors: list[Color]) -> UniformityReport:
|
|
260
|
+
deltas = _consecutive_deltas(colors)
|
|
261
|
+
mean = _mean(deltas)
|
|
262
|
+
# Constant colormaps have zero spread, which is trivially uniform.
|
|
263
|
+
cv = (statistics.pstdev(deltas) / mean) if mean > 0 else 0.0
|
|
264
|
+
return UniformityReport(
|
|
265
|
+
deltas=deltas,
|
|
266
|
+
mean=mean,
|
|
267
|
+
minimum=min(deltas) if deltas else 0.0,
|
|
268
|
+
maximum=max(deltas) if deltas else 0.0,
|
|
269
|
+
coefficient_of_variation=cv,
|
|
270
|
+
is_uniform=cv <= UNIFORMITY_GOOD_CV,
|
|
271
|
+
)
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def simulate_cvd(color: Color, kind: str, severity: float = 1.0) -> Color:
|
|
275
|
+
"""Return ``color`` as seen with the given color vision deficiency.
|
|
276
|
+
|
|
277
|
+
Args:
|
|
278
|
+
color: The color to transform.
|
|
279
|
+
kind: One of ``"protan"``, ``"deutan"``, ``"tritan"``.
|
|
280
|
+
severity: 0.0 (unaffected) to 1.0 (full dichromacy).
|
|
281
|
+
"""
|
|
282
|
+
return color.filter(kind, severity, space="srgb-linear", out_space="srgb")
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def _analyze_cvd(
|
|
286
|
+
colors: list[Color], baseline: list[float], positions: list[float]
|
|
287
|
+
) -> list[CVDReport]:
|
|
288
|
+
"""Compare each CVD simulation against the original step sizes.
|
|
289
|
+
|
|
290
|
+
Retention is measured per step as a *ratio* to the original, which makes the
|
|
291
|
+
verdict independent of how many samples were taken.
|
|
292
|
+
"""
|
|
293
|
+
baseline_mean = _mean(baseline)
|
|
294
|
+
reports: list[CVDReport] = []
|
|
295
|
+
|
|
296
|
+
for key, label in CVD_TYPES:
|
|
297
|
+
simulated = [simulate_cvd(c, key) for c in colors]
|
|
298
|
+
deltas = _consecutive_deltas(simulated)
|
|
299
|
+
|
|
300
|
+
worst_retention = 1.0
|
|
301
|
+
worst_position = 0.0
|
|
302
|
+
for i, (original, seen) in enumerate(zip(baseline, deltas, strict=True)):
|
|
303
|
+
if original < _NEGLIGIBLE_DE:
|
|
304
|
+
continue
|
|
305
|
+
retention = seen / original
|
|
306
|
+
if retention < worst_retention:
|
|
307
|
+
worst_retention = retention
|
|
308
|
+
# Report the midpoint of the affected step.
|
|
309
|
+
worst_position = (positions[i] + positions[i + 1]) / 2
|
|
310
|
+
|
|
311
|
+
mean = _mean(deltas)
|
|
312
|
+
reports.append(
|
|
313
|
+
CVDReport(
|
|
314
|
+
key=key,
|
|
315
|
+
label=label,
|
|
316
|
+
colors=[c.to_string(hex=True, fit="clip") for c in simulated],
|
|
317
|
+
retained_contrast=(mean / baseline_mean) if baseline_mean > 0 else 1.0,
|
|
318
|
+
worst_retention=worst_retention,
|
|
319
|
+
worst_position=worst_position,
|
|
320
|
+
is_distinguishable=worst_retention >= CVD_MIN_RETENTION,
|
|
321
|
+
)
|
|
322
|
+
)
|
|
323
|
+
return reports
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def analyze(colormap: Colormap, samples: int = DEFAULT_SAMPLES) -> ColormapAnalysis:
|
|
327
|
+
"""Profile a colormap's lightness, perceptual uniformity, and CVD safety.
|
|
328
|
+
|
|
329
|
+
Args:
|
|
330
|
+
colormap: The colormap to analyze.
|
|
331
|
+
samples: Number of evenly spaced samples to take. More samples give a
|
|
332
|
+
finer picture but exaggerate small local wobbles.
|
|
333
|
+
|
|
334
|
+
Returns:
|
|
335
|
+
A :class:`ColormapAnalysis` whose ``warnings`` list is empty for a
|
|
336
|
+
colormap with no detected problems.
|
|
337
|
+
"""
|
|
338
|
+
if samples < 2:
|
|
339
|
+
raise ValueError("analyze() requires at least 2 samples.")
|
|
340
|
+
|
|
341
|
+
positions = sample_positions(samples)
|
|
342
|
+
colors = [colormap.get_color_object(p).convert("srgb") for p in positions]
|
|
343
|
+
|
|
344
|
+
uniformity = _analyze_uniformity(colors)
|
|
345
|
+
return ColormapAnalysis(
|
|
346
|
+
name=colormap.name or "custom",
|
|
347
|
+
samples=samples,
|
|
348
|
+
positions=positions,
|
|
349
|
+
colors=[c.to_string(hex=True, fit="clip") for c in colors],
|
|
350
|
+
lightness=_analyze_lightness(colors),
|
|
351
|
+
uniformity=uniformity,
|
|
352
|
+
cvd=_analyze_cvd(colors, uniformity.deltas, positions),
|
|
353
|
+
)
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
def sparkline(values: list[float], height: int = 8) -> str:
|
|
357
|
+
"""Render values as a one-line Unicode bar chart.
|
|
358
|
+
|
|
359
|
+
Args:
|
|
360
|
+
values: The series to plot.
|
|
361
|
+
height: Number of distinct bar heights to use (max 8).
|
|
362
|
+
"""
|
|
363
|
+
blocks = "▁▂▃▄▅▆▇█"[: max(1, min(8, height))]
|
|
364
|
+
if not values:
|
|
365
|
+
return ""
|
|
366
|
+
low, high = min(values), max(values)
|
|
367
|
+
span = high - low
|
|
368
|
+
if span == 0:
|
|
369
|
+
return blocks[0] * len(values)
|
|
370
|
+
return "".join(
|
|
371
|
+
blocks[min(len(blocks) - 1, int((v - low) / span * len(blocks)))] for v in values
|
|
372
|
+
)
|