Skill Tracer
Skills › Scientific Agent Skills › scientific-visualization › palette_audit.py

palette_audit.py

327 lines · skills/scientific-visualization/scripts/palette_audit.py at 5f2bc03

1#!/usr/bin/env python3
2"""Audit palette contrast and heuristic grayscale distinguishability.
3
4WCAG contrast calculations are exact for the supplied sRGB values. Whether a
5specific WCAG success criterion applies depends on how a color is used. The
6grayscale delta-L* threshold is a screening heuristic, not a standard.
7"""
8
9from __future__ import annotations
11import argparse
12import importlib.util
13import itertools
14import math
15import sys
16from pathlib import Path
17from types import ModuleType
18from typing import Any, Iterable
20from _common import CliError, emit_json, positive_float
22SCHEMA_VERSION = "1.0"
23DEFAULT_BACKGROUND = "#FFFFFF"
24DEFAULT_GRAYSCALE_DELTA = 10.0
25ROLE_THRESHOLDS = {
26 "graphical": 3.0,
27 "large-text": 3.0,
28 "normal-text": 4.5,
29 "enhanced-text": 7.0,
33def parse_hex_color(value: str) -> tuple[int, int, int]:
34 """Parse an opaque six-digit sRGB color."""
35 normalized = value.strip().lstrip("#")
36 if len(normalized) != 6:
37 raise CliError(f"expected six-digit hex color, got {value!r}")
38 try:
39 channels = tuple(int(normalized[index : index + 2], 16) for index in (0, 2, 4))
40 except ValueError as exc:
41 raise CliError(f"invalid hex color: {value!r}") from exc
42 return channels # type: ignore[return-value]
45def canonical_hex(rgb: tuple[int, int, int]) -> str:
46 return "#" + "".join(f"{channel:02X}" for channel in rgb)
49def _linear_channel(channel: int) -> float:
50 encoded = channel / 255.0
51 return encoded / 12.92 if encoded <= 0.04045 else ((encoded + 0.055) / 1.055) ** 2.4
54def relative_luminance(rgb: tuple[int, int, int]) -> float:
55 """Return WCAG relative luminance for an sRGB color."""
56 red, green, blue = (_linear_channel(channel) for channel in rgb)
57 return 0.2126 * red + 0.7152 * green + 0.0722 * blue
60def contrast_ratio(
61 first: tuple[int, int, int], second: tuple[int, int, int]
62) -> float:
63 """Return WCAG contrast ratio for two opaque sRGB colors."""
64 first_luminance = relative_luminance(first)
65 second_luminance = relative_luminance(second)
66 lighter = max(first_luminance, second_luminance)
67 darker = min(first_luminance, second_luminance)
68 return (lighter + 0.05) / (darker + 0.05)
71def cie_lstar(rgb: tuple[int, int, int]) -> float:
72 """Return CIE L* from sRGB/D65, used for grayscale screening."""
73 luminance = relative_luminance(rgb)
74 delta = 6.0 / 29.0
75 if luminance > delta**3:
76 transformed = luminance ** (1.0 / 3.0)
77 else:
78 transformed = luminance / (3.0 * delta**2) + 4.0 / 29.0
79 return 116.0 * transformed - 16.0
82def _load_palette_asset() -> ModuleType:
83 asset = Path(__file__).resolve().parents[1] / "assets" / "color_palettes.py"
84 spec = importlib.util.spec_from_file_location(
85 "_scientific_visualization_color_palettes", asset
86 )
87 if spec is None or spec.loader is None:
88 raise CliError(f"cannot load bundled palette asset: {asset}")
89 module = importlib.util.module_from_spec(spec)
90 spec.loader.exec_module(module)
91 return module
94def bundled_palettes() -> dict[str, list[str]]:
95 """Load bundled palettes without importing Matplotlib."""
96 module = _load_palette_asset()
97 palettes = getattr(module, "PALETTES", None)
98 if not isinstance(palettes, dict):
99 raise CliError("bundled color_palettes.py does not define PALETTES")
100 normalized: dict[str, list[str]] = {}
101 for name, colors in palettes.items():
102 if isinstance(name, str) and isinstance(colors, (list, tuple)):
103 normalized[name] = [canonical_hex(parse_hex_color(item)) for item in colors]
104 return normalized
107def audit_palette(
108 colors: Iterable[str],
109 *,
110 background: str = DEFAULT_BACKGROUND,
111 role: str = "graphical",
112 contrast_threshold: float | None = None,
113 grayscale_min_delta: float = DEFAULT_GRAYSCALE_DELTA,
114 name: str | None = None,
115) -> dict[str, Any]:
116 """Return deterministic palette contrast and grayscale screening results."""
117 if role not in ROLE_THRESHOLDS and contrast_threshold is None:
118 raise CliError(
119 f"unknown role {role!r}; provide an explicit contrast threshold"
120 )
121 threshold = (
122 float(contrast_threshold)
123 if contrast_threshold is not None
124 else ROLE_THRESHOLDS[role]
125 )
126 if not math.isfinite(threshold) or threshold <= 1:
127 raise CliError("contrast threshold must be finite and greater than 1")
128 if not math.isfinite(grayscale_min_delta) or grayscale_min_delta <= 0:
129 raise CliError("grayscale delta threshold must be positive and finite")
131 canonical = [canonical_hex(parse_hex_color(color)) for color in colors]
132 if not canonical:
133 raise CliError("at least one color is required")
134 if len(canonical) > 32:
135 raise CliError("palette is limited to 32 colors")
136 background_rgb = parse_hex_color(background)
137 background_hex = canonical_hex(background_rgb)
139 color_records: list[dict[str, Any]] = []
140 parsed: list[tuple[int, int, int]] = []
141 for index, color in enumerate(canonical):
142 rgb = parse_hex_color(color)
143 parsed.append(rgb)
144 ratio = contrast_ratio(rgb, background_rgb)
145 color_records.append(
146 {
147 "index": index,
148 "hex": color,
149 "rgb": list(rgb),
150 "relative_luminance": round(relative_luminance(rgb), 6),
151 "cie_lstar": round(cie_lstar(rgb), 3),
152 "contrast_against_background": round(ratio, 3),
153 "background_screen": "pass" if ratio >= threshold else "review",
154 }
155 )
157 pair_records: list[dict[str, Any]] = []
158 for first, second in itertools.combinations(range(len(parsed)), 2):
159 first_rgb = parsed[first]
160 second_rgb = parsed[second]
161 delta = abs(cie_lstar(first_rgb) - cie_lstar(second_rgb))
162 pair_records.append(
163 {
164 "first_index": first,
165 "second_index": second,
166 "first_hex": canonical[first],
167 "second_hex": canonical[second],
168 "contrast_ratio": round(contrast_ratio(first_rgb, second_rgb), 3),
169 "grayscale_delta_lstar": round(delta, 3),
170 "grayscale_screen": (
171 "pass" if delta >= grayscale_min_delta else "review"
172 ),
173 }
174 )
176 background_reviews = sum(
177 item["background_screen"] == "review" for item in color_records
178 )
179 grayscale_reviews = sum(
180 item["grayscale_screen"] == "review" for item in pair_records
181 )
182 return {
183 "schema_version": SCHEMA_VERSION,
184 "palette": {
185 "name": name,
186 "colors": color_records,
187 "color_count": len(color_records),
188 },
189 "background": background_hex,
190 "contrast_screen": {
191 "role": role,
192 "threshold": threshold,
193 "review_count": background_reviews,
194 "basis": (
195 "WCAG 2.2 contrast mathematics. Applicability depends on use; "
196 "for example, SC 1.4.11 covers graphical objects required to "
197 "understand web content."
198 ),
199 },
200 "grayscale_screen": {
201 "minimum_delta_lstar": grayscale_min_delta,
202 "review_count": grayscale_reviews,
203 "pair_count": len(pair_records),
204 "basis": (
205 "Heuristic CIE L* separation after removing hue. It does not "
206 "predict all viewing, printing, or color-vision conditions."
207 ),
208 },
209 "pairs": pair_records,
210 "notice": (
211 "No palette can establish accessibility by itself. Preserve labels "
212 "and add redundant encodings such as markers, line styles, direct "
213 "labels, or patterns; evaluate the rendered figure in context."
214 ),
215 }
218def build_parser() -> argparse.ArgumentParser:
219 parser = argparse.ArgumentParser(
220 description=(
221 "Audit sRGB palette contrast against a background and screen "
222 "pairwise grayscale distinguishability. No network is used."
223 )
224 )
225 source = parser.add_mutually_exclusive_group(required=False)
226 source.add_argument(
227 "--palette", help="bundled palette name (use --list-palettes)"
228 )
229 source.add_argument(
230 "--color",
231 action="append",
232 default=[],
233 help="six-digit hex color; repeat for each palette color",
234 )
235 parser.add_argument(
236 "--list-palettes",
237 action="store_true",
238 help="print bundled palette names and exit",
239 )
240 parser.add_argument("--background", default=DEFAULT_BACKGROUND)
241 parser.add_argument(
242 "--role",
243 choices=sorted(ROLE_THRESHOLDS),
244 default="graphical",
245 help="select the WCAG contrast screening threshold",
246 )
247 parser.add_argument(
248 "--contrast-threshold",
249 type=positive_float,
250 help="override the role threshold",
251 )
252 parser.add_argument(
253 "--grayscale-min-delta",
254 type=positive_float,
255 default=DEFAULT_GRAYSCALE_DELTA,
256 help=(
257 "heuristic minimum CIE L* separation "
258 f"(default: {DEFAULT_GRAYSCALE_DELTA})"
259 ),
260 )
261 parser.add_argument(
262 "--fail-on",
263 choices=("none", "background", "grayscale", "any"),
264 default="none",
265 help="return exit 1 for selected review findings (default: none)",
266 )
267 parser.add_argument("--output", help="optional JSON report path")
268 parser.add_argument(
269 "--force", action="store_true", help="overwrite an existing JSON report"
270 )
271 return parser
274def main(argv: list[str] | None = None) -> int:
275 parser = build_parser()
276 try:
277 args = parser.parse_args(argv)
278 palettes = bundled_palettes()
279 if args.list_palettes:
280 emit_json(
281 {
282 "schema_version": SCHEMA_VERSION,
283 "palettes": {
284 name: {"count": len(colors), "colors": colors}
285 for name, colors in sorted(palettes.items())
286 },
287 },
288 output=args.output,
289 force=args.force,
290 )
291 return 0
292 if args.palette:
293 if args.palette not in palettes:
294 raise CliError(
295 f"unknown palette {args.palette!r}; "
296 f"available: {', '.join(sorted(palettes))}"
297 )
298 colors = palettes[args.palette]
299 palette_name = args.palette
300 else:
301 colors = args.color
302 palette_name = "custom"
303 if not colors:
304 raise CliError("provide --palette or at least one --color")
306 report = audit_palette(
307 colors,
308 background=args.background,
309 role=args.role,
310 contrast_threshold=args.contrast_threshold,
311 grayscale_min_delta=args.grayscale_min_delta,
312 name=palette_name,
313 )
314 emit_json(report, output=args.output, force=args.force)
315 background_review = report["contrast_screen"]["review_count"] > 0
316 grayscale_review = report["grayscale_screen"]["review_count"] > 0
317 should_fail = (
318 (args.fail_on in {"background", "any"} and background_review)
319 or (args.fail_on in {"grayscale", "any"} and grayscale_review)
320 )
321 return 1 if should_fail else 0
322 except CliError as exc:
323 parser.exit(2, f"error: {exc}\n")
326if __name__ == "__main__":
327 sys.exit(main())