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/__init__.py ADDED
@@ -0,0 +1,38 @@
1
+ """Script-first, content-addressed interactive scientific plots."""
2
+
3
+ from .api import (
4
+ Animation,
5
+ Result,
6
+ ResultError,
7
+ animation,
8
+ compile,
9
+ hash,
10
+ result,
11
+ same,
12
+ save_animation,
13
+ savefig,
14
+ show,
15
+ translate,
16
+ verify,
17
+ )
18
+ from .bfir import BfirBuffer, BfirDocument, BfirValidationError
19
+
20
+ __all__ = [
21
+ "Animation",
22
+ "BfirBuffer",
23
+ "BfirDocument",
24
+ "BfirValidationError",
25
+ "Result",
26
+ "ResultError",
27
+ "animation",
28
+ "compile",
29
+ "hash",
30
+ "result",
31
+ "same",
32
+ "save_animation",
33
+ "savefig",
34
+ "show",
35
+ "translate",
36
+ "verify",
37
+ ]
38
+ __version__ = "1.0.0"
@@ -0,0 +1 @@
1
+ """Source-library adapters that translate plots into storage-independent BFIR."""
@@ -0,0 +1,543 @@
1
+ from __future__ import annotations
2
+
3
+ import math
4
+ from dataclasses import dataclass, field
5
+ from typing import Any
6
+
7
+ import matplotlib.dates as mdates
8
+ import numpy as np
9
+ from matplotlib import colors
10
+ from matplotlib.artist import Artist
11
+ from matplotlib.collections import LineCollection, PathCollection, PolyCollection
12
+ from matplotlib.container import BarContainer, ErrorbarContainer
13
+ from matplotlib.image import AxesImage
14
+ from matplotlib.lines import Line2D
15
+ from matplotlib.patches import Patch, Polygon
16
+ from matplotlib.spines import Spine
17
+ from matplotlib.text import Text
18
+
19
+ from ..bfir import BfirBuilder, BfirDocument
20
+
21
+
22
+ def _finite(value: Any) -> Any:
23
+ if isinstance(value, np.generic):
24
+ value = value.item()
25
+ if isinstance(value, float):
26
+ return value if math.isfinite(value) else None
27
+ if isinstance(value, (str, int, bool)) or value is None:
28
+ return value
29
+ if isinstance(value, dict):
30
+ return {str(key): _finite(item) for key, item in value.items()}
31
+ if isinstance(value, (list, tuple, np.ndarray)):
32
+ return [_finite(item) for item in value]
33
+ return str(value)
34
+
35
+
36
+ def _color(value: Any) -> list[float] | None:
37
+ try:
38
+ return [float(item) for item in colors.to_rgba(value)]
39
+ except (TypeError, ValueError):
40
+ return None
41
+
42
+
43
+ def _common(artist: Artist) -> dict[str, Any]:
44
+ label = artist.get_label() if hasattr(artist, "get_label") else None
45
+ return {
46
+ "visible": bool(artist.get_visible()),
47
+ "alpha": _finite(artist.get_alpha()),
48
+ "zorder": _finite(artist.get_zorder()),
49
+ "label": label if isinstance(label, str) and not label.startswith("_") else None,
50
+ }
51
+
52
+
53
+ @dataclass
54
+ class ExtractionContext:
55
+ builder: BfirBuilder
56
+ next_artist: int = 0
57
+ artist_ids: dict[int, str] = field(default_factory=dict)
58
+
59
+ def array(self, value: Any) -> dict[str, str]:
60
+ return self.builder.buffer(value)
61
+
62
+ def artist_id(self, source: Any = None) -> str:
63
+ """A fresh artist ID, or the one already given to ``source`` (e.g. a colorbar mappable)."""
64
+ if source is not None and id(source) in self.artist_ids:
65
+ return self.artist_ids[id(source)]
66
+ artist_id = f"artist-{self.next_artist}"
67
+ self.next_artist += 1
68
+ if source is not None:
69
+ self.artist_ids[id(source)] = artist_id
70
+ return artist_id
71
+
72
+ def artist(
73
+ self,
74
+ type_name: str,
75
+ axes_id: str,
76
+ props: dict[str, Any],
77
+ data: dict[str, Any],
78
+ source: Any | None = None,
79
+ ) -> dict[str, Any]:
80
+ return {
81
+ "type": type_name,
82
+ "id": self.artist_id(source),
83
+ "axes_id": axes_id,
84
+ "props": _finite(props),
85
+ "data": data,
86
+ }
87
+
88
+
89
+ def translate_figures(
90
+ figures: list[Any],
91
+ *,
92
+ names: list[str | None] | None = None,
93
+ first_figure: int = 0,
94
+ first_artist: int = 0,
95
+ ) -> tuple[BfirDocument, int]:
96
+ """Translate Matplotlib figures into BFIR and return the next unused artist number.
97
+
98
+ ``first_figure`` and ``first_artist`` offset generated IDs so separately translated
99
+ groups can be combined into one document without collisions.
100
+ """
101
+ names = names or [None] * len(figures)
102
+ builder = BfirBuilder()
103
+ context = ExtractionContext(builder, next_artist=first_artist)
104
+ records = []
105
+ for index, (figure, name) in enumerate(zip(figures, names, strict=True)):
106
+ record = _extract_figure(figure, f"figure-{first_figure + index}", context)
107
+ if name is not None:
108
+ record["name"] = name
109
+ records.append(record)
110
+ return builder.build({"figures": records}), context.next_artist
111
+
112
+
113
+ def _extract_figure(figure: Any, figure_id: str, context: ExtractionContext) -> dict[str, Any]:
114
+ width, height = figure.get_size_inches()
115
+ result = {
116
+ "id": figure_id,
117
+ "size_inches": [float(width), float(height)],
118
+ "dpi": float(figure.dpi),
119
+ "facecolor": _color(figure.get_facecolor()),
120
+ "axes": [],
121
+ }
122
+ suptitle = getattr(figure, "_suptitle", None)
123
+ if suptitle is not None and suptitle.get_text():
124
+ result["title"] = suptitle.get_text()
125
+ axes_entries: list[tuple[Any, Any | None]] = []
126
+ for axes in figure.axes:
127
+ axes_entries.append((axes, None))
128
+ axes_entries.extend((child, axes) for child in getattr(axes, "child_axes", []))
129
+ subplot_axes = {id(axes) for axes, _ in axes_entries if _has_subplotspec(axes)}
130
+ axes_ids = {
131
+ id(axes): f"{figure_id}-axes-{index}" for index, (axes, _) in enumerate(axes_entries)
132
+ }
133
+ for index, (axes, explicit_parent) in enumerate(axes_entries):
134
+ extracted_axes = _extract_axes(axes, axes_ids[id(axes)], context)
135
+ if explicit_parent is not None and extracted_axes.get("role") != "colorbar":
136
+ extracted_axes["role"] = "inset"
137
+ extracted_axes["parent_axes_id"] = axes_ids[id(explicit_parent)]
138
+ elif id(axes) not in subplot_axes and extracted_axes.get("role") != "colorbar":
139
+ parent = _inset_parent(axes, [item for item, _ in axes_entries], subplot_axes)
140
+ if parent is not None:
141
+ extracted_axes["role"] = "inset"
142
+ extracted_axes["parent_axes_id"] = axes_ids[id(parent)]
143
+ result["axes"].append(extracted_axes)
144
+ return result
145
+
146
+
147
+ def _has_subplotspec(axes: Any) -> bool:
148
+ try:
149
+ return axes.get_subplotspec() is not None
150
+ except (AttributeError, TypeError):
151
+ return False
152
+
153
+
154
+ def _inset_parent(axes: Any, candidates: list[Any], subplot_axes: set[int]) -> Any | None:
155
+ left, bottom, width, height = axes.get_position().bounds
156
+ center = (left + width / 2, bottom + height / 2)
157
+ parents = []
158
+ for candidate in candidates:
159
+ if id(candidate) not in subplot_axes:
160
+ continue
161
+ candidate_left, candidate_bottom, candidate_width, candidate_height = (
162
+ candidate.get_position().bounds
163
+ )
164
+ if (
165
+ candidate_left <= center[0] <= candidate_left + candidate_width
166
+ and candidate_bottom <= center[1] <= candidate_bottom + candidate_height
167
+ ):
168
+ overlap_width = max(
169
+ 0.0, min(left + width, candidate_left + candidate_width) - max(left, candidate_left)
170
+ )
171
+ overlap_height = max(
172
+ 0.0,
173
+ min(bottom + height, candidate_bottom + candidate_height)
174
+ - max(bottom, candidate_bottom),
175
+ )
176
+ parents.append((overlap_width * overlap_height, candidate))
177
+ return max(parents, key=lambda item: item[0])[1] if parents else None
178
+
179
+
180
+ def _extract_axes(axes: Any, axes_id: str, context: ExtractionContext) -> dict[str, Any]:
181
+ is_3d = getattr(axes, "name", "") == "3d"
182
+ result: dict[str, Any] = {
183
+ "id": axes_id,
184
+ "projection": "3d" if is_3d else getattr(axes, "name", "rectilinear"),
185
+ "bounds": [float(item) for item in axes.get_position().bounds],
186
+ "title": axes.get_title(),
187
+ "x": _axis(axes, "x"),
188
+ "y": _axis(axes, "y"),
189
+ "facecolor": _color(axes.get_facecolor()),
190
+ "artists": [],
191
+ }
192
+ colorbar = getattr(axes, "_colorbar", None)
193
+ if axes.get_label() == "<colorbar>" or colorbar is not None:
194
+ result["role"] = "colorbar"
195
+ if colorbar is not None:
196
+ mappable = colorbar.mappable
197
+ lower, upper = mappable.get_clim()
198
+ result["colorbar"] = {
199
+ "orientation": colorbar.orientation,
200
+ "label": colorbar.ax.get_ylabel()
201
+ if colorbar.orientation == "vertical"
202
+ else colorbar.ax.get_xlabel(),
203
+ "limits": [_finite(lower), _finite(upper)],
204
+ "ticks": [_finite(item) for item in colorbar.get_ticks()],
205
+ "ticklabels": [item.get_text() for item in colorbar.ax.get_yticklabels()]
206
+ if colorbar.orientation == "vertical"
207
+ else [item.get_text() for item in colorbar.ax.get_xticklabels()],
208
+ "cmap": mappable.cmap.name,
209
+ "colors": [_color(mappable.cmap(value)) for value in np.linspace(0, 1, 64)],
210
+ "mappable": context.artist_id(mappable),
211
+ }
212
+ return result
213
+ if is_3d:
214
+ result["z"] = _axis(axes, "z")
215
+ result["view"] = {
216
+ "elev": float(axes.elev),
217
+ "azim": float(axes.azim),
218
+ "roll": float(getattr(axes, "roll", 0)),
219
+ }
220
+
221
+ consumed: set[int] = set()
222
+ axes_text = {
223
+ id(item)
224
+ for item in (
225
+ axes.title,
226
+ getattr(axes, "_left_title", None),
227
+ getattr(axes, "_right_title", None),
228
+ axes.xaxis.label,
229
+ axes.yaxis.label,
230
+ getattr(getattr(axes, "zaxis", None), "label", None),
231
+ )
232
+ if item is not None
233
+ }
234
+ for container in axes.containers:
235
+ extracted, children = _extract_container(container, axes_id, context)
236
+ if extracted:
237
+ result["artists"].append(extracted)
238
+ consumed.update(id(child) for child in children if child is not None)
239
+
240
+ for image in axes.images:
241
+ if id(image) not in consumed:
242
+ result["artists"].append(_extract_image(image, axes_id, context))
243
+ consumed.add(id(image))
244
+
245
+ for artist in axes.get_children():
246
+ if (
247
+ id(artist) in consumed
248
+ or id(artist) in axes_text
249
+ or not artist.get_visible()
250
+ or artist is axes.patch
251
+ or isinstance(artist, Spine)
252
+ ):
253
+ continue
254
+ extracted = _extract_artist(artist, axes_id, context, is_3d)
255
+ if extracted is not None:
256
+ result["artists"].append(extracted)
257
+ return result
258
+
259
+
260
+ def _axis(axes: Any, name: str) -> dict[str, Any]:
261
+ axis = getattr(axes, f"{name}axis")
262
+ lower, upper = getattr(axes, f"get_{name}lim")()
263
+ converter = (
264
+ axis.get_converter()
265
+ if hasattr(axis, "get_converter")
266
+ else getattr(axis, "_converter", None)
267
+ )
268
+ is_date = isinstance(converter, mdates.DateConverter) or isinstance(
269
+ axis.get_major_locator(), mdates.DateLocator
270
+ )
271
+ formatter = axis.get_major_formatter()
272
+ result = {
273
+ "label": axis.get_label_text(),
274
+ "scale": "date" if is_date else getattr(axes, f"get_{name}scale")(),
275
+ "limits": [_finite(lower), _finite(upper)],
276
+ "inverted": bool(getattr(axes, f"{name}axis_inverted")()),
277
+ "ticks": [_finite(item) for item in axis.get_ticklocs()],
278
+ "ticklabels": [item.get_text() for item in axis.get_ticklabels()],
279
+ }
280
+ if is_date:
281
+ result["date_epoch"] = "matplotlib"
282
+ result["date_format"] = getattr(formatter, "fmt", None) or "%b %d"
283
+ return result
284
+
285
+
286
+ def _extract_container(
287
+ container: Any, axes_id: str, context: ExtractionContext
288
+ ) -> tuple[dict[str, Any] | None, list[Any]]:
289
+ if isinstance(container, ErrorbarContainer):
290
+ data_line, caplines, barcols = container.lines
291
+ if data_line is not None:
292
+ x, y = data_line.get_data()
293
+ props = _line_props(data_line)
294
+ else:
295
+ x, y, props = [], [], {}
296
+ segments = [segment for collection in barcols for segment in collection.get_segments()]
297
+ cap_segments = [np.column_stack(cap.get_data()) for cap in caplines]
298
+ data = {"x": context.array(x), "y": context.array(y)}
299
+ if segments:
300
+ data["error_segments"] = context.array(np.asarray(segments))
301
+ if cap_segments:
302
+ data["cap_segments"] = context.array(np.asarray(cap_segments))
303
+ children = [data_line, *caplines, *barcols]
304
+ return context.artist("bfplot.errorbar", axes_id, props, data), children
305
+ if isinstance(container, BarContainer):
306
+ patches = list(container.patches)
307
+ if not patches:
308
+ return None, []
309
+ data = {
310
+ "left": context.array([patch.get_x() for patch in patches]),
311
+ "bottom": context.array([patch.get_y() for patch in patches]),
312
+ "width": context.array([patch.get_width() for patch in patches]),
313
+ "height": context.array([patch.get_height() for patch in patches]),
314
+ }
315
+ props = _patch_props(patches[0])
316
+ props["orientation"] = getattr(container, "orientation", None)
317
+ return context.artist("bfplot.histogram", axes_id, props, data), patches
318
+ return None, []
319
+
320
+
321
+ def _extract_artist(
322
+ artist: Any, axes_id: str, context: ExtractionContext, is_3d: bool
323
+ ) -> dict[str, Any] | None:
324
+ class_name = artist.__class__.__name__
325
+ if isinstance(artist, Line2D) and artist.axes is not None:
326
+ x, y = artist.get_data(orig=False)
327
+ if is_3d and hasattr(artist, "get_data_3d"):
328
+ x, y, z = artist.get_data_3d()
329
+ return context.artist(
330
+ "bfplot.line3d",
331
+ axes_id,
332
+ _line_props(artist),
333
+ {"x": context.array(x), "y": context.array(y), "z": context.array(z)},
334
+ )
335
+ return context.artist(
336
+ "bfplot.line",
337
+ axes_id,
338
+ _line_props(artist),
339
+ {"x": context.array(x), "y": context.array(y)},
340
+ )
341
+ if isinstance(artist, PathCollection):
342
+ type_name = "bfplot.scatter"
343
+ if is_3d and hasattr(artist, "_offsets3d"):
344
+ x, y, z = artist._offsets3d
345
+ data: dict[str, Any] = {
346
+ "x": context.array(x),
347
+ "y": context.array(y),
348
+ "z": context.array(z),
349
+ }
350
+ type_name = "bfplot.scatter3d"
351
+ else:
352
+ offsets = np.asarray(artist.get_offsets())
353
+ data = {"xy": context.array(offsets)}
354
+ sizes = artist.get_sizes()
355
+ if len(sizes):
356
+ data["sizes"] = context.array(sizes)
357
+ values = artist.get_array()
358
+ if values is not None:
359
+ data["values"] = context.array(values)
360
+ return context.artist(type_name, axes_id, _collection_props(artist), data, source=artist)
361
+ if class_name == "FillBetweenPolyCollection":
362
+ return _extract_paths("bfplot.fill_between", artist, axes_id, context)
363
+ if class_name in {"QuadContourSet", "ContourSet"}:
364
+ return _extract_contour(artist, axes_id, context)
365
+ if is_3d and class_name == "Poly3DCollection":
366
+ faces = _poly3d_faces(artist)
367
+ return _extract_3d_paths("bfplot.surface3d", faces, artist, axes_id, context)
368
+ if is_3d and isinstance(artist, LineCollection) and hasattr(artist, "_segments3d"):
369
+ return _extract_3d_paths(
370
+ "bfplot.mesh3d", list(artist._segments3d), artist, axes_id, context
371
+ )
372
+ if isinstance(artist, PolyCollection):
373
+ return _extract_paths("bfplot.patch", artist, axes_id, context)
374
+ if isinstance(artist, LineCollection):
375
+ return context.artist(
376
+ "bfplot.contour",
377
+ axes_id,
378
+ _collection_props(artist),
379
+ {"segments": context.array(np.asarray(artist.get_segments()))},
380
+ )
381
+ if isinstance(artist, AxesImage):
382
+ return _extract_image(artist, axes_id, context)
383
+ if isinstance(artist, Text) and artist.axes is not None and artist.get_text():
384
+ position = artist.get_position()
385
+ data = {"position": [_finite(position[0]), _finite(position[1])], "text": artist.get_text()}
386
+ props = _common(artist) | {
387
+ "color": _color(artist.get_color()),
388
+ "size": float(artist.get_fontsize()),
389
+ "rotation": float(artist.get_rotation()),
390
+ "horizontalalignment": artist.get_horizontalalignment(),
391
+ "verticalalignment": artist.get_verticalalignment(),
392
+ }
393
+ return context.artist("bfplot.text", axes_id, props, data)
394
+ if isinstance(artist, Patch) and artist.axes is not None:
395
+ data: dict[str, Any] = {}
396
+ if isinstance(artist, Polygon):
397
+ data["vertices"] = context.array(artist.get_xy())
398
+ else:
399
+ data["path"] = context.array(artist.get_path().vertices)
400
+ data["transform"] = _finite(artist.get_patch_transform().get_matrix())
401
+ return context.artist("bfplot.patch", axes_id, _patch_props(artist), data)
402
+ return None
403
+
404
+
405
+ def _line_props(line: Any) -> dict[str, Any]:
406
+ return _common(line) | {
407
+ "color": _color(line.get_color()),
408
+ "linewidth": float(line.get_linewidth()),
409
+ "linestyle": line.get_linestyle(),
410
+ "marker": line.get_marker(),
411
+ "markersize": float(line.get_markersize()),
412
+ "drawstyle": line.get_drawstyle(),
413
+ }
414
+
415
+
416
+ def _patch_props(patch: Any) -> dict[str, Any]:
417
+ return _common(patch) | {
418
+ "facecolor": _color(patch.get_facecolor()),
419
+ "edgecolor": _color(patch.get_edgecolor()),
420
+ "linewidth": float(patch.get_linewidth()),
421
+ "linestyle": patch.get_linestyle(),
422
+ "hatch": patch.get_hatch(),
423
+ }
424
+
425
+
426
+ def _collection_props(collection: Any) -> dict[str, Any]:
427
+ result = _common(collection)
428
+ for name, getter in (
429
+ ("facecolors", "get_facecolors"),
430
+ ("edgecolors", "get_edgecolors"),
431
+ ("linewidths", "get_linewidths"),
432
+ ):
433
+ value = getattr(collection, getter)()
434
+ if len(value):
435
+ result[name] = _finite(value)
436
+ cmap = getattr(collection, "cmap", None)
437
+ norm = getattr(collection, "norm", None)
438
+ if cmap is not None:
439
+ result["cmap"] = cmap.name
440
+ if norm is not None:
441
+ result["clim"] = [_finite(norm.vmin), _finite(norm.vmax)]
442
+ return result
443
+
444
+
445
+ def _extract_paths(
446
+ type_name: str, artist: Any, axes_id: str, context: ExtractionContext
447
+ ) -> dict[str, Any]:
448
+ paths = [path.vertices for path in artist.get_paths()]
449
+ lengths = np.asarray([len(path) for path in paths], dtype=np.int64)
450
+ vertices = np.concatenate(paths) if paths else np.empty((0, 2))
451
+ data = {"vertices": context.array(vertices), "path_lengths": context.array(lengths)}
452
+ values = artist.get_array() if hasattr(artist, "get_array") else None
453
+ if values is not None:
454
+ data["values"] = context.array(values)
455
+ return context.artist(type_name, axes_id, _collection_props(artist), data, source=artist)
456
+
457
+
458
+ def _poly3d_faces(artist: Any) -> list[np.ndarray]:
459
+ faces = getattr(artist, "_faces", None)
460
+ if isinstance(faces, np.ndarray) and faces.ndim == 3 and faces.shape[-1] == 3:
461
+ # Matplotlib pads faces to a common length; the padding vertices are garbage.
462
+ invalid = getattr(artist, "_invalid_vertices", None)
463
+ if isinstance(invalid, np.ndarray) and invalid.shape == faces.shape[:2]:
464
+ return [face[~mask] for face, mask in zip(faces, invalid, strict=True)]
465
+ return [face for face in faces]
466
+ vector = getattr(artist, "_vec", None)
467
+ slices = getattr(artist, "_segslices", None)
468
+ if vector is not None and slices is not None:
469
+ values = np.asarray(vector)
470
+ if values.ndim == 2 and values.shape[0] >= 3:
471
+ return [values[:3, segment].T for segment in slices]
472
+ return []
473
+
474
+
475
+ def _extract_3d_paths(
476
+ type_name: str, paths: list[Any], artist: Any, axes_id: str, context: ExtractionContext
477
+ ) -> dict[str, Any]:
478
+ arrays = [np.asarray(path, dtype=float) for path in paths]
479
+ arrays = [path for path in arrays if path.ndim == 2 and path.shape[1] == 3 and len(path)]
480
+ lengths = np.asarray([len(path) for path in arrays], dtype=np.int64)
481
+ vertices = np.concatenate(arrays) if arrays else np.empty((0, 3))
482
+ data = {"vertices": context.array(vertices), "path_lengths": context.array(lengths)}
483
+ values = artist.get_array() if hasattr(artist, "get_array") else None
484
+ if values is not None:
485
+ data["values"] = context.array(values)
486
+ return context.artist(type_name, axes_id, _collection_props(artist), data, source=artist)
487
+
488
+
489
+ def _extract_contour(artist: Any, axes_id: str, context: ExtractionContext) -> dict[str, Any]:
490
+ paths = []
491
+ path_values = []
492
+ cvalues = np.asarray(getattr(artist, "cvalues", []), dtype=float)
493
+ collections = getattr(artist, "collections", [])
494
+ for index, collection in enumerate(collections):
495
+ collection_paths = collection.get_paths()
496
+ paths.extend(collection_paths)
497
+ path_values.extend(
498
+ [cvalues[index] if index < len(cvalues) else float(index)] * len(collection_paths)
499
+ )
500
+ if not paths and hasattr(artist, "get_paths"):
501
+ paths.extend(artist.get_paths())
502
+ path_values.extend(cvalues[: len(paths)])
503
+ lengths = np.asarray([len(path.vertices) for path in paths], dtype=np.int64)
504
+ vertices = np.concatenate([path.vertices for path in paths]) if paths else np.empty((0, 2))
505
+ codes = (
506
+ np.concatenate(
507
+ [
508
+ path.codes
509
+ if path.codes is not None
510
+ else np.full(len(path.vertices), 2, dtype=np.uint8)
511
+ for path in paths
512
+ ]
513
+ )
514
+ if paths
515
+ else np.empty(0, dtype=np.uint8)
516
+ )
517
+ filled = bool(getattr(artist, "filled", False))
518
+ data = {
519
+ "vertices": context.array(vertices),
520
+ "path_codes": context.array(codes),
521
+ "path_lengths": context.array(lengths),
522
+ "path_values": context.array(np.asarray(path_values, dtype=float)),
523
+ "levels": context.array(artist.levels),
524
+ }
525
+ props = _collection_props(artist)
526
+ props["cmap"] = artist.cmap.name
527
+ return context.artist(
528
+ "bfplot.contourf" if filled else "bfplot.contour", axes_id, props, data, source=artist
529
+ )
530
+
531
+
532
+ def _extract_image(image: Any, axes_id: str, context: ExtractionContext) -> dict[str, Any]:
533
+ data = {
534
+ "values": context.array(image.get_array()),
535
+ "extent": [_finite(item) for item in image.get_extent()],
536
+ }
537
+ props = _common(image) | {
538
+ "cmap": image.get_cmap().name,
539
+ "clim": [_finite(item) for item in image.get_clim()],
540
+ "origin": image.origin,
541
+ "interpolation": image.get_interpolation(),
542
+ }
543
+ return context.artist("bfplot.image", axes_id, props, data, source=image)