bfplot 1.0.0__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.
bfplot/preview.py ADDED
@@ -0,0 +1,553 @@
1
+ """Rough text drawings of a BFIR document, written into the manifest so commits show the plot.
2
+
3
+ Lines, markers and outlines are drawn with Braille dots (2x4 per character), images
4
+ and colorbars with an ASCII shade ramp, and axes are placed where their figure bounds
5
+ put them. The drawing is approximate by design; the document stays the source of truth.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import math
11
+ from collections.abc import Callable
12
+ from datetime import datetime, timedelta
13
+ from typing import Any
14
+
15
+ import numpy as np
16
+
17
+ from .bfir import BfirDocument
18
+
19
+ WIDTH = 72
20
+ SHADES = " .:-=+*#%@"
21
+ _DOTS = np.array([[0x01, 0x08], [0x02, 0x10], [0x04, 0x20], [0x40, 0x80]], dtype=np.uint8)
22
+ _NO_LINE = {None, "None", "none", "", " "}
23
+ _CHUNK = 1 << 21 # rasterized points per batch, to bound memory on dense data
24
+
25
+ Project = Callable[..., np.ndarray]
26
+
27
+
28
+ def render(bfir: BfirDocument, width: int = WIDTH) -> list[str]:
29
+ """Text lines (without comment markers) drawing every figure in ``bfir``."""
30
+
31
+ def array(value: Any) -> np.ndarray:
32
+ if isinstance(value, dict) and "$buffer" in value:
33
+ return bfir.buffers[value["$buffer"]].array
34
+ return np.asarray(value)
35
+
36
+ document = bfir.document
37
+ animated = {
38
+ figure_id
39
+ for timeline in document.get("timelines", [])
40
+ for figure_id in timeline.get("figures", [])
41
+ }
42
+ lines: list[str] = []
43
+ for index, figure in enumerate(document.get("figures", [])):
44
+ if index:
45
+ lines.append("")
46
+ lines.append(_heading(figure, index, width, animated=figure.get("id") in animated))
47
+ try:
48
+ lines += _figure(figure, array, width)
49
+ except Exception as error: # a preview must never stop a result from being written
50
+ lines.append(f"(no preview: {type(error).__name__}: {_clean(str(error))})")
51
+ return lines
52
+
53
+
54
+ def _clean(text: Any) -> str:
55
+ return "".join(char if char.isprintable() else " " for char in str(text or ""))
56
+
57
+
58
+ def _heading(figure: dict[str, Any], index: int, width: int, *, animated: bool) -> str:
59
+ parts = [_clean(figure.get("name") or f"figure {index + 1}")]
60
+ if figure.get("title"):
61
+ parts.append(_clean(figure["title"]))
62
+ text = "── " + " · ".join(parts) + (" (first frame)" if animated else "") + " "
63
+ return text + "─" * max(0, width - len(text))
64
+
65
+
66
+ # Canvas
67
+
68
+
69
+ class _Canvas:
70
+ def __init__(self, rows: int, cols: int):
71
+ self.rows, self.cols = rows, cols
72
+ self.cells = [[" "] * cols for _ in range(rows)]
73
+
74
+ def put(self, row: int, col: int, text: str, *, stop: int | None = None) -> None:
75
+ stop = self.cols if stop is None else min(stop, self.cols)
76
+ if 0 <= row < self.rows:
77
+ for offset, char in enumerate(text):
78
+ if 0 <= col + offset < stop:
79
+ self.cells[row][col + offset] = char
80
+
81
+ def label(self, row: int, col: int, text: str) -> None:
82
+ """Write ``text`` only if all of it fits in blank cells, so labels never cut or overlap."""
83
+ if (
84
+ text
85
+ and 0 <= row < self.rows
86
+ and 0 <= col
87
+ and col + len(text) <= self.cols
88
+ and all(char == " " for char in self.cells[row][col : col + len(text)])
89
+ ):
90
+ self.put(row, col, text)
91
+
92
+ def lines(self) -> list[str]:
93
+ lines = ["".join(row).rstrip() for row in self.cells]
94
+ while lines and not lines[-1]:
95
+ lines.pop()
96
+ while lines and not lines[0]:
97
+ lines.pop(0)
98
+ return lines
99
+
100
+
101
+ class _Plot:
102
+ """A Braille dot grid over an axes interior, in unit coordinates (0, 0) bottom left."""
103
+
104
+ def __init__(self, rows: int, cols: int):
105
+ self.rows, self.cols = rows, cols
106
+ self.dots = np.zeros((rows, cols), dtype=np.uint8)
107
+ self.shade: list[list[str | None]] = [[None] * cols for _ in range(rows)]
108
+ self.labels: list[tuple[int, int, str]] = []
109
+
110
+ def _pixels(self, uv: np.ndarray) -> np.ndarray:
111
+ uv = np.asarray(uv, dtype=float).reshape(-1, 2)
112
+ return np.column_stack([uv[:, 0] * 2 * self.cols, (1 - uv[:, 1]) * 4 * self.rows])
113
+
114
+ def _set(self, pixels: np.ndarray) -> None:
115
+ pixels = pixels[np.all(np.isfinite(pixels), axis=1)]
116
+ x, y = np.floor(pixels[:, 0]).astype(np.int64), np.floor(pixels[:, 1]).astype(np.int64)
117
+ inside = (x >= 0) & (x < 2 * self.cols) & (y >= 0) & (y < 4 * self.rows)
118
+ x, y = x[inside], y[inside]
119
+ np.bitwise_or.at(self.dots, (y // 4, x // 2), _DOTS[y % 4, x % 2])
120
+
121
+ def points(self, uv: np.ndarray) -> None:
122
+ self._set(self._pixels(uv))
123
+
124
+ def segments(self, starts: np.ndarray, ends: np.ndarray) -> None:
125
+ if not len(starts):
126
+ return
127
+ size = (2 * self.cols - 1e-9, 4 * self.rows - 1e-9)
128
+ a, b = _clip(self._pixels(starts), self._pixels(ends), *size)
129
+ steps = np.ceil(np.max(np.abs(b - a), axis=1)).astype(np.int64) + 1
130
+ bounds = np.searchsorted(np.cumsum(steps), np.arange(_CHUNK, steps.sum(), _CHUNK))
131
+ for first, last in zip(np.r_[0, bounds], np.r_[bounds, len(steps)], strict=True):
132
+ if last <= first:
133
+ continue
134
+ count = steps[first:last]
135
+ segment = np.repeat(np.arange(first, last), count)
136
+ offset = np.arange(count.sum()) - np.repeat(np.cumsum(count) - count, count)
137
+ t = offset / np.maximum(np.repeat(count - 1, count), 1)
138
+ self._set(a[segment] + t[:, None] * (b - a)[segment])
139
+
140
+ def paths(
141
+ self,
142
+ uv: np.ndarray,
143
+ lengths: np.ndarray | None = None,
144
+ *,
145
+ closed: bool = False,
146
+ codes: np.ndarray | None = None,
147
+ ) -> None:
148
+ starts, ends = _path_segments(np.asarray(uv, dtype=float), lengths, closed, codes)
149
+ self.segments(starts, ends)
150
+
151
+ def fill(self, sample: Callable[[np.ndarray, np.ndarray], np.ndarray]) -> None:
152
+ """Shade cells by ``sample(u, v)`` at their centers (values in [0, 1], NaN for none)."""
153
+ u = (np.arange(self.cols) + 0.5) / self.cols
154
+ v = 1 - (np.arange(self.rows) + 0.5) / self.rows
155
+ grid_u, grid_v = np.meshgrid(u, v)
156
+ values = np.asarray(sample(grid_u, grid_v), dtype=float)
157
+ for row in range(self.rows):
158
+ for col in range(self.cols):
159
+ value = values[row, col]
160
+ if math.isfinite(value):
161
+ level = round(min(max(value, 0.0), 1.0) * (len(SHADES) - 1))
162
+ self.shade[row][col] = SHADES[level]
163
+
164
+ def text(self, uv: np.ndarray, text: str) -> None:
165
+ u, v = np.asarray(uv, dtype=float).ravel()[:2]
166
+ if math.isfinite(u) and math.isfinite(v) and 0 <= u <= 1 and 0 <= v <= 1:
167
+ row = min(int((1 - v) * self.rows), self.rows - 1)
168
+ self.labels.append((row, min(int(u * self.cols), self.cols - 1), _clean(text)))
169
+
170
+ def blit(self, canvas: _Canvas, row: int, col: int, *, opaque: bool) -> None:
171
+ for r in range(self.rows):
172
+ for c in range(self.cols):
173
+ bits = int(self.dots[r, c])
174
+ char = chr(0x2800 + bits) if bits else (self.shade[r][c] or " ")
175
+ if opaque or char != " ":
176
+ canvas.put(row + r, col + c, char)
177
+ for r, c, text in self.labels:
178
+ canvas.put(row + r, col + c, text, stop=col + self.cols)
179
+
180
+
181
+ def _clip(a: np.ndarray, b: np.ndarray, xmax: float, ymax: float) -> tuple[np.ndarray, np.ndarray]:
182
+ """Liang-Barsky clipping of segments ``a -> b`` to ``[0, xmax] x [0, ymax]``."""
183
+ keep = np.all(np.isfinite(a), axis=1) & np.all(np.isfinite(b), axis=1)
184
+ a = np.where(keep[:, None], a, 0.0)
185
+ d = np.where(keep[:, None], b - a, 0.0)
186
+ low, high = np.zeros(len(a)), np.ones(len(a))
187
+ for p, q in (
188
+ (-d[:, 0], a[:, 0]),
189
+ (d[:, 0], xmax - a[:, 0]),
190
+ (-d[:, 1], a[:, 1]),
191
+ (d[:, 1], ymax - a[:, 1]),
192
+ ):
193
+ parallel = p == 0
194
+ keep &= ~(parallel & (q < 0))
195
+ with np.errstate(divide="ignore", invalid="ignore"):
196
+ ratio = q / p
197
+ low = np.where(~parallel & (p < 0), np.maximum(low, ratio), low)
198
+ high = np.where(~parallel & (p > 0), np.minimum(high, ratio), high)
199
+ keep &= low <= high
200
+ return (a + low[:, None] * d)[keep], (a + high[:, None] * d)[keep]
201
+
202
+
203
+ def _path_segments(
204
+ points: np.ndarray,
205
+ lengths: np.ndarray | None,
206
+ closed: bool,
207
+ codes: np.ndarray | None,
208
+ ) -> tuple[np.ndarray, np.ndarray]:
209
+ """Segments of polylines stored back to back, split by ``lengths`` and MOVETO codes."""
210
+ count = len(points)
211
+ if count < 2:
212
+ return np.empty((0, 2)), np.empty((0, 2))
213
+ starts = np.zeros(count, dtype=bool)
214
+ starts[0] = True
215
+ if lengths is not None:
216
+ offsets = np.cumsum(np.asarray(lengths, dtype=np.int64))[:-1]
217
+ starts[offsets[offsets < count]] = True
218
+ closing = np.zeros(count, dtype=bool)
219
+ if codes is not None:
220
+ codes = np.asarray(codes)[:count]
221
+ starts[: len(codes)] |= codes == 1
222
+ closing[: len(codes)] = codes == 79
223
+ linked = ~starts[1:] & ~closing[1:]
224
+ first, last = [points[:-1][linked]], [points[1:][linked]]
225
+ group_start = np.flatnonzero(starts)[np.cumsum(starts) - 1]
226
+ if closed:
227
+ ends = np.r_[np.flatnonzero(starts)[1:] - 1, count - 1]
228
+ ends = ends[~closing[ends]]
229
+ first.append(points[ends])
230
+ last.append(points[group_start[ends]])
231
+ close_at = np.flatnonzero(closing[1:]) + 1
232
+ first.append(points[close_at - 1])
233
+ last.append(points[group_start[close_at]])
234
+ return np.concatenate(first), np.concatenate(last)
235
+
236
+
237
+ # Scales and projections
238
+
239
+
240
+ class _Scale:
241
+ """Maps one axis between data values and unit coordinates along the axes."""
242
+
243
+ TRANSFORMS: dict[str, tuple[Callable[[Any], Any], Callable[[Any], Any]]] = {
244
+ "log": (lambda v: np.log10(np.where(v > 0, v, np.nan)), lambda w: 10.0**w),
245
+ "symlog": (
246
+ lambda v: np.sign(v) * np.log10(1 + np.abs(v)),
247
+ lambda w: np.sign(w) * (10.0 ** np.abs(w) - 1),
248
+ ),
249
+ "logit": (
250
+ lambda v: np.log10(np.where((v > 0) & (v < 1), v, np.nan) / (1 - v)),
251
+ lambda w: 1 / (1 + 10.0 ** (-w)),
252
+ ),
253
+ "asinh": (np.arcsinh, np.sinh),
254
+ }
255
+
256
+ def __init__(self, axis: dict[str, Any]):
257
+ self.axis = axis
258
+ self._forward, self._inverse = self.TRANSFORMS.get(
259
+ axis.get("scale", "linear"), (lambda v: v, lambda w: w)
260
+ )
261
+ limits = axis.get("limits") or [None, None]
262
+ with np.errstate(all="ignore"):
263
+ low, high = (
264
+ self._forward(np.asarray(item, dtype=float)) if item is not None else np.nan
265
+ for item in limits[:2]
266
+ )
267
+ if not (np.isfinite(low) and np.isfinite(high)) or low == high:
268
+ raise ValueError(f"axis has unusable limits {limits!r}")
269
+ self.low, self.high = float(low), float(high)
270
+
271
+ def unit(self, values: Any) -> np.ndarray:
272
+ with np.errstate(all="ignore"):
273
+ return (self._forward(np.asarray(values, dtype=float)) - self.low) / (
274
+ self.high - self.low
275
+ )
276
+
277
+ def value(self, unit: np.ndarray) -> np.ndarray:
278
+ with np.errstate(all="ignore"):
279
+ return self._inverse(self.low + unit * (self.high - self.low))
280
+
281
+ def label(self, index: int) -> str:
282
+ value = self.axis["limits"][index]
283
+ if self.axis.get("scale") == "date":
284
+ try:
285
+ return (datetime(1970, 1, 1) + timedelta(days=float(value))).strftime("%Y-%m-%d")
286
+ except (OverflowError, ValueError):
287
+ pass
288
+ return f"{value:.3g}"
289
+
290
+
291
+ def _projection(axes: dict[str, Any]) -> tuple[Project, Callable[[_Plot], None]]:
292
+ """A function from data coordinates to unit coordinates, and one drawing the axes frame."""
293
+ kind = axes.get("projection", "rectilinear")
294
+ if kind == "3d":
295
+ scales = [_Scale(axes[name]) for name in "xyz"]
296
+ view = axes.get("view") or {}
297
+ azim, elev = math.radians(view.get("azim", -60)), math.radians(view.get("elev", 30))
298
+
299
+ def rotate(x: Any, y: Any, z: Any) -> np.ndarray:
300
+ u = -x * math.sin(azim) + y * math.cos(azim)
301
+ v = -(x * math.cos(azim) + y * math.sin(azim)) * math.sin(elev) + z * math.cos(elev)
302
+ return np.column_stack([np.ravel(u) / 1.5 + 0.5, np.ravel(v) / 1.8 + 0.5])
303
+
304
+ def project_3d(x: Any, y: Any, z: Any) -> np.ndarray:
305
+ return rotate(*(scale.unit(item) - 0.5 for scale, item in zip(scales, (x, y, z))))
306
+
307
+ def floor(plot: _Plot) -> None:
308
+ corners = rotate(
309
+ np.array([-0.5, 0.5, 0.5, -0.5]), np.array([-0.5, -0.5, 0.5, 0.5]), -0.5
310
+ )
311
+ plot.paths(corners, closed=True)
312
+
313
+ return project_3d, floor
314
+ x_scale, y_scale = _Scale(axes["x"]), _Scale(axes["y"])
315
+ if kind == "polar":
316
+
317
+ def project_polar(theta: Any, radius: Any) -> np.ndarray:
318
+ theta, r = np.ravel(np.asarray(theta, dtype=float)), np.ravel(y_scale.unit(radius))
319
+ r = np.where(r >= 0, r, np.nan)
320
+ return np.column_stack([0.5 + 0.5 * r * np.cos(theta), 0.5 + 0.5 * r * np.sin(theta)])
321
+
322
+ def rim(plot: _Plot) -> None:
323
+ angles = np.linspace(0, 2 * np.pi, 97)
324
+ plot.paths(np.column_stack([0.5 + 0.5 * np.cos(angles), 0.5 + 0.5 * np.sin(angles)]))
325
+
326
+ return project_polar, rim
327
+
328
+ def project(x: Any, y: Any) -> np.ndarray:
329
+ return np.column_stack([np.ravel(x_scale.unit(x)), np.ravel(y_scale.unit(y))])
330
+
331
+ return project, lambda plot: None
332
+
333
+
334
+ # Figures, axes and artists
335
+
336
+
337
+ def _figure(figure: dict[str, Any], array: Callable[[Any], np.ndarray], width: int) -> list[str]:
338
+ size = figure.get("size_inches") or [6.4, 4.8]
339
+ rows = int(np.clip(round(width * size[1] / size[0] * 0.45), 8, 60))
340
+ canvas = _Canvas(rows, width)
341
+ drawn: list[tuple[float, ...]] = []
342
+ for axes in figure.get("axes", []):
343
+ bounds = tuple(axes.get("bounds") or (0.125, 0.11, 0.775, 0.77))
344
+ _axes(canvas, axes, bounds, array, twin=bounds in drawn)
345
+ drawn.append(bounds)
346
+ return canvas.lines()
347
+
348
+
349
+ def _axes(
350
+ canvas: _Canvas,
351
+ axes: dict[str, Any],
352
+ bounds: tuple[float, ...],
353
+ array: Callable[[Any], np.ndarray],
354
+ *,
355
+ twin: bool,
356
+ ) -> None:
357
+ left, bottom, width, height = bounds
358
+ c0, c1 = round(left * canvas.cols), round((left + width) * canvas.cols) - 1
359
+ r0, r1 = round((1 - bottom - height) * canvas.rows), round((1 - bottom) * canvas.rows) - 1
360
+ c0, r0 = max(c0, 0), max(r0, 0)
361
+ c1, r1 = min(c1, canvas.cols - 1), min(r1, canvas.rows - 1)
362
+ if c1 - c0 < 3 or r1 - r0 < 2:
363
+ return
364
+ if axes.get("role") == "colorbar":
365
+ _colorbar(canvas, axes.get("colorbar") or {}, r0, r1, c0, c1)
366
+ return
367
+ kind = axes.get("projection", "rectilinear")
368
+ framed = kind not in ("3d", "polar")
369
+ title = _clean(axes.get("title"))
370
+ if framed:
371
+ plot = _Plot(r1 - r0 - 1, c1 - c0 - 1)
372
+ _frame(canvas, r0, r1, c0, c1, title)
373
+ else:
374
+ plot = _Plot(r1 - r0, c1 - c0 + 1)
375
+ if title:
376
+ canvas.put(r0, c0 + max(0, (c1 - c0 + 1 - len(title)) // 2), title, stop=c1 + 1)
377
+ project, decorate = _projection(axes)
378
+ decorate(plot)
379
+ for artist in sorted(
380
+ axes.get("artists", []), key=lambda item: item["props"].get("zorder") or 0
381
+ ):
382
+ if artist.get("props", {}).get("visible") is not False:
383
+ _artist(plot, artist, project, array, axes)
384
+ if framed:
385
+ plot.blit(canvas, r0 + 1, c0 + 1, opaque=axes.get("role") == "inset")
386
+ _limits(canvas, axes, r0, r1, c0, c1, twin=twin)
387
+ else:
388
+ plot.blit(canvas, r0 + (1 if title else 0), c0, opaque=False)
389
+
390
+
391
+ def _frame(canvas: _Canvas, r0: int, r1: int, c0: int, c1: int, title: str) -> None:
392
+ span = c1 - c0 - 1
393
+ top = "─" * span
394
+ if title and span >= 5:
395
+ label = f" {title[: span - 2]} "
396
+ start = (span - len(label)) // 2
397
+ top = top[:start] + label + top[start + len(label) :]
398
+ canvas.put(r0, c0, "┌" + top + "┐")
399
+ canvas.put(r1, c0, "└" + "─" * span + "┘")
400
+ for row in range(r0 + 1, r1):
401
+ canvas.put(row, c0, "│")
402
+ canvas.put(row, c1, "│")
403
+
404
+
405
+ def _limits(
406
+ canvas: _Canvas, axes: dict[str, Any], r0: int, r1: int, c0: int, c1: int, *, twin: bool
407
+ ) -> None:
408
+ x_scale, y_scale = _Scale(axes["x"]), _Scale(axes["y"])
409
+ for row, index in ((r0, 1), (r1, 0)):
410
+ label = y_scale.label(index)
411
+ canvas.label(row, c1 + 1 if twin else c0 - len(label), label)
412
+ if not twin:
413
+ low, high = x_scale.label(0), x_scale.label(1)
414
+ canvas.label(r1 + 1, c0, low)
415
+ canvas.label(r1 + 1, max(c1 + 1 - len(high), c0 + len(low) + 1), high)
416
+ xlabel = _clean(axes["x"].get("label"))[: c1 - c0 + 1]
417
+ canvas.label(r1 + 2, c0 + (c1 - c0 + 1 - len(xlabel)) // 2, xlabel)
418
+ ylabel = _clean(axes["y"].get("label"))
419
+ room = r1 - r0 - 1
420
+ if ylabel and room > 0:
421
+ column = c1 + 2 if twin else c0 - 2
422
+ text = ylabel[:room]
423
+ first = r0 + 1 + (room - len(text)) // 2
424
+ for offset, char in enumerate(text):
425
+ canvas.label(first + offset, column, char)
426
+
427
+
428
+ def _colorbar(
429
+ canvas: _Canvas, colorbar: dict[str, Any], r0: int, r1: int, c0: int, c1: int
430
+ ) -> None:
431
+ vertical = colorbar.get("orientation", "vertical") == "vertical"
432
+ _frame(canvas, r0, r1, c0, c1, "")
433
+ plot = _Plot(r1 - r0 - 1, c1 - c0 - 1)
434
+ plot.fill(lambda u, v: v if vertical else u)
435
+ plot.blit(canvas, r0 + 1, c0 + 1, opaque=True)
436
+ limits = [f"{item:.3g}" if item is not None else "" for item in colorbar.get("limits", [])]
437
+ if len(limits) == 2:
438
+ if vertical:
439
+ canvas.label(r0, c1 + 1, limits[1])
440
+ canvas.label(r1, c1 + 1, limits[0])
441
+ else:
442
+ canvas.label(r1 + 1, c0, limits[0])
443
+ canvas.label(r1 + 1, max(c1 + 1 - len(limits[1]), c0), limits[1])
444
+
445
+
446
+ def _artist(
447
+ plot: _Plot,
448
+ artist: dict[str, Any],
449
+ project: Project,
450
+ array: Callable[[Any], np.ndarray],
451
+ axes: dict[str, Any],
452
+ ) -> None:
453
+ kind = artist["type"].removeprefix("bfplot.")
454
+ data, props = artist.get("data", {}), artist.get("props", {})
455
+
456
+ def values(key: str) -> np.ndarray:
457
+ return np.asarray(array(data[key]), dtype=float)
458
+
459
+ if kind in ("line", "line3d", "errorbar"):
460
+ uv = project(*(values(key) for key in ("x", "y", "z") if key in data))
461
+ if props.get("linestyle") not in _NO_LINE:
462
+ plot.paths(uv)
463
+ if props.get("marker") not in _NO_LINE or props.get("linestyle") in _NO_LINE:
464
+ plot.points(uv)
465
+ if "error_segments" in data:
466
+ segments = values("error_segments").reshape(-1, 2, 2)
467
+ plot.segments(
468
+ project(segments[:, 0, 0], segments[:, 0, 1]),
469
+ project(segments[:, 1, 0], segments[:, 1, 1]),
470
+ )
471
+ if "cap_segments" in data:
472
+ caps = values("cap_segments").reshape(-1, 2)
473
+ plot.points(project(caps[:, 0], caps[:, 1]))
474
+ elif kind == "scatter":
475
+ xy = values("xy").reshape(-1, 2)
476
+ plot.points(project(xy[:, 0], xy[:, 1]))
477
+ elif kind == "scatter3d":
478
+ plot.points(project(values("x"), values("y"), values("z")))
479
+ elif kind == "histogram":
480
+ x0, y0 = values("left"), values("bottom")
481
+ x1, y1 = x0 + values("width"), y0 + values("height")
482
+ corners = np.stack(
483
+ [np.stack(pair, axis=-1) for pair in ((x0, y0), (x1, y0), (x1, y1), (x0, y1))], axis=1
484
+ )
485
+ flat = corners.reshape(-1, 2)
486
+ plot.paths(project(flat[:, 0], flat[:, 1]), np.full(len(x0), 4), closed=True)
487
+ elif kind in ("surface3d", "mesh3d"):
488
+ vertices = values("vertices").reshape(-1, 3)
489
+ uv = project(vertices[:, 0], vertices[:, 1], vertices[:, 2])
490
+ plot.paths(uv, array(data["path_lengths"]), closed=kind == "surface3d")
491
+ elif kind in ("fill_between", "patch", "contour", "contourf") and "vertices" in data:
492
+ vertices = values("vertices").reshape(-1, 2)
493
+ lengths = array(data["path_lengths"]) if "path_lengths" in data else None
494
+ codes = array(data["path_codes"]) if "path_codes" in data else None
495
+ plot.paths(
496
+ project(vertices[:, 0], vertices[:, 1]),
497
+ lengths,
498
+ closed=kind in ("fill_between", "patch"),
499
+ codes=codes,
500
+ )
501
+ elif kind == "patch" and "path" in data:
502
+ vertices = values("path").reshape(-1, 2)
503
+ matrix = np.asarray(data.get("transform") or np.eye(3), dtype=float)
504
+ moved = np.column_stack([vertices, np.ones(len(vertices))]) @ matrix.T
505
+ plot.paths(project(moved[:, 0], moved[:, 1]), closed=True)
506
+ elif kind == "contour" and "segments" in data:
507
+ segments = values("segments")
508
+ if segments.ndim == 3 and segments.shape[1]:
509
+ flat = segments.reshape(-1, 2)
510
+ plot.paths(project(flat[:, 0], flat[:, 1]), np.full(len(segments), segments.shape[1]))
511
+ elif kind == "image" and axes.get("projection", "rectilinear") == "rectilinear":
512
+ _image(plot, values("values"), data.get("extent"), props, axes)
513
+ elif kind == "text":
514
+ position = data.get("position") or [None, None]
515
+ if None not in position:
516
+ plot.text(project(*position), data.get("text", ""))
517
+
518
+
519
+ def _image(
520
+ plot: _Plot,
521
+ pixels: np.ndarray,
522
+ extent: list[float] | None,
523
+ props: dict[str, Any],
524
+ axes: dict[str, Any],
525
+ ) -> None:
526
+ if pixels.ndim == 3:
527
+ rgb = pixels[..., :3] / (255.0 if pixels.max(initial=0) > 1 else 1.0)
528
+ intensity = rgb @ np.array([0.2126, 0.7152, 0.0722])
529
+ if pixels.shape[-1] == 4:
530
+ intensity = np.where(pixels[..., 3] > 0, intensity, np.nan)
531
+ else:
532
+ low, high = props.get("clim") or [None, None]
533
+ low = np.nanmin(pixels) if low is None else low
534
+ high = np.nanmax(pixels) if high is None else high
535
+ intensity = (pixels - low) / (high - low) if high != low else np.zeros_like(pixels)
536
+ if intensity.ndim != 2 or not intensity.size:
537
+ return
538
+ image_rows, image_cols = intensity.shape
539
+ x0, x1, y0, y1 = extent or (-0.5, image_cols - 0.5, image_rows - 0.5, -0.5)
540
+ x_scale, y_scale = _Scale(axes["x"]), _Scale(axes["y"])
541
+ upper = props.get("origin", "upper") == "upper"
542
+
543
+ def sample(u: np.ndarray, v: np.ndarray) -> np.ndarray:
544
+ with np.errstate(all="ignore"):
545
+ col = (x_scale.value(u) - x0) / (x1 - x0) * image_cols
546
+ frac = (y_scale.value(v) - y0) / (y1 - y0)
547
+ row = (1 - frac if upper else frac) * image_rows
548
+ inside = (col >= 0) & (col < image_cols) & (row >= 0) & (row < image_rows)
549
+ col = np.clip(np.nan_to_num(col), 0, image_cols - 1).astype(np.int64)
550
+ row = np.clip(np.nan_to_num(row), 0, image_rows - 1).astype(np.int64)
551
+ return np.where(inside, intensity[row, col], np.nan)
552
+
553
+ plot.fill(sample)
bfplot/py.typed ADDED
File without changes