Coverage for src / tracekit / visualization / palettes.py: 100%
92 statements
« prev ^ index » next coverage.py v7.13.1, created at 2026-01-11 23:04 +0000
« prev ^ index » next coverage.py v7.13.1, created at 2026-01-11 23:04 +0000
1"""Colorblind-safe palettes for TraceKit visualizations.
3This module provides colorblind-safe color palettes and utilities for
4creating accessible visualizations.
7Example:
8 >>> from tracekit.visualization.palettes import get_palette, show_palette
9 >>> colors = get_palette("colorblind_safe")
10 >>> show_palette("viridis")
12References:
13 - Wong, B. (2011). Color blindness. Nature Methods, 8(6), 441.
14 - Colorbrewer 2.0 (colorbrewer2.org)
15 - WCAG 2.1 color contrast guidelines
16"""
18from __future__ import annotations
20from typing import TYPE_CHECKING, Literal
22import matplotlib.pyplot as plt
23import numpy as np
24from matplotlib.patches import Rectangle
26if TYPE_CHECKING:
27 from matplotlib.colors import Colormap
29# Define colorblind-safe palettes
30# Based on Wong 2011 and Paul Tol's schemes
31PALETTES = {
32 "colorblind_safe": [
33 "#0173B2", # Blue
34 "#DE8F05", # Orange
35 "#029E73", # Green
36 "#CC78BC", # Purple
37 "#CA9161", # Brown
38 "#FBAFE4", # Pink
39 "#949494", # Gray
40 "#ECE133", # Yellow
41 ],
42 "colorblind8": [ # Paul Tol's bright scheme
43 "#4477AA", # Blue
44 "#EE6677", # Red
45 "#228833", # Green
46 "#CCBB44", # Yellow
47 "#66CCEE", # Cyan
48 "#AA3377", # Purple
49 "#BBBBBB", # Gray
50 "#EE8866", # Orange
51 ],
52 "high_contrast": [
53 "#000000", # Black
54 "#E69F00", # Orange
55 "#56B4E9", # Sky Blue
56 "#009E73", # Bluish Green
57 "#F0E442", # Yellow
58 "#0072B2", # Blue
59 "#D55E00", # Vermillion
60 "#CC79A7", # Reddish Purple
61 ],
62 "grayscale": [
63 "#000000", # Black
64 "#404040", # Dark Gray
65 "#808080", # Medium Gray
66 "#BFBFBF", # Light Gray
67 "#E0E0E0", # Very Light Gray
68 ],
69}
71# Line styles for multi-line plots
72LINE_STYLES = ["solid", "dashed", "dotted", "dashdot"]
74# Marker styles for scatter plots
75MARKER_STYLES = ["o", "s", "^", "D", "v", "p", "*", "h"]
78def get_palette(
79 name: Literal[
80 "colorblind_safe", "colorblind8", "high_contrast", "grayscale"
81 ] = "colorblind_safe",
82) -> list[str]:
83 """Get a colorblind-safe color palette.
85 : Default palette is colorblind-safe.
86 Returns list of hex color codes that are distinguishable for colorblind users.
88 Args:
89 name: Palette name. Options:
90 - "colorblind_safe": Default palette (Wong 2011) with 8 colors
91 - "colorblind8": Paul Tol's bright scheme with 8 colors
92 - "high_contrast": High contrast palette suitable for presentations
93 - "grayscale": Grayscale palette for printing
95 Returns:
96 List of hex color codes
98 Raises:
99 ValueError: If palette name is not recognized
101 Example:
102 >>> from tracekit.visualization.palettes import get_palette
103 >>> colors = get_palette("colorblind_safe")
104 >>> print(colors[0]) # First color (blue)
105 #0173B2
107 References:
108 ACC-001: Colorblind-Safe Visualization Palette
109 """
110 if name not in PALETTES:
111 valid = ", ".join(PALETTES.keys())
112 raise ValueError(f"Unknown palette: {name}. Valid options: {valid}")
113 return PALETTES[name].copy()
116def get_colormap(
117 name: Literal["viridis", "cividis", "plasma", "inferno", "magma"] = "viridis",
118) -> Colormap:
119 """Get a colorblind-safe matplotlib colormap.
121 : Default palette is colorblind-safe (e.g., viridis, cividis).
122 Returns perceptually uniform colormaps suitable for continuous data.
124 Args:
125 name: Colormap name. All options are colorblind-safe:
126 - "viridis": Default, blue-green-yellow (recommended)
127 - "cividis": Blue-yellow, optimized for CVD
128 - "plasma": Purple-red-yellow
129 - "inferno": Black-purple-yellow
130 - "magma": Black-purple-white
132 Returns:
133 Matplotlib Colormap object
135 Raises:
136 ValueError: If colormap name is not recognized
138 Example:
139 >>> from tracekit.visualization.palettes import get_colormap
140 >>> import matplotlib.pyplot as plt
141 >>> cmap = get_colormap("viridis")
142 >>> plt.imshow(data, cmap=cmap)
144 References:
145 ACC-001: Colorblind-Safe Visualization Palette
146 """
147 valid = ["viridis", "cividis", "plasma", "inferno", "magma"]
148 if name not in valid:
149 raise ValueError(f"Unknown colormap: {name}. Valid options: {', '.join(valid)}")
150 return plt.get_cmap(name)
153def get_line_styles(
154 n_lines: int,
155 *,
156 palette: str = "colorblind_safe",
157 cycle_styles: bool = True,
158) -> list[dict]: # type: ignore[type-arg]
159 """Get line styles for multi-line plots.
161 : Multi-line plots use distinct line styles in addition to colors.
162 Combines colors with line styles for maximum distinguishability.
164 Args:
165 n_lines: Number of lines to style
166 palette: Palette name for colors
167 cycle_styles: Cycle through line styles if more lines than styles
169 Returns:
170 List of style dictionaries with 'color' and 'linestyle' keys
172 Example:
173 >>> from tracekit.visualization.palettes import get_line_styles
174 >>> styles = get_line_styles(4)
175 >>> for i, style in enumerate(styles):
176 ... plt.plot(x, y[i], **style, label=f"Line {i}")
178 References:
179 ACC-001: Multi-line plots use distinct line styles in addition to colors
180 """
181 colors = get_palette(palette) # type: ignore[arg-type]
182 styles = []
184 for i in range(n_lines):
185 color = colors[i % len(colors)]
186 linestyle = LINE_STYLES[i % len(LINE_STYLES)] if cycle_styles else "solid"
188 styles.append({"color": color, "linestyle": linestyle})
190 return styles
193def get_pass_fail_symbols() -> dict[str, str]:
194 """Get pass/fail symbols for accessible reporting.
196 : Pass/fail uses symbols (✓/✗) not just red/green.
197 Returns symbols that work in text and don't rely on color alone.
199 Returns:
200 Dictionary with 'pass' and 'fail' symbol keys
202 Example:
203 >>> from tracekit.visualization.palettes import get_pass_fail_symbols
204 >>> symbols = get_pass_fail_symbols()
205 >>> print(f"{symbols['pass']} Test passed")
206 ✓ Test passed
208 References:
209 ACC-001: Pass/fail uses symbols (✓/✗) not just red/green
210 """
211 return {"pass": "✓", "fail": "✗"}
214def get_pass_fail_colors(
215 *,
216 colorblind_safe: bool = True,
217) -> dict[str, str]:
218 """Get pass/fail colors.
220 : Pass/fail colors are colorblind-safe when combined with symbols.
221 Returns green/red or blue/orange based on colorblind_safe flag.
223 Args:
224 colorblind_safe: Use colorblind-safe blue/orange instead of green/red
226 Returns:
227 Dictionary with 'pass' and 'fail' color keys (hex codes)
229 Example:
230 >>> from tracekit.visualization.palettes import get_pass_fail_colors
231 >>> colors = get_pass_fail_colors(colorblind_safe=True)
232 >>> print(colors['pass']) # Blue for pass
233 #0173B2
235 References:
236 ACC-001: Colorblind-Safe Visualization Palette
237 """
238 if colorblind_safe:
239 return {
240 "pass": "#0173B2", # Blue
241 "fail": "#DE8F05", # Orange
242 }
243 else:
244 return {
245 "pass": "#2CA02C", # Green
246 "fail": "#D62728", # Red
247 }
250def show_palette(
251 name: str = "colorblind_safe",
252 *,
253 save_path: str | None = None,
254) -> None:
255 """Display a color palette preview.
257 : Palette preview: show_palette(name).
258 Shows palette colors in a matplotlib figure for visual inspection.
260 Args:
261 name: Palette name or colormap name
262 save_path: Optional path to save the figure
264 Raises:
265 ValueError: If unknown palette or colormap name.
267 Example:
268 >>> from tracekit.visualization.palettes import show_palette
269 >>> show_palette("colorblind_safe")
270 >>> show_palette("viridis", save_path="palette.png")
272 References:
273 ACC-001: Colorblind-Safe Visualization Palette
274 """
275 _fig, ax = plt.subplots(figsize=(8, 2))
277 # Check if it's a discrete palette or continuous colormap
278 if name in PALETTES:
279 # Discrete palette
280 colors = PALETTES[name]
281 n_colors = len(colors)
282 x = np.arange(n_colors)
284 # Create color swatches
285 for i, color in enumerate(colors):
286 ax.add_patch(Rectangle((i, 0), 1, 1, facecolor=color, edgecolor="black"))
288 ax.set_xlim(0, n_colors)
289 ax.set_ylim(0, 1)
290 ax.set_yticks([])
291 ax.set_xticks(x + 0.5)
292 ax.set_xticklabels([f"C{i}" for i in range(n_colors)])
293 ax.set_title(f"Palette: {name}")
295 else:
296 # Continuous colormap
297 try:
298 cmap = get_colormap(name) # type: ignore[arg-type]
299 gradient = np.linspace(0, 1, 256).reshape(1, -1)
300 ax.imshow(gradient, aspect="auto", cmap=cmap)
301 ax.set_yticks([])
302 ax.set_xticks([0, 64, 128, 192, 255])
303 ax.set_xticklabels(["0", "0.25", "0.5", "0.75", "1.0"])
304 ax.set_title(f"Colormap: {name}")
305 except ValueError:
306 raise ValueError(f"Unknown palette or colormap: {name}") # noqa: B904
308 plt.tight_layout()
310 if save_path:
311 plt.savefig(save_path, dpi=150, bbox_inches="tight")
312 else:
313 plt.show()
316def create_custom_palette(
317 colors: list[str],
318 *,
319 name: str = "custom",
320) -> list[str]:
321 """Create a custom color palette.
323 : Custom palette creation support.
324 Validates and registers a custom color palette.
326 Args:
327 colors: List of hex color codes
328 name: Name for the custom palette
330 Returns:
331 List of validated hex color codes
333 Raises:
334 ValueError: If color codes are invalid
336 Example:
337 >>> from tracekit.visualization.palettes import create_custom_palette
338 >>> custom = create_custom_palette(
339 ... ["#FF0000", "#00FF00", "#0000FF"],
340 ... name="rgb"
341 ... )
342 >>> print(custom)
343 ['#FF0000', '#00FF00', '#0000FF']
345 References:
346 ACC-001: Custom palette creation support
347 """
348 # Validate hex codes
349 import re
351 hex_pattern = re.compile(r"^#[0-9A-Fa-f]{6}$")
352 validated = []
354 for color in colors:
355 if not hex_pattern.match(color):
356 raise ValueError(f"Invalid hex color code: {color}")
357 validated.append(color.upper())
359 # Optionally register the palette
360 PALETTES[name] = validated
362 return validated
365def simulate_colorblindness(
366 color: str,
367 *,
368 deficiency: Literal["protanopia", "deuteranopia", "tritanopia"] = "deuteranopia",
369) -> str:
370 """Simulate how a color appears with color vision deficiency.
372 : Test with color blindness simulators.
373 Converts a color to approximate how it appears with CVD.
375 Args:
376 color: Hex color code
377 deficiency: Type of color vision deficiency:
378 - "protanopia": Red-blind (1% of males)
379 - "deuteranopia": Green-blind (1% of males)
380 - "tritanopia": Blue-blind (rare)
382 Returns:
383 Simulated hex color code
385 Raises:
386 ValueError: If unknown deficiency type.
388 Example:
389 >>> from tracekit.visualization.palettes import simulate_colorblindness
390 >>> red = "#FF0000"
391 >>> simulated = simulate_colorblindness(red, deficiency="deuteranopia")
392 >>> print(simulated) # Appears brownish
393 #9C7A00
395 References:
396 ACC-001: Test with color blindness simulators
397 Brettel, H., Viénot, F., & Mollon, J. D. (1997). Computerized simulation
398 of color appearance for dichromats. JOSA A, 14(10), 2647-2655.
399 """
400 # Convert hex to RGB
401 hex_color = color.lstrip("#")
402 r, g, b = int(hex_color[0:2], 16), int(hex_color[2:4], 16), int(hex_color[4:6], 16)
404 # Normalize to 0-1
405 r, g, b = r / 255.0, g / 255.0, b / 255.0 # type: ignore[assignment]
407 # Apply transformation matrices (simplified Brettel algorithm)
408 if deficiency == "protanopia":
409 # Red-blind: confuse red and green
410 r_sim = 0.56667 * r + 0.43333 * g
411 g_sim = 0.55833 * r + 0.44167 * g
412 b_sim = b
413 elif deficiency == "deuteranopia":
414 # Green-blind: confuse red and green
415 r_sim = 0.625 * r + 0.375 * g
416 g_sim = 0.7 * r + 0.3 * g
417 b_sim = b
418 elif deficiency == "tritanopia":
419 # Blue-blind: confuse blue and yellow
420 r_sim = r
421 g_sim = 0.95 * g + 0.05 * b
422 b_sim = 0.433 * g + 0.567 * b # type: ignore[assignment]
423 else:
424 raise ValueError(f"Unknown deficiency: {deficiency}")
426 # Convert back to 0-255 and hex
427 r_int = int(np.clip(r_sim * 255, 0, 255))
428 g_int = int(np.clip(g_sim * 255, 0, 255))
429 b_int = int(np.clip(b_sim * 255, 0, 255))
431 return f"#{r_int:02X}{g_int:02X}{b_int:02X}"
434__all__ = [
435 "LINE_STYLES",
436 "MARKER_STYLES",
437 "PALETTES",
438 "create_custom_palette",
439 "get_colormap",
440 "get_line_styles",
441 "get_palette",
442 "get_pass_fail_colors",
443 "get_pass_fail_symbols",
444 "show_palette",
445 "simulate_colorblindness",
446]