arrayview 0.48.4__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.4 → arrayview-0.49.0}/PKG-INFO +1 -1
- {arrayview-0.48.4 → arrayview-0.49.0}/pyproject.toml +1 -1
- {arrayview-0.48.4 → arrayview-0.49.0}/src/arrayview/_analysis.py +9 -0
- {arrayview-0.48.4 → arrayview-0.49.0}/src/arrayview/_config.py +19 -1
- {arrayview-0.48.4 → arrayview-0.49.0}/src/arrayview/_diff.py +31 -1
- {arrayview-0.48.4 → arrayview-0.49.0}/src/arrayview/_io.py +63 -4
- {arrayview-0.48.4 → arrayview-0.49.0}/src/arrayview/_launcher.py +23 -4
- {arrayview-0.48.4 → arrayview-0.49.0}/src/arrayview/_platform.py +10 -1
- {arrayview-0.48.4 → arrayview-0.49.0}/src/arrayview/_routes_preferences.py +22 -0
- {arrayview-0.48.4 → arrayview-0.49.0}/src/arrayview/_routes_rendering.py +7 -0
- {arrayview-0.48.4 → arrayview-0.49.0}/src/arrayview/_routes_websocket.py +29 -0
- {arrayview-0.48.4 → arrayview-0.49.0}/src/arrayview/_server.py +4 -0
- {arrayview-0.48.4 → arrayview-0.49.0}/src/arrayview/_session.py +9 -0
- arrayview-0.49.0/src/arrayview/_view_memory.py +102 -0
- {arrayview-0.48.4 → arrayview-0.49.0}/src/arrayview/_viewer.html +768 -433
- {arrayview-0.48.4 → arrayview-0.49.0}/src/arrayview/_vscode_extension.py +1 -1
- {arrayview-0.48.4 → arrayview-0.49.0}/src/arrayview/_vscode_signal.py +3 -3
- {arrayview-0.48.4 → arrayview-0.49.0}/tests/test_api.py +3 -1
- {arrayview-0.48.4 → arrayview-0.49.0}/tests/test_backend_shared.py +14 -0
- {arrayview-0.48.4 → arrayview-0.49.0}/tests/test_browser.py +132 -9
- {arrayview-0.48.4 → arrayview-0.49.0}/tests/test_config.py +8 -0
- {arrayview-0.48.4 → arrayview-0.49.0}/tests/test_instance_registry.py +15 -0
- {arrayview-0.48.4 → arrayview-0.49.0}/tests/test_io.py +40 -1
- arrayview-0.49.0/tests/test_view_memory.py +32 -0
- {arrayview-0.48.4 → arrayview-0.49.0}/.gitignore +0 -0
- {arrayview-0.48.4 → arrayview-0.49.0}/LICENSE +0 -0
- {arrayview-0.48.4 → arrayview-0.49.0}/README.md +0 -0
- {arrayview-0.48.4 → arrayview-0.49.0}/src/arrayview/ARCHITECTURE.md +0 -0
- {arrayview-0.48.4 → arrayview-0.49.0}/src/arrayview/__init__.py +0 -0
- {arrayview-0.48.4 → arrayview-0.49.0}/src/arrayview/__main__.py +0 -0
- {arrayview-0.48.4 → arrayview-0.49.0}/src/arrayview/_app.py +0 -0
- {arrayview-0.48.4 → arrayview-0.49.0}/src/arrayview/_bootstrap_app.py +0 -0
- {arrayview-0.48.4 → arrayview-0.49.0}/src/arrayview/_diagnostics.py +0 -0
- {arrayview-0.48.4 → arrayview-0.49.0}/src/arrayview/_dicom.py +0 -0
- {arrayview-0.48.4 → arrayview-0.49.0}/src/arrayview/_extra_ports.py +0 -0
- {arrayview-0.48.4 → arrayview-0.49.0}/src/arrayview/_icon.png +0 -0
- {arrayview-0.48.4 → arrayview-0.49.0}/src/arrayview/_imaging.py +0 -0
- {arrayview-0.48.4 → arrayview-0.49.0}/src/arrayview/_instance_registry.py +0 -0
- {arrayview-0.48.4 → arrayview-0.49.0}/src/arrayview/_launch_plan.py +0 -0
- {arrayview-0.48.4 → arrayview-0.49.0}/src/arrayview/_launch_trace.py +0 -0
- {arrayview-0.48.4 → arrayview-0.49.0}/src/arrayview/_lifecycle.py +0 -0
- {arrayview-0.48.4 → arrayview-0.49.0}/src/arrayview/_overlays.py +0 -0
- {arrayview-0.48.4 → arrayview-0.49.0}/src/arrayview/_render.py +0 -0
- {arrayview-0.48.4 → arrayview-0.49.0}/src/arrayview/_routes_analysis.py +0 -0
- {arrayview-0.48.4 → arrayview-0.49.0}/src/arrayview/_routes_drop.py +0 -0
- {arrayview-0.48.4 → arrayview-0.49.0}/src/arrayview/_routes_export.py +0 -0
- {arrayview-0.48.4 → arrayview-0.49.0}/src/arrayview/_routes_loading.py +0 -0
- {arrayview-0.48.4 → arrayview-0.49.0}/src/arrayview/_routes_persistence.py +0 -0
- {arrayview-0.48.4 → arrayview-0.49.0}/src/arrayview/_routes_preload.py +0 -0
- {arrayview-0.48.4 → arrayview-0.49.0}/src/arrayview/_routes_query.py +0 -0
- {arrayview-0.48.4 → arrayview-0.49.0}/src/arrayview/_routes_segmentation.py +0 -0
- {arrayview-0.48.4 → arrayview-0.49.0}/src/arrayview/_routes_state.py +0 -0
- {arrayview-0.48.4 → arrayview-0.49.0}/src/arrayview/_routes_vectorfield.py +0 -0
- {arrayview-0.48.4 → arrayview-0.49.0}/src/arrayview/_segmentation.py +0 -0
- {arrayview-0.48.4 → arrayview-0.49.0}/src/arrayview/_session_spec.py +0 -0
- {arrayview-0.48.4 → arrayview-0.49.0}/src/arrayview/_shell.html +0 -0
- {arrayview-0.48.4 → arrayview-0.49.0}/src/arrayview/_source_safety.py +0 -0
- {arrayview-0.48.4 → arrayview-0.49.0}/src/arrayview/_synthetic_mri.py +0 -0
- {arrayview-0.48.4 → arrayview-0.49.0}/src/arrayview/_torch.py +0 -0
- {arrayview-0.48.4 → arrayview-0.49.0}/src/arrayview/_tutorial.py +0 -0
- {arrayview-0.48.4 → arrayview-0.49.0}/src/arrayview/_vectorfield.py +0 -0
- {arrayview-0.48.4 → arrayview-0.49.0}/src/arrayview/_vscode.py +0 -0
- {arrayview-0.48.4 → arrayview-0.49.0}/src/arrayview/_vscode_browser.py +0 -0
- {arrayview-0.48.4 → arrayview-0.49.0}/src/arrayview/_watch.py +0 -0
- {arrayview-0.48.4 → arrayview-0.49.0}/src/arrayview/arrayview-opener.vsix +0 -0
- {arrayview-0.48.4 → arrayview-0.49.0}/src/arrayview/gsap.min.js +0 -0
- {arrayview-0.48.4 → arrayview-0.49.0}/src/arrayview/todo.md +0 -0
- {arrayview-0.48.4 → arrayview-0.49.0}/tests/capture_patient_loading.py +0 -0
- {arrayview-0.48.4 → arrayview-0.49.0}/tests/capture_v_animation.py +0 -0
- {arrayview-0.48.4 → arrayview-0.49.0}/tests/conftest.py +0 -0
- {arrayview-0.48.4 → arrayview-0.49.0}/tests/make_vectorfield_test_arrays.py +0 -0
- {arrayview-0.48.4 → arrayview-0.49.0}/tests/test_1d_arrays.py +0 -0
- {arrayview-0.48.4 → arrayview-0.49.0}/tests/test_cli.py +0 -0
- {arrayview-0.48.4 → arrayview-0.49.0}/tests/test_dicom.py +0 -0
- {arrayview-0.48.4 → arrayview-0.49.0}/tests/test_drop_import.py +0 -0
- {arrayview-0.48.4 → arrayview-0.49.0}/tests/test_large_arrays.py +0 -0
- {arrayview-0.48.4 → arrayview-0.49.0}/tests/test_launch_blackbox.py +0 -0
- {arrayview-0.48.4 → arrayview-0.49.0}/tests/test_mode_roundtrip.py +0 -0
- {arrayview-0.48.4 → arrayview-0.49.0}/tests/test_nifti_meta.py +0 -0
- {arrayview-0.48.4 → arrayview-0.49.0}/tests/test_nifti_series.py +0 -0
- {arrayview-0.48.4 → arrayview-0.49.0}/tests/test_rgb_pixel_art.py +0 -0
- {arrayview-0.48.4 → arrayview-0.49.0}/tests/test_safe_server_stop.py +0 -0
- {arrayview-0.48.4 → arrayview-0.49.0}/tests/test_source_safety.py +0 -0
- {arrayview-0.48.4 → arrayview-0.49.0}/tests/test_torch.py +0 -0
- {arrayview-0.48.4 → arrayview-0.49.0}/tests/test_tutorial.py +0 -0
- {arrayview-0.48.4 → arrayview-0.49.0}/tests/test_vsix_bundle.py +0 -0
- {arrayview-0.48.4 → arrayview-0.49.0}/tests/ui_audit.py +0 -0
- {arrayview-0.48.4 → arrayview-0.49.0}/tests/v_anim_frames/.gitkeep +0 -0
- {arrayview-0.48.4 → 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", {})
|
|
@@ -135,8 +135,13 @@ def _compute_diff(
|
|
|
135
135
|
diff_mode,
|
|
136
136
|
indices_a=None,
|
|
137
137
|
indices_b=None,
|
|
138
|
+
stats_out=None,
|
|
138
139
|
):
|
|
139
|
-
"""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
|
+
"""
|
|
140
145
|
def _parse_indices(raw_indices):
|
|
141
146
|
if raw_indices is None:
|
|
142
147
|
return ()
|
|
@@ -170,6 +175,8 @@ def _compute_diff(
|
|
|
170
175
|
a = _render_values(session_a, dim_x, dim_y, idx_a, complex_mode, log_scale)
|
|
171
176
|
b = _render_values(session_b, dim_x, dim_y, idx_b, complex_mode, log_scale)
|
|
172
177
|
b = _resize_values_like(b, a)
|
|
178
|
+
if stats_out is not None:
|
|
179
|
+
stats_out.update(_diff_summary(a, b, nan_mask))
|
|
173
180
|
if diff_mode == 1:
|
|
174
181
|
raw = a - b
|
|
175
182
|
lo = float(np.nanpercentile(raw, 1))
|
|
@@ -192,6 +199,29 @@ def _compute_diff(
|
|
|
192
199
|
return raw, vmin, vmax, colormap, nan_mask
|
|
193
200
|
|
|
194
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
|
+
|
|
195
225
|
def _render_diff_rgba(
|
|
196
226
|
raw: np.ndarray,
|
|
197
227
|
vmin: float,
|
|
@@ -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:
|
|
@@ -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:
|
|
@@ -360,6 +360,7 @@ def register_rendering_routes(app, *, get_session_or_404) -> None:
|
|
|
360
360
|
session_b = SESSIONS.get(sid_b)
|
|
361
361
|
if not session_a or not session_b:
|
|
362
362
|
return Response(status_code=404)
|
|
363
|
+
stats: dict[str, float] = {}
|
|
363
364
|
try:
|
|
364
365
|
raw, vmin, vmax, colormap, nan_mask = _compute_diff(
|
|
365
366
|
session_a,
|
|
@@ -374,6 +375,7 @@ def register_rendering_routes(app, *, get_session_or_404) -> None:
|
|
|
374
375
|
diff_mode,
|
|
375
376
|
indices_a=indices_a,
|
|
376
377
|
indices_b=indices_b,
|
|
378
|
+
stats_out=stats,
|
|
377
379
|
)
|
|
378
380
|
except Exception:
|
|
379
381
|
return Response(status_code=422)
|
|
@@ -399,6 +401,11 @@ def register_rendering_routes(app, *, get_session_or_404) -> None:
|
|
|
399
401
|
"X-ArrayView-Vmin": str(vmin),
|
|
400
402
|
"X-ArrayView-Vmax": str(vmax),
|
|
401
403
|
"X-ArrayView-Colormap": colormap,
|
|
404
|
+
# Numbers for the A−B title: RMS difference, largest
|
|
405
|
+
# difference, RMS difference relative to A.
|
|
406
|
+
"X-ArrayView-Diff-Rmse": str(stats.get("rmse", "nan")),
|
|
407
|
+
"X-ArrayView-Diff-Max": str(stats.get("max_abs", "nan")),
|
|
408
|
+
"X-ArrayView-Diff-Rel": str(stats.get("rel", "nan")),
|
|
402
409
|
},
|
|
403
410
|
)
|
|
404
411
|
|
|
@@ -284,6 +284,29 @@ async def _send_json_quietly(ws, payload):
|
|
|
284
284
|
pass
|
|
285
285
|
|
|
286
286
|
|
|
287
|
+
# Viewer tabs whose slice position follows the other linked tabs (Shift+L).
|
|
288
|
+
# Rides on each viewer's existing socket: VS Code tabs share one channel with
|
|
289
|
+
# a small socket budget, so linking must not open connections of its own.
|
|
290
|
+
_LINKED_VIEWER_SOCKETS: set = set()
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def _handle_link_message(ws, msg: dict) -> None:
|
|
294
|
+
action = msg.get("action")
|
|
295
|
+
if action == "join":
|
|
296
|
+
_LINKED_VIEWER_SOCKETS.add(ws)
|
|
297
|
+
elif action == "leave":
|
|
298
|
+
_LINKED_VIEWER_SOCKETS.discard(ws)
|
|
299
|
+
elif action == "position" and ws in _LINKED_VIEWER_SOCKETS:
|
|
300
|
+
indices = msg.get("indices")
|
|
301
|
+
shape = msg.get("shape")
|
|
302
|
+
if not isinstance(indices, list) or not isinstance(shape, list):
|
|
303
|
+
return
|
|
304
|
+
payload = {"type": "link", "indices": indices, "shape": shape}
|
|
305
|
+
for other in list(_LINKED_VIEWER_SOCKETS):
|
|
306
|
+
if other is not ws:
|
|
307
|
+
asyncio.ensure_future(_send_json_quietly(other, payload))
|
|
308
|
+
|
|
309
|
+
|
|
287
310
|
def _render_rgba_from_data(data, colormap, vmin, vmax):
|
|
288
311
|
if vmax > vmin:
|
|
289
312
|
normalized = np.clip((data - vmin) / (vmax - vmin), 0, 1)
|
|
@@ -503,6 +526,11 @@ def register_websocket_routes(app) -> None:
|
|
|
503
526
|
try:
|
|
504
527
|
while True:
|
|
505
528
|
msg = await ws.receive_json()
|
|
529
|
+
if isinstance(msg, dict) and msg.get("type") == "link":
|
|
530
|
+
# Not a frame request: answer here so it never
|
|
531
|
+
# displaces (or is displaced by) a pending render.
|
|
532
|
+
_handle_link_message(ws, msg)
|
|
533
|
+
continue
|
|
506
534
|
try:
|
|
507
535
|
_pending.get_nowait()
|
|
508
536
|
except asyncio.QueueEmpty:
|
|
@@ -904,6 +932,7 @@ def register_websocket_routes(app) -> None:
|
|
|
904
932
|
_session_mod.LIVE_VIEWER_SOCKETS.remove(ws)
|
|
905
933
|
except ValueError:
|
|
906
934
|
pass
|
|
935
|
+
_LINKED_VIEWER_SOCKETS.discard(ws)
|
|
907
936
|
try:
|
|
908
937
|
from arrayview import _extra_ports
|
|
909
938
|
|
|
@@ -55,6 +55,8 @@ from arrayview._config import (
|
|
|
55
55
|
get_viewer_ortho_layout,
|
|
56
56
|
get_viewer_rounded_panes,
|
|
57
57
|
get_viewer_qmri_gapless,
|
|
58
|
+
get_viewer_compare_gapless,
|
|
59
|
+
get_viewer_remember_view,
|
|
58
60
|
get_viewer_theme,
|
|
59
61
|
)
|
|
60
62
|
|
|
@@ -484,6 +486,8 @@ def _viewer_ui_response(
|
|
|
484
486
|
.replace("__DEFAULT_THEME_IDX__", str(_default_theme_idx))
|
|
485
487
|
.replace("__DEFAULT_ROUNDED_PANES__", _default_rounded_panes)
|
|
486
488
|
.replace("__DEFAULT_QMRI_GAPLESS__", "true" if get_viewer_qmri_gapless() else "false")
|
|
489
|
+
.replace("__DEFAULT_COMPARE_GAPLESS__", "true" if get_viewer_compare_gapless() else "false")
|
|
490
|
+
.replace("__DEFAULT_REMEMBER_VIEW__", "true" if get_viewer_remember_view() else "false")
|
|
487
491
|
.replace("__DEFAULT_ORTHO_LAYOUT__", _default_ortho_layout)
|
|
488
492
|
.replace("__DEFAULT_DIMBAR_MODE__", _default_dimbar_mode)
|
|
489
493
|
.replace("__BODY_CLASS__", "av-loading" if sid else "")
|
|
@@ -690,6 +690,7 @@ def initial_view_spec(
|
|
|
690
690
|
vmax=None,
|
|
691
691
|
log=False,
|
|
692
692
|
complex=None,
|
|
693
|
+
voxel_size=None,
|
|
693
694
|
) -> dict | None:
|
|
694
695
|
"""Validate how a caller asks the viewer to open; None when nothing is set.
|
|
695
696
|
|
|
@@ -734,6 +735,14 @@ def initial_view_spec(
|
|
|
734
735
|
if complex not in COMPLEX_PARTS:
|
|
735
736
|
raise ValueError(f"complex must be one of: {', '.join(COMPLEX_PARTS)}")
|
|
736
737
|
spec["complex"] = complex
|
|
738
|
+
if voxel_size is not None:
|
|
739
|
+
try:
|
|
740
|
+
sizes = [float(v) for v in voxel_size]
|
|
741
|
+
except (TypeError, ValueError):
|
|
742
|
+
raise ValueError("voxel_size must be one size per dimension, e.g. (1, 1, 2)")
|
|
743
|
+
if not sizes or not all(np.isfinite(v) and v > 0 for v in sizes):
|
|
744
|
+
raise ValueError("voxel_size must be positive numbers, e.g. (1, 1, 2)")
|
|
745
|
+
spec["voxel_size"] = sizes
|
|
737
746
|
return spec or None
|
|
738
747
|
|
|
739
748
|
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""Remember the last view of each file, for Preferences → Reopen where you left off.
|
|
2
|
+
|
|
3
|
+
Stored in ~/.arrayview/view_memory.json, keyed by the file's real path (plus the
|
|
4
|
+
array name inside .npz/.mat files). A remembered view is only handed back for
|
|
5
|
+
an array of the same shape, so a file rewritten with new dimensions starts fresh.
|
|
6
|
+
A file rewritten with the same shape keeps its slice, view and colormap, but not
|
|
7
|
+
its display range, which no longer fits the new values.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
import os
|
|
14
|
+
import tempfile
|
|
15
|
+
import threading
|
|
16
|
+
import time
|
|
17
|
+
|
|
18
|
+
VIEW_MEMORY_PATH = os.path.expanduser("~/.arrayview/view_memory.json")
|
|
19
|
+
_MAX_ENTRIES = 200
|
|
20
|
+
_RANGE_KEYS = ("manualVmin", "manualVmax")
|
|
21
|
+
_LOCK = threading.Lock()
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def view_memory_key(session) -> str | None:
|
|
25
|
+
filepath = getattr(session, "filepath", None)
|
|
26
|
+
if not filepath:
|
|
27
|
+
return None
|
|
28
|
+
try:
|
|
29
|
+
key = os.path.realpath(str(filepath))
|
|
30
|
+
except (OSError, ValueError):
|
|
31
|
+
return None
|
|
32
|
+
array_key = getattr(session, "array_key", None)
|
|
33
|
+
return f"{key}#{array_key}" if array_key else key
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _file_signature(session) -> list | None:
|
|
37
|
+
from arrayview._session import file_signature
|
|
38
|
+
|
|
39
|
+
sig = file_signature(str(session.filepath))
|
|
40
|
+
return list(sig) if sig else None
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _load() -> dict:
|
|
44
|
+
try:
|
|
45
|
+
with open(VIEW_MEMORY_PATH, encoding="utf-8") as f:
|
|
46
|
+
data = json.load(f)
|
|
47
|
+
except (OSError, ValueError):
|
|
48
|
+
return {}
|
|
49
|
+
return data if isinstance(data, dict) else {}
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _save(entries: dict) -> None:
|
|
53
|
+
folder = os.path.dirname(VIEW_MEMORY_PATH)
|
|
54
|
+
os.makedirs(folder, exist_ok=True)
|
|
55
|
+
fd, temp_path = tempfile.mkstemp(prefix=".view_memory.", suffix=".tmp", dir=folder)
|
|
56
|
+
try:
|
|
57
|
+
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
|
58
|
+
json.dump(entries, f)
|
|
59
|
+
os.replace(temp_path, VIEW_MEMORY_PATH)
|
|
60
|
+
except Exception:
|
|
61
|
+
try:
|
|
62
|
+
os.unlink(temp_path)
|
|
63
|
+
except FileNotFoundError:
|
|
64
|
+
pass
|
|
65
|
+
raise
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def remembered_view(session) -> dict | None:
|
|
69
|
+
key = view_memory_key(session)
|
|
70
|
+
if key is None:
|
|
71
|
+
return None
|
|
72
|
+
with _LOCK:
|
|
73
|
+
entry = _load().get(key)
|
|
74
|
+
if not isinstance(entry, dict) or not isinstance(entry.get("view"), dict):
|
|
75
|
+
return None
|
|
76
|
+
if entry.get("shape") != [int(s) for s in session.shape]:
|
|
77
|
+
return None
|
|
78
|
+
view = dict(entry["view"])
|
|
79
|
+
if entry.get("signature") is None or entry.get("signature") != _file_signature(session):
|
|
80
|
+
for k in _RANGE_KEYS:
|
|
81
|
+
view.pop(k, None)
|
|
82
|
+
return view
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def remember_view(session, view: dict) -> bool:
|
|
86
|
+
key = view_memory_key(session)
|
|
87
|
+
if key is None or not isinstance(view, dict):
|
|
88
|
+
return False
|
|
89
|
+
with _LOCK:
|
|
90
|
+
entries = _load()
|
|
91
|
+
entries[key] = {
|
|
92
|
+
"shape": [int(s) for s in session.shape],
|
|
93
|
+
"view": view,
|
|
94
|
+
"signature": _file_signature(session),
|
|
95
|
+
"updated": time.time(),
|
|
96
|
+
}
|
|
97
|
+
if len(entries) > _MAX_ENTRIES:
|
|
98
|
+
oldest = sorted(entries, key=lambda k: entries[k].get("updated", 0) if isinstance(entries[k], dict) else 0)
|
|
99
|
+
for stale in oldest[: len(entries) - _MAX_ENTRIES]:
|
|
100
|
+
entries.pop(stale, None)
|
|
101
|
+
_save(entries)
|
|
102
|
+
return True
|