Coverage for pyrc \ core \ visualization \ color \ color.py: 76%

104 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-04-13 16:59 +0200

1# ------------------------------------------------------------------------------- 

2# Copyright (C) 2026 Joel Kimmich, Tim Jourdan 

3# ------------------------------------------------------------------------------ 

4# License 

5# This file is part of PyRC, distributed under GPL-3.0-or-later. 

6# ------------------------------------------------------------------------------ 

7 

8from importlib import resources 

9import numpy as np 

10 

11from typing import Callable 

12 

13initial_uniform_color_map = "managua" 

14initial_discrete_color_map = "managua10" 

15 

16RGB = tuple[float, float, float] 

17 

18# name -> either: 

19# ("uniform", rgb[N,3]) 

20# ("explicit", t[N], rgb[N,3]) 

21CMAPS: dict[str, tuple] = {} 

22 

23 

24def _parse_gradient(data: np.ndarray, name: str) -> tuple: 

25 if data.shape[1] == 3: 

26 return "uniform", data[:, :3] 

27 elif data.shape[1] >= 4: 

28 t = data[:, 0] 

29 rgb = data[:, 1:4] 

30 order = np.argsort(t) 

31 return "explicit", t[order], rgb[order] 

32 raise ValueError(f"Bad colormap file {name}: expected 3 or 4 columns") 

33 

34 

35def _load_txt_colormaps() -> None: 

36 pkg = __package__ 

37 root = resources.files(pkg) 

38 

39 for subfolder, kind in (("uniform", "gradient"), ("discrete", "discrete")): 

40 try: 

41 folder = root.joinpath(subfolder) 

42 entries = list(folder.iterdir()) 

43 except (FileNotFoundError, NotADirectoryError): 

44 continue 

45 

46 for p in entries: 

47 if not (p.is_file() and p.name.lower().endswith(".txt")): 

48 continue 

49 cmap_name = p.name.rsplit(".", 1)[0] 

50 lines = p.read_text(encoding="utf-8").splitlines() 

51 

52 def _first_numeric_line(lines: list[str]) -> int: 

53 for i, line in enumerate(lines): 

54 parts = line.split() 

55 if parts and parts[0].lstrip("-").replace(".", "", 1).isdigit(): 

56 return i 

57 raise ValueError("No numeric line found") 

58 

59 if kind == "discrete": 

60 skip = _first_numeric_line(lines) 

61 rgb_rows = [] 

62 for line in lines[skip:]: 

63 parts = line.split() 

64 try: 

65 r, g, b = float(parts[0]), float(parts[1]), float(parts[2]) 

66 if all(v <= 255 for v in (r, g, b)): 

67 rgb_rows.append([r / 255.0, g / 255.0, b / 255.0]) 

68 except (ValueError, IndexError): 

69 continue 

70 if not rgb_rows: 

71 raise ValueError(f"No valid colours in discrete palette {p.name}") 

72 CMAPS[cmap_name] = ("discrete", np.array(rgb_rows, dtype=np.float32)) 

73 else: 

74 skip = _first_numeric_line(lines) 

75 data = np.loadtxt(p, dtype=np.float32, skiprows=skip, encoding="utf-8") 

76 if data.ndim == 1: 

77 data = data[None, :] 

78 CMAPS[cmap_name] = _parse_gradient(data, p.name) 

79 

80 

81_load_txt_colormaps() 

82 

83 

84def available(kind: str | None = None) -> tuple[str, ...]: 

85 """ 

86 Return names of all loaded colormaps. 

87 

88 Parameters 

89 ---------- 

90 kind : str or None 

91 Filter by type: 'uniform', 'explicit', 'discrete', or None for all. 

92 """ 

93 if kind is None: 

94 return tuple(sorted(CMAPS.keys())) 

95 return tuple(sorted(k for k, v in CMAPS.items() if v[0] == kind)) 

96 

97 

98def value_to_rgb(value: float | int | np.ndarray, cmap: str = initial_uniform_color_map) -> np.ndarray: 

99 """ 

100 Map relative value(s) in [0, 1] to RGB using a loaded colormap. 

101 

102 Parameters 

103 ---------- 

104 value : float, int, or np.ndarray 

105 Scalar or array of values in [0, 1]. 

106 cmap : str 

107 Colormap name. 

108 

109 Returns 

110 ------- 

111 np.ndarray 

112 Shape (..., 3), dtype float32. 

113 """ 

114 if cmap not in CMAPS: 

115 raise KeyError(f"Unknown colormap '{cmap}'. Available: {available()}") 

116 

117 value = np.clip(np.asarray(value, dtype=np.float32), 0.0, 1.0) 

118 entry = CMAPS[cmap] 

119 kind = entry[0] 

120 

121 if kind == "uniform": 

122 rgb = entry[1] 

123 n = rgb.shape[0] 

124 if n == 1: 

125 return np.broadcast_to(rgb[0], value.shape + (3,)).astype(np.float32, copy=False) 

126 x = value * (n - 1) 

127 i = np.floor(x).astype(np.int32) 

128 j = np.minimum(i + 1, n - 1) 

129 u = (x - i)[..., None] 

130 return (rgb[i] * (1.0 - u) + rgb[j] * u).astype(np.float32, copy=False) 

131 

132 if kind == "explicit": 

133 t, rgb = entry[1], entry[2] 

134 r = np.interp(value, t, rgb[:, 0]).astype(np.float32, copy=False) 

135 g = np.interp(value, t, rgb[:, 1]).astype(np.float32, copy=False) 

136 b = np.interp(value, t, rgb[:, 2]).astype(np.float32, copy=False) 

137 return np.stack([r, g, b], axis=-1) 

138 

139 # discrete: map value to nearest swatch index 

140 rgb = entry[1] 

141 n = rgb.shape[0] 

142 idx = np.clip(np.floor(value * n).astype(np.int32), 0, n - 1) 

143 return rgb[idx] 

144 

145 

146def get_palette(cmap: str = initial_discrete_color_map) -> np.ndarray: 

147 """ 

148 Return all swatches of a discrete palette as (N, 3) float32 array. 

149 

150 Parameters 

151 ---------- 

152 cmap : str 

153 Name of a discrete colormap. 

154 

155 Returns 

156 ------- 

157 np.ndarray 

158 Shape (N, 3), dtype float32. 

159 

160 Raises 

161 ------ 

162 KeyError 

163 If cmap is not found. 

164 TypeError 

165 If cmap is not a discrete palette. 

166 """ 

167 if cmap not in CMAPS: 

168 raise KeyError(f"Unknown colormap '{cmap}'. Available: {available()}") 

169 if CMAPS[cmap][0] != "discrete": 

170 raise TypeError(f"'{cmap}' is not a discrete palette.") 

171 return CMAPS[cmap][1] 

172 

173 

174def make_color_mapper(cmap: str = initial_uniform_color_map) -> Callable[[float | int | np.ndarray], np.ndarray]: 

175 """Convenience: returns f(v)->rgb for one colormap name.""" 

176 return lambda v: value_to_rgb(v, cmap=cmap) 

177 

178 

179def cycle_palette(cmap: str = initial_discrete_color_map) -> "Generator[np.ndarray, None, None]": 

180 """ 

181 Yield RGB colours from a colormap in order, cycling indefinitely. 

182 

183 Parameters 

184 ---------- 

185 cmap : str 

186 Colormap name. 

187 

188 Yields 

189 ------ 

190 np.ndarray 

191 Shape (3,), dtype float32. 

192 """ 

193 if cmap not in CMAPS: 

194 raise KeyError(f"Unknown colormap '{cmap}'. Available: {available()}") 

195 entry = CMAPS[cmap] 

196 rgb = entry[1] if entry[0] in ("uniform", "discrete") else entry[2] 

197 i = 0 

198 n = rgb.shape[0] 

199 while True: 

200 yield rgb[i % n] 

201 i += 1