arrayview 0.48.4__py3-none-any.whl → 0.49.1__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/_analysis.py +9 -0
- arrayview/_config.py +19 -1
- arrayview/_diff.py +31 -1
- arrayview/_instance_registry.py +4 -1
- arrayview/_io.py +88 -20
- arrayview/_launch_trace.py +2 -1
- arrayview/_launcher.py +63 -8
- arrayview/_platform.py +10 -1
- arrayview/_routes_export.py +9 -3
- arrayview/_routes_loading.py +12 -0
- arrayview/_routes_preferences.py +22 -0
- arrayview/_routes_rendering.py +7 -0
- arrayview/_routes_websocket.py +59 -0
- arrayview/_server.py +4 -0
- arrayview/_session.py +9 -0
- arrayview/_view_memory.py +102 -0
- arrayview/_viewer.html +1193 -947
- arrayview/_vscode_extension.py +15 -15
- arrayview/_vscode_signal.py +60 -15
- arrayview/arrayview-opener.vsix +0 -0
- {arrayview-0.48.4.dist-info → arrayview-0.49.1.dist-info}/METADATA +1 -1
- {arrayview-0.48.4.dist-info → arrayview-0.49.1.dist-info}/RECORD +25 -24
- {arrayview-0.48.4.dist-info → arrayview-0.49.1.dist-info}/WHEEL +0 -0
- {arrayview-0.48.4.dist-info → arrayview-0.49.1.dist-info}/entry_points.txt +0 -0
- {arrayview-0.48.4.dist-info → arrayview-0.49.1.dist-info}/licenses/LICENSE +0 -0
arrayview/_analysis.py
CHANGED
|
@@ -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
|
arrayview/_config.py
CHANGED
|
@@ -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", {})
|
arrayview/_diff.py
CHANGED
|
@@ -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,
|
arrayview/_instance_registry.py
CHANGED
|
@@ -70,7 +70,10 @@ def runtime_directory() -> Path:
|
|
|
70
70
|
return Path(base) / "arrayview"
|
|
71
71
|
if hasattr(os, "getuid"):
|
|
72
72
|
return Path(tempfile.gettempdir()) / f"arrayview-{os.getuid()}"
|
|
73
|
-
|
|
73
|
+
# Windows: no uid, but the temp folder is already per user. One shared
|
|
74
|
+
# name lets every command see the servers the others started.
|
|
75
|
+
user = "".join(c for c in os.environ.get("USERNAME", "") if c.isalnum() or c in "-_.")
|
|
76
|
+
return Path(tempfile.gettempdir()) / f"arrayview-{user or 'user'}"
|
|
74
77
|
|
|
75
78
|
|
|
76
79
|
def process_start_identity(pid: int) -> str | None:
|
arrayview/_io.py
CHANGED
|
@@ -503,7 +503,7 @@ def read_gzip_file_parallel(filepath):
|
|
|
503
503
|
"""
|
|
504
504
|
try:
|
|
505
505
|
size = os.path.getsize(filepath)
|
|
506
|
-
fd = os.open(filepath,
|
|
506
|
+
fd = os.open(filepath, _O_RDONLY_BINARY)
|
|
507
507
|
except OSError:
|
|
508
508
|
return None
|
|
509
509
|
buf = bytearray(size)
|
|
@@ -518,7 +518,7 @@ def read_gzip_file_parallel(filepath):
|
|
|
518
518
|
pos = start
|
|
519
519
|
try:
|
|
520
520
|
while pos < stop:
|
|
521
|
-
n =
|
|
521
|
+
n = _pread_into(fd, view[pos:stop], pos)
|
|
522
522
|
if n <= 0:
|
|
523
523
|
raise OSError("unexpected end of file")
|
|
524
524
|
pos += n
|
|
@@ -624,10 +624,21 @@ def _mapped_slice_is_scattered(view) -> bool:
|
|
|
624
624
|
return nbytes > 4 * view.nbytes and nbytes > _REGION_READ_CHUNK_BYTES
|
|
625
625
|
|
|
626
626
|
|
|
627
|
+
# Windows has neither pread nor preadv: reads there seek and read under one
|
|
628
|
+
# lock, so they are correct but no longer run in parallel.
|
|
629
|
+
_SEEK_READ_LOCK = threading.Lock()
|
|
630
|
+
_O_RDONLY_BINARY = os.O_RDONLY | getattr(os, "O_BINARY", 0)
|
|
631
|
+
|
|
632
|
+
|
|
627
633
|
def _pread_into(fd, buf, offset):
|
|
628
634
|
if hasattr(os, "preadv"):
|
|
629
635
|
return os.preadv(fd, [buf], offset)
|
|
630
|
-
|
|
636
|
+
if hasattr(os, "pread"):
|
|
637
|
+
data = os.pread(fd, len(buf), offset)
|
|
638
|
+
else:
|
|
639
|
+
with _SEEK_READ_LOCK:
|
|
640
|
+
os.lseek(fd, offset, os.SEEK_SET)
|
|
641
|
+
data = os.read(fd, len(buf))
|
|
631
642
|
buf[: len(data)] = data
|
|
632
643
|
return len(data)
|
|
633
644
|
|
|
@@ -646,7 +657,7 @@ def read_mapped_region(view, progress=None):
|
|
|
646
657
|
mv = memoryview(buf)
|
|
647
658
|
lock = threading.Lock()
|
|
648
659
|
done = [0]
|
|
649
|
-
fd = os.open(filename,
|
|
660
|
+
fd = os.open(filename, _O_RDONLY_BINARY)
|
|
650
661
|
try:
|
|
651
662
|
|
|
652
663
|
def _read(start):
|
|
@@ -681,9 +692,9 @@ class _ScatteredRegionMixin:
|
|
|
681
692
|
map of a 4D one. When a requested slice would be scattered across its
|
|
682
693
|
region, the region is read in one go (see ``read_mapped_region``) and kept;
|
|
683
694
|
every later slice of it, in any direction, is then served from memory.
|
|
684
|
-
|
|
685
|
-
the background, so the next step —
|
|
686
|
-
usually already there.
|
|
695
|
+
Whichever way the case is first viewed, its regions and the neighbouring
|
|
696
|
+
case's are then read in the background, so the next step — scrolling,
|
|
697
|
+
another map, a crossing view, the next patient — is usually already there.
|
|
687
698
|
"""
|
|
688
699
|
|
|
689
700
|
def _init_region_cache(self):
|
|
@@ -793,6 +804,9 @@ class _ScatteredRegionMixin:
|
|
|
793
804
|
if region is None:
|
|
794
805
|
try:
|
|
795
806
|
if not _mapped_slice_is_scattered(mapped[tuple(spatial_key)]):
|
|
807
|
+
# Cheap to read now, but the next scroll steps and any
|
|
808
|
+
# switch to a crossing view are not: load the case anyway.
|
|
809
|
+
self._schedule_region_warm(p_idx, m_idx, rest, spatial_key)
|
|
796
810
|
return None
|
|
797
811
|
view = self._region_view(mapped, rest)
|
|
798
812
|
if view.nbytes > self._region_cache_max_bytes // 2:
|
|
@@ -825,11 +839,6 @@ class _ScatteredRegionMixin:
|
|
|
825
839
|
else:
|
|
826
840
|
step = -1 if last is not None and last[0] > p_idx else 1
|
|
827
841
|
neighbours = [(p_idx + step, m_idx), (p_idx - step, m_idx)]
|
|
828
|
-
# Which axes are on screen is all that carries over to another case;
|
|
829
|
-
# the indices along the others may not fit a differently-sized one.
|
|
830
|
-
probe = tuple(
|
|
831
|
-
slice(None) if isinstance(k, slice) else 0 for k in spatial_key[: min(3, len(spatial_key))]
|
|
832
|
-
)
|
|
833
842
|
plan = [(p_idx, m_idx)] + [
|
|
834
843
|
(p, m) for p, m in neighbours if 0 <= p < n_p and 0 <= m < n_m
|
|
835
844
|
]
|
|
@@ -845,7 +854,7 @@ class _ScatteredRegionMixin:
|
|
|
845
854
|
if self._file_matrix[p][m] is None:
|
|
846
855
|
continue
|
|
847
856
|
mapped = self._mapped_volume(p, m, self._get_volume(p, m))
|
|
848
|
-
if mapped is None
|
|
857
|
+
if mapped is None:
|
|
849
858
|
continue
|
|
850
859
|
n_sp = min(3, mapped.ndim)
|
|
851
860
|
rests = list(np.ndindex(*mapped.shape[n_sp:])) or [()]
|
|
@@ -1106,11 +1115,12 @@ class _FileSeries:
|
|
|
1106
1115
|
|
|
1107
1116
|
_av_lazy = True
|
|
1108
1117
|
|
|
1109
|
-
def __init__(self, file_matrix, vol_shape, dtype, spatial_meta=None):
|
|
1118
|
+
def __init__(self, file_matrix, vol_shape, dtype, spatial_meta=None, spacing_matrix=None):
|
|
1110
1119
|
self._file_matrix = file_matrix
|
|
1111
1120
|
self._vol_shape = tuple(vol_shape)
|
|
1112
1121
|
self._dtype = np.dtype(dtype)
|
|
1113
1122
|
self._spatial_meta = spatial_meta
|
|
1123
|
+
self._spacing_matrix = spacing_matrix
|
|
1114
1124
|
n_patients = len(file_matrix)
|
|
1115
1125
|
n_modalities = len(file_matrix[0]) if file_matrix else 0
|
|
1116
1126
|
if n_modalities <= 1:
|
|
@@ -1126,6 +1136,10 @@ class _FileSeries:
|
|
|
1126
1136
|
)
|
|
1127
1137
|
self._cache_lock = threading.Lock()
|
|
1128
1138
|
|
|
1139
|
+
@property
|
|
1140
|
+
def case_voxel_spacings(self):
|
|
1141
|
+
return self._spacing_matrix
|
|
1142
|
+
|
|
1129
1143
|
@property
|
|
1130
1144
|
def dtype(self):
|
|
1131
1145
|
return self._dtype
|
|
@@ -1378,6 +1392,11 @@ def _is_nifti_path(filepath):
|
|
|
1378
1392
|
return filepath.endswith(".nii") or filepath.endswith(".nii.gz")
|
|
1379
1393
|
|
|
1380
1394
|
|
|
1395
|
+
def _is_gipl_path(filepath):
|
|
1396
|
+
"""True if *filepath* ends with .gipl or .gipl.gz."""
|
|
1397
|
+
return filepath.lower().endswith((".gipl", ".gipl.gz"))
|
|
1398
|
+
|
|
1399
|
+
|
|
1381
1400
|
def _get_ext(filepath):
|
|
1382
1401
|
"""Return the extension of *filepath*, normalising .nii.gz and .zarr.zip."""
|
|
1383
1402
|
lower = filepath.lower()
|
|
@@ -1596,11 +1615,15 @@ def _series_from_file_matrix(
|
|
|
1596
1615
|
return series, spatial_meta
|
|
1597
1616
|
|
|
1598
1617
|
spatial_shapes = []
|
|
1618
|
+
# GIPL headers carry a voxel size; use it when every file has one.
|
|
1619
|
+
gipl_metas = [] if all(_is_gipl_path(path) for path in all_paths) else None
|
|
1599
1620
|
for fpath in all_paths:
|
|
1600
1621
|
arr = load_data(fpath)
|
|
1601
1622
|
shape = arr.shape
|
|
1602
1623
|
dtype = arr.dtype
|
|
1603
1624
|
spatial_shapes.append(shape)
|
|
1625
|
+
if gipl_metas is not None:
|
|
1626
|
+
gipl_metas.append(_gipl_spatial_meta(fpath, arr))
|
|
1604
1627
|
if ref_shape is None:
|
|
1605
1628
|
ref_shape = shape
|
|
1606
1629
|
ref_dtype = dtype
|
|
@@ -1616,30 +1639,47 @@ def _series_from_file_matrix(
|
|
|
1616
1639
|
)
|
|
1617
1640
|
if scan_progress is not None:
|
|
1618
1641
|
scan_progress(scan_label, fpath)
|
|
1642
|
+
spacing_matrix = None
|
|
1643
|
+
if gipl_metas and all(meta is not None for meta in gipl_metas):
|
|
1644
|
+
spatial_meta = gipl_metas[0]
|
|
1645
|
+
spacing_matrix = []
|
|
1619
1646
|
shape_matrix = []
|
|
1620
1647
|
pos = 0
|
|
1621
1648
|
for row in file_matrix:
|
|
1622
1649
|
row_shapes = []
|
|
1650
|
+
row_spacings = []
|
|
1623
1651
|
for path in row:
|
|
1624
1652
|
if path is None:
|
|
1625
1653
|
row_shapes.append(ref_shape)
|
|
1654
|
+
if spacing_matrix is not None:
|
|
1655
|
+
row_spacings.append(gipl_metas[0]["voxel_sizes"])
|
|
1626
1656
|
else:
|
|
1627
1657
|
row_shapes.append(spatial_shapes[pos])
|
|
1658
|
+
if spacing_matrix is not None:
|
|
1659
|
+
row_spacings.append(gipl_metas[pos]["voxel_sizes"])
|
|
1628
1660
|
pos += 1
|
|
1629
1661
|
shape_matrix.append(row_shapes)
|
|
1662
|
+
if spacing_matrix is not None:
|
|
1663
|
+
spacing_matrix.append(row_spacings)
|
|
1630
1664
|
shapes_differ = any(shape != ref_shape for shape in spatial_shapes)
|
|
1631
1665
|
if stack == "dense" and shapes_differ:
|
|
1632
1666
|
raise ValueError("Dense stacking requires every file to have the same shape.")
|
|
1633
1667
|
if stack == "ragged" or shapes_differ:
|
|
1634
|
-
series = _RaggedFileSeries(
|
|
1668
|
+
series = _RaggedFileSeries(
|
|
1669
|
+
file_matrix, shape_matrix, ref_dtype,
|
|
1670
|
+
spatial_meta=spatial_meta, spacing_matrix=spacing_matrix,
|
|
1671
|
+
)
|
|
1635
1672
|
else:
|
|
1636
|
-
series = _FileSeries(
|
|
1673
|
+
series = _FileSeries(
|
|
1674
|
+
file_matrix, ref_shape, ref_dtype,
|
|
1675
|
+
spatial_meta=spatial_meta, spacing_matrix=spacing_matrix,
|
|
1676
|
+
)
|
|
1637
1677
|
if load == "eager":
|
|
1638
1678
|
for p, row in enumerate(file_matrix):
|
|
1639
1679
|
for m, path in enumerate(row):
|
|
1640
1680
|
if path is not None:
|
|
1641
1681
|
series._get_volume(p, m)
|
|
1642
|
-
return series,
|
|
1682
|
+
return series, spatial_meta
|
|
1643
1683
|
|
|
1644
1684
|
|
|
1645
1685
|
def _collection_scan_workers():
|
|
@@ -2182,7 +2222,7 @@ def prefetch_slice_pages(mapped, filepath, dims, index, progress=None) -> bool:
|
|
|
2182
2222
|
return False
|
|
2183
2223
|
|
|
2184
2224
|
try:
|
|
2185
|
-
fd = os.open(filepath,
|
|
2225
|
+
fd = os.open(filepath, _O_RDONLY_BINARY)
|
|
2186
2226
|
except OSError:
|
|
2187
2227
|
return False
|
|
2188
2228
|
try:
|
|
@@ -2292,13 +2332,13 @@ def read_npy_into_ram(mapped, progress=None):
|
|
|
2292
2332
|
lock = threading.Lock()
|
|
2293
2333
|
done = [0]
|
|
2294
2334
|
|
|
2295
|
-
fd = os.open(mapped.filename,
|
|
2335
|
+
fd = os.open(mapped.filename, _O_RDONLY_BINARY)
|
|
2296
2336
|
try:
|
|
2297
2337
|
def _read(start):
|
|
2298
2338
|
stop = min(start + _NPY_READ_CHUNK_BYTES, total)
|
|
2299
2339
|
pos = start
|
|
2300
2340
|
while pos < stop:
|
|
2301
|
-
n =
|
|
2341
|
+
n = _pread_into(fd, dst[pos:stop], offset + pos)
|
|
2302
2342
|
if n <= 0:
|
|
2303
2343
|
raise OSError(f"unexpected end of file reading {mapped.filename!r}")
|
|
2304
2344
|
pos += n
|
|
@@ -2350,6 +2390,9 @@ def load_data_with_meta(filepath, key=None, *, load="lazy", stack="auto", select
|
|
|
2350
2390
|
return _load_file_series(filepath, load=load, stack=stack)
|
|
2351
2391
|
if filepath.endswith(".nii") or filepath.endswith(".nii.gz"):
|
|
2352
2392
|
return _load_nifti_with_meta(filepath)
|
|
2393
|
+
if filepath.lower().endswith((".gipl", ".gipl.gz")):
|
|
2394
|
+
arr = _load_gipl(filepath)
|
|
2395
|
+
return arr, _gipl_spatial_meta(filepath, arr)
|
|
2353
2396
|
return load_data(
|
|
2354
2397
|
filepath, key=key, progress=progress, fast_first_frame=fast_first_frame
|
|
2355
2398
|
), None
|
|
@@ -2546,6 +2589,31 @@ def _load_gipl(filepath):
|
|
|
2546
2589
|
return np.ascontiguousarray(arr)
|
|
2547
2590
|
|
|
2548
2591
|
|
|
2592
|
+
def _gipl_spatial_meta(filepath, arr):
|
|
2593
|
+
"""Voxel sizes from a GIPL header, so the viewer shows true proportions.
|
|
2594
|
+
|
|
2595
|
+
GIPL stores no orientation, so axis labels stay empty. Returns None when
|
|
2596
|
+
the header has no usable voxel sizes.
|
|
2597
|
+
"""
|
|
2598
|
+
import gzip
|
|
2599
|
+
|
|
2600
|
+
opener = gzip.open if filepath.lower().endswith(".gz") else open
|
|
2601
|
+
with opener(filepath, "rb") as f:
|
|
2602
|
+
header = f.read(_GIPL_HEADER_SIZE)
|
|
2603
|
+
pixdim = np.frombuffer(header, ">f4", count=4, offset=10).astype(float)
|
|
2604
|
+
n = min(arr.ndim, 3)
|
|
2605
|
+
voxel_sizes = tuple(float(v) for v in pixdim[:n])
|
|
2606
|
+
if not all(np.isfinite(v) and v > 0 for v in voxel_sizes):
|
|
2607
|
+
return None
|
|
2608
|
+
return {
|
|
2609
|
+
"voxel_sizes": voxel_sizes,
|
|
2610
|
+
"axis_labels": (),
|
|
2611
|
+
"is_oblique": False,
|
|
2612
|
+
"is_canonical": True,
|
|
2613
|
+
"spatial_shape": tuple(int(v) for v in arr.shape[:n]),
|
|
2614
|
+
}
|
|
2615
|
+
|
|
2616
|
+
|
|
2549
2617
|
def _tensor_to_numpy(obj, source="object"):
|
|
2550
2618
|
"""Convert a tensor-like object (PyTorch, etc.) to a numpy array."""
|
|
2551
2619
|
if isinstance(obj, np.ndarray):
|
arrayview/_launch_trace.py
CHANGED
|
@@ -151,7 +151,8 @@ def emit_launch_event(event: str, **attrs: object) -> None:
|
|
|
151
151
|
encoded = (json.dumps(payload, separators=(",", ":")) + "\n").encode()
|
|
152
152
|
fd = os.open(_path, os.O_APPEND | os.O_CREAT | os.O_WRONLY, 0o600)
|
|
153
153
|
try:
|
|
154
|
-
os
|
|
154
|
+
if hasattr(os, "fchmod"): # Windows has none before Python 3.13
|
|
155
|
+
os.fchmod(fd, 0o600)
|
|
155
156
|
os.write(fd, encoded)
|
|
156
157
|
finally:
|
|
157
158
|
os.close(fd)
|
arrayview/_launcher.py
CHANGED
|
@@ -74,10 +74,15 @@ def _detached_child_kwargs() -> dict[str, object]:
|
|
|
74
74
|
control event raised for that group reaches *every* process attached to it,
|
|
75
75
|
so a child being shut down can deliver a KeyboardInterrupt to the process
|
|
76
76
|
that spawned it — under pytest, to the test runner itself. Both branches
|
|
77
|
-
state the same intent: the child gets its own group.
|
|
77
|
+
state the same intent: the child gets its own group. On Windows it also
|
|
78
|
+
gets no console of its own: started from a notebook or a GUI app, a
|
|
79
|
+
background server would otherwise pop up an empty black console window.
|
|
78
80
|
"""
|
|
79
81
|
if sys.platform == "win32":
|
|
80
|
-
return {
|
|
82
|
+
return {
|
|
83
|
+
"creationflags": subprocess.CREATE_NEW_PROCESS_GROUP
|
|
84
|
+
| subprocess.CREATE_NO_WINDOW
|
|
85
|
+
}
|
|
81
86
|
return {"start_new_session": True}
|
|
82
87
|
|
|
83
88
|
|
|
@@ -391,6 +396,19 @@ def _build_inline_shell_html(url: str, shell_port: int) -> str | None:
|
|
|
391
396
|
return None
|
|
392
397
|
|
|
393
398
|
|
|
399
|
+
def _set_listen_reuse(sock: "socket.socket") -> None:
|
|
400
|
+
"""Let a restarted server rebind its port at once, without sharing it.
|
|
401
|
+
|
|
402
|
+
On Windows ``SO_REUSEADDR`` lets a second process bind a port another
|
|
403
|
+
listener already holds, so two servers could answer on one port; the
|
|
404
|
+
exclusive option there is what the POSIX flag means elsewhere.
|
|
405
|
+
"""
|
|
406
|
+
if sys.platform == "win32":
|
|
407
|
+
sock.setsockopt(socket.SOL_SOCKET, socket.SO_EXCLUSIVEADDRUSE, 1)
|
|
408
|
+
else:
|
|
409
|
+
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
|
410
|
+
|
|
411
|
+
|
|
394
412
|
def _make_loopback_socket(port: int) -> "socket.socket":
|
|
395
413
|
"""Bind a single TCP listener on the same loopback host the viewer URLs use."""
|
|
396
414
|
for family, socktype, proto, _, sockaddr in socket.getaddrinfo(
|
|
@@ -401,7 +419,7 @@ def _make_loopback_socket(port: int) -> "socket.socket":
|
|
|
401
419
|
sock = None
|
|
402
420
|
try:
|
|
403
421
|
sock = socket.socket(family, socktype, proto)
|
|
404
|
-
sock
|
|
422
|
+
_set_listen_reuse(sock)
|
|
405
423
|
sock.bind(sockaddr)
|
|
406
424
|
sock.listen(128)
|
|
407
425
|
sock.set_inheritable(True)
|
|
@@ -439,7 +457,7 @@ def _make_loopback_sockets(port: int) -> list["socket.socket"]:
|
|
|
439
457
|
sock = None
|
|
440
458
|
try:
|
|
441
459
|
sock = socket.socket(family, socktype, proto)
|
|
442
|
-
sock
|
|
460
|
+
_set_listen_reuse(sock)
|
|
443
461
|
# On Linux, binding ::1 first then 127.0.0.1 on the same port is
|
|
444
462
|
# fine (different addresses). IPV6_V6ONLY stays default (off on
|
|
445
463
|
# Linux) which is harmless since we bind explicit addresses.
|
|
@@ -3539,11 +3557,12 @@ def view(
|
|
|
3539
3557
|
vmax: float | None = None,
|
|
3540
3558
|
log: bool = False,
|
|
3541
3559
|
complex: str | None = None,
|
|
3560
|
+
voxel_size=None,
|
|
3542
3561
|
):
|
|
3543
3562
|
"""
|
|
3544
3563
|
Launch the viewer. Does not block the main Python process.
|
|
3545
3564
|
|
|
3546
|
-
Accepts one to
|
|
3565
|
+
Accepts one to six arrays (the same limit as the command line)::
|
|
3547
3566
|
|
|
3548
3567
|
handle = arrayview.view(a)
|
|
3549
3568
|
ha, hb = arrayview.view(a, b)
|
|
@@ -3588,6 +3607,10 @@ def view(
|
|
|
3588
3607
|
``"imag"`` (``"mag"`` shows the absolute value of real data). Pressing
|
|
3589
3608
|
``E`` in the viewer copies the current view as such a call.
|
|
3590
3609
|
|
|
3610
|
+
``voxel_size`` — physical size of one step along each dimension, e.g.
|
|
3611
|
+
``(1, 1, 2)`` for 1 mm pixels and 2 mm slices. Slices then show their
|
|
3612
|
+
true proportions. Leading dimensions only; later ones keep size 1.
|
|
3613
|
+
|
|
3591
3614
|
Returns a ``ViewHandle`` for a single array, or a tuple of ``ViewHandle``
|
|
3592
3615
|
objects for multiple arrays (one per array). In inline/Jupyter mode with
|
|
3593
3616
|
multiple arrays, the IFrame is displayed automatically and a uniform tuple
|
|
@@ -3601,9 +3624,14 @@ def view(
|
|
|
3601
3624
|
|
|
3602
3625
|
_initial_view = _session_mod.initial_view_spec(
|
|
3603
3626
|
mode=mode, dims=dims, index=index, cmap=cmap, vmin=vmin, vmax=vmax, log=log,
|
|
3604
|
-
complex=complex,
|
|
3627
|
+
complex=complex, voxel_size=voxel_size,
|
|
3605
3628
|
)
|
|
3606
3629
|
_code_name = _caller_variable_name(arrays[0]) if arrays else None
|
|
3630
|
+
# Several arrays side by side need telling apart; the caller's variable
|
|
3631
|
+
# names do that better than identical "Array (shape)" labels.
|
|
3632
|
+
_var_names = (
|
|
3633
|
+
[_caller_variable_name(arr) for arr in arrays] if len(arrays) > 1 else []
|
|
3634
|
+
)
|
|
3607
3635
|
|
|
3608
3636
|
height = _normalize_inline_height(height)
|
|
3609
3637
|
_inline_mode_heights = _normalize_inline_mode_heights(mode_heights)
|
|
@@ -3612,8 +3640,8 @@ def view(
|
|
|
3612
3640
|
n_arrays = len(arrays)
|
|
3613
3641
|
if n_arrays == 0:
|
|
3614
3642
|
raise ValueError("view() requires at least one array.")
|
|
3615
|
-
if n_arrays >
|
|
3616
|
-
raise ValueError(f"view() accepts at most
|
|
3643
|
+
if n_arrays > 6:
|
|
3644
|
+
raise ValueError(f"view() accepts at most 6 arrays, got {n_arrays}.")
|
|
3617
3645
|
|
|
3618
3646
|
# --- Normalise per-array kwargs (name, rgb) ---
|
|
3619
3647
|
if isinstance(name, list):
|
|
@@ -3664,6 +3692,8 @@ def view(
|
|
|
3664
3692
|
arrays = tuple(_convert_array(arr) for arr in arrays)
|
|
3665
3693
|
|
|
3666
3694
|
# --- Apply name defaults after conversion (shape is available now) ---
|
|
3695
|
+
if len(set(filter(None, _var_names))) == n_arrays:
|
|
3696
|
+
names = [names[i] if names[i] is not None else _var_names[i] for i in range(n_arrays)]
|
|
3667
3697
|
names = [
|
|
3668
3698
|
(f"Array {getattr(arrays[i], 'shape', '?')}" if names[i] is None else names[i])
|
|
3669
3699
|
for i in range(n_arrays)
|
|
@@ -3680,6 +3710,13 @@ def view(
|
|
|
3680
3710
|
f"index has {len(_initial_view['index'])} entries but the array "
|
|
3681
3711
|
f"has {_ndim} dimensions"
|
|
3682
3712
|
)
|
|
3713
|
+
if _initial_view and "voxel_size" in _initial_view:
|
|
3714
|
+
_ndim = len(getattr(data, "shape", ()))
|
|
3715
|
+
if len(_initial_view["voxel_size"]) > _ndim:
|
|
3716
|
+
raise ValueError(
|
|
3717
|
+
f"voxel_size has {len(_initial_view['voxel_size'])} entries but the "
|
|
3718
|
+
f"array has {_ndim} dimensions"
|
|
3719
|
+
)
|
|
3683
3720
|
|
|
3684
3721
|
# --- Normalise string window modes ---
|
|
3685
3722
|
_raw_window = window
|
|
@@ -5633,8 +5670,26 @@ def _handle_management_command(argv: list[str]) -> bool:
|
|
|
5633
5670
|
return True
|
|
5634
5671
|
|
|
5635
5672
|
|
|
5673
|
+
def _replace_unprintable_output() -> None:
|
|
5674
|
+
"""Keep messages with arrows or ticks from crashing a Windows console.
|
|
5675
|
+
|
|
5676
|
+
Output redirected to a file or pipe on Windows uses an old code page that
|
|
5677
|
+
cannot encode such characters, so printing them raised instead of showing
|
|
5678
|
+
the message. The servers this command starts inherit the same setting.
|
|
5679
|
+
"""
|
|
5680
|
+
if os.name != "nt":
|
|
5681
|
+
return
|
|
5682
|
+
os.environ.setdefault("PYTHONIOENCODING", ":replace")
|
|
5683
|
+
for stream in (sys.stdout, sys.stderr):
|
|
5684
|
+
try:
|
|
5685
|
+
stream.reconfigure(errors="replace")
|
|
5686
|
+
except (AttributeError, ValueError):
|
|
5687
|
+
pass
|
|
5688
|
+
|
|
5689
|
+
|
|
5636
5690
|
def arrayview():
|
|
5637
5691
|
"""Command Line Interface Entry Point."""
|
|
5692
|
+
_replace_unprintable_output()
|
|
5638
5693
|
# Handle "arrayview config ..." subcommand before argparse
|
|
5639
5694
|
if len(sys.argv) > 1 and sys.argv[1] == "config":
|
|
5640
5695
|
_handle_config_command(sys.argv[2:])
|
arrayview/_platform.py
CHANGED
|
@@ -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:
|
arrayview/_routes_export.py
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import asyncio
|
|
2
2
|
import io
|
|
3
|
+
import re
|
|
3
4
|
|
|
4
5
|
import numpy as np
|
|
5
6
|
from fastapi import Body, Depends, Request, Response
|
|
@@ -8,6 +9,12 @@ from fastapi.responses import JSONResponse
|
|
|
8
9
|
from arrayview._render import render_rgba
|
|
9
10
|
|
|
10
11
|
|
|
12
|
+
def _safe_filename(name: str, default: str) -> str:
|
|
13
|
+
"""A bare file name every OS accepts: no folders, no reserved characters."""
|
|
14
|
+
name = re.sub(r'[\\/:*?"<>|\x00-\x1f\s]+', "_", str(name or "")).strip("._")
|
|
15
|
+
return name or default
|
|
16
|
+
|
|
17
|
+
|
|
11
18
|
def register_export_routes(app, *, get_session_or_404, pil_image) -> None:
|
|
12
19
|
@app.get("/export_array/{sid}")
|
|
13
20
|
def export_array(
|
|
@@ -20,8 +27,7 @@ def register_export_routes(app, *, get_session_or_404, pil_image) -> None:
|
|
|
20
27
|
buf = io.BytesIO()
|
|
21
28
|
np.save(buf, data)
|
|
22
29
|
buf.seek(0)
|
|
23
|
-
|
|
24
|
-
filename = f"{name_stem}.npy"
|
|
30
|
+
filename = f"{_safe_filename(session.name, 'array')}.npy"
|
|
25
31
|
if save_to_downloads:
|
|
26
32
|
import pathlib
|
|
27
33
|
|
|
@@ -39,7 +45,7 @@ def register_export_routes(app, *, get_session_or_404, pil_image) -> None:
|
|
|
39
45
|
async def save_file(request: Request):
|
|
40
46
|
"""Save a client-generated file to the Downloads folder."""
|
|
41
47
|
body = await request.json()
|
|
42
|
-
filename = body.get("filename", "arrayview_export")
|
|
48
|
+
filename = _safe_filename(body.get("filename"), "arrayview_export")
|
|
43
49
|
data_b64 = body.get("data", "")
|
|
44
50
|
if "," in data_b64:
|
|
45
51
|
data_b64 = data_b64.split(",", 1)[1]
|
arrayview/_routes_loading.py
CHANGED
|
@@ -618,6 +618,18 @@ def register_loading_routes(app, *, notify_shells, setup_rgb) -> None:
|
|
|
618
618
|
)
|
|
619
619
|
return {"sid": session.sid, "name": name, "notified": notified}
|
|
620
620
|
|
|
621
|
+
@app.post("/reveal/{sid}")
|
|
622
|
+
async def reveal_session_tab(sid: str):
|
|
623
|
+
"""Bring the VS Code browser tab already showing *sid* forward."""
|
|
624
|
+
from arrayview._routes_websocket import viewer_tab_reveal_target
|
|
625
|
+
from arrayview._vscode_signal import _request_vscode_tab_reveal
|
|
626
|
+
|
|
627
|
+
target = viewer_tab_reveal_target(sid)
|
|
628
|
+
if target is None:
|
|
629
|
+
return {"sid": sid, "revealed": False}
|
|
630
|
+
revealed = await asyncio.to_thread(_request_vscode_tab_reveal, *target)
|
|
631
|
+
return {"sid": sid, "revealed": revealed}
|
|
632
|
+
|
|
621
633
|
@app.post("/release/{sid}")
|
|
622
634
|
def release_viewer_session(sid: str, request: Request):
|
|
623
635
|
"""Release a session when its owning viewer tab/window closes."""
|
arrayview/_routes_preferences.py
CHANGED
|
@@ -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:
|