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

1"""Colorblind-safe palettes for TraceKit visualizations. 

2 

3This module provides colorblind-safe color palettes and utilities for 

4creating accessible visualizations. 

5 

6 

7Example: 

8 >>> from tracekit.visualization.palettes import get_palette, show_palette 

9 >>> colors = get_palette("colorblind_safe") 

10 >>> show_palette("viridis") 

11 

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""" 

17 

18from __future__ import annotations 

19 

20from typing import TYPE_CHECKING, Literal 

21 

22import matplotlib.pyplot as plt 

23import numpy as np 

24from matplotlib.patches import Rectangle 

25 

26if TYPE_CHECKING: 

27 from matplotlib.colors import Colormap 

28 

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} 

70 

71# Line styles for multi-line plots 

72LINE_STYLES = ["solid", "dashed", "dotted", "dashdot"] 

73 

74# Marker styles for scatter plots 

75MARKER_STYLES = ["o", "s", "^", "D", "v", "p", "*", "h"] 

76 

77 

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. 

84 

85 : Default palette is colorblind-safe. 

86 Returns list of hex color codes that are distinguishable for colorblind users. 

87 

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 

94 

95 Returns: 

96 List of hex color codes 

97 

98 Raises: 

99 ValueError: If palette name is not recognized 

100 

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 

106 

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

114 

115 

116def get_colormap( 

117 name: Literal["viridis", "cividis", "plasma", "inferno", "magma"] = "viridis", 

118) -> Colormap: 

119 """Get a colorblind-safe matplotlib colormap. 

120 

121 : Default palette is colorblind-safe (e.g., viridis, cividis). 

122 Returns perceptually uniform colormaps suitable for continuous data. 

123 

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 

131 

132 Returns: 

133 Matplotlib Colormap object 

134 

135 Raises: 

136 ValueError: If colormap name is not recognized 

137 

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) 

143 

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) 

151 

152 

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. 

160 

161 : Multi-line plots use distinct line styles in addition to colors. 

162 Combines colors with line styles for maximum distinguishability. 

163 

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 

168 

169 Returns: 

170 List of style dictionaries with 'color' and 'linestyle' keys 

171 

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}") 

177 

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 = [] 

183 

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" 

187 

188 styles.append({"color": color, "linestyle": linestyle}) 

189 

190 return styles 

191 

192 

193def get_pass_fail_symbols() -> dict[str, str]: 

194 """Get pass/fail symbols for accessible reporting. 

195 

196 : Pass/fail uses symbols (✓/✗) not just red/green. 

197 Returns symbols that work in text and don't rely on color alone. 

198 

199 Returns: 

200 Dictionary with 'pass' and 'fail' symbol keys 

201 

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 

207 

208 References: 

209 ACC-001: Pass/fail uses symbols (✓/✗) not just red/green 

210 """ 

211 return {"pass": "✓", "fail": "✗"} 

212 

213 

214def get_pass_fail_colors( 

215 *, 

216 colorblind_safe: bool = True, 

217) -> dict[str, str]: 

218 """Get pass/fail colors. 

219 

220 : Pass/fail colors are colorblind-safe when combined with symbols. 

221 Returns green/red or blue/orange based on colorblind_safe flag. 

222 

223 Args: 

224 colorblind_safe: Use colorblind-safe blue/orange instead of green/red 

225 

226 Returns: 

227 Dictionary with 'pass' and 'fail' color keys (hex codes) 

228 

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 

234 

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 } 

248 

249 

250def show_palette( 

251 name: str = "colorblind_safe", 

252 *, 

253 save_path: str | None = None, 

254) -> None: 

255 """Display a color palette preview. 

256 

257 : Palette preview: show_palette(name). 

258 Shows palette colors in a matplotlib figure for visual inspection. 

259 

260 Args: 

261 name: Palette name or colormap name 

262 save_path: Optional path to save the figure 

263 

264 Raises: 

265 ValueError: If unknown palette or colormap name. 

266 

267 Example: 

268 >>> from tracekit.visualization.palettes import show_palette 

269 >>> show_palette("colorblind_safe") 

270 >>> show_palette("viridis", save_path="palette.png") 

271 

272 References: 

273 ACC-001: Colorblind-Safe Visualization Palette 

274 """ 

275 _fig, ax = plt.subplots(figsize=(8, 2)) 

276 

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) 

283 

284 # Create color swatches 

285 for i, color in enumerate(colors): 

286 ax.add_patch(Rectangle((i, 0), 1, 1, facecolor=color, edgecolor="black")) 

287 

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}") 

294 

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 

307 

308 plt.tight_layout() 

309 

310 if save_path: 

311 plt.savefig(save_path, dpi=150, bbox_inches="tight") 

312 else: 

313 plt.show() 

314 

315 

316def create_custom_palette( 

317 colors: list[str], 

318 *, 

319 name: str = "custom", 

320) -> list[str]: 

321 """Create a custom color palette. 

322 

323 : Custom palette creation support. 

324 Validates and registers a custom color palette. 

325 

326 Args: 

327 colors: List of hex color codes 

328 name: Name for the custom palette 

329 

330 Returns: 

331 List of validated hex color codes 

332 

333 Raises: 

334 ValueError: If color codes are invalid 

335 

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'] 

344 

345 References: 

346 ACC-001: Custom palette creation support 

347 """ 

348 # Validate hex codes 

349 import re 

350 

351 hex_pattern = re.compile(r"^#[0-9A-Fa-f]{6}$") 

352 validated = [] 

353 

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

358 

359 # Optionally register the palette 

360 PALETTES[name] = validated 

361 

362 return validated 

363 

364 

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. 

371 

372 : Test with color blindness simulators. 

373 Converts a color to approximate how it appears with CVD. 

374 

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) 

381 

382 Returns: 

383 Simulated hex color code 

384 

385 Raises: 

386 ValueError: If unknown deficiency type. 

387 

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 

394 

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) 

403 

404 # Normalize to 0-1 

405 r, g, b = r / 255.0, g / 255.0, b / 255.0 # type: ignore[assignment] 

406 

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}") 

425 

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

430 

431 return f"#{r_int:02X}{g_int:02X}{b_int:02X}" 

432 

433 

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]