arrayview 0.48.2__py3-none-any.whl → 0.48.4__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.
- arrayview/_diff.py +60 -44
- arrayview/_render.py +49 -0
- arrayview/_routes_rendering.py +58 -0
- arrayview/_routes_websocket.py +53 -4
- arrayview/_session.py +3 -0
- arrayview/_viewer.html +606 -110
- {arrayview-0.48.2.dist-info → arrayview-0.48.4.dist-info}/METADATA +1 -1
- {arrayview-0.48.2.dist-info → arrayview-0.48.4.dist-info}/RECORD +11 -11
- {arrayview-0.48.2.dist-info → arrayview-0.48.4.dist-info}/WHEEL +0 -0
- {arrayview-0.48.2.dist-info → arrayview-0.48.4.dist-info}/entry_points.txt +0 -0
- {arrayview-0.48.2.dist-info → arrayview-0.48.4.dist-info}/licenses/LICENSE +0 -0
arrayview/_diff.py
CHANGED
|
@@ -85,6 +85,43 @@ def _resize_normalized_like(source: np.ndarray, target: np.ndarray) -> np.ndarra
|
|
|
85
85
|
return np.array(image, dtype=np.float32) / 255.0
|
|
86
86
|
|
|
87
87
|
|
|
88
|
+
def _render_values(session, dim_x, dim_y, idx_tuple, complex_mode, log_scale):
|
|
89
|
+
"""Extract a slice in the array's own units (complex/log applied, no rescale)."""
|
|
90
|
+
raw = extract_slice(session, dim_x, dim_y, list(idx_tuple))
|
|
91
|
+
data = apply_complex_mode(raw, complex_mode)
|
|
92
|
+
if log_scale:
|
|
93
|
+
data = np.log1p(np.abs(data))
|
|
94
|
+
return np.asarray(data, dtype=np.float32)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _render_values_mosaic(session, dim_x, dim_y, dim_z, idx_tuple, complex_mode, log_scale):
|
|
98
|
+
"""Mosaic of every dim_z slice in the array's own units, plus separator mask."""
|
|
99
|
+
idx_list = list(idx_tuple)
|
|
100
|
+
frames = [
|
|
101
|
+
_render_values(
|
|
102
|
+
session,
|
|
103
|
+
dim_x,
|
|
104
|
+
dim_y,
|
|
105
|
+
[i if j == dim_z else idx_list[j] for j in range(len(session.shape))],
|
|
106
|
+
complex_mode,
|
|
107
|
+
log_scale,
|
|
108
|
+
)
|
|
109
|
+
for i in range(session.shape[dim_z])
|
|
110
|
+
]
|
|
111
|
+
all_data = np.stack(frames)
|
|
112
|
+
grid, _, _ = _build_mosaic_grid(all_data, len(frames))
|
|
113
|
+
return grid.astype(np.float32), np.isnan(grid)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _resize_values_like(source: np.ndarray, target: np.ndarray) -> np.ndarray:
|
|
117
|
+
"""Resize float source data to target shape without quantising it."""
|
|
118
|
+
if source.shape == target.shape:
|
|
119
|
+
return source
|
|
120
|
+
image = _pil_image().fromarray(np.nan_to_num(source).astype(np.float32), mode="F")
|
|
121
|
+
image = image.resize((target.shape[1], target.shape[0]), _pil_image().BILINEAR)
|
|
122
|
+
return np.array(image, dtype=np.float32)
|
|
123
|
+
|
|
124
|
+
|
|
88
125
|
def _compute_diff(
|
|
89
126
|
session_a,
|
|
90
127
|
session_b,
|
|
@@ -114,65 +151,41 @@ def _compute_diff(
|
|
|
114
151
|
idx_b = _parse_indices(indices_b)[:ndim_b] if indices_b is not None else idx_tuple[:ndim_b]
|
|
115
152
|
nan_mask = None
|
|
116
153
|
|
|
154
|
+
# True A−B in the arrays' own units. Each side used to be rescaled to its
|
|
155
|
+
# own per-slice display range first, which cancelled real differences
|
|
156
|
+
# (B = 0.9·A showed as zero) and made the map flicker while scrolling.
|
|
117
157
|
if dim_z >= 0:
|
|
118
|
-
a, nan_mask_a =
|
|
119
|
-
session_a,
|
|
120
|
-
dim_x,
|
|
121
|
-
dim_y,
|
|
122
|
-
dim_z,
|
|
123
|
-
idx_a,
|
|
124
|
-
dr,
|
|
125
|
-
complex_mode,
|
|
126
|
-
log_scale,
|
|
158
|
+
a, nan_mask_a = _render_values_mosaic(
|
|
159
|
+
session_a, dim_x, dim_y, dim_z, idx_a, complex_mode, log_scale
|
|
127
160
|
)
|
|
128
|
-
b, nan_mask_b =
|
|
129
|
-
session_b,
|
|
130
|
-
dim_x,
|
|
131
|
-
dim_y,
|
|
132
|
-
dim_z,
|
|
133
|
-
idx_b,
|
|
134
|
-
dr,
|
|
135
|
-
complex_mode,
|
|
136
|
-
log_scale,
|
|
161
|
+
b, nan_mask_b = _render_values_mosaic(
|
|
162
|
+
session_b, dim_x, dim_y, dim_z, idx_b, complex_mode, log_scale
|
|
137
163
|
)
|
|
138
|
-
|
|
164
|
+
b = _resize_values_like(b, a)
|
|
165
|
+
if nan_mask_b.shape == nan_mask_a.shape:
|
|
166
|
+
nan_mask = nan_mask_a | nan_mask_b
|
|
167
|
+
else:
|
|
168
|
+
nan_mask = nan_mask_a
|
|
139
169
|
else:
|
|
140
|
-
a =
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
dim_y,
|
|
144
|
-
idx_a,
|
|
145
|
-
dr,
|
|
146
|
-
complex_mode,
|
|
147
|
-
log_scale,
|
|
148
|
-
)
|
|
149
|
-
b = _render_normalized(
|
|
150
|
-
session_b,
|
|
151
|
-
dim_x,
|
|
152
|
-
dim_y,
|
|
153
|
-
idx_b,
|
|
154
|
-
dr,
|
|
155
|
-
complex_mode,
|
|
156
|
-
log_scale,
|
|
157
|
-
)
|
|
158
|
-
|
|
159
|
-
b = _resize_normalized_like(b, a)
|
|
170
|
+
a = _render_values(session_a, dim_x, dim_y, idx_a, complex_mode, log_scale)
|
|
171
|
+
b = _render_values(session_b, dim_x, dim_y, idx_b, complex_mode, log_scale)
|
|
172
|
+
b = _resize_values_like(b, a)
|
|
160
173
|
if diff_mode == 1:
|
|
161
174
|
raw = a - b
|
|
162
175
|
lo = float(np.nanpercentile(raw, 1))
|
|
163
176
|
hi = float(np.nanpercentile(raw, 99))
|
|
164
177
|
vmax = max(abs(lo), abs(hi)) or 1.0
|
|
165
178
|
vmin = -vmax
|
|
166
|
-
colormap = "
|
|
179
|
+
colormap = "icefire"
|
|
167
180
|
elif diff_mode == 2:
|
|
168
181
|
raw = np.abs(a - b)
|
|
169
|
-
vmax = float(
|
|
182
|
+
vmax = float(np.nanmax(raw)) or 1.0
|
|
170
183
|
vmin = 0.0
|
|
171
184
|
colormap = "afmhot"
|
|
172
185
|
else:
|
|
173
186
|
raw = np.abs(a - b) / np.maximum(np.abs(a), 1e-6)
|
|
174
187
|
raw = np.clip(raw, 0.0, 2.0).astype(np.float32)
|
|
175
|
-
vmax = float(
|
|
188
|
+
vmax = float(np.nanmax(raw)) or 1.0
|
|
176
189
|
vmin = 0.0
|
|
177
190
|
colormap = "afmhot"
|
|
178
191
|
|
|
@@ -188,7 +201,7 @@ def _render_diff_rgba(
|
|
|
188
201
|
) -> np.ndarray:
|
|
189
202
|
"""Map raw diff data to RGBA."""
|
|
190
203
|
if vmax > vmin:
|
|
191
|
-
normalized = np.clip((raw - vmin) / (vmax - vmin), 0, 1)
|
|
204
|
+
normalized = np.clip(np.nan_to_num((raw - vmin) / (vmax - vmin)), 0, 1)
|
|
192
205
|
else:
|
|
193
206
|
normalized = np.zeros_like(raw)
|
|
194
207
|
_ensure_lut(colormap)
|
|
@@ -201,9 +214,12 @@ def _render_diff_rgba(
|
|
|
201
214
|
|
|
202
215
|
def _diff_histogram(raw: np.ndarray, bins: int) -> dict[str, object]:
|
|
203
216
|
"""Return histogram payload for raw diff data."""
|
|
217
|
+
raw = raw[np.isfinite(raw)]
|
|
218
|
+
if raw.size == 0:
|
|
219
|
+
raw = np.zeros(1, dtype=np.float32)
|
|
204
220
|
vmin = float(raw.min())
|
|
205
221
|
vmax = float(raw.max())
|
|
206
|
-
counts, edges = np.histogram(raw
|
|
222
|
+
counts, edges = np.histogram(raw, bins=bins)
|
|
207
223
|
return {
|
|
208
224
|
"counts": counts.tolist(),
|
|
209
225
|
"edges": edges.tolist(),
|
arrayview/_render.py
CHANGED
|
@@ -34,6 +34,43 @@ _mpl_colormaps = None
|
|
|
34
34
|
_luts_lock = threading.Lock()
|
|
35
35
|
|
|
36
36
|
|
|
37
|
+
_ICEFIRE_STOPS = [
|
|
38
|
+
(0.7394, 0.9044, 0.8576),
|
|
39
|
+
(0.6645, 0.8497, 0.8387),
|
|
40
|
+
(0.5749, 0.7908, 0.8202),
|
|
41
|
+
(0.4818, 0.734, 0.8084),
|
|
42
|
+
(0.3863, 0.6778, 0.8043),
|
|
43
|
+
(0.3052, 0.6184, 0.8053),
|
|
44
|
+
(0.2418, 0.5555, 0.8106),
|
|
45
|
+
(0.2141, 0.4873, 0.8157),
|
|
46
|
+
(0.2487, 0.4134, 0.7887),
|
|
47
|
+
(0.2831, 0.3444, 0.715),
|
|
48
|
+
(0.2846, 0.2908, 0.6028),
|
|
49
|
+
(0.2577, 0.2497, 0.4821),
|
|
50
|
+
(0.2205, 0.2121, 0.3737),
|
|
51
|
+
(0.182, 0.1769, 0.2809),
|
|
52
|
+
(0.1492, 0.1465, 0.2053),
|
|
53
|
+
(0.1276, 0.1254, 0.1501),
|
|
54
|
+
(0.1213, 0.1183, 0.1188),
|
|
55
|
+
(0.1538, 0.1167, 0.1227),
|
|
56
|
+
(0.2063, 0.1255, 0.1416),
|
|
57
|
+
(0.2734, 0.1407, 0.1703),
|
|
58
|
+
(0.3514, 0.157, 0.2039),
|
|
59
|
+
(0.4375, 0.1703, 0.2357),
|
|
60
|
+
(0.5298, 0.1784, 0.2601),
|
|
61
|
+
(0.6251, 0.1832, 0.2686),
|
|
62
|
+
(0.7138, 0.2028, 0.2533),
|
|
63
|
+
(0.7898, 0.2469, 0.2268),
|
|
64
|
+
(0.85, 0.3142, 0.2058),
|
|
65
|
+
(0.8971, 0.3932, 0.2012),
|
|
66
|
+
(0.9279, 0.482, 0.2415),
|
|
67
|
+
(0.9479, 0.5722, 0.3318),
|
|
68
|
+
(0.9659, 0.659, 0.4438),
|
|
69
|
+
(0.9829, 0.7447, 0.5597),
|
|
70
|
+
(0.9992, 0.831, 0.6764),
|
|
71
|
+
]
|
|
72
|
+
|
|
73
|
+
|
|
37
74
|
def _init_luts():
|
|
38
75
|
"""Build colourmap LUTs on first use (defers matplotlib + qmricolors import)."""
|
|
39
76
|
global _mpl_colormaps
|
|
@@ -46,6 +83,12 @@ def _init_luts():
|
|
|
46
83
|
from matplotlib import colormaps
|
|
47
84
|
from matplotlib.colors import LinearSegmentedColormap
|
|
48
85
|
|
|
86
|
+
if "icefire" not in colormaps:
|
|
87
|
+
# seaborn's icefire (blue → near-black → red), sampled at 33 stops so
|
|
88
|
+
# we don't depend on seaborn. Default for the A−B compare center.
|
|
89
|
+
colormaps.register(
|
|
90
|
+
LinearSegmentedColormap.from_list("icefire", _ICEFIRE_STOPS)
|
|
91
|
+
)
|
|
49
92
|
_mpl_colormaps = colormaps
|
|
50
93
|
for name in COLORMAPS:
|
|
51
94
|
lut = np.concatenate(
|
|
@@ -210,6 +253,12 @@ def _read_slice(session, dim_x, dim_y, idx_list, progress):
|
|
|
210
253
|
if progress is None:
|
|
211
254
|
progress = getattr(session, 'progress_sink', None)
|
|
212
255
|
data = session.data
|
|
256
|
+
if not getattr(session, "_ram_upgrade_started", False):
|
|
257
|
+
# Not every loading route asks for this (compare arrays, for one), and
|
|
258
|
+
# without it a scattered plane is re-read from the file on every step.
|
|
259
|
+
from ._session import upgrade_memmap_in_background
|
|
260
|
+
|
|
261
|
+
upgrade_memmap_in_background(session)
|
|
213
262
|
if isinstance(data, np.memmap) and data.filename:
|
|
214
263
|
# A slice scattered one element per page reads far faster as many
|
|
215
264
|
# parallel small reads than by faulting through the map (local disks).
|
arrayview/_routes_rendering.py
CHANGED
|
@@ -226,6 +226,16 @@ def register_rendering_routes(app, *, get_session_or_404) -> None:
|
|
|
226
226
|
if qmri_role
|
|
227
227
|
else extract_slice(session, dim_x, dim_y, list(idx_tuple))
|
|
228
228
|
)
|
|
229
|
+
if qmri_role and (vmin_override is None or vmax_override is None):
|
|
230
|
+
# Colour with the role-adjusted window reported back to the
|
|
231
|
+
# viewer, which locks it for later slices; otherwise the
|
|
232
|
+
# first scroll step visibly changes contrast.
|
|
233
|
+
_, _lo, _hi = _prepare_display(
|
|
234
|
+
session, raw, complex_mode, dr, log_scale
|
|
235
|
+
)
|
|
236
|
+
vmin_override, vmax_override = _qmri_adjust_vmin_vmax(
|
|
237
|
+
_lo, _hi, qmri_role
|
|
238
|
+
)
|
|
229
239
|
rgba = (
|
|
230
240
|
render_rgba_from_prepared(
|
|
231
241
|
session,
|
|
@@ -431,6 +441,54 @@ def register_rendering_routes(app, *, get_session_or_404) -> None:
|
|
|
431
441
|
return Response(status_code=422)
|
|
432
442
|
return _diff_histogram(raw, bins)
|
|
433
443
|
|
|
444
|
+
@app.get("/diff-values/{sid_a}/{sid_b}")
|
|
445
|
+
def get_diff_values(
|
|
446
|
+
sid_a: str,
|
|
447
|
+
sid_b: str,
|
|
448
|
+
dim_x: int,
|
|
449
|
+
dim_y: int,
|
|
450
|
+
indices: str,
|
|
451
|
+
indices_a: str | None = None,
|
|
452
|
+
indices_b: str | None = None,
|
|
453
|
+
dim_z: int = -1,
|
|
454
|
+
dr: int = 1,
|
|
455
|
+
complex_mode: int = 0,
|
|
456
|
+
log_scale: bool = False,
|
|
457
|
+
diff_mode: int = 1,
|
|
458
|
+
):
|
|
459
|
+
"""Raw float32 diff values, so hovering a histogram bin can highlight pixels."""
|
|
460
|
+
session_a = SESSIONS.get(sid_a)
|
|
461
|
+
session_b = SESSIONS.get(sid_b)
|
|
462
|
+
if not session_a or not session_b:
|
|
463
|
+
return Response(status_code=404)
|
|
464
|
+
try:
|
|
465
|
+
raw, _, _, _, _ = _compute_diff(
|
|
466
|
+
session_a,
|
|
467
|
+
session_b,
|
|
468
|
+
dim_x,
|
|
469
|
+
dim_y,
|
|
470
|
+
indices,
|
|
471
|
+
dim_z,
|
|
472
|
+
dr,
|
|
473
|
+
complex_mode,
|
|
474
|
+
log_scale,
|
|
475
|
+
diff_mode,
|
|
476
|
+
indices_a=indices_a,
|
|
477
|
+
indices_b=indices_b,
|
|
478
|
+
)
|
|
479
|
+
except Exception:
|
|
480
|
+
return Response(status_code=422)
|
|
481
|
+
raw = np.ascontiguousarray(raw, dtype=np.float32)
|
|
482
|
+
return Response(
|
|
483
|
+
content=raw.tobytes(),
|
|
484
|
+
media_type="application/octet-stream",
|
|
485
|
+
headers={
|
|
486
|
+
"X-ArrayView-Width": str(raw.shape[1]),
|
|
487
|
+
"X-ArrayView-Height": str(raw.shape[0]),
|
|
488
|
+
"Cache-Control": "no-cache",
|
|
489
|
+
},
|
|
490
|
+
)
|
|
491
|
+
|
|
434
492
|
@app.get("/oblique/{sid}")
|
|
435
493
|
def get_oblique(
|
|
436
494
|
sid: str,
|
arrayview/_routes_websocket.py
CHANGED
|
@@ -50,6 +50,47 @@ _RECOVERABLE_DISCONNECT_GRACE_SECONDS = float(
|
|
|
50
50
|
)
|
|
51
51
|
|
|
52
52
|
|
|
53
|
+
# Leading word of a compressed frame: b"AVZ1" read as a little-endian uint32.
|
|
54
|
+
# A raw frame starts with its sequence number, which never gets this large.
|
|
55
|
+
_FRAME_Z_MAGIC = b"AVZ1"
|
|
56
|
+
# Below this a frame crosses any link quickly enough as it is.
|
|
57
|
+
_FRAME_Z_MIN_BYTES = 32 * 1024
|
|
58
|
+
_frame_z_remote = None
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _frame_compression_wanted() -> bool:
|
|
62
|
+
"""True when frames should be deflated before they are sent.
|
|
63
|
+
|
|
64
|
+
Over a VS Code tunnel or Remote-SSH forward every frame crosses the network
|
|
65
|
+
as raw RGBA: three compared 224x224 slices are 600 KB per scroll step, and
|
|
66
|
+
that transfer, not rendering, is the lag. Locally the bytes are free, so
|
|
67
|
+
frames stay raw. ``ARRAYVIEW_FRAME_COMPRESS=1``/``0`` forces either way.
|
|
68
|
+
"""
|
|
69
|
+
global _frame_z_remote
|
|
70
|
+
forced = os.environ.get("ARRAYVIEW_FRAME_COMPRESS")
|
|
71
|
+
if forced in ("0", "1"):
|
|
72
|
+
return forced == "1"
|
|
73
|
+
if _frame_z_remote is None:
|
|
74
|
+
try:
|
|
75
|
+
from arrayview._platform import _is_vscode_remote
|
|
76
|
+
|
|
77
|
+
_frame_z_remote = bool(_is_vscode_remote())
|
|
78
|
+
except Exception:
|
|
79
|
+
_frame_z_remote = False
|
|
80
|
+
return _frame_z_remote
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _pack_frame(payload: bytes) -> bytes:
|
|
84
|
+
"""Deflate *payload* behind the magic word, or return it unchanged when
|
|
85
|
+
that does not save at least a third (noise-like images barely shrink)."""
|
|
86
|
+
import zlib
|
|
87
|
+
|
|
88
|
+
packed = zlib.compress(payload, 1)
|
|
89
|
+
if len(packed) * 3 > len(payload) * 2:
|
|
90
|
+
return payload
|
|
91
|
+
return _FRAME_Z_MAGIC + packed
|
|
92
|
+
|
|
93
|
+
|
|
53
94
|
def _advance_viewer_epoch(sid: str) -> int:
|
|
54
95
|
epoch = _session_mod.VIEWER_CONNECTION_EPOCHS.get(sid, 0) + 1
|
|
55
96
|
_session_mod.VIEWER_CONNECTION_EPOCHS[sid] = epoch
|
|
@@ -271,9 +312,10 @@ def _render_qmri_rgba(
|
|
|
271
312
|
Kept separate from the generic path so the slice read and the window
|
|
272
313
|
computation happen exactly once, and so multi-pane qMRI/ortho renders run in
|
|
273
314
|
the render thread pool instead of blocking the event loop serially. The
|
|
274
|
-
colour map is applied with the
|
|
275
|
-
|
|
276
|
-
|
|
315
|
+
colour map is applied with the qMRI-role-adjusted window, the same one sent
|
|
316
|
+
in the frame header: the viewer locks that window and sends it back on the
|
|
317
|
+
next slice, so colouring with anything else made the first scroll step
|
|
318
|
+
visibly change the contrast while the colorbar labels stayed put.
|
|
277
319
|
"""
|
|
278
320
|
raw = qmri_display_slice(session, dim_x, dim_y, list(idx_tuple), qmri_role)
|
|
279
321
|
data, vmin, vmax = _prepare_display(
|
|
@@ -285,6 +327,7 @@ def _render_qmri_rgba(
|
|
|
285
327
|
vmin_override=vmin_override,
|
|
286
328
|
vmax_override=vmax_override,
|
|
287
329
|
)
|
|
330
|
+
vmin, vmax = _qmri_adjust_vmin_vmax(vmin, vmax, qmri_role)
|
|
288
331
|
if vmax > vmin:
|
|
289
332
|
normalized = np.clip((data - vmin) / (vmax - vmin), 0, 1)
|
|
290
333
|
else:
|
|
@@ -301,7 +344,6 @@ def _render_qmri_rgba(
|
|
|
301
344
|
if transparent.any():
|
|
302
345
|
rgba = rgba.copy()
|
|
303
346
|
rgba[transparent, 3] = 0
|
|
304
|
-
vmin, vmax = _qmri_adjust_vmin_vmax(vmin, vmax, qmri_role)
|
|
305
347
|
return rgba, vmin, vmax
|
|
306
348
|
|
|
307
349
|
|
|
@@ -367,6 +409,10 @@ def register_websocket_routes(app) -> None:
|
|
|
367
409
|
native_request_id = ws.query_params.get("native_request_id")
|
|
368
410
|
launch_request_id = ws.query_params.get("launch_request_id")
|
|
369
411
|
launch_token = ws.query_params.get("launch_token")
|
|
412
|
+
# Only viewers that can inflate frames ask for them packed.
|
|
413
|
+
compress_frames = (
|
|
414
|
+
ws.query_params.get("frame_z") == "1" and _frame_compression_wanted()
|
|
415
|
+
)
|
|
370
416
|
# Answer the handshake before the array is ready. Holding it closed
|
|
371
417
|
# until the load finished made the viewer decide the backend was dead:
|
|
372
418
|
# it gives up after three attempts and shows "could not connect", which
|
|
@@ -751,6 +797,9 @@ def register_websocket_routes(app) -> None:
|
|
|
751
797
|
).tobytes()
|
|
752
798
|
vf_scale = np.array([vf_result["scale"]], dtype=np.float32).tobytes()
|
|
753
799
|
payload += vf_hdr + vf_scale + arrows.tobytes()
|
|
800
|
+
if compress_frames and len(payload) >= _FRAME_Z_MIN_BYTES:
|
|
801
|
+
raw_payload = payload
|
|
802
|
+
payload = await _render(loop, lambda: _pack_frame(raw_payload))
|
|
754
803
|
await ws.send_bytes(payload)
|
|
755
804
|
if perf:
|
|
756
805
|
total_ms = (time.perf_counter() - total_t0) * 1000.0
|
arrayview/_session.py
CHANGED
|
@@ -870,6 +870,9 @@ def upgrade_memmap_in_background(session) -> bool:
|
|
|
870
870
|
identical, so a render already holding the lazy array stays correct and the
|
|
871
871
|
slice caches stay valid.
|
|
872
872
|
"""
|
|
873
|
+
if getattr(session, "_ram_upgrade_started", False):
|
|
874
|
+
return False
|
|
875
|
+
session._ram_upgrade_started = True
|
|
873
876
|
data = getattr(session, "data", None)
|
|
874
877
|
filepath = getattr(session, "filepath", None)
|
|
875
878
|
if data is None or not filepath:
|