de-shell 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.
- de_shell/__init__.py +25 -0
- de_shell/actions/__init__.py +0 -0
- de_shell/actions/context.py +62 -0
- de_shell/actions/figure_registry.py +53 -0
- de_shell/actions/lifecycle.py +295 -0
- de_shell/actions/registry.py +141 -0
- de_shell/actions/wizard.py +115 -0
- de_shell/app.py +170 -0
- de_shell/compute.py +103 -0
- de_shell/debug_flags.py +69 -0
- de_shell/ipc.py +236 -0
- de_shell/js/__init__.py +38 -0
- de_shell/js/__main__.py +4 -0
- de_shell/js/main/backendProcess.test.ts +70 -0
- de_shell/js/main/backendProcess.ts +330 -0
- de_shell/js/main/config.ts +53 -0
- de_shell/js/main/dialogs.ts +62 -0
- de_shell/js/main/envProgress.ts +126 -0
- de_shell/js/main/errorReport.ts +261 -0
- de_shell/js/main/index.ts +57 -0
- de_shell/js/main/problemLog.ts +53 -0
- de_shell/js/main/pythonEnv.test.ts +125 -0
- de_shell/js/main/pythonEnv.ts +442 -0
- de_shell/js/main/sentryEnvelope.test.ts +94 -0
- de_shell/js/main/sentryEnvelope.ts +100 -0
- de_shell/js/main/updater.ts +322 -0
- de_shell/js/main/updaterErrors.test.ts +111 -0
- de_shell/js/main/updaterErrors.ts +65 -0
- de_shell/js/main/window.ts +141 -0
- de_shell/js/package.json +5 -0
- de_shell/js/preload/index.ts +130 -0
- de_shell/js/renderer/FigureFrame.tsx +88 -0
- de_shell/js/renderer/figureBridge.react.ts +58 -0
- de_shell/js/renderer/figureBridge.test.ts +184 -0
- de_shell/js/renderer/figureBridge.ts +169 -0
- de_shell/js/renderer/index.ts +34 -0
- de_shell/js/renderer/protocol.ts +164 -0
- de_shell/js/renderer/shellState.test.ts +193 -0
- de_shell/js/renderer/shellState.ts +310 -0
- de_shell/js/testing/harness.cjs +244 -0
- de_shell/js/testing/harness.test.cjs +73 -0
- de_shell/log_stream.py +185 -0
- de_shell/plotting/__init__.py +0 -0
- de_shell/plotting/colormaps.py +27 -0
- de_shell/plotting/figure.py +601 -0
- de_shell/plotting/selectors/__init__.py +0 -0
- de_shell/plotting/selectors/utils.py +29 -0
- de_shell/plotting/stream.py +172 -0
- de_shell/process_guard.py +190 -0
- de_shell/session.py +211 -0
- de_shell/testing/__init__.py +0 -0
- de_shell/timing.py +28 -0
- de_shell-0.2.0.dist-info/METADATA +196 -0
- de_shell-0.2.0.dist-info/RECORD +57 -0
- de_shell-0.2.0.dist-info/WHEEL +5 -0
- de_shell-0.2.0.dist-info/licenses/LICENSE +21 -0
- de_shell-0.2.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,601 @@
|
|
|
1
|
+
"""
|
|
2
|
+
figure.py — a single image pane, created once and repainted in place.
|
|
3
|
+
|
|
4
|
+
The smallest correct anyplotlib figure a shell app can own. Creating one emits
|
|
5
|
+
its HTML to the renderer, which mounts it in an iframe keyed by ``fig_id``;
|
|
6
|
+
every later frame is a ``set_data`` push down the same channel, so the iframe is
|
|
7
|
+
never rebuilt and the user's zoom survives the next frame.
|
|
8
|
+
|
|
9
|
+
**This is deliberately the small shape, not SpyDE's.** SpyDE's ``Plot`` carries
|
|
10
|
+
the array cache, the tiered navigator read, overlay layers and tile mode — all
|
|
11
|
+
of which exist because it displays multi-gigabyte lazy datasets on disk. A live
|
|
12
|
+
camera hands over a frame that is already in RAM, so none of it applies. The
|
|
13
|
+
shared wrapper is the in-memory core; an app that needs the out-of-core
|
|
14
|
+
machinery layers it on top rather than the shell carrying it for everyone.
|
|
15
|
+
|
|
16
|
+
It satisfies the WindowController protocol (a ``window_id`` and a ``close()``),
|
|
17
|
+
so a session's window registry can own its lifetime.
|
|
18
|
+
|
|
19
|
+
Two things here are easy to get wrong and cost a debugging session each; both
|
|
20
|
+
are enforced rather than documented-and-hoped:
|
|
21
|
+
|
|
22
|
+
* **Registration mints the fig_id.** ``_electron.register(fig)`` attaches the
|
|
23
|
+
trait observers that turn a later ``set_data`` into a push, and returns the id
|
|
24
|
+
the renderer routes those pushes by. Inventing an id instead leaves the figure
|
|
25
|
+
unregistered: it mounts, sizes and titles correctly, and then never updates,
|
|
26
|
+
with no error anywhere.
|
|
27
|
+
* **Levels are robust by default.** One hot pixel — which every real detector
|
|
28
|
+
has — sets the ceiling under a plain min/max and renders the whole image
|
|
29
|
+
black.
|
|
30
|
+
"""
|
|
31
|
+
from __future__ import annotations
|
|
32
|
+
|
|
33
|
+
import logging
|
|
34
|
+
import warnings
|
|
35
|
+
|
|
36
|
+
import numpy as np
|
|
37
|
+
|
|
38
|
+
import anyplotlib as apl
|
|
39
|
+
import anyplotlib._electron as _electron
|
|
40
|
+
from anyplotlib.embed import build_standalone_html
|
|
41
|
+
|
|
42
|
+
from de_shell.ipc import emit
|
|
43
|
+
from de_shell.plotting.colormaps import DEFAULT_COLORMAP
|
|
44
|
+
|
|
45
|
+
log = logging.getLogger(__name__)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
#: Subsample any axis longer than this before measuring levels. Percentiles over
|
|
49
|
+
#: 16 M pixels cost tens of ms and land on the paint path; a ≤512² sample gives
|
|
50
|
+
#: the same answer to well within a display level.
|
|
51
|
+
LEVEL_SAMPLE = 512
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def robust_levels(frame: np.ndarray, *,
|
|
55
|
+
low: float | None = 2.0, high: float = 98.0,
|
|
56
|
+
sample: int = LEVEL_SAMPLE) -> tuple[float, float]:
|
|
57
|
+
"""A display range for *frame* that a hot pixel cannot wreck.
|
|
58
|
+
|
|
59
|
+
Percentiles rather than min/max: one saturated pixel — which every real
|
|
60
|
+
detector has — otherwise sets the ceiling and renders everything else black.
|
|
61
|
+
|
|
62
|
+
``low=None`` uses the true minimum instead of a low percentile. That is the
|
|
63
|
+
right choice for an image with no saturating spike (a navigator, a virtual
|
|
64
|
+
image), where clipping the floor throws away real dynamic range; an image
|
|
65
|
+
that DOES have one (a diffraction pattern, whose central beam is orders of
|
|
66
|
+
magnitude brighter than the spots) wants both ends clipped.
|
|
67
|
+
|
|
68
|
+
Lifted from SpyDE's ``Plot._robust_levels``, which had already paid for the
|
|
69
|
+
subsampling and the non-finite handling.
|
|
70
|
+
"""
|
|
71
|
+
arr = np.asarray(frame)
|
|
72
|
+
try:
|
|
73
|
+
sy = max(1, arr.shape[0] // sample)
|
|
74
|
+
sx = max(1, arr.shape[1] // sample) if arr.ndim > 1 else 1
|
|
75
|
+
data = np.asarray(arr[::sy, ::sx] if arr.ndim > 1 else arr[::sy],
|
|
76
|
+
dtype=np.float64)
|
|
77
|
+
data = data[np.isfinite(data)]
|
|
78
|
+
if data.size == 0:
|
|
79
|
+
# Nothing measurable — an all-NaN frame, or an empty one.
|
|
80
|
+
return 0.0, 1.0
|
|
81
|
+
# No warnings.catch_warnings() here, deliberately. `data` is already
|
|
82
|
+
# finite-filtered above, so numpy's all-NaN RuntimeWarning cannot fire —
|
|
83
|
+
# and this runs on the PAINT path, where entering a context manager that
|
|
84
|
+
# saves and restores global warning state on every frame is pure cost.
|
|
85
|
+
lo = float(np.percentile(data, low)) if low is not None else float(data.min())
|
|
86
|
+
hi = float(np.percentile(data, high))
|
|
87
|
+
# Collapsed percentiles mean the BULK of the frame is one value. Fall
|
|
88
|
+
# back to the true maximum — load-bearing for a SPARSE image, e.g. a
|
|
89
|
+
# count map that is >99.5% zeros with a few bright spots: the percentile
|
|
90
|
+
# is zero there, and without this the spots all saturate against a
|
|
91
|
+
# 1-wide window instead of scaling properly.
|
|
92
|
+
#
|
|
93
|
+
# The cost is that a perfectly FLAT frame carrying one hot pixel scales
|
|
94
|
+
# to that pixel and renders dark. That is the right trade: a sparse real
|
|
95
|
+
# image is common and a flat synthetic one is not, and any frame with
|
|
96
|
+
# genuine variation never reaches this branch.
|
|
97
|
+
if hi <= lo:
|
|
98
|
+
hi = float(data.max())
|
|
99
|
+
# Still collapsed: genuinely uniform. Widen by a hair rather than return
|
|
100
|
+
# a zero-width window, which renders as a solid block and is
|
|
101
|
+
# indistinguishable from a broken decode.
|
|
102
|
+
if hi <= lo:
|
|
103
|
+
hi = lo + 1.0
|
|
104
|
+
return lo, hi
|
|
105
|
+
except Exception:
|
|
106
|
+
return 0.0, 1.0
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
#: Figure chrome background. The apps are dark; anyplotlib's template is not.
|
|
110
|
+
FIGURE_BACKGROUND = "#1e1e2e"
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def fill_iframe_html(html: str, *, background: str = FIGURE_BACKGROUND,
|
|
114
|
+
extra_head: str = "") -> str:
|
|
115
|
+
"""Make a standalone figure FILL its iframe, and match the app's theme.
|
|
116
|
+
|
|
117
|
+
Load-bearing for any app that drives figure size with
|
|
118
|
+
``_electron.resize_figure``. anyplotlib's standalone template pins
|
|
119
|
+
``html``/``body`` to the figure's INITIAL pixel size with
|
|
120
|
+
``overflow:hidden`` — correct for a fixed docs or notebook embed. A shell app
|
|
121
|
+
resizes the figure live, so once the pane is larger than that initial size
|
|
122
|
+
the grown figure is CLIPPED to the old body box: the image spills past the
|
|
123
|
+
panel and the bottom is cut off, while everything around it looks fine.
|
|
124
|
+
|
|
125
|
+
``extra_head`` is injected alongside, for an app that needs its own script in
|
|
126
|
+
the frame (SpyDE relays a pointerdown to bring its subwindow to the front).
|
|
127
|
+
"""
|
|
128
|
+
style = (f"<style>html,body{{background:{background} !important;color-scheme:dark;"
|
|
129
|
+
"width:100% !important;height:100% !important;overflow:hidden}"
|
|
130
|
+
f"#widget-root{{background:{background} !important;"
|
|
131
|
+
"width:100% !important;height:100% !important;display:block !important}"
|
|
132
|
+
"</style>")
|
|
133
|
+
return html.replace("<body>", style + extra_head + "<body>", 1)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
class FigureView:
|
|
137
|
+
"""One always-on image pane.
|
|
138
|
+
|
|
139
|
+
Parameters
|
|
140
|
+
----------
|
|
141
|
+
window_id
|
|
142
|
+
The window this figure belongs to, as minted by the session.
|
|
143
|
+
title
|
|
144
|
+
Shown above the image and used as the window title.
|
|
145
|
+
colormap
|
|
146
|
+
Initial colormap name.
|
|
147
|
+
gpu
|
|
148
|
+
Passed through to ``imshow``. ``"auto"`` renders large scalar images on
|
|
149
|
+
the GPU and falls back to Canvas2D for small ones, RGB, and machines
|
|
150
|
+
without it; ``"off"`` forces Canvas2D (what a CPU-reference screenshot
|
|
151
|
+
test wants).
|
|
152
|
+
"""
|
|
153
|
+
|
|
154
|
+
def __init__(self, window_id: int, title: str = "", *,
|
|
155
|
+
colormap: str = DEFAULT_COLORMAP, gpu: str = "auto") -> None:
|
|
156
|
+
self.window_id = window_id
|
|
157
|
+
self.title = title
|
|
158
|
+
#: Assigned by `open()` from `_electron.register` — see the module note.
|
|
159
|
+
self.fig_id: str | None = None
|
|
160
|
+
self._fig = None
|
|
161
|
+
self._axes = None
|
|
162
|
+
self._plot2d = None
|
|
163
|
+
self._colormap = colormap
|
|
164
|
+
self._gpu = gpu
|
|
165
|
+
self._closed = False
|
|
166
|
+
self._tiled = False
|
|
167
|
+
#: The last frame `show` painted, for `auto_clim`. One reference, not a
|
|
168
|
+
#: copy; None while tiled, since `show` is not on that path.
|
|
169
|
+
self._last_frame = None
|
|
170
|
+
|
|
171
|
+
# ── Lifecycle ─────────────────────────────────────────────────────────────
|
|
172
|
+
|
|
173
|
+
@property
|
|
174
|
+
def is_open(self) -> bool:
|
|
175
|
+
return self._plot2d is not None and not self._closed
|
|
176
|
+
|
|
177
|
+
def open(self, shape: tuple[int, int], *, is_navigator: bool = False) -> str | None:
|
|
178
|
+
"""Create the figure and send it to the renderer. Idempotent.
|
|
179
|
+
|
|
180
|
+
*shape* is the frame size to lay out for. Passing the REAL size (rather
|
|
181
|
+
than a small stand-in) matters: a placeholder of a different shape makes
|
|
182
|
+
the pane resize the moment the first frame lands, which reads as the
|
|
183
|
+
window jumping.
|
|
184
|
+
|
|
185
|
+
Returns the fig_id.
|
|
186
|
+
"""
|
|
187
|
+
if self._fig is not None:
|
|
188
|
+
return self.fig_id
|
|
189
|
+
h, w = int(shape[0]), int(shape[1])
|
|
190
|
+
self._fig, axes_obj = apl.subplots(1, 1)
|
|
191
|
+
self._axes = axes_obj[0][0] if isinstance(axes_obj, list) else axes_obj
|
|
192
|
+
self._plot2d = self._axes.imshow(
|
|
193
|
+
np.zeros((h, w), dtype=np.float32), cmap=self._colormap, gpu=self._gpu)
|
|
194
|
+
if self.title:
|
|
195
|
+
self._plot2d.set_title(self.title)
|
|
196
|
+
|
|
197
|
+
# Register BEFORE building the HTML: registration attaches the trait
|
|
198
|
+
# observers AND mints the id, so the HTML must be built with that id.
|
|
199
|
+
self.fig_id = _electron.register(self._fig)
|
|
200
|
+
html = fill_iframe_html(
|
|
201
|
+
build_standalone_html(self._fig, fig_id=self.fig_id, resizable=False))
|
|
202
|
+
|
|
203
|
+
emit({
|
|
204
|
+
"type": "figure",
|
|
205
|
+
"fig_id": self.fig_id,
|
|
206
|
+
"window_id": self.window_id,
|
|
207
|
+
"html": html,
|
|
208
|
+
"title": self.title,
|
|
209
|
+
"is_navigator": is_navigator,
|
|
210
|
+
"aspect": (w / h) if h else None,
|
|
211
|
+
})
|
|
212
|
+
return self.fig_id
|
|
213
|
+
|
|
214
|
+
def close(self) -> None:
|
|
215
|
+
"""WindowController.close — idempotent, and never raises during teardown."""
|
|
216
|
+
if self._closed:
|
|
217
|
+
return
|
|
218
|
+
self._closed = True
|
|
219
|
+
self._tiled = False
|
|
220
|
+
self._plot2d = None
|
|
221
|
+
self._axes = None
|
|
222
|
+
self._fig = None
|
|
223
|
+
self._last_frame = None # a closed figure must not pin a 4096² frame
|
|
224
|
+
|
|
225
|
+
# ── Painting ──────────────────────────────────────────────────────────────
|
|
226
|
+
|
|
227
|
+
def show(self, frame: np.ndarray, *,
|
|
228
|
+
clim: tuple[float, float] | None = None) -> bool:
|
|
229
|
+
"""Paint one frame. **Main thread only** — see the threading contract in
|
|
230
|
+
``de_shell.actions.lifecycle``.
|
|
231
|
+
|
|
232
|
+
``clim=None`` re-derives a robust range per frame, which is what a live
|
|
233
|
+
scene wants; pass an explicit range to hold contrast steady across
|
|
234
|
+
frames. Returns whether the paint landed, so a caller that must know
|
|
235
|
+
(a live preview, a test) is not left inferring it from a counter that
|
|
236
|
+
increments either way.
|
|
237
|
+
"""
|
|
238
|
+
if not self.is_open:
|
|
239
|
+
return False
|
|
240
|
+
try:
|
|
241
|
+
# Pin BEFORE set_data when the plot is already tiled (a figure
|
|
242
|
+
# opened at a large shape tiles on its zeros placeholder), so the
|
|
243
|
+
# frame is encoded over a valid band in the same push. The
|
|
244
|
+
# after-call catches the other route: this set_data being the one
|
|
245
|
+
# that swaps the plot into tile mode.
|
|
246
|
+
pinned = self._pin_tile_band(frame)
|
|
247
|
+
self._plot2d.set_data(
|
|
248
|
+
frame, clim=clim if clim is not None else robust_levels(frame))
|
|
249
|
+
if not pinned and self._pin_tile_band(frame):
|
|
250
|
+
# Swapped mid-call: the push above quantised over the encoder's
|
|
251
|
+
# display-window fallback; re-encode once over the real band.
|
|
252
|
+
self._plot2d.update_tile_source()
|
|
253
|
+
# Kept so Auto can re-derive a range later. A live scene re-derives
|
|
254
|
+
# per frame and never needs it, but a STILL figure — a calibration
|
|
255
|
+
# image, a motion average — has no next frame to hand the job to,
|
|
256
|
+
# so without this its Auto button has nothing to compute from.
|
|
257
|
+
self._last_frame = frame
|
|
258
|
+
return True
|
|
259
|
+
except Exception as e:
|
|
260
|
+
log.debug("painting figure %s failed: %s", self.fig_id, e)
|
|
261
|
+
return False
|
|
262
|
+
|
|
263
|
+
def _pin_tile_band(self, frame: np.ndarray) -> bool:
|
|
264
|
+
"""Give a tiled plot a valid quantisation band, from *frame* itself.
|
|
265
|
+
|
|
266
|
+
anyplotlib auto-tiles any frame over its size threshold and derives the
|
|
267
|
+
raw_min/raw_max band ONCE — from whatever the tile source holds at
|
|
268
|
+
enable time. A figure opened at a large shape tiles on its ZEROS
|
|
269
|
+
placeholder, so the band comes out (0, 0), and `_set_data_tiled` never
|
|
270
|
+
re-derives it. The two ends of the protocol then disagree about that
|
|
271
|
+
degenerate band: the Python encoder falls back to the display window,
|
|
272
|
+
but the frontend LUT honours (0, 0) — every byte maps below the display
|
|
273
|
+
floor and the pane renders black however good the data. (`set_tile_band`
|
|
274
|
+
documents the same failure for the explicit-tile live path, which is
|
|
275
|
+
why the camera view survived while every large STILL — Calibrate, the
|
|
276
|
+
FFT panels, Motion sums — went black.)
|
|
277
|
+
|
|
278
|
+
Returns whether the band was already valid or has been pinned; False
|
|
279
|
+
means the plot is not in tile mode (nothing to do). Upstream fix worth
|
|
280
|
+
proposing (CSSFrancis/anyplotlib): derive the band in `_set_data_tiled`,
|
|
281
|
+
and treat raw_max <= raw_min as unset in the frontend LUT.
|
|
282
|
+
"""
|
|
283
|
+
st = getattr(self._plot2d, "_state", None)
|
|
284
|
+
if not isinstance(st, dict) or not st.get("tile_enabled"):
|
|
285
|
+
return False
|
|
286
|
+
lo, hi = st.get("raw_min"), st.get("raw_max")
|
|
287
|
+
if lo is not None and hi is not None and hi > lo:
|
|
288
|
+
return True
|
|
289
|
+
arr = np.asarray(frame)
|
|
290
|
+
if arr.size == 0:
|
|
291
|
+
return False
|
|
292
|
+
if arr.dtype.kind == "f":
|
|
293
|
+
# nanmin/nanmax rather than a finite mask: the mask materialises a
|
|
294
|
+
# copy of the frame (~½ GB transient on an 8k float), this doesn't.
|
|
295
|
+
with warnings.catch_warnings():
|
|
296
|
+
warnings.simplefilter("ignore", RuntimeWarning) # all-NaN frame
|
|
297
|
+
lo, hi = float(np.nanmin(arr)), float(np.nanmax(arr))
|
|
298
|
+
if not (np.isfinite(lo) and np.isfinite(hi)):
|
|
299
|
+
finite = arr[np.isfinite(arr)] # rare: ±inf in the frame
|
|
300
|
+
if finite.size == 0:
|
|
301
|
+
return False
|
|
302
|
+
lo, hi = float(finite.min()), float(finite.max())
|
|
303
|
+
else:
|
|
304
|
+
lo, hi = float(arr.min()), float(arr.max())
|
|
305
|
+
if not hi > lo:
|
|
306
|
+
hi = lo + 1.0
|
|
307
|
+
st["raw_min"], st["raw_max"] = lo, hi
|
|
308
|
+
return True
|
|
309
|
+
|
|
310
|
+
@property
|
|
311
|
+
def last_frame(self):
|
|
312
|
+
"""The last frame painted by :meth:`show`, or None.
|
|
313
|
+
|
|
314
|
+
For a caller that needs to recompute something ABOUT the displayed
|
|
315
|
+
pixels — a histogram to re-send after the range changed, say — rather
|
|
316
|
+
than re-deriving it from wherever the frame originally came from.
|
|
317
|
+
"""
|
|
318
|
+
return self._last_frame
|
|
319
|
+
|
|
320
|
+
def auto_clim(self) -> tuple[float, float] | None:
|
|
321
|
+
"""Re-derive the robust display range from the last painted frame.
|
|
322
|
+
|
|
323
|
+
Returns the range applied, or None when there is nothing to derive it
|
|
324
|
+
from — a figure that has only ever been tiled (``show`` is never called
|
|
325
|
+
in tile mode) or one that has not painted yet.
|
|
326
|
+
"""
|
|
327
|
+
frame = self._last_frame
|
|
328
|
+
if frame is None:
|
|
329
|
+
return None
|
|
330
|
+
lo, hi = robust_levels(frame)
|
|
331
|
+
return (float(lo), float(hi)) if self.set_clim(lo, hi) else None
|
|
332
|
+
|
|
333
|
+
# ── Tiled display ─────────────────────────────────────────────────────────
|
|
334
|
+
|
|
335
|
+
def enable_tile(self, backend, *, integration_method: str = "mean") -> bool:
|
|
336
|
+
"""Render through a :class:`TileBackend` instead of pushed frames.
|
|
337
|
+
|
|
338
|
+
anyplotlib then owns the loop: it shows a downsampled overview as the
|
|
339
|
+
base and, on its own debounced ``view_changed``, asks the backend for a
|
|
340
|
+
hi-res tile of just the visible region at panel resolution. So the
|
|
341
|
+
source can be larger than anything worth sending whole — an 8192²
|
|
342
|
+
detector, or a camera that is only ever asked for the crop on screen.
|
|
343
|
+
|
|
344
|
+
Backends are duck-typed (``full_shape``, ``dtype``, ``origin``,
|
|
345
|
+
``extent()``, ``sample()``); the shell requires no particular class.
|
|
346
|
+
|
|
347
|
+
Returns whether tiling was enabled — an anyplotlib without the tile API
|
|
348
|
+
is a soft failure, and the caller can fall back to :meth:`show`.
|
|
349
|
+
"""
|
|
350
|
+
if not self.is_open or not hasattr(self._plot2d, "enable_tile"):
|
|
351
|
+
return False
|
|
352
|
+
try:
|
|
353
|
+
self._plot2d.enable_tile(backend, integration_method=integration_method)
|
|
354
|
+
self._tiled = True
|
|
355
|
+
return True
|
|
356
|
+
except Exception as e:
|
|
357
|
+
log.warning("enable_tile failed, falling back to pushed frames: %s", e)
|
|
358
|
+
return False
|
|
359
|
+
|
|
360
|
+
@property
|
|
361
|
+
def is_tiled(self) -> bool:
|
|
362
|
+
return self._tiled
|
|
363
|
+
|
|
364
|
+
def refresh_tile(self) -> bool:
|
|
365
|
+
"""Re-read the CURRENT view from the backend — the live-data path.
|
|
366
|
+
|
|
367
|
+
The zoom and pan persist across the refresh, which is the contract that
|
|
368
|
+
matters for a live camera: new pixels arrive without the user's
|
|
369
|
+
viewport being reset out from under them.
|
|
370
|
+
"""
|
|
371
|
+
if not self.is_open or not self._tiled:
|
|
372
|
+
return False
|
|
373
|
+
try:
|
|
374
|
+
self._plot2d.update_tile_source()
|
|
375
|
+
return True
|
|
376
|
+
except Exception as e:
|
|
377
|
+
log.debug("refresh_tile failed: %s", e)
|
|
378
|
+
return False
|
|
379
|
+
|
|
380
|
+
def set_tile_band(self, lo: float, hi: float) -> bool:
|
|
381
|
+
"""Fix the tile QUANTISATION band (raw_min/raw_max) and re-encode.
|
|
382
|
+
|
|
383
|
+
anyplotlib derives this band once, at `enable_tile`, by sampling the
|
|
384
|
+
backend — which cannot work for a source that has no data yet (a real
|
|
385
|
+
DE Server serves no frame before an acquisition, and the tile source
|
|
386
|
+
deliberately answers zeros until then). With the band unset, tile
|
|
387
|
+
bytes are quantised over fallback guesses and the display window then
|
|
388
|
+
re-windows THOSE — on real hardware that rendered a healthy frame as
|
|
389
|
+
a black pane. The caller knows the true whole-frame range (the
|
|
390
|
+
server's histogram rides along with every read) and pins the band
|
|
391
|
+
here the moment data exists, or when the range outgrows it.
|
|
392
|
+
|
|
393
|
+
Reaches into `_plot2d._state` because anyplotlib has no public
|
|
394
|
+
band-setter yet — worth an upstream API (CSSFrancis/anyplotlib);
|
|
395
|
+
until then this is the one sanctioned touch point.
|
|
396
|
+
"""
|
|
397
|
+
if not self.is_open or not self._tiled:
|
|
398
|
+
return False
|
|
399
|
+
try:
|
|
400
|
+
st = self._plot2d._state
|
|
401
|
+
st["raw_min"], st["raw_max"] = float(lo), float(hi)
|
|
402
|
+
self._plot2d.update_tile_source() # re-encode over the new band
|
|
403
|
+
return True
|
|
404
|
+
except Exception as e:
|
|
405
|
+
log.debug("set_tile_band(%s, %s) failed: %s", lo, hi, e)
|
|
406
|
+
return False
|
|
407
|
+
|
|
408
|
+
def set_clim(self, vmin: float, vmax: float) -> bool:
|
|
409
|
+
"""Set the display range without touching the pixels.
|
|
410
|
+
|
|
411
|
+
The separate call matters in tile mode: :meth:`show` is never called
|
|
412
|
+
there, so there is no ``clim=`` argument to ride along with. Left
|
|
413
|
+
unset, a tiled plot keeps whatever range the placeholder passed to
|
|
414
|
+
``imshow`` established — which renders real data as a uniform white or
|
|
415
|
+
black panel.
|
|
416
|
+
"""
|
|
417
|
+
if not self.is_open:
|
|
418
|
+
return False
|
|
419
|
+
try:
|
|
420
|
+
self._plot2d.set_clim(float(vmin), float(vmax))
|
|
421
|
+
return True
|
|
422
|
+
except Exception as e:
|
|
423
|
+
log.debug("set_clim(%s, %s) failed: %s", vmin, vmax, e)
|
|
424
|
+
return False
|
|
425
|
+
|
|
426
|
+
def on_event(self, *event_types: str):
|
|
427
|
+
"""Register a handler for anyplotlib pointer/key events on this figure.
|
|
428
|
+
|
|
429
|
+
A decorator, matching anyplotlib's own API::
|
|
430
|
+
|
|
431
|
+
@view.on_event("pointer_down")
|
|
432
|
+
def clicked(event): ...
|
|
433
|
+
|
|
434
|
+
The handler receives an `Event` whose ``xdata``/``ydata`` are in DATA
|
|
435
|
+
space — image pixels for an ``imshow`` — which is what a caller
|
|
436
|
+
measuring in the image wants, not canvas pixels.
|
|
437
|
+
|
|
438
|
+
Handlers fire on the asyncio main thread (the stdin reader dispatches
|
|
439
|
+
them), so a slow one blocks every other message: hand real work to a
|
|
440
|
+
worker rather than doing it inline.
|
|
441
|
+
|
|
442
|
+
Returns a no-op decorator when the figure is not open, so registration
|
|
443
|
+
at construction time is safe.
|
|
444
|
+
"""
|
|
445
|
+
def _register(fn):
|
|
446
|
+
if self.is_open:
|
|
447
|
+
try:
|
|
448
|
+
self._plot2d.add_event_handler(fn, *event_types)
|
|
449
|
+
except Exception as e:
|
|
450
|
+
log.warning("could not register %s handler: %s", event_types, e)
|
|
451
|
+
return fn
|
|
452
|
+
return _register
|
|
453
|
+
|
|
454
|
+
def add_circle_widget(self, *, cx: float, cy: float, r: float,
|
|
455
|
+
color: str = "#00e5ff", on_change=None,
|
|
456
|
+
lock_center: bool = False,
|
|
457
|
+
linewidth: float = 2, show_handles: bool = True):
|
|
458
|
+
"""A draggable circle overlay. Returns the widget, or None.
|
|
459
|
+
|
|
460
|
+
WIDGETS, not raw pointer handlers, are the working way to get user
|
|
461
|
+
geometry out of an anyplotlib figure: the plot owns the hit-testing and
|
|
462
|
+
pushes the new state back, whereas a bare `pointer_down` never arrives
|
|
463
|
+
(the plot's own pan consumes it).
|
|
464
|
+
|
|
465
|
+
`on_change(cx, cy, r)` fires on ``pointer_up`` — when the drag settles —
|
|
466
|
+
not on every frame, so a callback that recomputes something expensive
|
|
467
|
+
does not run sixty times a second.
|
|
468
|
+
|
|
469
|
+
`lock_center` pins the centre and lets only the radius change. On a
|
|
470
|
+
power spectrum the centre IS the DC term, so a draggable one is a
|
|
471
|
+
control that can only ever be wrong — and a ring nudged off-centre
|
|
472
|
+
silently corrupts every radius measured from it. anyplotlib enforces it
|
|
473
|
+
in the HIT-TEST: a grab on the ring body is refused outright, so the
|
|
474
|
+
centre never moves at all.
|
|
475
|
+
|
|
476
|
+
`linewidth` and `show_handles` are anyplotlib's own, passed through at
|
|
477
|
+
its own defaults. They matter for a circle used as an ANNOTATION rather
|
|
478
|
+
than a control: a set of them drawn at the default 2 px with grab
|
|
479
|
+
handles reads as a cluster of filled discs and a row of dots, which
|
|
480
|
+
obscures the very image it is marking up. A caller drawing many should
|
|
481
|
+
thin them and drop the handles — a handle on something nothing reads
|
|
482
|
+
back is an affordance that promises an effect it does not have.
|
|
483
|
+
"""
|
|
484
|
+
if not self.is_open:
|
|
485
|
+
return None
|
|
486
|
+
try:
|
|
487
|
+
w = self._plot2d.add_circle_widget(
|
|
488
|
+
cx=float(cx), cy=float(cy), r=float(r), color=color,
|
|
489
|
+
lock_center=bool(lock_center), linewidth=float(linewidth),
|
|
490
|
+
show_handles=bool(show_handles))
|
|
491
|
+
except Exception as e:
|
|
492
|
+
log.warning("could not add circle widget: %s", e)
|
|
493
|
+
return None
|
|
494
|
+
|
|
495
|
+
def _settled(_event, _w=w):
|
|
496
|
+
try:
|
|
497
|
+
if on_change is not None:
|
|
498
|
+
on_change(float(_w.cx), float(_w.cy), float(_w.r))
|
|
499
|
+
except Exception as e:
|
|
500
|
+
log.debug("circle widget callback failed: %s", e)
|
|
501
|
+
|
|
502
|
+
if on_change is not None:
|
|
503
|
+
try:
|
|
504
|
+
w.add_event_handler(_settled, "pointer_up")
|
|
505
|
+
except Exception as e:
|
|
506
|
+
log.warning("could not observe circle widget: %s", e)
|
|
507
|
+
return w
|
|
508
|
+
|
|
509
|
+
def add_line_widget(self, *, x1: float, y1: float, x2: float, y2: float,
|
|
510
|
+
color: str = "#00e5ff", on_change=None):
|
|
511
|
+
"""A draggable two-endpoint line overlay. Returns the widget, or None.
|
|
512
|
+
|
|
513
|
+
The measuring counterpart to the circle: drag either end onto a feature
|
|
514
|
+
and the length between them is the measurement. A LINE widget rather
|
|
515
|
+
than two clicks because the same argument applies — the plot owns the
|
|
516
|
+
hit-testing and pushes the geometry back, whereas a bare `pointer_down`
|
|
517
|
+
never arrives (the plot's own pan consumes it).
|
|
518
|
+
|
|
519
|
+
`on_change(x1, y1, x2, y2)` fires when a drag settles.
|
|
520
|
+
"""
|
|
521
|
+
if not self.is_open:
|
|
522
|
+
return None
|
|
523
|
+
try:
|
|
524
|
+
w = self._plot2d.add_line_widget(
|
|
525
|
+
x1=float(x1), y1=float(y1), x2=float(x2), y2=float(y2),
|
|
526
|
+
color=color)
|
|
527
|
+
except Exception as e:
|
|
528
|
+
log.warning("could not add line widget: %s", e)
|
|
529
|
+
return None
|
|
530
|
+
|
|
531
|
+
def _settled(_event, _w=w):
|
|
532
|
+
try:
|
|
533
|
+
if on_change is not None:
|
|
534
|
+
on_change(float(_w.x1), float(_w.y1),
|
|
535
|
+
float(_w.x2), float(_w.y2))
|
|
536
|
+
except Exception as e:
|
|
537
|
+
log.debug("line widget callback failed: %s", e)
|
|
538
|
+
|
|
539
|
+
if on_change is not None:
|
|
540
|
+
try:
|
|
541
|
+
w.add_event_handler(_settled, "pointer_up")
|
|
542
|
+
except Exception as e:
|
|
543
|
+
log.warning("could not observe line widget: %s", e)
|
|
544
|
+
return w
|
|
545
|
+
|
|
546
|
+
@staticmethod
|
|
547
|
+
def set_widget_geometry(widget, **geometry) -> bool:
|
|
548
|
+
"""Move an overlay from Python. Returns whether it landed.
|
|
549
|
+
|
|
550
|
+
`_notify=False` is the whole point: a `set()` is otherwise
|
|
551
|
+
indistinguishable from a user drag, so the widget's own `on_change`
|
|
552
|
+
fires on it — and a handler that recomputes a measurement from the
|
|
553
|
+
widget would then be recomputing from a value it was just given. The
|
|
554
|
+
caller already knows what it set.
|
|
555
|
+
"""
|
|
556
|
+
if widget is None:
|
|
557
|
+
return False
|
|
558
|
+
try:
|
|
559
|
+
widget.set(_notify=False, **{k: float(v)
|
|
560
|
+
for k, v in geometry.items()})
|
|
561
|
+
return True
|
|
562
|
+
except Exception as e:
|
|
563
|
+
log.debug("set_widget_geometry(%s) failed: %s", geometry, e)
|
|
564
|
+
return False
|
|
565
|
+
|
|
566
|
+
def remove_widget(self, widget) -> None:
|
|
567
|
+
"""Drop ONE overlay. Tolerates None and an already-removed widget, so a
|
|
568
|
+
caller swapping overlays does not have to track which it still holds."""
|
|
569
|
+
if widget is None or not self.is_open:
|
|
570
|
+
return
|
|
571
|
+
try:
|
|
572
|
+
widget.remove()
|
|
573
|
+
except Exception as e:
|
|
574
|
+
log.debug("remove_widget failed: %s", e)
|
|
575
|
+
|
|
576
|
+
def clear_widgets(self) -> None:
|
|
577
|
+
"""Drop every overlay widget. Idempotent, never raises in teardown."""
|
|
578
|
+
if not self.is_open:
|
|
579
|
+
return
|
|
580
|
+
try:
|
|
581
|
+
self._plot2d.clear_widgets()
|
|
582
|
+
except Exception as e:
|
|
583
|
+
log.debug("clear_widgets failed: %s", e)
|
|
584
|
+
|
|
585
|
+
def set_colormap(self, name: str) -> None:
|
|
586
|
+
self._colormap = name
|
|
587
|
+
if not self.is_open:
|
|
588
|
+
return
|
|
589
|
+
try:
|
|
590
|
+
self._plot2d.set_colormap(name)
|
|
591
|
+
except Exception as e:
|
|
592
|
+
log.debug("set_colormap(%s) failed: %s", name, e)
|
|
593
|
+
|
|
594
|
+
def set_title(self, title: str) -> None:
|
|
595
|
+
self.title = title
|
|
596
|
+
if not self.is_open:
|
|
597
|
+
return
|
|
598
|
+
try:
|
|
599
|
+
self._plot2d.set_title(title)
|
|
600
|
+
except Exception as e:
|
|
601
|
+
log.debug("set_title(%s) failed: %s", title, e)
|
|
File without changes
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""Selector utility functions."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import numpy as np
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def broadcast_rows_cartesian(*arrays: np.ndarray) -> np.ndarray:
|
|
8
|
+
"""
|
|
9
|
+
Cartesian product over *rows* of multiple index arrays, keeping
|
|
10
|
+
the columns of each array together.
|
|
11
|
+
|
|
12
|
+
Each input is treated as shape (Ni, Ci): Ni rows, Ci columns.
|
|
13
|
+
The output has shape (N_total, sum(Ci)), where N_total is the
|
|
14
|
+
product of all Ni.
|
|
15
|
+
"""
|
|
16
|
+
if len(arrays) == 0:
|
|
17
|
+
return np.empty((0, 0), dtype=int)
|
|
18
|
+
|
|
19
|
+
mats = [np.atleast_2d(a) for a in arrays]
|
|
20
|
+
n_rows = [m.shape[0] for m in mats]
|
|
21
|
+
|
|
22
|
+
grids = np.meshgrid(*[np.arange(n) for n in n_rows], indexing="ij")
|
|
23
|
+
|
|
24
|
+
parts = []
|
|
25
|
+
for m, g in zip(mats, grids):
|
|
26
|
+
chosen_rows = m[g.ravel()]
|
|
27
|
+
parts.append(chosen_rows)
|
|
28
|
+
|
|
29
|
+
return np.concatenate(parts, axis=1)
|