anyplotlib 0.1.0__py3-none-any.whl → 0.2.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.
- anyplotlib/_binary_frame.py +78 -0
- anyplotlib/_electron.py +120 -6
- anyplotlib/_repr_utils.py +43 -5
- anyplotlib/_utils.py +49 -13
- anyplotlib/axes/_axes.py +11 -3
- anyplotlib/callbacks.py +9 -1
- anyplotlib/figure/_figure.py +46 -0
- anyplotlib/figure_esm.js +1173 -73
- anyplotlib/markers.py +7 -0
- anyplotlib/plot1d/_plot1d.py +48 -24
- anyplotlib/plot2d/_plot2d.py +873 -50
- anyplotlib/plot2d/_plotmesh.py +1 -1
- anyplotlib/plot2d/_tile_backend.py +181 -0
- {anyplotlib-0.1.0.dist-info → anyplotlib-0.2.0.dist-info}/METADATA +1 -1
- {anyplotlib-0.1.0.dist-info → anyplotlib-0.2.0.dist-info}/RECORD +17 -15
- {anyplotlib-0.1.0.dist-info → anyplotlib-0.2.0.dist-info}/WHEEL +1 -1
- {anyplotlib-0.1.0.dist-info → anyplotlib-0.2.0.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""
|
|
2
|
+
_binary_frame.py — a tiny length-prefixed binary wire format for shipping raw
|
|
3
|
+
image pixels to an Electron host without base64-in-JSON.
|
|
4
|
+
|
|
5
|
+
Used ONLY by the Electron transport (``_electron.py``); Jupyter / Pyodide /
|
|
6
|
+
standalone keep the base64-in-JSON path untouched. The format is a single
|
|
7
|
+
self-describing frame written to a byte stream (stdout), interleaved with the
|
|
8
|
+
existing text ``PLOTAPP:{json}\\n`` lines:
|
|
9
|
+
|
|
10
|
+
PLOTBIN:<header_len>:<payload_len>\\n<header_json_bytes><payload_bytes>
|
|
11
|
+
|
|
12
|
+
- ``PLOTBIN:`` is the ASCII marker (distinct from ``PLOTAPP:``) so a host reading
|
|
13
|
+
the raw stream can tell a binary frame from a text line.
|
|
14
|
+
- ``<header_len>`` / ``<payload_len>`` are ASCII decimal byte counts.
|
|
15
|
+
- ``<header_json_bytes>`` is UTF-8 JSON: ``{fig_id, key, ...metadata}`` (dims,
|
|
16
|
+
dtype, clim — everything the renderer needs EXCEPT the pixels).
|
|
17
|
+
- ``<payload_bytes>`` is the raw payload (e.g. the single-channel uint8 image),
|
|
18
|
+
exactly ``payload_len`` bytes, NO base64, NO JSON.
|
|
19
|
+
|
|
20
|
+
Both the Python producer and the JS host use this one definition, so the wire
|
|
21
|
+
format has a single source of truth and can be unit-tested in isolation (the
|
|
22
|
+
producer's ``encode_frame`` round-trips through ``decode_frame`` without any
|
|
23
|
+
Electron / stdout involved).
|
|
24
|
+
"""
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import json
|
|
28
|
+
|
|
29
|
+
MARKER = b"PLOTBIN:"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def encode_frame(fig_id: str, key: str, header: dict, payload: bytes) -> bytes:
|
|
33
|
+
"""Serialise one binary frame to bytes (marker + lengths + header + payload).
|
|
34
|
+
|
|
35
|
+
``header`` is arbitrary JSON-able metadata; ``fig_id`` and ``key`` are merged
|
|
36
|
+
in (so the decoder always recovers them). ``payload`` is the raw bytes."""
|
|
37
|
+
hdr = dict(header or {})
|
|
38
|
+
hdr["fig_id"] = fig_id
|
|
39
|
+
hdr["key"] = key
|
|
40
|
+
hdr_bytes = json.dumps(hdr, default=str).encode("utf-8")
|
|
41
|
+
prefix = MARKER + f"{len(hdr_bytes)}:{len(payload)}\n".encode("ascii")
|
|
42
|
+
return prefix + hdr_bytes + bytes(payload)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def decode_frame(buf: bytes):
|
|
46
|
+
"""Parse ONE complete frame from ``buf`` (as produced by ``encode_frame``).
|
|
47
|
+
|
|
48
|
+
Returns ``(header_dict, payload_bytes, n_consumed)`` where ``n_consumed`` is
|
|
49
|
+
the number of bytes the frame occupied. Raises ``ValueError`` if ``buf`` does
|
|
50
|
+
not begin with a complete frame. (A streaming host uses ``parse_prefix`` +
|
|
51
|
+
incremental reads instead; this is the whole-buffer convenience for tests.)"""
|
|
52
|
+
if not buf.startswith(MARKER):
|
|
53
|
+
raise ValueError("not a PLOTBIN frame")
|
|
54
|
+
nl = buf.find(b"\n")
|
|
55
|
+
if nl < 0:
|
|
56
|
+
raise ValueError("incomplete PLOTBIN prefix (no newline)")
|
|
57
|
+
prefix = buf[len(MARKER):nl].decode("ascii")
|
|
58
|
+
hdr_len_s, pay_len_s = prefix.split(":")
|
|
59
|
+
hdr_len, pay_len = int(hdr_len_s), int(pay_len_s)
|
|
60
|
+
start = nl + 1
|
|
61
|
+
end_hdr = start + hdr_len
|
|
62
|
+
end_pay = end_hdr + pay_len
|
|
63
|
+
if len(buf) < end_pay:
|
|
64
|
+
raise ValueError("incomplete PLOTBIN frame (truncated body)")
|
|
65
|
+
header = json.loads(buf[start:end_hdr].decode("utf-8"))
|
|
66
|
+
payload = buf[end_hdr:end_pay]
|
|
67
|
+
return header, payload, end_pay
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def parse_prefix(line: bytes):
|
|
71
|
+
"""Parse a ``PLOTBIN:<hlen>:<plen>`` prefix line (the bytes up to, not
|
|
72
|
+
including, the newline). Returns ``(header_len, payload_len)``. For a
|
|
73
|
+
streaming host that reads the prefix line, then exactly ``header_len +
|
|
74
|
+
payload_len`` more bytes."""
|
|
75
|
+
if not line.startswith(MARKER):
|
|
76
|
+
raise ValueError("not a PLOTBIN prefix")
|
|
77
|
+
hdr_len_s, pay_len_s = line[len(MARKER):].decode("ascii").split(":")
|
|
78
|
+
return int(hdr_len_s), int(pay_len_s)
|
anyplotlib/_electron.py
CHANGED
|
@@ -9,12 +9,109 @@ send interaction events back to Python.
|
|
|
9
9
|
"""
|
|
10
10
|
from __future__ import annotations
|
|
11
11
|
|
|
12
|
+
import base64
|
|
12
13
|
import json
|
|
14
|
+
import os
|
|
13
15
|
import sys
|
|
14
16
|
import uuid
|
|
17
|
+
import zlib
|
|
18
|
+
|
|
19
|
+
from anyplotlib._binary_frame import encode_frame
|
|
15
20
|
|
|
16
21
|
_figures: dict[str, object] = {} # fig_id -> Figure
|
|
17
22
|
|
|
23
|
+
# Opt-in binary transport for large image pixels (Electron host only). When on,
|
|
24
|
+
# the big ``image_b64`` / ``overlay_mask_b64`` pixel traits are shipped as a raw
|
|
25
|
+
# PLOTBIN binary frame (no base64, no JSON for the pixels) instead of a giant
|
|
26
|
+
# base64 string in the state_update JSON — the base64 encode + 5.6MB JSON line +
|
|
27
|
+
# JS atob cost ~200 ms/frame on a 4k movie. Off by default (base64 path) so this
|
|
28
|
+
# is zero-risk until the host opts in; SpyDE sets APL_BINARY_TRANSPORT=1.
|
|
29
|
+
_BINARY_TRANSPORT = os.environ.get("APL_BINARY_TRANSPORT") == "1"
|
|
30
|
+
# State keys whose value is a base64 pixel string worth sending as binary.
|
|
31
|
+
# detail_b64 is the zoom DETAIL TILE — it MUST be here too, else with binary
|
|
32
|
+
# transport on its _encode_pixels "\x00bin:<checksum>" token stays in the geom JSON
|
|
33
|
+
# and the real bytes are never shipped, so the renderer can't decode it and the crisp
|
|
34
|
+
# zoom tile never displays (you only ever see the downsampled overview base).
|
|
35
|
+
_BINARY_KEYS = frozenset({"image_b64", "overlay_mask_b64", "detail_b64"})
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _route_change(fig_id: str, name: str, value) -> None:
|
|
39
|
+
"""Forward ONE trait change to the host — as a raw PLOTBIN binary frame for a
|
|
40
|
+
large image pixel trait (when binary transport is enabled), else as a
|
|
41
|
+
base64-in-JSON ``state_update``. Extracted from the observer so it's unit-
|
|
42
|
+
testable without a real Figure/stdout."""
|
|
43
|
+
# Binary fast path: ship large image pixels as a raw PLOTBIN frame (the
|
|
44
|
+
# renderer rebuilds the bytes from the ArrayBuffer — no atob).
|
|
45
|
+
#
|
|
46
|
+
# The pixel keys ride INSIDE a panel geometry trait — ``panel_<pid>_geom`` is
|
|
47
|
+
# a JSON string ``{"image_b64": <token|b64>, "colormap_data": …, …}`` (see
|
|
48
|
+
# Figure._push). Two producer modes:
|
|
49
|
+
# • RAW (Plot2D.set_data with binary transport): the geom carries a tiny
|
|
50
|
+
# ``"\x00bin:<n>"`` change-token, and the real uint8 bytes sit in the
|
|
51
|
+
# Figure's ``_raw_pixels`` side-table. We emit those bytes directly —
|
|
52
|
+
# NO json-parse of a megabyte string, NO base64 decode. This is the hot
|
|
53
|
+
# scrub path (a 4k movie frame every tick).
|
|
54
|
+
# • BASE64 (initial __init__ frame / non-set_data pushes / robustness): the
|
|
55
|
+
# geom carries a real base64 string, which we decode once here.
|
|
56
|
+
# Either way the REST of the geom (LUT + flags, small) goes as a normal JSON
|
|
57
|
+
# state_update with the pixels removed. A bare pixel trait is handled too.
|
|
58
|
+
if _BINARY_TRANSPORT and isinstance(value, str) and value:
|
|
59
|
+
if name.startswith("panel_") and name.endswith("_geom"):
|
|
60
|
+
try:
|
|
61
|
+
geom = json.loads(value)
|
|
62
|
+
except Exception:
|
|
63
|
+
geom = None
|
|
64
|
+
if isinstance(geom, dict) and any(k in geom for k in _BINARY_KEYS):
|
|
65
|
+
panel_id = name[len("panel_"):-len("_geom")]
|
|
66
|
+
fig = _figures.get(fig_id)
|
|
67
|
+
raw_tbl = getattr(fig, "_raw_pixels", None)
|
|
68
|
+
sent_binary = False
|
|
69
|
+
for k in list(geom.keys()):
|
|
70
|
+
if k not in _BINARY_KEYS or not isinstance(geom[k], str) or not geom[k]:
|
|
71
|
+
continue
|
|
72
|
+
raw = None
|
|
73
|
+
if geom[k].startswith("\x00bin:") and raw_tbl is not None:
|
|
74
|
+
# RAW fast path: bytes are in the side-table (no decode).
|
|
75
|
+
raw = raw_tbl.get((panel_id, k))
|
|
76
|
+
if raw is None:
|
|
77
|
+
# BASE64 path (initial frame / fallback): decode once.
|
|
78
|
+
try:
|
|
79
|
+
raw = base64.b64decode(geom[k])
|
|
80
|
+
except Exception:
|
|
81
|
+
continue # leave it in geom → goes via JSON below
|
|
82
|
+
# Replace the pixel value with a small content TOKEN instead of
|
|
83
|
+
# popping the key: the renderer's caches (blitCache, GPU
|
|
84
|
+
# texture, maskCache) key their "unchanged → skip re-upload"
|
|
85
|
+
# checks on this string. Popping it left st.<key> empty in JS,
|
|
86
|
+
# whose fallback fingerprint samples only 4 bytes of the
|
|
87
|
+
# buffer — two frames differing anywhere else COLLIDED and the
|
|
88
|
+
# skip froze the display on the old frame (found: a movie
|
|
89
|
+
# overview refresh that only moved a mid-frame feature).
|
|
90
|
+
tok = geom[k]
|
|
91
|
+
if not tok.startswith("\x00bin:"):
|
|
92
|
+
tok = f"\x00bin:{zlib.adler32(raw) & 0xFFFFFFFF}"
|
|
93
|
+
geom[k] = tok
|
|
94
|
+
# key carries which pixel field; geom= the trait name so the
|
|
95
|
+
# renderer routes it back into the right panel state.
|
|
96
|
+
emit_binary(fig_id, k,
|
|
97
|
+
{"nbytes": len(raw), "geom": name}, raw)
|
|
98
|
+
sent_binary = True
|
|
99
|
+
if sent_binary:
|
|
100
|
+
# Send the slimmed geom (LUT etc., pixels removed) as JSON.
|
|
101
|
+
emit({"type": "state_update", "fig_id": fig_id, "key": name,
|
|
102
|
+
"value": json.dumps(geom)})
|
|
103
|
+
return
|
|
104
|
+
elif name in _BINARY_KEYS:
|
|
105
|
+
try:
|
|
106
|
+
raw = base64.b64decode(value)
|
|
107
|
+
emit_binary(fig_id, name, {"nbytes": len(raw)}, raw)
|
|
108
|
+
return
|
|
109
|
+
except Exception:
|
|
110
|
+
pass # fall back to the base64-in-JSON path below
|
|
111
|
+
if isinstance(value, (bytes, bytearray)):
|
|
112
|
+
value = {"buffer": base64.b64encode(value).decode()}
|
|
113
|
+
emit({"type": "state_update", "fig_id": fig_id, "key": name, "value": value})
|
|
114
|
+
|
|
18
115
|
|
|
19
116
|
def register(fig) -> str:
|
|
20
117
|
"""Register *fig* for bidirectional state sync and return its fig_id."""
|
|
@@ -22,12 +119,7 @@ def register(fig) -> str:
|
|
|
22
119
|
_figures[fig_id] = fig
|
|
23
120
|
|
|
24
121
|
def _on_change(change):
|
|
25
|
-
|
|
26
|
-
value = change["new"]
|
|
27
|
-
if isinstance(value, (bytes, bytearray)):
|
|
28
|
-
import base64
|
|
29
|
-
value = {"buffer": base64.b64encode(value).decode()}
|
|
30
|
-
emit({"type": "state_update", "fig_id": fig_id, "key": name, "value": value})
|
|
122
|
+
_route_change(fig_id, change["name"], change["new"])
|
|
31
123
|
|
|
32
124
|
for name in fig.traits(sync=True):
|
|
33
125
|
if not name.startswith("_"):
|
|
@@ -72,3 +164,25 @@ def dispatch_event(fig_id: str, event_json: str) -> None:
|
|
|
72
164
|
def emit(obj: dict) -> None:
|
|
73
165
|
sys.stdout.write(f"PLOTAPP:{json.dumps(obj, default=str)}\n")
|
|
74
166
|
sys.stdout.flush()
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def emit_binary(fig_id: str, key: str, header: dict, payload: bytes) -> None:
|
|
170
|
+
"""Write a raw PLOTBIN binary frame to stdout (pixels, no base64/JSON).
|
|
171
|
+
|
|
172
|
+
Goes to the RAW ``sys.stdout.buffer`` so the payload is bytes on the wire; the
|
|
173
|
+
Electron host demuxes ``PLOTBIN:`` frames from the same stdout stream that
|
|
174
|
+
carries the text ``PLOTAPP:`` lines. Flushed so the host sees it promptly."""
|
|
175
|
+
frame = encode_frame(fig_id, key, header, payload)
|
|
176
|
+
buf = getattr(sys.stdout, "buffer", None)
|
|
177
|
+
if buf is None: # no binary stdout (unusual) → base64 fallback
|
|
178
|
+
emit({"type": "state_update", "fig_id": fig_id, "key": key,
|
|
179
|
+
"value": base64.b64encode(payload).decode()})
|
|
180
|
+
return
|
|
181
|
+
# NOTE: a host that redirects sys.stdout (e.g. SpyDE points sys.stdout at
|
|
182
|
+
# stderr to keep the protocol channel clean) MUST patch emit_binary to write
|
|
183
|
+
# to its real protocol stdout — otherwise these bytes go to the wrong stream.
|
|
184
|
+
# Flush the TEXT stream first so any pending PLOTAPP: line is on the wire
|
|
185
|
+
# before this binary frame — the host demuxes a single ordered byte stream.
|
|
186
|
+
sys.stdout.flush()
|
|
187
|
+
buf.write(frame)
|
|
188
|
+
buf.flush()
|
anyplotlib/_repr_utils.py
CHANGED
|
@@ -207,11 +207,49 @@ import(blobUrl).then(mod => {{
|
|
|
207
207
|
|
|
208
208
|
// ── Inbound state updates from parent-page Pyodide ───────────────────────────
|
|
209
209
|
window.addEventListener('message', (e) => {{
|
|
210
|
-
if (!e.data
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
210
|
+
if (!e.data) return;
|
|
211
|
+
if (e.data.type === 'awi_state') {{
|
|
212
|
+
_fromParent = true;
|
|
213
|
+
const _t0 = performance.now();
|
|
214
|
+
model.set(e.data.key, e.data.value);
|
|
215
|
+
model.save_changes();
|
|
216
|
+
if (e.data.key === 'image_b64') {{
|
|
217
|
+
try {{
|
|
218
|
+
globalThis.__apl_paint_ms = performance.now() - _t0;
|
|
219
|
+
globalThis.__apl_paint_n = (globalThis.__apl_paint_n || 0) + 1;
|
|
220
|
+
globalThis.__apl_paint_kind = 'base64';
|
|
221
|
+
}} catch (_) {{}}
|
|
222
|
+
}}
|
|
223
|
+
_fromParent = false;
|
|
224
|
+
return;
|
|
225
|
+
}}
|
|
226
|
+
// Binary image frame (raw pixels, no base64). Set the bytes under a parallel
|
|
227
|
+
// `<key>_bytes` trait (a Uint8Array) that the draw path uses directly — no atob.
|
|
228
|
+
// We also bump the base64 key to a short token so listeners keyed on it still
|
|
229
|
+
// fire, without carrying the (large) base64 string.
|
|
230
|
+
if (e.data.type === 'awi_state_binary') {{
|
|
231
|
+
_fromParent = true;
|
|
232
|
+
const _t0 = performance.now();
|
|
233
|
+
const hdr = e.data.header || {{}};
|
|
234
|
+
const bytes = e.data.buffer instanceof Uint8Array
|
|
235
|
+
? e.data.buffer : new Uint8Array(e.data.buffer);
|
|
236
|
+
// Stash the raw bytes in a global side-table (the anywidget model does NOT
|
|
237
|
+
// round-trip a Uint8Array set on an ad-hoc trait — model.get returns
|
|
238
|
+
// undefined), then bump a small TOKEN trait to fire the ESM's change: event,
|
|
239
|
+
// which reads the bytes from the side-table. Key = "<geom_trait>::<pixelKey>".
|
|
240
|
+
const slot = (hdr.geom ? hdr.geom : 'fig') + '::' + e.data.key;
|
|
241
|
+
(globalThis.__apl_pixbytes || (globalThis.__apl_pixbytes = {{}}))[slot] = bytes;
|
|
242
|
+
model.set(slot, (bytes ? bytes.length : 0) + ':' + (globalThis.__apl_paint_n || 0));
|
|
243
|
+
model.save_changes(); // triggers the paint synchronously
|
|
244
|
+
// Paint-latency telemetry (message receipt → draw done) for the binary path.
|
|
245
|
+
try {{
|
|
246
|
+
globalThis.__apl_paint_ms = performance.now() - _t0;
|
|
247
|
+
globalThis.__apl_paint_n = (globalThis.__apl_paint_n || 0) + 1;
|
|
248
|
+
globalThis.__apl_paint_kind = 'binary';
|
|
249
|
+
}} catch (_) {{}}
|
|
250
|
+
_fromParent = false;
|
|
251
|
+
return;
|
|
252
|
+
}}
|
|
215
253
|
}});
|
|
216
254
|
</script>
|
|
217
255
|
</body>
|
anyplotlib/_utils.py
CHANGED
|
@@ -6,6 +6,8 @@ Shared low-level utilities used across plot subpackages.
|
|
|
6
6
|
|
|
7
7
|
from __future__ import annotations
|
|
8
8
|
|
|
9
|
+
import functools
|
|
10
|
+
|
|
9
11
|
import numpy as np
|
|
10
12
|
|
|
11
13
|
_LINESTYLE_ALIASES: dict[str, str] = {
|
|
@@ -79,20 +81,48 @@ def _to_rgba_u8(data: np.ndarray) -> np.ndarray:
|
|
|
79
81
|
return np.ascontiguousarray(data)
|
|
80
82
|
|
|
81
83
|
|
|
82
|
-
def _normalize_image(data: np.ndarray):
|
|
83
|
-
"""Normalise data to uint8, returning (img_u8, vmin, vmax)
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
84
|
+
def _normalize_image(data: np.ndarray, clim: "tuple | None" = None):
|
|
85
|
+
"""Normalise data to uint8, returning (img_u8, vmin, vmax) where vmin/vmax are
|
|
86
|
+
the QUANTISATION endpoints the 8-bit codes span (the caller stores them as
|
|
87
|
+
raw_min/raw_max so the renderer can reconstruct each code's value).
|
|
88
|
+
|
|
89
|
+
When ``clim=(lo, hi)`` is given, quantise over THAT range instead of the raw
|
|
90
|
+
data min/max, clipping outliers to 0/255. This is what makes a single hot pixel
|
|
91
|
+
(or the saturating zero-order beam) NOT crush the real signal: without it the
|
|
92
|
+
256 codes are stretched across the full data range, so a 60000-count hot pixel
|
|
93
|
+
over a 0-4000 signal leaves the signal only ~12 distinct codes (≈3.5-bit) before
|
|
94
|
+
it ever reaches the display window; clipping to the robust display range instead
|
|
95
|
+
gives the signal the full 256 codes and just saturates the outlier. When no
|
|
96
|
+
clim is given the behaviour is unchanged (quantise over raw min/max).
|
|
97
|
+
|
|
98
|
+
Uses float32 for the rescale when the data range is small enough that float32's
|
|
99
|
+
~7 significant digits don't lose low bits (the common case: uint8/uint16 movie
|
|
100
|
+
frames), else float64. float32 halves the memory bandwidth of the subtract/
|
|
101
|
+
divide/multiply passes (~60ms → ~27ms on a 2048² frame — this runs every movie
|
|
102
|
+
frame) with a byte-identical result. vmin/vmax are always returned as full-
|
|
103
|
+
precision Python floats."""
|
|
104
|
+
if clim is not None:
|
|
105
|
+
vmin, vmax = float(clim[0]), float(clim[1])
|
|
93
106
|
else:
|
|
94
|
-
|
|
95
|
-
|
|
107
|
+
vmin = float(np.nanmin(data))
|
|
108
|
+
vmax = float(np.nanmax(data))
|
|
109
|
+
if vmax <= vmin:
|
|
110
|
+
return np.zeros(data.shape, dtype=np.uint8), vmin, vmax
|
|
111
|
+
# float32 is safe when |values| and the span are within its ~1.6e7 exact-integer
|
|
112
|
+
# range with headroom; a huge-magnitude float source keeps float64 to avoid
|
|
113
|
+
# banding. (subtract of two near-equal large float32s loses precision.)
|
|
114
|
+
span = vmax - vmin
|
|
115
|
+
use32 = (max(abs(vmin), abs(vmax)) < 1e6) and (span > 1e-6)
|
|
116
|
+
dt = np.float32 if use32 else np.float64
|
|
117
|
+
img = data.astype(dt, copy=False)
|
|
118
|
+
buf = np.empty_like(img)
|
|
119
|
+
np.subtract(img, dt(vmin), out=buf)
|
|
120
|
+
np.divide(buf, dt(span), out=buf)
|
|
121
|
+
np.multiply(buf, dt(255.0), out=buf)
|
|
122
|
+
# Clip into [0, 255] BEFORE the uint8 cast so out-of-clim values saturate
|
|
123
|
+
# instead of wrapping (a hot pixel above vmax would otherwise overflow uint8).
|
|
124
|
+
np.clip(buf, 0.0, 255.0, out=buf)
|
|
125
|
+
return buf.astype(np.uint8), vmin, vmax
|
|
96
126
|
|
|
97
127
|
|
|
98
128
|
# Mapping from common matplotlib colormap names to their nearest colorcet
|
|
@@ -115,9 +145,15 @@ _CMAP_ALIASES: dict[str, str] = {
|
|
|
115
145
|
}
|
|
116
146
|
|
|
117
147
|
|
|
148
|
+
@functools.lru_cache(maxsize=64)
|
|
118
149
|
def _build_colormap_lut(name: str) -> list:
|
|
119
150
|
"""Return a 256-entry ``[[r, g, b], ...]`` LUT for the named colormap.
|
|
120
151
|
|
|
152
|
+
CACHED (lru_cache) — it's a pure function of ``name`` but costs ~100 ms
|
|
153
|
+
(colorcet lookup + 256 hex parses), and it was being rebuilt on EVERY frame of
|
|
154
|
+
a movie scrub even though the colormap doesn't change. The returned list is
|
|
155
|
+
treated as read-only by callers (they JSON-serialise it); do NOT mutate it.
|
|
156
|
+
|
|
121
157
|
Priority order:
|
|
122
158
|
|
|
123
159
|
1. **colorcet** — preferred; common matplotlib names are remapped via
|
anyplotlib/axes/_axes.py
CHANGED
|
@@ -33,13 +33,18 @@ class Axes:
|
|
|
33
33
|
self._plot: "Plot1D | Plot2D | None" = None
|
|
34
34
|
|
|
35
35
|
# ------------------------------------------------------------------
|
|
36
|
-
def imshow(self, data
|
|
36
|
+
def imshow(self, data,
|
|
37
37
|
axes: list | None = None,
|
|
38
38
|
units: str = "px",
|
|
39
39
|
cmap: str | None = None,
|
|
40
40
|
vmin: float | None = None,
|
|
41
41
|
vmax: float | None = None,
|
|
42
|
-
origin: str = "upper"
|
|
42
|
+
origin: str = "upper",
|
|
43
|
+
gpu: "str | bool" = "auto",
|
|
44
|
+
tile: "str | bool" = "auto",
|
|
45
|
+
integration_method: str = "mean",
|
|
46
|
+
overview_method: str = "mean",
|
|
47
|
+
tile_backend=None) -> "Plot2D":
|
|
43
48
|
"""Attach a 2-D image to this axes cell.
|
|
44
49
|
|
|
45
50
|
Parameters
|
|
@@ -74,7 +79,10 @@ class Axes:
|
|
|
74
79
|
x_axis = axes[0] if axes and len(axes) > 0 else None
|
|
75
80
|
y_axis = axes[1] if axes and len(axes) > 1 else None
|
|
76
81
|
plot = Plot2D(data, x_axis=x_axis, y_axis=y_axis, units=units,
|
|
77
|
-
cmap=cmap, vmin=vmin, vmax=vmax, origin=origin
|
|
82
|
+
cmap=cmap, vmin=vmin, vmax=vmax, origin=origin, gpu=gpu,
|
|
83
|
+
tile=tile, integration_method=integration_method,
|
|
84
|
+
overview_method=overview_method,
|
|
85
|
+
tile_backend=tile_backend)
|
|
78
86
|
self._attach(plot)
|
|
79
87
|
return plot
|
|
80
88
|
|
anyplotlib/callbacks.py
CHANGED
|
@@ -24,7 +24,7 @@ from typing import Any, Callable
|
|
|
24
24
|
VALID_EVENT_TYPES = frozenset({
|
|
25
25
|
"pointer_down", "pointer_up", "pointer_move", "pointer_settled",
|
|
26
26
|
"pointer_enter", "pointer_leave", "double_click", "wheel",
|
|
27
|
-
"key_down", "key_up", "close", "*",
|
|
27
|
+
"key_down", "key_up", "close", "view_changed", "*",
|
|
28
28
|
})
|
|
29
29
|
|
|
30
30
|
|
|
@@ -79,6 +79,14 @@ class Event:
|
|
|
79
79
|
# Wheel
|
|
80
80
|
dx: float | None = None
|
|
81
81
|
dy: float | None = None
|
|
82
|
+
# View (view_changed events — the current zoom/pan viewport)
|
|
83
|
+
zoom: float | None = None
|
|
84
|
+
center_x: float | None = None
|
|
85
|
+
center_y: float | None = None
|
|
86
|
+
image_width: int | None = None
|
|
87
|
+
image_height: int | None = None
|
|
88
|
+
display_width: int | None = None # panel device px (JS) → tile output resolution
|
|
89
|
+
display_height: int | None = None
|
|
82
90
|
# Key
|
|
83
91
|
key: str | None = None
|
|
84
92
|
last_widget_id: str | None = None
|
anyplotlib/figure/_figure.py
CHANGED
|
@@ -24,6 +24,16 @@ _HERE = pathlib.Path(__file__).parent.parent
|
|
|
24
24
|
_ESM_SOURCE = (_HERE / "figure_esm.js").read_text(encoding="utf-8")
|
|
25
25
|
|
|
26
26
|
|
|
27
|
+
def _binary_wire() -> bool:
|
|
28
|
+
"""True when the Electron binary pixel transport is live, so a pixel
|
|
29
|
+
change-token in the panel state should stay a token (the real bytes ride
|
|
30
|
+
PLOTBIN) rather than being resolved to inline base64. Read fresh from the
|
|
31
|
+
environment — the same gate ``Plot2D._encode_pixels`` uses — so producer and
|
|
32
|
+
serializer never disagree."""
|
|
33
|
+
import os
|
|
34
|
+
return os.environ.get("APL_BINARY_TRANSPORT") == "1"
|
|
35
|
+
|
|
36
|
+
|
|
27
37
|
class Figure(anywidget.AnyWidget):
|
|
28
38
|
"""Multi-panel interactive figure widget.
|
|
29
39
|
|
|
@@ -124,6 +134,14 @@ class Figure(anywidget.AnyWidget):
|
|
|
124
134
|
# when its values genuinely change.
|
|
125
135
|
self._geom_rev: dict = {}
|
|
126
136
|
self._geom_last: dict = {}
|
|
137
|
+
# Raw pixel side-table for the Electron BINARY transport: maps
|
|
138
|
+
# ``(panel_id, pixel_key)`` → the raw ``bytes`` of the current frame.
|
|
139
|
+
# When binary transport is active, ``Plot2D.set_data`` stashes the
|
|
140
|
+
# uint8 image bytes here (and puts a tiny change-token in the geom
|
|
141
|
+
# field instead of a 5.6 MB base64 string), so ``_electron._route_change``
|
|
142
|
+
# ships them straight to a PLOTBIN frame with NO base64 encode/decode
|
|
143
|
+
# and NO megabyte JSON. Empty (and ignored) on every non-Electron path.
|
|
144
|
+
self._raw_pixels: dict = {}
|
|
127
145
|
with self.hold_trait_notifications():
|
|
128
146
|
self.fig_width = figsize[0]
|
|
129
147
|
self.fig_height = figsize[1]
|
|
@@ -279,6 +297,14 @@ class Figure(anywidget.AnyWidget):
|
|
|
279
297
|
state = plot.to_state_dict()
|
|
280
298
|
geom_keys = getattr(plot, "_GEOM_KEYS", None)
|
|
281
299
|
gname = f"panel_{panel_id}_geom"
|
|
300
|
+
# ``state`` may carry a ``"\x00bin:…"`` pixel change-token (binary
|
|
301
|
+
# transport: the real bytes ride PLOTBIN via ``_route_change``). Keep
|
|
302
|
+
# the token when that channel is live; otherwise — a standalone /
|
|
303
|
+
# save_html / Jupyter figure with no binary channel — materialise the
|
|
304
|
+
# real base64 inline so the pixels actually travel. ``_binary_wire``
|
|
305
|
+
# matches the producer's gate (``Plot2D._encode_pixels``).
|
|
306
|
+
if geom_keys and not _binary_wire() and hasattr(plot, "resolve_pixel_tokens"):
|
|
307
|
+
plot.resolve_pixel_tokens(state)
|
|
282
308
|
if geom_keys and self.has_trait(gname):
|
|
283
309
|
# Split heavy geometry into its own channel. Detect change by
|
|
284
310
|
# comparing the geom values themselves (the b64 strings / LUT
|
|
@@ -501,8 +527,21 @@ class Figure(anywidget.AnyWidget):
|
|
|
501
527
|
|
|
502
528
|
plot = self._plots_map.get(panel_id)
|
|
503
529
|
if plot is None:
|
|
530
|
+
if event_type == "view_changed":
|
|
531
|
+
import logging
|
|
532
|
+
# WARNING so SpyDE's log stream forwards it (it drops non-spyde.* INFO).
|
|
533
|
+
logging.getLogger("anyplotlib.tile").warning(
|
|
534
|
+
"[TILE] view_changed for UNKNOWN panel %r (have %r) — dropped",
|
|
535
|
+
panel_id, list(self._plots_map.keys()))
|
|
504
536
|
return
|
|
505
537
|
|
|
538
|
+
if event_type == "view_changed":
|
|
539
|
+
import logging
|
|
540
|
+
logging.getLogger("anyplotlib.tile").warning(
|
|
541
|
+
"[TILE] view_changed RECEIVED panel=%s zoom=%s center=(%s,%s) disp=(%s,%s)",
|
|
542
|
+
panel_id, msg.get("zoom"), msg.get("center_x"), msg.get("center_y"),
|
|
543
|
+
msg.get("display_width"), msg.get("display_height"))
|
|
544
|
+
|
|
506
545
|
# GPU activation status echo (WebGPU path) — not a user event.
|
|
507
546
|
if event_type == "gpu_status":
|
|
508
547
|
if hasattr(plot, "_set_gpu_active"):
|
|
@@ -537,6 +576,13 @@ class Figure(anywidget.AnyWidget):
|
|
|
537
576
|
group_index=msg.get("group_index"),
|
|
538
577
|
dx=msg.get("dx"),
|
|
539
578
|
dy=msg.get("dy"),
|
|
579
|
+
zoom=msg.get("zoom"),
|
|
580
|
+
center_x=msg.get("center_x"),
|
|
581
|
+
center_y=msg.get("center_y"),
|
|
582
|
+
image_width=msg.get("image_width"),
|
|
583
|
+
image_height=msg.get("image_height"),
|
|
584
|
+
display_width=msg.get("display_width"),
|
|
585
|
+
display_height=msg.get("display_height"),
|
|
540
586
|
key=msg.get("key"),
|
|
541
587
|
last_widget_id=msg.get("last_widget_id"),
|
|
542
588
|
)
|