plotpress 0.23.2__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.
plotpress/colors.py ADDED
@@ -0,0 +1,498 @@
1
+ """Colormaps and normalization for ``pcolormesh`` / mapped scatter.
2
+
3
+ Colormaps are lookup tables (256x3 uint8). ``viridis`` is stored as a small set
4
+ of anchor stops and linearly interpolated to 256 entries at import time to keep
5
+ the source compact while staying visually faithful.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import copy
11
+ import re
12
+
13
+ import numpy as np
14
+
15
+ # Viridis anchor stops at t = 0.0, 0.1, ... 1.0 (RGB 0-255).
16
+ _VIRIDIS_ANCHORS = np.array([
17
+ [68, 1, 84], [72, 40, 120], [62, 74, 137], [49, 104, 142],
18
+ [38, 130, 142], [31, 158, 137], [53, 183, 121], [110, 206, 88],
19
+ [181, 222, 43], [221, 227, 24], [253, 231, 37],
20
+ ], dtype=float)
21
+
22
+ # Plasma anchor stops.
23
+ _PLASMA_ANCHORS = np.array([
24
+ [13, 8, 135], [84, 2, 163], [139, 10, 165], [185, 50, 137],
25
+ [219, 92, 104], [244, 136, 73], [254, 188, 43], [240, 249, 33],
26
+ ], dtype=float)
27
+
28
+ # Inferno / magma / cividis: the rest of the perceptually-uniform family.
29
+ _INFERNO_ANCHORS = np.array([
30
+ [0, 0, 4], [40, 11, 84], [101, 21, 110], [159, 42, 99],
31
+ [212, 72, 66], [245, 125, 21], [250, 193, 39], [252, 255, 164],
32
+ ], dtype=float)
33
+
34
+ _MAGMA_ANCHORS = np.array([
35
+ [0, 0, 4], [28, 16, 68], [79, 18, 123], [129, 37, 129],
36
+ [181, 54, 122], [229, 80, 100], [251, 135, 97], [254, 194, 135],
37
+ [252, 253, 191],
38
+ ], dtype=float)
39
+
40
+ _CIVIDIS_ANCHORS = np.array([
41
+ [0, 32, 76], [0, 42, 102], [45, 63, 108], [87, 86, 109],
42
+ [124, 109, 107], [165, 133, 93], [210, 160, 68], [255, 234, 70],
43
+ ], dtype=float)
44
+
45
+ # Coolwarm: a blue-white-red diverging map (for signed data around a midpoint).
46
+ _COOLWARM_ANCHORS = np.array([
47
+ [59, 76, 192], [124, 159, 249], [192, 212, 245], [221, 221, 221],
48
+ [246, 193, 169], [241, 133, 103], [180, 4, 38],
49
+ ], dtype=float)
50
+
51
+ # RdBu: red-white-blue diverging (coolwarm's classic ColorBrewer cousin).
52
+ _RDBU_ANCHORS = np.array([
53
+ [178, 24, 43], [214, 96, 77], [244, 165, 130], [247, 247, 247],
54
+ [146, 197, 222], [67, 147, 195], [33, 102, 172],
55
+ ], dtype=float)
56
+
57
+ # Spectral: red-orange-yellow-green-blue diverging (ColorBrewer).
58
+ _SPECTRAL_ANCHORS = np.array([
59
+ [213, 62, 79], [252, 141, 89], [254, 224, 139], [255, 255, 191],
60
+ [230, 245, 152], [153, 213, 148], [50, 136, 189],
61
+ ], dtype=float)
62
+
63
+ # PiYG: pink-white-green diverging (ColorBrewer).
64
+ _PIYG_ANCHORS = np.array([
65
+ [197, 27, 125], [233, 163, 201], [253, 224, 239], [247, 247, 247],
66
+ [230, 245, 208], [161, 215, 106], [77, 146, 33],
67
+ ], dtype=float)
68
+
69
+ # BrBG: brown-white-teal diverging (ColorBrewer).
70
+ _BRBG_ANCHORS = np.array([
71
+ [140, 81, 10], [216, 179, 101], [246, 232, 195], [245, 245, 245],
72
+ [199, 234, 229], [90, 180, 172], [1, 102, 94],
73
+ ], dtype=float)
74
+
75
+ # seismic: sharp blue-white-red diverging (matplotlib), for data that pivots
76
+ # hard at zero rather than shading gradually through it like coolwarm/RdBu.
77
+ _SEISMIC_ANCHORS = np.array([
78
+ [0, 0, 127], [0, 0, 255], [255, 255, 255], [255, 0, 0], [127, 0, 0],
79
+ ], dtype=float)
80
+
81
+ # Single-hue sequential family (ColorBrewer): light-to-dark, for data with no
82
+ # natural midpoint -- a density, a count, a magnitude.
83
+ _BLUES_ANCHORS = np.array([
84
+ [247, 251, 255], [222, 235, 247], [198, 219, 239], [158, 202, 225],
85
+ [107, 174, 214], [49, 130, 189], [8, 81, 156],
86
+ ], dtype=float)
87
+
88
+ _GREENS_ANCHORS = np.array([
89
+ [247, 252, 245], [229, 245, 224], [199, 233, 192], [161, 217, 155],
90
+ [116, 196, 118], [49, 163, 84], [0, 109, 44],
91
+ ], dtype=float)
92
+
93
+ _ORANGES_ANCHORS = np.array([
94
+ [255, 245, 235], [254, 230, 206], [253, 208, 162], [253, 174, 107],
95
+ [253, 141, 60], [230, 85, 13], [166, 54, 3],
96
+ ], dtype=float)
97
+
98
+ _REDS_ANCHORS = np.array([
99
+ [255, 245, 240], [254, 224, 210], [252, 187, 161], [252, 146, 114],
100
+ [251, 106, 74], [222, 45, 38], [165, 15, 21],
101
+ ], dtype=float)
102
+
103
+ _PURPLES_ANCHORS = np.array([
104
+ [252, 251, 253], [239, 237, 245], [218, 218, 235], [188, 189, 220],
105
+ [158, 154, 200], [117, 107, 177], [84, 39, 143],
106
+ ], dtype=float)
107
+
108
+ # YlOrRd: yellow-orange-red sequential (ColorBrewer) -- a common heatmap map.
109
+ _YLORRD_ANCHORS = np.array([
110
+ [255, 255, 178], [254, 217, 118], [254, 178, 76], [253, 141, 60],
111
+ [252, 78, 42], [227, 26, 28], [177, 0, 38],
112
+ ], dtype=float)
113
+
114
+ # twilight: cyclic (matplotlib) -- the first and last anchor match, so it
115
+ # wraps cleanly for data with no true minimum/maximum, like a phase or angle.
116
+ _TWILIGHT_ANCHORS = np.array([
117
+ [23, 22, 25], [76, 66, 127], [152, 137, 192], [223, 206, 208],
118
+ [219, 159, 150], [164, 88, 79], [97, 46, 48], [23, 22, 25],
119
+ ], dtype=float)
120
+
121
+ # jet: the classic MATLAB/matplotlib rainbow map. Not perceptually uniform
122
+ # (it implies edges in the data where its own hue turns sharply, brightest at
123
+ # cyan/yellow -- see docs/scale/limitations' colormap-uniformity example) but
124
+ # kept for the code that still asks for it by name.
125
+ _JET_ANCHORS = np.array([
126
+ [0, 0, 128], [0, 0, 255], [0, 255, 255], [0, 255, 0],
127
+ [255, 255, 0], [255, 0, 0], [128, 0, 0],
128
+ ], dtype=float)
129
+
130
+ # turbo: Google's perceptually-improved rainbow -- similar use case to jet
131
+ # (a wide, intuitively-ordered hue sweep) without jet's flat middle band or
132
+ # its hard clipping at black/dark red.
133
+ _TURBO_ANCHORS = np.array([
134
+ [48, 18, 59], [63, 71, 204], [40, 142, 222], [26, 187, 156],
135
+ [64, 209, 72], [170, 222, 36], [247, 182, 32], [230, 86, 20],
136
+ [122, 4, 3],
137
+ ], dtype=float)
138
+
139
+ # hot: black-red-yellow-white -- a thermal/blackbody-radiation ramp.
140
+ _HOT_ANCHORS = np.array([
141
+ [0, 0, 0], [255, 0, 0], [255, 255, 0], [255, 255, 255],
142
+ ], dtype=float)
143
+
144
+ # cool: a two-stop cyan-to-magenta linear ramp.
145
+ _COOL_ANCHORS = np.array([
146
+ [0, 255, 255], [255, 0, 255],
147
+ ], dtype=float)
148
+
149
+
150
+ def _build_lut(anchors: np.ndarray, n: int = 256) -> np.ndarray:
151
+ """Linearly interpolate anchor stops into an ``(n, 3)`` uint8 LUT."""
152
+ m = anchors.shape[0]
153
+ src = np.linspace(0.0, 1.0, m)
154
+ dst = np.linspace(0.0, 1.0, n)
155
+ lut = np.empty((n, 3), dtype=np.uint8)
156
+ for c in range(3):
157
+ lut[:, c] = np.round(np.interp(dst, src, anchors[:, c])).astype(np.uint8)
158
+ return lut
159
+
160
+
161
+ _GRAY_LUT = np.repeat(np.linspace(0, 255, 256, dtype=np.uint8)[:, None], 3, axis=1)
162
+
163
+ _COLORMAPS = {
164
+ # Perceptually uniform sequential
165
+ "viridis": _build_lut(_VIRIDIS_ANCHORS),
166
+ "plasma": _build_lut(_PLASMA_ANCHORS),
167
+ "inferno": _build_lut(_INFERNO_ANCHORS),
168
+ "magma": _build_lut(_MAGMA_ANCHORS),
169
+ "cividis": _build_lut(_CIVIDIS_ANCHORS),
170
+ # Single-hue / heat sequential
171
+ "gray": _GRAY_LUT,
172
+ "grey": _GRAY_LUT,
173
+ "Blues": _build_lut(_BLUES_ANCHORS),
174
+ "Greens": _build_lut(_GREENS_ANCHORS),
175
+ "Oranges": _build_lut(_ORANGES_ANCHORS),
176
+ "Reds": _build_lut(_REDS_ANCHORS),
177
+ "Purples": _build_lut(_PURPLES_ANCHORS),
178
+ "YlOrRd": _build_lut(_YLORRD_ANCHORS),
179
+ "hot": _build_lut(_HOT_ANCHORS),
180
+ # Diverging
181
+ "coolwarm": _build_lut(_COOLWARM_ANCHORS),
182
+ "RdBu": _build_lut(_RDBU_ANCHORS),
183
+ "Spectral": _build_lut(_SPECTRAL_ANCHORS),
184
+ "PiYG": _build_lut(_PIYG_ANCHORS),
185
+ "BrBG": _build_lut(_BRBG_ANCHORS),
186
+ "seismic": _build_lut(_SEISMIC_ANCHORS),
187
+ # Cyclic
188
+ "twilight": _build_lut(_TWILIGHT_ANCHORS),
189
+ # Miscellaneous / rainbow
190
+ "jet": _build_lut(_JET_ANCHORS),
191
+ "turbo": _build_lut(_TURBO_ANCHORS),
192
+ "cool": _build_lut(_COOL_ANCHORS),
193
+ }
194
+
195
+
196
+ def get_cmap(name) -> np.ndarray:
197
+ """Return a 256x3 uint8 LUT for ``name`` (or pass an LUT through).
198
+
199
+ A trailing ``_r`` reverses any known map, e.g. ``"viridis_r"`` -- matching
200
+ matplotlib's reversed-colormap convention.
201
+ """
202
+ if isinstance(name, np.ndarray):
203
+ return name
204
+ key, reverse = name, False
205
+ if isinstance(name, str) and name.endswith("_r"):
206
+ key, reverse = name[:-2], True
207
+ try:
208
+ lut = _COLORMAPS[key]
209
+ except KeyError:
210
+ raise ValueError(
211
+ f"Unknown colormap {name!r}. Available: {available_colormaps()}"
212
+ )
213
+ return lut[::-1].copy() if reverse else lut
214
+
215
+
216
+ def available_colormaps():
217
+ """Named colormaps, including the ``_r`` reversed variants."""
218
+ base = sorted(_COLORMAPS)
219
+ return base + [n + "_r" for n in base]
220
+
221
+
222
+ # Common named colors (the full CSS4/matplotlib named-color set, plus
223
+ # matplotlib's single-letter aliases), so both the SVG and raster/PDF
224
+ # backends accept any name a matplotlib user would reach for -- "crimson",
225
+ # "cornflowerblue" -- not just the handful of X11 basics. SVG understands
226
+ # CSS names natively, which used to mask this: a name outside a small table
227
+ # still rendered fine in the SVG backend (the browser resolved it), while
228
+ # the raster backend's own hex parser crashed on the exact same name with a
229
+ # confusing ``int(..., 16)`` error pointing nowhere near the real cause.
230
+ NAMED_COLORS = {
231
+ "red": "#ff0000", "green": "#008000", "blue": "#0000ff",
232
+ "black": "#000000", "white": "#ffffff", "gray": "#808080",
233
+ "grey": "#808080", "orange": "#ffa500", "purple": "#800080",
234
+ "brown": "#a52a2a", "pink": "#ffc0cb", "cyan": "#00ffff",
235
+ "magenta": "#ff00ff", "yellow": "#ffff00", "lime": "#00ff00",
236
+ "navy": "#000080", "teal": "#008080", "olive": "#808000",
237
+ "maroon": "#800000", "silver": "#c0c0c0", "gold": "#ffd700",
238
+ # matplotlib single-letter base colors
239
+ "b": "#0000ff", "g": "#008000", "r": "#ff0000", "c": "#00bfbf",
240
+ "m": "#bf00bf", "y": "#bfbf00", "k": "#000000", "w": "#ffffff",
241
+ # The rest of the CSS4 named-color set (matplotlib.colors.CSS4_COLORS).
242
+ "aliceblue": "#F0F8FF", "antiquewhite": "#FAEBD7", "aqua": "#00FFFF",
243
+ "aquamarine": "#7FFFD4", "azure": "#F0FFFF", "beige": "#F5F5DC", "bisque": "#FFE4C4",
244
+ "blanchedalmond": "#FFEBCD", "blueviolet": "#8A2BE2", "burlywood": "#DEB887",
245
+ "cadetblue": "#5F9EA0", "chartreuse": "#7FFF00", "chocolate": "#D2691E",
246
+ "coral": "#FF7F50", "cornflowerblue": "#6495ED", "cornsilk": "#FFF8DC",
247
+ "crimson": "#DC143C", "darkblue": "#00008B", "darkcyan": "#008B8B",
248
+ "darkgoldenrod": "#B8860B", "darkgray": "#A9A9A9", "darkgreen": "#006400",
249
+ "darkgrey": "#A9A9A9", "darkkhaki": "#BDB76B", "darkmagenta": "#8B008B",
250
+ "darkolivegreen": "#556B2F", "darkorange": "#FF8C00", "darkorchid": "#9932CC",
251
+ "darkred": "#8B0000", "darksalmon": "#E9967A", "darkseagreen": "#8FBC8F",
252
+ "darkslateblue": "#483D8B", "darkslategray": "#2F4F4F", "darkslategrey": "#2F4F4F",
253
+ "darkturquoise": "#00CED1", "darkviolet": "#9400D3", "deeppink": "#FF1493",
254
+ "deepskyblue": "#00BFFF", "dimgray": "#696969", "dimgrey": "#696969",
255
+ "dodgerblue": "#1E90FF", "firebrick": "#B22222", "floralwhite": "#FFFAF0",
256
+ "forestgreen": "#228B22", "fuchsia": "#FF00FF", "gainsboro": "#DCDCDC",
257
+ "ghostwhite": "#F8F8FF", "goldenrod": "#DAA520", "greenyellow": "#ADFF2F",
258
+ "honeydew": "#F0FFF0", "hotpink": "#FF69B4", "indianred": "#CD5C5C", "indigo": "#4B0082",
259
+ "ivory": "#FFFFF0", "khaki": "#F0E68C", "lavender": "#E6E6FA",
260
+ "lavenderblush": "#FFF0F5", "lawngreen": "#7CFC00", "lemonchiffon": "#FFFACD",
261
+ "lightblue": "#ADD8E6", "lightcoral": "#F08080", "lightcyan": "#E0FFFF",
262
+ "lightgoldenrodyellow": "#FAFAD2", "lightgray": "#D3D3D3", "lightgreen": "#90EE90",
263
+ "lightgrey": "#D3D3D3", "lightpink": "#FFB6C1", "lightsalmon": "#FFA07A",
264
+ "lightseagreen": "#20B2AA", "lightskyblue": "#87CEFA", "lightslategray": "#778899",
265
+ "lightslategrey": "#778899", "lightsteelblue": "#B0C4DE", "lightyellow": "#FFFFE0",
266
+ "limegreen": "#32CD32", "linen": "#FAF0E6", "mediumaquamarine": "#66CDAA",
267
+ "mediumblue": "#0000CD", "mediumorchid": "#BA55D3", "mediumpurple": "#9370DB",
268
+ "mediumseagreen": "#3CB371", "mediumslateblue": "#7B68EE",
269
+ "mediumspringgreen": "#00FA9A", "mediumturquoise": "#48D1CC",
270
+ "mediumvioletred": "#C71585", "midnightblue": "#191970", "mintcream": "#F5FFFA",
271
+ "mistyrose": "#FFE4E1", "moccasin": "#FFE4B5", "navajowhite": "#FFDEAD",
272
+ "oldlace": "#FDF5E6", "olivedrab": "#6B8E23", "orangered": "#FF4500",
273
+ "orchid": "#DA70D6", "palegoldenrod": "#EEE8AA", "palegreen": "#98FB98",
274
+ "paleturquoise": "#AFEEEE", "palevioletred": "#DB7093", "papayawhip": "#FFEFD5",
275
+ "peachpuff": "#FFDAB9", "peru": "#CD853F", "plum": "#DDA0DD", "powderblue": "#B0E0E6",
276
+ "rebeccapurple": "#663399", "rosybrown": "#BC8F8F", "royalblue": "#4169E1",
277
+ "saddlebrown": "#8B4513", "salmon": "#FA8072", "sandybrown": "#F4A460",
278
+ "seagreen": "#2E8B57", "seashell": "#FFF5EE", "sienna": "#A0522D", "skyblue": "#87CEEB",
279
+ "slateblue": "#6A5ACD", "slategray": "#708090", "slategrey": "#708090",
280
+ "snow": "#FFFAFA", "springgreen": "#00FF7F", "steelblue": "#4682B4", "tan": "#D2B48C",
281
+ "thistle": "#D8BFD8", "tomato": "#FF6347", "turquoise": "#40E0D0", "violet": "#EE82EE",
282
+ "wheat": "#F5DEB3", "whitesmoke": "#F5F5F5", "yellowgreen": "#9ACD32",
283
+ }
284
+
285
+ #: SVG/CSS paint keywords that are deliberately *not* colors -- passed
286
+ #: through as-is rather than resolved. ``_BBOX_DEFAULTS["edgecolor"]`` and
287
+ #: several call sites use ``"none"`` as "draw nothing"; ``"transparent"`` is
288
+ #: the equivalent CSS keyword a caller might reach for instead.
289
+ _PAINT_KEYWORDS = frozenset(("none", "transparent"))
290
+
291
+ _HEX_RE = re.compile(r"^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$")
292
+
293
+
294
+ def to_hex(color) -> str:
295
+ """Resolve a color name -- or a matplotlib-style RGB(A) tuple -- to
296
+ ``#rrggbb``; pass hex (and the ``"none"``/``"transparent"`` paint
297
+ keywords) through unchanged.
298
+
299
+ A flat, purely-numeric 3- or 4-element sequence (``(1.0, 0.0, 0.0)``,
300
+ ``(255, 128, 0)``) is treated as a single RGB(A) color and converted --
301
+ this used to pass straight through unresolved, reaching the SVG backend
302
+ as a literal, invalid ``stroke="(1.0, 0.0, 0.0)"`` (silently invisible:
303
+ a browser treats an unrecognized value as unset) or the raster
304
+ backend's hex parser, which failed with ``'tuple' object has no
305
+ attribute 'lstrip'``, never mentioning color was the problem. Anything
306
+ else non-string -- notably a *nested* per-item array/list, the shape
307
+ ``bar(color=[[r,g,b,a], ...])``'s one-color-per-bar path uses -- is left
308
+ exactly alone: resolving each of *those* colors individually is that
309
+ caller's own job (see ``artists._as_colors``), not this function's.
310
+
311
+ Raises ``ValueError`` only for a string that resolves to neither a
312
+ known name nor valid hex -- a misspelled name (``"crimon"``) or
313
+ malformed hex (``"#zzzzzz"``).
314
+ """
315
+ if color is None:
316
+ return color
317
+ if not isinstance(color, str):
318
+ arr = np.asarray(color)
319
+ if arr.ndim == 1 and arr.size in (3, 4) and arr.dtype.kind in "iuf":
320
+ rgb = arr[:3].astype(float)
321
+ if rgb.max() <= 1.0:
322
+ rgb = rgb * 255
323
+ r, g, b = (int(round(v)) for v in rgb)
324
+ return f"#{r:02x}{g:02x}{b:02x}"
325
+ return color
326
+ if color.lower() in _PAINT_KEYWORDS:
327
+ return color
328
+ if color.startswith("#"):
329
+ if not _HEX_RE.match(color):
330
+ raise ValueError(
331
+ f"Invalid hex color {color!r} -- expected '#rgb' or '#rrggbb'."
332
+ )
333
+ return color
334
+ resolved = NAMED_COLORS.get(color.lower())
335
+ if resolved is None:
336
+ raise ValueError(
337
+ f"Unknown color {color!r}. Use a '#rrggbb'/'#rgb' hex code, an "
338
+ "RGB(A) tuple, or a named CSS color (e.g. 'crimson', 'steelblue')."
339
+ )
340
+ return resolved
341
+
342
+
343
+ class Normalize:
344
+ """Linearly map data to [0, 1] using ``vmin``/``vmax``.
345
+
346
+ Unset limits are inferred from the data on first use. That inference writes
347
+ back to the instance, so artists take a private copy (see
348
+ :func:`resolve_norm`) rather than scaling the norm you handed them -- one
349
+ norm passed to two figures would otherwise pin the second to the first's
350
+ data range.
351
+ """
352
+
353
+ def __init__(self, vmin=None, vmax=None):
354
+ self.vmin = vmin
355
+ self.vmax = vmax
356
+
357
+ def autoscale_none(self, A):
358
+ """Fill unset limits from the data, tolerating a fully masked field.
359
+
360
+ A frame where every cell is ``nan`` is a real case, not a mistake -- a
361
+ detector exposure that failed quality control, a tile with no coverage,
362
+ one panel of a stack that a shared norm still has to accept. NumPy's
363
+ ``nanmin`` warns on an all-NaN slice and returns NaN, which then
364
+ propagated into the transform; fall back to a unit range instead, since
365
+ there is nothing to scale and every cell will be drawn transparent.
366
+ """
367
+ A = np.asarray(A, dtype=float)
368
+ finite = A[np.isfinite(A)] if A.size else A
369
+ if self.vmin is None:
370
+ self.vmin = float(finite.min()) if finite.size else 0.0
371
+ if self.vmax is None:
372
+ self.vmax = float(finite.max()) if finite.size else 1.0
373
+
374
+ def __call__(self, A):
375
+ A = np.asarray(A, dtype=float)
376
+ self.autoscale_none(A)
377
+ span = self.vmax - self.vmin
378
+ if span == 0:
379
+ span = 1.0
380
+ return (A - self.vmin) / span
381
+
382
+
383
+ class LogNorm(Normalize):
384
+ """Map data to [0, 1] on a **log10** scale between ``vmin`` and ``vmax``.
385
+
386
+ Non-positive values map to NaN (rendered transparent, like matplotlib's
387
+ masked handling). Unset limits are inferred from the positive data.
388
+ """
389
+
390
+ def autoscale_none(self, A):
391
+ A = np.asarray(A, dtype=float)
392
+ if self.vmin is None or self.vmax is None:
393
+ pos = A[np.isfinite(A) & (A > 0)]
394
+ if self.vmin is None:
395
+ self.vmin = float(pos.min()) if pos.size else 1e-10
396
+ if self.vmax is None:
397
+ self.vmax = float(pos.max()) if pos.size else 1.0
398
+
399
+ def __call__(self, A):
400
+ A = np.asarray(A, dtype=float)
401
+ self.autoscale_none(A)
402
+ vmin = max(self.vmin, 1e-300)
403
+ lmin, lmax = np.log10(vmin), np.log10(max(self.vmax, vmin * 10))
404
+ span = (lmax - lmin) or 1.0
405
+ with np.errstate(divide="ignore", invalid="ignore"):
406
+ logA = np.log10(np.where(A > 0, A, np.nan))
407
+ return (logA - lmin) / span
408
+
409
+
410
+ class PowerNorm(Normalize):
411
+ """Map data to [0, 1] then raise to ``gamma`` (matplotlib's PowerNorm).
412
+
413
+ ``gamma < 1`` emphasizes low values, ``gamma > 1`` the high end.
414
+ """
415
+
416
+ def __init__(self, gamma=1.0, vmin=None, vmax=None):
417
+ super().__init__(vmin, vmax)
418
+ self.gamma = float(gamma)
419
+
420
+ def __call__(self, A):
421
+ A = np.asarray(A, dtype=float)
422
+ self.autoscale_none(A)
423
+ span = (self.vmax - self.vmin) or 1.0
424
+ t = np.clip((A - self.vmin) / span, 0.0, 1.0)
425
+ return np.power(t, self.gamma)
426
+
427
+
428
+ class SymLogNorm(Normalize):
429
+ """Symmetric-log mapping: linear within ``+/-linthresh``, log beyond.
430
+
431
+ Handles data spanning zero and both signs (matplotlib's SymLogNorm).
432
+ """
433
+
434
+ def __init__(self, linthresh, vmin=None, vmax=None):
435
+ super().__init__(vmin, vmax)
436
+ self.linthresh = float(linthresh)
437
+
438
+ def _symlog(self, x):
439
+ lt = self.linthresh
440
+ x = np.asarray(x, dtype=float)
441
+ with np.errstate(divide="ignore", invalid="ignore"):
442
+ far = np.sign(x) * (1.0 + np.log10(np.abs(x) / lt))
443
+ return np.where(np.abs(x) <= lt, x / lt, far)
444
+
445
+ def __call__(self, A):
446
+ A = np.asarray(A, dtype=float)
447
+ self.autoscale_none(A)
448
+ lo, hi = self._symlog(self.vmin), self._symlog(self.vmax)
449
+ span = (hi - lo) or 1.0
450
+ return (self._symlog(A) - lo) / span
451
+
452
+
453
+ def resolve_norm(norm, vmin=None, vmax=None) -> Normalize:
454
+ """Return the norm instance an artist should own.
455
+
456
+ A caller-supplied norm is *copied*. Autoscaling mutates ``vmin``/``vmax`` in
457
+ place, so without this the first artist to use a norm would pin it for every
458
+ later one -- including artists on a different figure, which is exactly the
459
+ shared mutable state this library sets out not to have. Copying keeps the
460
+ caller's object pristine and makes each artist's scaling depend only on its
461
+ own data.
462
+
463
+ Limits set explicitly on the norm survive the copy, so passing one
464
+ ``Normalize(0, 100)`` to several artists still puts them on a common scale.
465
+ """
466
+ if norm is None:
467
+ return Normalize(vmin, vmax)
468
+ return copy.copy(norm)
469
+
470
+
471
+ def colorbar_ticks(norm):
472
+ """Tick ``(values, fractions, labels)`` for a colorbar honoring ``norm``.
473
+
474
+ The gradient strip is an even colormap ramp; ticks are positioned at
475
+ ``norm(value)`` (their fractional height), so a ``LogNorm``/``PowerNorm``/
476
+ ``SymLogNorm`` colorbar places its labels correctly instead of linearly.
477
+ """
478
+ from .ticker import format_ticks, log_ticks, nice_ticks
479
+
480
+ vmin, vmax = norm.vmin, norm.vmax
481
+ vals = log_ticks(vmin, vmax) if isinstance(norm, LogNorm) else nice_ticks(vmin, vmax)
482
+ vals = np.asarray(vals, dtype=float)
483
+ with np.errstate(invalid="ignore", divide="ignore"):
484
+ fracs = np.asarray(norm(vals), dtype=float)
485
+ keep = np.isfinite(fracs) & (fracs >= -1e-9) & (fracs <= 1 + 1e-9)
486
+ vals, fracs = vals[keep], np.clip(fracs[keep], 0.0, 1.0)
487
+ return vals, fracs, format_ticks(vals)
488
+
489
+
490
+ def apply_colormap(A, lut, norm: Normalize) -> np.ndarray:
491
+ """Map data array ``A`` to an RGBA uint8 array. NaNs become transparent."""
492
+ normed = norm(A)
493
+ finite = np.isfinite(normed)
494
+ idx = np.clip(np.nan_to_num(normed) * (lut.shape[0] - 1), 0, lut.shape[0] - 1)
495
+ idx = idx.astype(np.intp)
496
+ rgb = lut[idx]
497
+ alpha = np.where(finite, 255, 0).astype(np.uint8)
498
+ return np.concatenate([rgb, alpha[..., None]], axis=-1)