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 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
+ )