arrayview 0.48.3__tar.gz → 0.49.0__tar.gz
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-0.48.3 → arrayview-0.49.0}/PKG-INFO +1 -1
- {arrayview-0.48.3 → arrayview-0.49.0}/pyproject.toml +1 -1
- {arrayview-0.48.3 → arrayview-0.49.0}/src/arrayview/_analysis.py +9 -0
- {arrayview-0.48.3 → arrayview-0.49.0}/src/arrayview/_config.py +19 -1
- {arrayview-0.48.3 → arrayview-0.49.0}/src/arrayview/_diff.py +91 -45
- {arrayview-0.48.3 → arrayview-0.49.0}/src/arrayview/_io.py +63 -4
- {arrayview-0.48.3 → arrayview-0.49.0}/src/arrayview/_launcher.py +23 -4
- {arrayview-0.48.3 → arrayview-0.49.0}/src/arrayview/_platform.py +10 -1
- {arrayview-0.48.3 → arrayview-0.49.0}/src/arrayview/_render.py +49 -0
- {arrayview-0.48.3 → arrayview-0.49.0}/src/arrayview/_routes_preferences.py +22 -0
- {arrayview-0.48.3 → arrayview-0.49.0}/src/arrayview/_routes_rendering.py +55 -0
- {arrayview-0.48.3 → arrayview-0.49.0}/src/arrayview/_routes_websocket.py +77 -0
- {arrayview-0.48.3 → arrayview-0.49.0}/src/arrayview/_server.py +4 -0
- {arrayview-0.48.3 → arrayview-0.49.0}/src/arrayview/_session.py +12 -0
- arrayview-0.49.0/src/arrayview/_view_memory.py +102 -0
- {arrayview-0.48.3 → arrayview-0.49.0}/src/arrayview/_viewer.html +1068 -513
- {arrayview-0.48.3 → arrayview-0.49.0}/src/arrayview/_vscode_extension.py +1 -1
- {arrayview-0.48.3 → arrayview-0.49.0}/src/arrayview/_vscode_signal.py +3 -3
- {arrayview-0.48.3 → arrayview-0.49.0}/tests/test_api.py +3 -1
- {arrayview-0.48.3 → arrayview-0.49.0}/tests/test_backend_shared.py +37 -1
- {arrayview-0.48.3 → arrayview-0.49.0}/tests/test_browser.py +132 -9
- {arrayview-0.48.3 → arrayview-0.49.0}/tests/test_config.py +8 -0
- {arrayview-0.48.3 → arrayview-0.49.0}/tests/test_instance_registry.py +15 -0
- {arrayview-0.48.3 → arrayview-0.49.0}/tests/test_io.py +40 -1
- arrayview-0.49.0/tests/test_view_memory.py +32 -0
- {arrayview-0.48.3 → arrayview-0.49.0}/.gitignore +0 -0
- {arrayview-0.48.3 → arrayview-0.49.0}/LICENSE +0 -0
- {arrayview-0.48.3 → arrayview-0.49.0}/README.md +0 -0
- {arrayview-0.48.3 → arrayview-0.49.0}/src/arrayview/ARCHITECTURE.md +0 -0
- {arrayview-0.48.3 → arrayview-0.49.0}/src/arrayview/__init__.py +0 -0
- {arrayview-0.48.3 → arrayview-0.49.0}/src/arrayview/__main__.py +0 -0
- {arrayview-0.48.3 → arrayview-0.49.0}/src/arrayview/_app.py +0 -0
- {arrayview-0.48.3 → arrayview-0.49.0}/src/arrayview/_bootstrap_app.py +0 -0
- {arrayview-0.48.3 → arrayview-0.49.0}/src/arrayview/_diagnostics.py +0 -0
- {arrayview-0.48.3 → arrayview-0.49.0}/src/arrayview/_dicom.py +0 -0
- {arrayview-0.48.3 → arrayview-0.49.0}/src/arrayview/_extra_ports.py +0 -0
- {arrayview-0.48.3 → arrayview-0.49.0}/src/arrayview/_icon.png +0 -0
- {arrayview-0.48.3 → arrayview-0.49.0}/src/arrayview/_imaging.py +0 -0
- {arrayview-0.48.3 → arrayview-0.49.0}/src/arrayview/_instance_registry.py +0 -0
- {arrayview-0.48.3 → arrayview-0.49.0}/src/arrayview/_launch_plan.py +0 -0
- {arrayview-0.48.3 → arrayview-0.49.0}/src/arrayview/_launch_trace.py +0 -0
- {arrayview-0.48.3 → arrayview-0.49.0}/src/arrayview/_lifecycle.py +0 -0
- {arrayview-0.48.3 → arrayview-0.49.0}/src/arrayview/_overlays.py +0 -0
- {arrayview-0.48.3 → arrayview-0.49.0}/src/arrayview/_routes_analysis.py +0 -0
- {arrayview-0.48.3 → arrayview-0.49.0}/src/arrayview/_routes_drop.py +0 -0
- {arrayview-0.48.3 → arrayview-0.49.0}/src/arrayview/_routes_export.py +0 -0
- {arrayview-0.48.3 → arrayview-0.49.0}/src/arrayview/_routes_loading.py +0 -0
- {arrayview-0.48.3 → arrayview-0.49.0}/src/arrayview/_routes_persistence.py +0 -0
- {arrayview-0.48.3 → arrayview-0.49.0}/src/arrayview/_routes_preload.py +0 -0
- {arrayview-0.48.3 → arrayview-0.49.0}/src/arrayview/_routes_query.py +0 -0
- {arrayview-0.48.3 → arrayview-0.49.0}/src/arrayview/_routes_segmentation.py +0 -0
- {arrayview-0.48.3 → arrayview-0.49.0}/src/arrayview/_routes_state.py +0 -0
- {arrayview-0.48.3 → arrayview-0.49.0}/src/arrayview/_routes_vectorfield.py +0 -0
- {arrayview-0.48.3 → arrayview-0.49.0}/src/arrayview/_segmentation.py +0 -0
- {arrayview-0.48.3 → arrayview-0.49.0}/src/arrayview/_session_spec.py +0 -0
- {arrayview-0.48.3 → arrayview-0.49.0}/src/arrayview/_shell.html +0 -0
- {arrayview-0.48.3 → arrayview-0.49.0}/src/arrayview/_source_safety.py +0 -0
- {arrayview-0.48.3 → arrayview-0.49.0}/src/arrayview/_synthetic_mri.py +0 -0
- {arrayview-0.48.3 → arrayview-0.49.0}/src/arrayview/_torch.py +0 -0
- {arrayview-0.48.3 → arrayview-0.49.0}/src/arrayview/_tutorial.py +0 -0
- {arrayview-0.48.3 → arrayview-0.49.0}/src/arrayview/_vectorfield.py +0 -0
- {arrayview-0.48.3 → arrayview-0.49.0}/src/arrayview/_vscode.py +0 -0
- {arrayview-0.48.3 → arrayview-0.49.0}/src/arrayview/_vscode_browser.py +0 -0
- {arrayview-0.48.3 → arrayview-0.49.0}/src/arrayview/_watch.py +0 -0
- {arrayview-0.48.3 → arrayview-0.49.0}/src/arrayview/arrayview-opener.vsix +0 -0
- {arrayview-0.48.3 → arrayview-0.49.0}/src/arrayview/gsap.min.js +0 -0
- {arrayview-0.48.3 → arrayview-0.49.0}/src/arrayview/todo.md +0 -0
- {arrayview-0.48.3 → arrayview-0.49.0}/tests/capture_patient_loading.py +0 -0
- {arrayview-0.48.3 → arrayview-0.49.0}/tests/capture_v_animation.py +0 -0
- {arrayview-0.48.3 → arrayview-0.49.0}/tests/conftest.py +0 -0
- {arrayview-0.48.3 → arrayview-0.49.0}/tests/make_vectorfield_test_arrays.py +0 -0
- {arrayview-0.48.3 → arrayview-0.49.0}/tests/test_1d_arrays.py +0 -0
- {arrayview-0.48.3 → arrayview-0.49.0}/tests/test_cli.py +0 -0
- {arrayview-0.48.3 → arrayview-0.49.0}/tests/test_dicom.py +0 -0
- {arrayview-0.48.3 → arrayview-0.49.0}/tests/test_drop_import.py +0 -0
- {arrayview-0.48.3 → arrayview-0.49.0}/tests/test_large_arrays.py +0 -0
- {arrayview-0.48.3 → arrayview-0.49.0}/tests/test_launch_blackbox.py +0 -0
- {arrayview-0.48.3 → arrayview-0.49.0}/tests/test_mode_roundtrip.py +0 -0
- {arrayview-0.48.3 → arrayview-0.49.0}/tests/test_nifti_meta.py +0 -0
- {arrayview-0.48.3 → arrayview-0.49.0}/tests/test_nifti_series.py +0 -0
- {arrayview-0.48.3 → arrayview-0.49.0}/tests/test_rgb_pixel_art.py +0 -0
- {arrayview-0.48.3 → arrayview-0.49.0}/tests/test_safe_server_stop.py +0 -0
- {arrayview-0.48.3 → arrayview-0.49.0}/tests/test_source_safety.py +0 -0
- {arrayview-0.48.3 → arrayview-0.49.0}/tests/test_torch.py +0 -0
- {arrayview-0.48.3 → arrayview-0.49.0}/tests/test_tutorial.py +0 -0
- {arrayview-0.48.3 → arrayview-0.49.0}/tests/test_vsix_bundle.py +0 -0
- {arrayview-0.48.3 → arrayview-0.49.0}/tests/ui_audit.py +0 -0
- {arrayview-0.48.3 → arrayview-0.49.0}/tests/v_anim_frames/.gitkeep +0 -0
- {arrayview-0.48.3 → arrayview-0.49.0}/tests/visual_smoke.py +0 -0
|
@@ -60,6 +60,15 @@ def _build_metadata(session) -> dict:
|
|
|
60
60
|
}
|
|
61
61
|
if getattr(session, "initial_view", None):
|
|
62
62
|
meta["initial_view"] = session.initial_view
|
|
63
|
+
if meta["has_source_file"]:
|
|
64
|
+
from arrayview._config import get_viewer_remember_view
|
|
65
|
+
|
|
66
|
+
if get_viewer_remember_view():
|
|
67
|
+
from arrayview._view_memory import remembered_view
|
|
68
|
+
|
|
69
|
+
remembered = remembered_view(session)
|
|
70
|
+
if remembered:
|
|
71
|
+
meta["remembered_view"] = remembered
|
|
63
72
|
code_source = _code_source(session)
|
|
64
73
|
if code_source is not None:
|
|
65
74
|
meta["code_source"] = code_source
|
|
@@ -39,6 +39,8 @@ BUILTIN_PREFERENCES = {
|
|
|
39
39
|
"theme": "dark",
|
|
40
40
|
"rounded_panes": True,
|
|
41
41
|
"qmri_gapless": False,
|
|
42
|
+
"compare_gapless": False,
|
|
43
|
+
"remember_view": False,
|
|
42
44
|
"ortho_layout": None,
|
|
43
45
|
"dimbar_mode": "compact",
|
|
44
46
|
},
|
|
@@ -49,6 +51,8 @@ PREFERENCE_SCHEMA = {
|
|
|
49
51
|
"theme": sorted(_VALID_THEMES),
|
|
50
52
|
"rounded_panes": "boolean",
|
|
51
53
|
"qmri_gapless": "boolean",
|
|
54
|
+
"compare_gapless": "boolean",
|
|
55
|
+
"remember_view": "boolean",
|
|
52
56
|
"colormaps": "non-empty list of names",
|
|
53
57
|
"ortho_layout": sorted(_VALID_ORTHO_LAYOUTS),
|
|
54
58
|
"dimbar_mode": sorted(_VALID_DIMBAR_MODES),
|
|
@@ -120,7 +124,7 @@ def _validate_preference(section: str, key: str, value):
|
|
|
120
124
|
)
|
|
121
125
|
if section == "viewer" and key == "theme":
|
|
122
126
|
return _normalized_string(value, _VALID_THEMES, "viewer.theme")
|
|
123
|
-
if section == "viewer" and key in ("rounded_panes", "qmri_gapless"):
|
|
127
|
+
if section == "viewer" and key in ("rounded_panes", "qmri_gapless", "compare_gapless", "remember_view"):
|
|
124
128
|
if not isinstance(value, bool):
|
|
125
129
|
raise InvalidPreferenceError(f"viewer.{key} must be a boolean")
|
|
126
130
|
return value
|
|
@@ -246,6 +250,20 @@ def get_viewer_qmri_gapless() -> bool:
|
|
|
246
250
|
return isinstance(viewer_cfg, dict) and viewer_cfg.get("qmri_gapless") is True
|
|
247
251
|
|
|
248
252
|
|
|
253
|
+
def get_viewer_compare_gapless() -> bool:
|
|
254
|
+
"""Whether arrays compared side by side touch, with no space between them."""
|
|
255
|
+
viewer_cfg = load_config().get("viewer", {})
|
|
256
|
+
return isinstance(viewer_cfg, dict) and viewer_cfg.get("compare_gapless") is True
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def get_viewer_remember_view() -> bool:
|
|
260
|
+
"""Whether reopening a file brings back the view it was last left in."""
|
|
261
|
+
viewer_cfg = load_config().get("viewer", {})
|
|
262
|
+
val = viewer_cfg.get("remember_view") if isinstance(viewer_cfg, dict) else None
|
|
263
|
+
# `arrayview config set` stores the word "true"; the Preferences menu a boolean.
|
|
264
|
+
return val is True or (isinstance(val, str) and val.strip().lower() in ("true", "1", "yes", "on"))
|
|
265
|
+
|
|
266
|
+
|
|
249
267
|
def get_viewer_ortho_layout() -> str | None:
|
|
250
268
|
"""Return the preferred initial ortho-view layout."""
|
|
251
269
|
viewer_cfg = load_config().get("viewer", {})
|
|
@@ -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,
|
|
@@ -98,8 +135,13 @@ def _compute_diff(
|
|
|
98
135
|
diff_mode,
|
|
99
136
|
indices_a=None,
|
|
100
137
|
indices_b=None,
|
|
138
|
+
stats_out=None,
|
|
101
139
|
):
|
|
102
|
-
"""Return raw diff, display range, colormap, and optional separator mask.
|
|
140
|
+
"""Return raw diff, display range, colormap, and optional separator mask.
|
|
141
|
+
|
|
142
|
+
When *stats_out* is a dict it is filled with summary numbers for the
|
|
143
|
+
compared view (see `_diff_summary`).
|
|
144
|
+
"""
|
|
103
145
|
def _parse_indices(raw_indices):
|
|
104
146
|
if raw_indices is None:
|
|
105
147
|
return ()
|
|
@@ -114,71 +156,72 @@ def _compute_diff(
|
|
|
114
156
|
idx_b = _parse_indices(indices_b)[:ndim_b] if indices_b is not None else idx_tuple[:ndim_b]
|
|
115
157
|
nan_mask = None
|
|
116
158
|
|
|
159
|
+
# True A−B in the arrays' own units. Each side used to be rescaled to its
|
|
160
|
+
# own per-slice display range first, which cancelled real differences
|
|
161
|
+
# (B = 0.9·A showed as zero) and made the map flicker while scrolling.
|
|
117
162
|
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,
|
|
163
|
+
a, nan_mask_a = _render_values_mosaic(
|
|
164
|
+
session_a, dim_x, dim_y, dim_z, idx_a, complex_mode, log_scale
|
|
127
165
|
)
|
|
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,
|
|
166
|
+
b, nan_mask_b = _render_values_mosaic(
|
|
167
|
+
session_b, dim_x, dim_y, dim_z, idx_b, complex_mode, log_scale
|
|
137
168
|
)
|
|
138
|
-
|
|
169
|
+
b = _resize_values_like(b, a)
|
|
170
|
+
if nan_mask_b.shape == nan_mask_a.shape:
|
|
171
|
+
nan_mask = nan_mask_a | nan_mask_b
|
|
172
|
+
else:
|
|
173
|
+
nan_mask = nan_mask_a
|
|
139
174
|
else:
|
|
140
|
-
a =
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
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)
|
|
175
|
+
a = _render_values(session_a, dim_x, dim_y, idx_a, complex_mode, log_scale)
|
|
176
|
+
b = _render_values(session_b, dim_x, dim_y, idx_b, complex_mode, log_scale)
|
|
177
|
+
b = _resize_values_like(b, a)
|
|
178
|
+
if stats_out is not None:
|
|
179
|
+
stats_out.update(_diff_summary(a, b, nan_mask))
|
|
160
180
|
if diff_mode == 1:
|
|
161
181
|
raw = a - b
|
|
162
182
|
lo = float(np.nanpercentile(raw, 1))
|
|
163
183
|
hi = float(np.nanpercentile(raw, 99))
|
|
164
184
|
vmax = max(abs(lo), abs(hi)) or 1.0
|
|
165
185
|
vmin = -vmax
|
|
166
|
-
colormap = "
|
|
186
|
+
colormap = "icefire"
|
|
167
187
|
elif diff_mode == 2:
|
|
168
188
|
raw = np.abs(a - b)
|
|
169
|
-
vmax = float(
|
|
189
|
+
vmax = float(np.nanmax(raw)) or 1.0
|
|
170
190
|
vmin = 0.0
|
|
171
191
|
colormap = "afmhot"
|
|
172
192
|
else:
|
|
173
193
|
raw = np.abs(a - b) / np.maximum(np.abs(a), 1e-6)
|
|
174
194
|
raw = np.clip(raw, 0.0, 2.0).astype(np.float32)
|
|
175
|
-
vmax = float(
|
|
195
|
+
vmax = float(np.nanmax(raw)) or 1.0
|
|
176
196
|
vmin = 0.0
|
|
177
197
|
colormap = "afmhot"
|
|
178
198
|
|
|
179
199
|
return raw, vmin, vmax, colormap, nan_mask
|
|
180
200
|
|
|
181
201
|
|
|
202
|
+
def _diff_summary(a: np.ndarray, b: np.ndarray, nan_mask: np.ndarray | None) -> dict[str, float]:
|
|
203
|
+
"""RMS of A−B, largest |A−B|, and RMS of A−B relative to the RMS of A.
|
|
204
|
+
|
|
205
|
+
Pixels that are not finite in either array (or are mosaic separators)
|
|
206
|
+
are left out. Empty or all-zero inputs give NaN for what is undefined.
|
|
207
|
+
"""
|
|
208
|
+
a = np.asarray(a, dtype=np.float64)
|
|
209
|
+
b = np.asarray(b, dtype=np.float64)
|
|
210
|
+
valid = np.isfinite(a) & np.isfinite(b)
|
|
211
|
+
if nan_mask is not None and nan_mask.shape == valid.shape:
|
|
212
|
+
valid &= ~nan_mask
|
|
213
|
+
if not valid.any():
|
|
214
|
+
return {"rmse": float("nan"), "max_abs": float("nan"), "rel": float("nan")}
|
|
215
|
+
d = a[valid] - b[valid]
|
|
216
|
+
rmse = float(np.sqrt(np.mean(d * d)))
|
|
217
|
+
rms_a = float(np.sqrt(np.mean(a[valid] ** 2)))
|
|
218
|
+
return {
|
|
219
|
+
"rmse": rmse,
|
|
220
|
+
"max_abs": float(np.max(np.abs(d))),
|
|
221
|
+
"rel": rmse / rms_a if rms_a > 0 else float("nan"),
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
|
|
182
225
|
def _render_diff_rgba(
|
|
183
226
|
raw: np.ndarray,
|
|
184
227
|
vmin: float,
|
|
@@ -188,7 +231,7 @@ def _render_diff_rgba(
|
|
|
188
231
|
) -> np.ndarray:
|
|
189
232
|
"""Map raw diff data to RGBA."""
|
|
190
233
|
if vmax > vmin:
|
|
191
|
-
normalized = np.clip((raw - vmin) / (vmax - vmin), 0, 1)
|
|
234
|
+
normalized = np.clip(np.nan_to_num((raw - vmin) / (vmax - vmin)), 0, 1)
|
|
192
235
|
else:
|
|
193
236
|
normalized = np.zeros_like(raw)
|
|
194
237
|
_ensure_lut(colormap)
|
|
@@ -201,9 +244,12 @@ def _render_diff_rgba(
|
|
|
201
244
|
|
|
202
245
|
def _diff_histogram(raw: np.ndarray, bins: int) -> dict[str, object]:
|
|
203
246
|
"""Return histogram payload for raw diff data."""
|
|
247
|
+
raw = raw[np.isfinite(raw)]
|
|
248
|
+
if raw.size == 0:
|
|
249
|
+
raw = np.zeros(1, dtype=np.float32)
|
|
204
250
|
vmin = float(raw.min())
|
|
205
251
|
vmax = float(raw.max())
|
|
206
|
-
counts, edges = np.histogram(raw
|
|
252
|
+
counts, edges = np.histogram(raw, bins=bins)
|
|
207
253
|
return {
|
|
208
254
|
"counts": counts.tolist(),
|
|
209
255
|
"edges": edges.tolist(),
|
|
@@ -1106,11 +1106,12 @@ class _FileSeries:
|
|
|
1106
1106
|
|
|
1107
1107
|
_av_lazy = True
|
|
1108
1108
|
|
|
1109
|
-
def __init__(self, file_matrix, vol_shape, dtype, spatial_meta=None):
|
|
1109
|
+
def __init__(self, file_matrix, vol_shape, dtype, spatial_meta=None, spacing_matrix=None):
|
|
1110
1110
|
self._file_matrix = file_matrix
|
|
1111
1111
|
self._vol_shape = tuple(vol_shape)
|
|
1112
1112
|
self._dtype = np.dtype(dtype)
|
|
1113
1113
|
self._spatial_meta = spatial_meta
|
|
1114
|
+
self._spacing_matrix = spacing_matrix
|
|
1114
1115
|
n_patients = len(file_matrix)
|
|
1115
1116
|
n_modalities = len(file_matrix[0]) if file_matrix else 0
|
|
1116
1117
|
if n_modalities <= 1:
|
|
@@ -1126,6 +1127,10 @@ class _FileSeries:
|
|
|
1126
1127
|
)
|
|
1127
1128
|
self._cache_lock = threading.Lock()
|
|
1128
1129
|
|
|
1130
|
+
@property
|
|
1131
|
+
def case_voxel_spacings(self):
|
|
1132
|
+
return self._spacing_matrix
|
|
1133
|
+
|
|
1129
1134
|
@property
|
|
1130
1135
|
def dtype(self):
|
|
1131
1136
|
return self._dtype
|
|
@@ -1378,6 +1383,11 @@ def _is_nifti_path(filepath):
|
|
|
1378
1383
|
return filepath.endswith(".nii") or filepath.endswith(".nii.gz")
|
|
1379
1384
|
|
|
1380
1385
|
|
|
1386
|
+
def _is_gipl_path(filepath):
|
|
1387
|
+
"""True if *filepath* ends with .gipl or .gipl.gz."""
|
|
1388
|
+
return filepath.lower().endswith((".gipl", ".gipl.gz"))
|
|
1389
|
+
|
|
1390
|
+
|
|
1381
1391
|
def _get_ext(filepath):
|
|
1382
1392
|
"""Return the extension of *filepath*, normalising .nii.gz and .zarr.zip."""
|
|
1383
1393
|
lower = filepath.lower()
|
|
@@ -1596,11 +1606,15 @@ def _series_from_file_matrix(
|
|
|
1596
1606
|
return series, spatial_meta
|
|
1597
1607
|
|
|
1598
1608
|
spatial_shapes = []
|
|
1609
|
+
# GIPL headers carry a voxel size; use it when every file has one.
|
|
1610
|
+
gipl_metas = [] if all(_is_gipl_path(path) for path in all_paths) else None
|
|
1599
1611
|
for fpath in all_paths:
|
|
1600
1612
|
arr = load_data(fpath)
|
|
1601
1613
|
shape = arr.shape
|
|
1602
1614
|
dtype = arr.dtype
|
|
1603
1615
|
spatial_shapes.append(shape)
|
|
1616
|
+
if gipl_metas is not None:
|
|
1617
|
+
gipl_metas.append(_gipl_spatial_meta(fpath, arr))
|
|
1604
1618
|
if ref_shape is None:
|
|
1605
1619
|
ref_shape = shape
|
|
1606
1620
|
ref_dtype = dtype
|
|
@@ -1616,30 +1630,47 @@ def _series_from_file_matrix(
|
|
|
1616
1630
|
)
|
|
1617
1631
|
if scan_progress is not None:
|
|
1618
1632
|
scan_progress(scan_label, fpath)
|
|
1633
|
+
spacing_matrix = None
|
|
1634
|
+
if gipl_metas and all(meta is not None for meta in gipl_metas):
|
|
1635
|
+
spatial_meta = gipl_metas[0]
|
|
1636
|
+
spacing_matrix = []
|
|
1619
1637
|
shape_matrix = []
|
|
1620
1638
|
pos = 0
|
|
1621
1639
|
for row in file_matrix:
|
|
1622
1640
|
row_shapes = []
|
|
1641
|
+
row_spacings = []
|
|
1623
1642
|
for path in row:
|
|
1624
1643
|
if path is None:
|
|
1625
1644
|
row_shapes.append(ref_shape)
|
|
1645
|
+
if spacing_matrix is not None:
|
|
1646
|
+
row_spacings.append(gipl_metas[0]["voxel_sizes"])
|
|
1626
1647
|
else:
|
|
1627
1648
|
row_shapes.append(spatial_shapes[pos])
|
|
1649
|
+
if spacing_matrix is not None:
|
|
1650
|
+
row_spacings.append(gipl_metas[pos]["voxel_sizes"])
|
|
1628
1651
|
pos += 1
|
|
1629
1652
|
shape_matrix.append(row_shapes)
|
|
1653
|
+
if spacing_matrix is not None:
|
|
1654
|
+
spacing_matrix.append(row_spacings)
|
|
1630
1655
|
shapes_differ = any(shape != ref_shape for shape in spatial_shapes)
|
|
1631
1656
|
if stack == "dense" and shapes_differ:
|
|
1632
1657
|
raise ValueError("Dense stacking requires every file to have the same shape.")
|
|
1633
1658
|
if stack == "ragged" or shapes_differ:
|
|
1634
|
-
series = _RaggedFileSeries(
|
|
1659
|
+
series = _RaggedFileSeries(
|
|
1660
|
+
file_matrix, shape_matrix, ref_dtype,
|
|
1661
|
+
spatial_meta=spatial_meta, spacing_matrix=spacing_matrix,
|
|
1662
|
+
)
|
|
1635
1663
|
else:
|
|
1636
|
-
series = _FileSeries(
|
|
1664
|
+
series = _FileSeries(
|
|
1665
|
+
file_matrix, ref_shape, ref_dtype,
|
|
1666
|
+
spatial_meta=spatial_meta, spacing_matrix=spacing_matrix,
|
|
1667
|
+
)
|
|
1637
1668
|
if load == "eager":
|
|
1638
1669
|
for p, row in enumerate(file_matrix):
|
|
1639
1670
|
for m, path in enumerate(row):
|
|
1640
1671
|
if path is not None:
|
|
1641
1672
|
series._get_volume(p, m)
|
|
1642
|
-
return series,
|
|
1673
|
+
return series, spatial_meta
|
|
1643
1674
|
|
|
1644
1675
|
|
|
1645
1676
|
def _collection_scan_workers():
|
|
@@ -2350,6 +2381,9 @@ def load_data_with_meta(filepath, key=None, *, load="lazy", stack="auto", select
|
|
|
2350
2381
|
return _load_file_series(filepath, load=load, stack=stack)
|
|
2351
2382
|
if filepath.endswith(".nii") or filepath.endswith(".nii.gz"):
|
|
2352
2383
|
return _load_nifti_with_meta(filepath)
|
|
2384
|
+
if filepath.lower().endswith((".gipl", ".gipl.gz")):
|
|
2385
|
+
arr = _load_gipl(filepath)
|
|
2386
|
+
return arr, _gipl_spatial_meta(filepath, arr)
|
|
2353
2387
|
return load_data(
|
|
2354
2388
|
filepath, key=key, progress=progress, fast_first_frame=fast_first_frame
|
|
2355
2389
|
), None
|
|
@@ -2546,6 +2580,31 @@ def _load_gipl(filepath):
|
|
|
2546
2580
|
return np.ascontiguousarray(arr)
|
|
2547
2581
|
|
|
2548
2582
|
|
|
2583
|
+
def _gipl_spatial_meta(filepath, arr):
|
|
2584
|
+
"""Voxel sizes from a GIPL header, so the viewer shows true proportions.
|
|
2585
|
+
|
|
2586
|
+
GIPL stores no orientation, so axis labels stay empty. Returns None when
|
|
2587
|
+
the header has no usable voxel sizes.
|
|
2588
|
+
"""
|
|
2589
|
+
import gzip
|
|
2590
|
+
|
|
2591
|
+
opener = gzip.open if filepath.lower().endswith(".gz") else open
|
|
2592
|
+
with opener(filepath, "rb") as f:
|
|
2593
|
+
header = f.read(_GIPL_HEADER_SIZE)
|
|
2594
|
+
pixdim = np.frombuffer(header, ">f4", count=4, offset=10).astype(float)
|
|
2595
|
+
n = min(arr.ndim, 3)
|
|
2596
|
+
voxel_sizes = tuple(float(v) for v in pixdim[:n])
|
|
2597
|
+
if not all(np.isfinite(v) and v > 0 for v in voxel_sizes):
|
|
2598
|
+
return None
|
|
2599
|
+
return {
|
|
2600
|
+
"voxel_sizes": voxel_sizes,
|
|
2601
|
+
"axis_labels": (),
|
|
2602
|
+
"is_oblique": False,
|
|
2603
|
+
"is_canonical": True,
|
|
2604
|
+
"spatial_shape": tuple(int(v) for v in arr.shape[:n]),
|
|
2605
|
+
}
|
|
2606
|
+
|
|
2607
|
+
|
|
2549
2608
|
def _tensor_to_numpy(obj, source="object"):
|
|
2550
2609
|
"""Convert a tensor-like object (PyTorch, etc.) to a numpy array."""
|
|
2551
2610
|
if isinstance(obj, np.ndarray):
|
|
@@ -3539,11 +3539,12 @@ def view(
|
|
|
3539
3539
|
vmax: float | None = None,
|
|
3540
3540
|
log: bool = False,
|
|
3541
3541
|
complex: str | None = None,
|
|
3542
|
+
voxel_size=None,
|
|
3542
3543
|
):
|
|
3543
3544
|
"""
|
|
3544
3545
|
Launch the viewer. Does not block the main Python process.
|
|
3545
3546
|
|
|
3546
|
-
Accepts one to
|
|
3547
|
+
Accepts one to six arrays (the same limit as the command line)::
|
|
3547
3548
|
|
|
3548
3549
|
handle = arrayview.view(a)
|
|
3549
3550
|
ha, hb = arrayview.view(a, b)
|
|
@@ -3588,6 +3589,10 @@ def view(
|
|
|
3588
3589
|
``"imag"`` (``"mag"`` shows the absolute value of real data). Pressing
|
|
3589
3590
|
``E`` in the viewer copies the current view as such a call.
|
|
3590
3591
|
|
|
3592
|
+
``voxel_size`` — physical size of one step along each dimension, e.g.
|
|
3593
|
+
``(1, 1, 2)`` for 1 mm pixels and 2 mm slices. Slices then show their
|
|
3594
|
+
true proportions. Leading dimensions only; later ones keep size 1.
|
|
3595
|
+
|
|
3591
3596
|
Returns a ``ViewHandle`` for a single array, or a tuple of ``ViewHandle``
|
|
3592
3597
|
objects for multiple arrays (one per array). In inline/Jupyter mode with
|
|
3593
3598
|
multiple arrays, the IFrame is displayed automatically and a uniform tuple
|
|
@@ -3601,9 +3606,14 @@ def view(
|
|
|
3601
3606
|
|
|
3602
3607
|
_initial_view = _session_mod.initial_view_spec(
|
|
3603
3608
|
mode=mode, dims=dims, index=index, cmap=cmap, vmin=vmin, vmax=vmax, log=log,
|
|
3604
|
-
complex=complex,
|
|
3609
|
+
complex=complex, voxel_size=voxel_size,
|
|
3605
3610
|
)
|
|
3606
3611
|
_code_name = _caller_variable_name(arrays[0]) if arrays else None
|
|
3612
|
+
# Several arrays side by side need telling apart; the caller's variable
|
|
3613
|
+
# names do that better than identical "Array (shape)" labels.
|
|
3614
|
+
_var_names = (
|
|
3615
|
+
[_caller_variable_name(arr) for arr in arrays] if len(arrays) > 1 else []
|
|
3616
|
+
)
|
|
3607
3617
|
|
|
3608
3618
|
height = _normalize_inline_height(height)
|
|
3609
3619
|
_inline_mode_heights = _normalize_inline_mode_heights(mode_heights)
|
|
@@ -3612,8 +3622,8 @@ def view(
|
|
|
3612
3622
|
n_arrays = len(arrays)
|
|
3613
3623
|
if n_arrays == 0:
|
|
3614
3624
|
raise ValueError("view() requires at least one array.")
|
|
3615
|
-
if n_arrays >
|
|
3616
|
-
raise ValueError(f"view() accepts at most
|
|
3625
|
+
if n_arrays > 6:
|
|
3626
|
+
raise ValueError(f"view() accepts at most 6 arrays, got {n_arrays}.")
|
|
3617
3627
|
|
|
3618
3628
|
# --- Normalise per-array kwargs (name, rgb) ---
|
|
3619
3629
|
if isinstance(name, list):
|
|
@@ -3664,6 +3674,8 @@ def view(
|
|
|
3664
3674
|
arrays = tuple(_convert_array(arr) for arr in arrays)
|
|
3665
3675
|
|
|
3666
3676
|
# --- Apply name defaults after conversion (shape is available now) ---
|
|
3677
|
+
if len(set(filter(None, _var_names))) == n_arrays:
|
|
3678
|
+
names = [names[i] if names[i] is not None else _var_names[i] for i in range(n_arrays)]
|
|
3667
3679
|
names = [
|
|
3668
3680
|
(f"Array {getattr(arrays[i], 'shape', '?')}" if names[i] is None else names[i])
|
|
3669
3681
|
for i in range(n_arrays)
|
|
@@ -3680,6 +3692,13 @@ def view(
|
|
|
3680
3692
|
f"index has {len(_initial_view['index'])} entries but the array "
|
|
3681
3693
|
f"has {_ndim} dimensions"
|
|
3682
3694
|
)
|
|
3695
|
+
if _initial_view and "voxel_size" in _initial_view:
|
|
3696
|
+
_ndim = len(getattr(data, "shape", ()))
|
|
3697
|
+
if len(_initial_view["voxel_size"]) > _ndim:
|
|
3698
|
+
raise ValueError(
|
|
3699
|
+
f"voxel_size has {len(_initial_view['voxel_size'])} entries but the "
|
|
3700
|
+
f"array has {_ndim} dimensions"
|
|
3701
|
+
)
|
|
3683
3702
|
|
|
3684
3703
|
# --- Normalise string window modes ---
|
|
3685
3704
|
_raw_window = window
|
|
@@ -452,8 +452,17 @@ def _has_vscode_window_registration() -> bool:
|
|
|
452
452
|
def _process_is_alive(pid: object) -> bool:
|
|
453
453
|
try:
|
|
454
454
|
numeric_pid = int(pid)
|
|
455
|
+
except (TypeError, ValueError):
|
|
456
|
+
return False
|
|
457
|
+
if os.name == "nt":
|
|
458
|
+
# Signal 0 is CTRL_C_EVENT on Windows: os.kill would try to interrupt
|
|
459
|
+
# a console process group rather than test whether the pid exists.
|
|
460
|
+
from arrayview._instance_registry import process_start_identity
|
|
461
|
+
|
|
462
|
+
return process_start_identity(numeric_pid) is not None
|
|
463
|
+
try:
|
|
455
464
|
os.kill(numeric_pid, 0)
|
|
456
|
-
except
|
|
465
|
+
except OSError:
|
|
457
466
|
return False
|
|
458
467
|
if sys.platform.startswith("linux"):
|
|
459
468
|
try:
|
|
@@ -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).
|
|
@@ -68,6 +68,28 @@ def register_preferences_routes(app) -> None:
|
|
|
68
68
|
return JSONResponse({"error": "session_not_found"}, status_code=404)
|
|
69
69
|
return _response_payload()
|
|
70
70
|
|
|
71
|
+
@app.post("/view-memory/{sid}")
|
|
72
|
+
async def view_memory_post(sid: str, request: Request):
|
|
73
|
+
"""Store the view a file was left in (Preferences → Reopen where you left off)."""
|
|
74
|
+
session = SESSIONS.get(sid)
|
|
75
|
+
if session is None:
|
|
76
|
+
return JSONResponse({"error": "session_not_found"}, status_code=404)
|
|
77
|
+
from arrayview._config import get_viewer_remember_view
|
|
78
|
+
from arrayview._view_memory import remember_view
|
|
79
|
+
|
|
80
|
+
if not get_viewer_remember_view():
|
|
81
|
+
return {"saved": False}
|
|
82
|
+
try:
|
|
83
|
+
body = await request.json()
|
|
84
|
+
except Exception:
|
|
85
|
+
return JSONResponse({"error": "invalid_json"}, status_code=400)
|
|
86
|
+
view = body.get("view") if isinstance(body, dict) else None
|
|
87
|
+
try:
|
|
88
|
+
saved = remember_view(session, view)
|
|
89
|
+
except OSError:
|
|
90
|
+
saved = False
|
|
91
|
+
return {"saved": saved}
|
|
92
|
+
|
|
71
93
|
@app.patch("/preferences/{sid}")
|
|
72
94
|
async def preferences_patch(sid: str, request: Request):
|
|
73
95
|
if SESSIONS.get(sid) is None:
|