arrayview 0.27.2__py3-none-any.whl → 0.28.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- arrayview/ARCHITECTURE.md +6 -12
- arrayview/__init__.py +2 -1
- arrayview/_analysis.py +1 -1
- arrayview/_io.py +428 -1
- arrayview/_launch_plan.py +0 -4
- arrayview/_launcher.py +230 -331
- arrayview/_routes_loading.py +2 -0
- arrayview/_viewer.html +349 -250
- arrayview/_vscode.py +0 -3
- arrayview/_vscode_browser.py +24 -27
- arrayview/_vscode_extension.py +8 -1
- arrayview/_vscode_signal.py +127 -72
- arrayview/arrayview-opener.vsix +0 -0
- {arrayview-0.27.2.dist-info → arrayview-0.28.0.dist-info}/METADATA +1 -1
- {arrayview-0.27.2.dist-info → arrayview-0.28.0.dist-info}/RECORD +18 -20
- arrayview/_stdio_server.py +0 -964
- arrayview/_vscode_shm.py +0 -77
- {arrayview-0.27.2.dist-info → arrayview-0.28.0.dist-info}/WHEEL +0 -0
- {arrayview-0.27.2.dist-info → arrayview-0.28.0.dist-info}/entry_points.txt +0 -0
- {arrayview-0.27.2.dist-info → arrayview-0.28.0.dist-info}/licenses/LICENSE +0 -0
arrayview/ARCHITECTURE.md
CHANGED
|
@@ -6,14 +6,12 @@
|
|
|
6
6
|
CLI / Python API
|
|
7
7
|
├─ view() / _launcher.py → FastAPI server (_server.py) [network mode]
|
|
8
8
|
│ /ws/{sid} WebSocket, /load register arrays, /seg/* segmentation
|
|
9
|
-
└─ Stdio server (_stdio_server.py) [direct webview mode]
|
|
10
|
-
stdin/stdout JSON+binary, no network — VS Code extension spawns subprocess
|
|
11
9
|
|
|
12
|
-
Server
|
|
10
|
+
Server
|
|
13
11
|
├─ _session.py Session objects, caches, render thread
|
|
14
12
|
├─ _render.py Slice extraction → RGBA → PNG pipeline
|
|
15
13
|
├─ _analysis.py / _diff.py / _overlays.py / _vectorfield.py
|
|
16
|
-
│ Shared backend helpers used by
|
|
14
|
+
│ Shared backend helpers used by routes and WebSocket handlers
|
|
17
15
|
└─ _io.py File loading (numpy, nifti, zarr, DICOM, …)
|
|
18
16
|
|
|
19
17
|
Frontend (_viewer.html — single self-contained HTML file)
|
|
@@ -29,7 +27,7 @@ Frontend (_viewer.html — single self-contained HTML file)
|
|
|
29
27
|
|--------------------|------------------------------------|-------------|
|
|
30
28
|
| Jupyter | Inline iframe | network |
|
|
31
29
|
| VS Code local | Webview panel (network) | network |
|
|
32
|
-
| VS Code tunnel |
|
|
30
|
+
| VS Code tunnel | Webview panel (forwarded WebSocket)| network |
|
|
33
31
|
| Julia | System browser | network |
|
|
34
32
|
| CLI / Python script | Native pywebview | network |
|
|
35
33
|
| SSH terminal | Prints URL — user forwards port with `ssh -L` | network |
|
|
@@ -54,10 +52,9 @@ Detection logic lives in `_platform.py`. Display opening logic lives in `_launch
|
|
|
54
52
|
| `_segmentation.py` | 227 | nnInteractive segmentation client (pure HTTP, no nnInteractive dependency) |
|
|
55
53
|
| `_server.py` | 3704 | FastAPI app, all REST + WebSocket routes, HTML template serving |
|
|
56
54
|
| `_session.py` | 344 | `Session` class, global state (sockets, loops), render thread, prefetch, cache budgets, constants |
|
|
57
|
-
| `_stdio_server.py` | 767 | Stdio transport for VS Code direct webview — JSON stdin, binary stdout |
|
|
58
55
|
| `_torch.py` | 217 | PyTorch integration: `view_batch()`, `TrainingMonitor` (lazy torch import) |
|
|
59
56
|
| `_vectorfield.py` | 231 | Shared vector field layout validation and arrow sampling |
|
|
60
|
-
| `_vscode.py` | 1014 | VS Code extension install/management, signal-file IPC,
|
|
57
|
+
| `_vscode.py` | 1014 | VS Code extension install/management, signal-file IPC, tunnel URL opening |
|
|
61
58
|
| `_viewer.html` | 24103 | **The entire frontend** — CSS + JS in a single file, all viewing modes |
|
|
62
59
|
| `_shell.html` | 174 | Tab-bar shell for native pywebview — wraps viewer iframes, manages multi-tab sessions |
|
|
63
60
|
|
|
@@ -80,7 +77,7 @@ The frontend is a single self-contained HTML file (~24k lines). No build step, n
|
|
|
80
77
|
**JavaScript (lines ~2439–24103)**
|
|
81
78
|
| Section | What it covers |
|
|
82
79
|
|---------|----------------|
|
|
83
|
-
| Constants and Transport Setup |
|
|
80
|
+
| Constants and Transport Setup | HTTP/WebSocket URL construction |
|
|
84
81
|
| Viewer State Variables | All mutable state: current slice indices, zoom, mode flags |
|
|
85
82
|
| Mode Registry | Mode name → enter/exit function mapping |
|
|
86
83
|
| PanManager | Canvas panning state machine (normal + compare modes) |
|
|
@@ -218,7 +215,7 @@ Slice data arrives as raw binary (RGBA bytes). The header format and byte offset
|
|
|
218
215
|
└─ Open display # _launcher.py → _vscode.py / pywebview / browser
|
|
219
216
|
|
|
220
217
|
2. Browser loads _viewer.html # _server.py serves from package resources
|
|
221
|
-
├─ Establish WebSocket /ws/{sid}
|
|
218
|
+
├─ Establish WebSocket /ws/{sid}
|
|
222
219
|
└─ Fetch /meta/{sid} # Session metadata: shape, dtype, colormaps
|
|
223
220
|
|
|
224
221
|
3. User scrolls / interacts
|
|
@@ -233,6 +230,3 @@ Slice data arrives as raw binary (RGBA bytes). The header format and byte offset
|
|
|
233
230
|
├─ drawImage() to canvas
|
|
234
231
|
└─ Update info bar, colorbar, eggs
|
|
235
232
|
```
|
|
236
|
-
|
|
237
|
-
### Stdio Transport Variant (VS Code Direct Webview)
|
|
238
|
-
Same pipeline, but `_stdio_server.py` replaces the FastAPI+WebSocket layer. Messages are JSON on stdin, binary responses are length-prefixed on stdout. The VS Code extension bridges between the webview's `postMessage` and the subprocess stdio.
|
arrayview/__init__.py
CHANGED
|
@@ -7,12 +7,13 @@ __all__ = [
|
|
|
7
7
|
"arrayview",
|
|
8
8
|
"view",
|
|
9
9
|
"view_batch",
|
|
10
|
+
"view_dir",
|
|
10
11
|
"zarr_chunk_preset",
|
|
11
12
|
]
|
|
12
13
|
|
|
13
14
|
|
|
14
15
|
def __getattr__(name: str):
|
|
15
|
-
if name in {"arrayview", "view", "ViewHandle"}:
|
|
16
|
+
if name in {"arrayview", "view", "view_dir", "ViewHandle"}:
|
|
16
17
|
from arrayview import _launcher
|
|
17
18
|
|
|
18
19
|
return getattr(_launcher, name)
|
arrayview/_analysis.py
CHANGED
|
@@ -19,7 +19,7 @@ def _visible_shape(session) -> list[int]:
|
|
|
19
19
|
|
|
20
20
|
|
|
21
21
|
def _build_metadata(session) -> dict:
|
|
22
|
-
"""Build metadata
|
|
22
|
+
"""Build metadata shared by HTTP routes and WebSocket startup."""
|
|
23
23
|
target_shape = session.spatial_shape if session.rgb_axis is not None else session.shape
|
|
24
24
|
meta = {
|
|
25
25
|
"shape": [int(s) for s in target_shape],
|
arrayview/_io.py
CHANGED
|
@@ -2,7 +2,10 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
+
import fnmatch
|
|
5
6
|
import os
|
|
7
|
+
import threading
|
|
8
|
+
from collections import OrderedDict
|
|
6
9
|
|
|
7
10
|
import numpy as np
|
|
8
11
|
|
|
@@ -174,17 +177,438 @@ def _load_nifti_with_meta(filepath):
|
|
|
174
177
|
return arr, meta
|
|
175
178
|
|
|
176
179
|
|
|
177
|
-
|
|
180
|
+
# ---------------------------------------------------------------------------
|
|
181
|
+
# File series — lazy 4D/5D view over a directory of same-shape volumes
|
|
182
|
+
# ---------------------------------------------------------------------------
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
class _NiftiSeries:
|
|
186
|
+
"""Lazy 4D/5D view over a directory of same-shape NIfTI files.
|
|
187
|
+
|
|
188
|
+
``file_matrix`` is a P x M list-of-lists of filepaths (P patients,
|
|
189
|
+
M modalities). With M == 1 the shape is ``(*vol_shape, P)``; with
|
|
190
|
+
M > 1 it is ``(*vol_shape, P, M)``. ``__getitem__`` maps the trailing
|
|
191
|
+
stack axis/axes to the source file, opens it (canonical reorient,
|
|
192
|
+
LRU-cached), and forwards the spatial key so only the requested slice
|
|
193
|
+
is materialised.
|
|
194
|
+
"""
|
|
195
|
+
|
|
196
|
+
_av_lazy = True
|
|
197
|
+
|
|
198
|
+
def __init__(self, file_matrix, vol_shape, dtype, spatial_meta=None):
|
|
199
|
+
self._file_matrix = file_matrix
|
|
200
|
+
self._vol_shape = tuple(vol_shape)
|
|
201
|
+
self._dtype = np.dtype(dtype)
|
|
202
|
+
self._spatial_meta = spatial_meta
|
|
203
|
+
n_patients = len(file_matrix)
|
|
204
|
+
n_modalities = len(file_matrix[0]) if file_matrix else 0
|
|
205
|
+
if n_modalities <= 1:
|
|
206
|
+
self.shape = (*self._vol_shape, n_patients)
|
|
207
|
+
self._stack_axes = (len(self.shape) - 1,)
|
|
208
|
+
else:
|
|
209
|
+
self.shape = (*self._vol_shape, n_patients, n_modalities)
|
|
210
|
+
self._stack_axes = (len(self.shape) - 2, len(self.shape) - 1)
|
|
211
|
+
self.ndim = len(self.shape)
|
|
212
|
+
self._vol_cache: OrderedDict = OrderedDict()
|
|
213
|
+
self._vol_cache_cap = int(
|
|
214
|
+
os.environ.get("ARRAYVIEW_NIFTI_SERIES_VOL_CACHE", 3)
|
|
215
|
+
)
|
|
216
|
+
self._cache_lock = threading.Lock()
|
|
217
|
+
|
|
218
|
+
@property
|
|
219
|
+
def dtype(self):
|
|
220
|
+
return self._dtype
|
|
221
|
+
|
|
222
|
+
def _get_volume(self, p_idx, m_idx):
|
|
223
|
+
cache_key = (p_idx, m_idx)
|
|
224
|
+
with self._cache_lock:
|
|
225
|
+
if cache_key in self._vol_cache:
|
|
226
|
+
self._vol_cache.move_to_end(cache_key)
|
|
227
|
+
return self._vol_cache[cache_key]
|
|
228
|
+
filepath = self._file_matrix[p_idx][m_idx]
|
|
229
|
+
nib = _nib()
|
|
230
|
+
img = nib.load(filepath)
|
|
231
|
+
canon = nib.as_closest_canonical(img)
|
|
232
|
+
vol = np.asarray(canon.dataobj)
|
|
233
|
+
with self._cache_lock:
|
|
234
|
+
self._vol_cache[cache_key] = vol
|
|
235
|
+
self._vol_cache.move_to_end(cache_key)
|
|
236
|
+
while len(self._vol_cache) > self._vol_cache_cap:
|
|
237
|
+
self._vol_cache.popitem(last=False)
|
|
238
|
+
return vol
|
|
239
|
+
|
|
240
|
+
def __getitem__(self, key):
|
|
241
|
+
if not isinstance(key, tuple):
|
|
242
|
+
key = (key,)
|
|
243
|
+
has_special = any(k is None or k is Ellipsis for k in key)
|
|
244
|
+
if not has_special and len(key) <= self.ndim:
|
|
245
|
+
padded = list(key) + [slice(None)] * (self.ndim - len(key))
|
|
246
|
+
stack_vals = [padded[i] for i in self._stack_axes]
|
|
247
|
+
if all(isinstance(s, (int, np.integer)) for s in stack_vals):
|
|
248
|
+
spatial_positions = [
|
|
249
|
+
i for i in range(self.ndim) if i not in self._stack_axes
|
|
250
|
+
]
|
|
251
|
+
spatial_key = tuple(padded[i] for i in spatial_positions)
|
|
252
|
+
n_p = len(self._file_matrix)
|
|
253
|
+
n_m = len(self._file_matrix[0]) if self._file_matrix else 0
|
|
254
|
+
p_idx = int(stack_vals[0])
|
|
255
|
+
if p_idx < 0:
|
|
256
|
+
p_idx += n_p
|
|
257
|
+
if len(self._stack_axes) == 1:
|
|
258
|
+
m_idx = 0
|
|
259
|
+
else:
|
|
260
|
+
m_idx = int(stack_vals[1])
|
|
261
|
+
if m_idx < 0:
|
|
262
|
+
m_idx += n_m
|
|
263
|
+
vol = self._get_volume(p_idx, m_idx)
|
|
264
|
+
return vol[spatial_key]
|
|
265
|
+
return np.asarray(self)[key]
|
|
266
|
+
|
|
267
|
+
def __array__(self, dtype=None):
|
|
268
|
+
result = np.empty(self.shape, dtype=self._dtype)
|
|
269
|
+
for p, row in enumerate(self._file_matrix):
|
|
270
|
+
for m, fpath in enumerate(row):
|
|
271
|
+
vol = self._get_volume(p, m)
|
|
272
|
+
if len(self._stack_axes) == 1:
|
|
273
|
+
result[..., p] = vol
|
|
274
|
+
else:
|
|
275
|
+
result[..., p, m] = vol
|
|
276
|
+
if dtype is not None:
|
|
277
|
+
result = result.astype(dtype)
|
|
278
|
+
return result
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
class _FileSeries:
|
|
282
|
+
"""Lazy 4D/5D view over a directory of same-shape array files.
|
|
283
|
+
|
|
284
|
+
Like ``_NiftiSeries`` but works with any supported file format
|
|
285
|
+
(.npy, .npz, .zarr, .pt/.pth, .h5/.hdf5, .tif/.tiff, .mat).
|
|
286
|
+
Uses ``load_data`` for each volume — no nibabel reorientation.
|
|
287
|
+
"""
|
|
288
|
+
|
|
289
|
+
_av_lazy = True
|
|
290
|
+
|
|
291
|
+
def __init__(self, file_matrix, vol_shape, dtype, spatial_meta=None):
|
|
292
|
+
self._file_matrix = file_matrix
|
|
293
|
+
self._vol_shape = tuple(vol_shape)
|
|
294
|
+
self._dtype = np.dtype(dtype)
|
|
295
|
+
self._spatial_meta = spatial_meta
|
|
296
|
+
n_patients = len(file_matrix)
|
|
297
|
+
n_modalities = len(file_matrix[0]) if file_matrix else 0
|
|
298
|
+
if n_modalities <= 1:
|
|
299
|
+
self.shape = (*self._vol_shape, n_patients)
|
|
300
|
+
self._stack_axes = (len(self.shape) - 1,)
|
|
301
|
+
else:
|
|
302
|
+
self.shape = (*self._vol_shape, n_patients, n_modalities)
|
|
303
|
+
self._stack_axes = (len(self.shape) - 2, len(self.shape) - 1)
|
|
304
|
+
self.ndim = len(self.shape)
|
|
305
|
+
self._vol_cache: OrderedDict = OrderedDict()
|
|
306
|
+
self._vol_cache_cap = int(
|
|
307
|
+
os.environ.get("ARRAYVIEW_FILE_SERIES_VOL_CACHE", 3)
|
|
308
|
+
)
|
|
309
|
+
self._cache_lock = threading.Lock()
|
|
310
|
+
|
|
311
|
+
@property
|
|
312
|
+
def dtype(self):
|
|
313
|
+
return self._dtype
|
|
314
|
+
|
|
315
|
+
def _get_volume(self, p_idx, m_idx):
|
|
316
|
+
cache_key = (p_idx, m_idx)
|
|
317
|
+
with self._cache_lock:
|
|
318
|
+
if cache_key in self._vol_cache:
|
|
319
|
+
self._vol_cache.move_to_end(cache_key)
|
|
320
|
+
return self._vol_cache[cache_key]
|
|
321
|
+
filepath = self._file_matrix[p_idx][m_idx]
|
|
322
|
+
vol = load_data(filepath)
|
|
323
|
+
with self._cache_lock:
|
|
324
|
+
self._vol_cache[cache_key] = vol
|
|
325
|
+
while len(self._vol_cache) > self._vol_cache_cap:
|
|
326
|
+
self._vol_cache.popitem(last=False)
|
|
327
|
+
return vol
|
|
328
|
+
|
|
329
|
+
def __getitem__(self, key):
|
|
330
|
+
if not isinstance(key, tuple):
|
|
331
|
+
key = (key,)
|
|
332
|
+
has_special = any(k is None or k is Ellipsis for k in key)
|
|
333
|
+
if not has_special and len(key) <= self.ndim:
|
|
334
|
+
padded = list(key) + [slice(None)] * (self.ndim - len(key))
|
|
335
|
+
stack_vals = [padded[i] for i in self._stack_axes]
|
|
336
|
+
if all(isinstance(s, (int, np.integer)) for s in stack_vals):
|
|
337
|
+
spatial_positions = [
|
|
338
|
+
i for i in range(self.ndim) if i not in self._stack_axes
|
|
339
|
+
]
|
|
340
|
+
spatial_key = tuple(padded[i] for i in spatial_positions)
|
|
341
|
+
n_p = len(self._file_matrix)
|
|
342
|
+
n_m = len(self._file_matrix[0]) if self._file_matrix else 0
|
|
343
|
+
p_idx = int(stack_vals[0])
|
|
344
|
+
if p_idx < 0:
|
|
345
|
+
p_idx += n_p
|
|
346
|
+
if len(self._stack_axes) == 1:
|
|
347
|
+
m_idx = 0
|
|
348
|
+
else:
|
|
349
|
+
m_idx = int(stack_vals[1])
|
|
350
|
+
if m_idx < 0:
|
|
351
|
+
m_idx += n_m
|
|
352
|
+
vol = self._get_volume(p_idx, m_idx)
|
|
353
|
+
return vol[spatial_key]
|
|
354
|
+
return np.asarray(self)[key]
|
|
355
|
+
|
|
356
|
+
def __array__(self, dtype=None):
|
|
357
|
+
result = np.empty(self.shape, dtype=self._dtype)
|
|
358
|
+
for p, row in enumerate(self._file_matrix):
|
|
359
|
+
for m, fpath in enumerate(row):
|
|
360
|
+
vol = self._get_volume(p, m)
|
|
361
|
+
if len(self._stack_axes) == 1:
|
|
362
|
+
result[..., p] = vol
|
|
363
|
+
else:
|
|
364
|
+
result[..., p, m] = vol
|
|
365
|
+
if dtype is not None:
|
|
366
|
+
result = result.astype(dtype)
|
|
367
|
+
return result
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
def _is_nifti_path(filepath):
|
|
371
|
+
"""True if *filepath* ends with .nii or .nii.gz."""
|
|
372
|
+
return filepath.endswith(".nii") or filepath.endswith(".nii.gz")
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
def _get_ext(filepath):
|
|
376
|
+
"""Return the extension of *filepath*, normalising .nii.gz and .zarr.zip."""
|
|
377
|
+
lower = filepath.lower()
|
|
378
|
+
if lower.endswith(".nii.gz"):
|
|
379
|
+
return ".nii.gz"
|
|
380
|
+
if lower.endswith(".zarr.zip"):
|
|
381
|
+
return ".zarr.zip"
|
|
382
|
+
return os.path.splitext(lower)[1]
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
def _load_file_series(path, select=None):
|
|
386
|
+
"""Build a lazy series from a directory of supported array files.
|
|
387
|
+
|
|
388
|
+
Walks *path* recursively, groups files of any supported format by
|
|
389
|
+
immediate parent folder (= patient). With *select* (a list of fnmatch
|
|
390
|
+
patterns), picks one file per pattern per patient → 5D ``(*vol, P, M)``.
|
|
391
|
+
Without *select*, requires exactly one file per patient → 4D ``(*vol, P)``.
|
|
392
|
+
|
|
393
|
+
If every file is NIfTI (.nii/.nii.gz), delegates to
|
|
394
|
+
``_load_nifti_series`` for canonical reorientation. Otherwise builds
|
|
395
|
+
a ``_FileSeries`` using ``load_data`` per volume.
|
|
396
|
+
|
|
397
|
+
Returns ``(series, spatial_meta)``.
|
|
398
|
+
"""
|
|
399
|
+
# ── collect files by parent directory ──────────────────────────
|
|
400
|
+
patients: dict[str, list[str]] = {}
|
|
401
|
+
all_nifti = True
|
|
402
|
+
|
|
403
|
+
for root, dirs, files in os.walk(path):
|
|
404
|
+
supported = sorted(
|
|
405
|
+
os.path.join(root, f)
|
|
406
|
+
for f in files
|
|
407
|
+
if _get_ext(f) in _SUPPORTED_EXTS
|
|
408
|
+
)
|
|
409
|
+
if supported:
|
|
410
|
+
patients[root] = supported
|
|
411
|
+
if all_nifti:
|
|
412
|
+
all_nifti = all(_is_nifti_path(p) for p in supported)
|
|
413
|
+
# A directory with supported files is the series unit. Do not
|
|
414
|
+
# treat nested sub-folders as additional patients.
|
|
415
|
+
dirs[:] = []
|
|
416
|
+
|
|
417
|
+
if not patients:
|
|
418
|
+
raise ValueError(
|
|
419
|
+
f"No supported array files found under {path!r}. "
|
|
420
|
+
f"Supported: {', '.join(sorted(_SUPPORTED_EXTS))}"
|
|
421
|
+
)
|
|
422
|
+
|
|
423
|
+
# NIfTI-only → preserve canonical reorientation + lazy dataobj slicing
|
|
424
|
+
if all_nifti:
|
|
425
|
+
return _load_nifti_series(path, select=select)
|
|
426
|
+
|
|
427
|
+
# ── build file matrix ──────────────────────────────────────────
|
|
428
|
+
select_patterns = select or []
|
|
429
|
+
patient_dirs = sorted(patients.keys())
|
|
430
|
+
file_matrix: list[list[str]] = []
|
|
431
|
+
|
|
432
|
+
if select_patterns:
|
|
433
|
+
for pdir in patient_dirs:
|
|
434
|
+
selected: list[str] = []
|
|
435
|
+
for pattern in select_patterns:
|
|
436
|
+
matches = [
|
|
437
|
+
f
|
|
438
|
+
for f in patients[pdir]
|
|
439
|
+
if fnmatch.fnmatch(os.path.basename(f), pattern)
|
|
440
|
+
]
|
|
441
|
+
if not matches:
|
|
442
|
+
raise ValueError(
|
|
443
|
+
f"Folder {pdir!r}: no file matches --select "
|
|
444
|
+
f"pattern {pattern!r}. Available: "
|
|
445
|
+
f"{[os.path.basename(f) for f in patients[pdir]]}"
|
|
446
|
+
)
|
|
447
|
+
if len(matches) > 1:
|
|
448
|
+
raise ValueError(
|
|
449
|
+
f"Folder {pdir!r}: multiple files match "
|
|
450
|
+
f"--select pattern {pattern!r}: "
|
|
451
|
+
f"{[os.path.basename(f) for f in matches]}. "
|
|
452
|
+
"Make patterns more specific."
|
|
453
|
+
)
|
|
454
|
+
selected.append(matches[0])
|
|
455
|
+
file_matrix.append(selected)
|
|
456
|
+
else:
|
|
457
|
+
for pdir in patient_dirs:
|
|
458
|
+
files = patients[pdir]
|
|
459
|
+
if len(files) == 1:
|
|
460
|
+
file_matrix.append([files[0]])
|
|
461
|
+
else:
|
|
462
|
+
raise ValueError(
|
|
463
|
+
f"Folder {pdir!r} contains {len(files)} files: "
|
|
464
|
+
f"{[os.path.basename(f) for f in files]}. "
|
|
465
|
+
"Use --select PATTERN to pick one (or more) per folder. "
|
|
466
|
+
"Example: --select '*t1*' --select '*t2*' --select '*flair*'"
|
|
467
|
+
)
|
|
468
|
+
|
|
469
|
+
# ── validate shape / dtype uniformity ──────────────────────────
|
|
470
|
+
ref_shape = None
|
|
471
|
+
ref_dtype = None
|
|
472
|
+
for row in file_matrix:
|
|
473
|
+
for fpath in row:
|
|
474
|
+
arr = load_data(fpath)
|
|
475
|
+
shape = arr.shape
|
|
476
|
+
dtype = arr.dtype
|
|
477
|
+
if ref_shape is None:
|
|
478
|
+
ref_shape = shape
|
|
479
|
+
ref_dtype = dtype
|
|
480
|
+
elif shape != ref_shape:
|
|
481
|
+
raise ValueError(
|
|
482
|
+
f"Shape mismatch: {os.path.basename(fpath)!r} has shape "
|
|
483
|
+
f"{shape}, expected {ref_shape}."
|
|
484
|
+
)
|
|
485
|
+
elif dtype != ref_dtype:
|
|
486
|
+
raise ValueError(
|
|
487
|
+
f"Dtype mismatch: {os.path.basename(fpath)!r} has dtype "
|
|
488
|
+
f"{dtype}, expected {ref_dtype}."
|
|
489
|
+
)
|
|
490
|
+
|
|
491
|
+
series = _FileSeries(file_matrix, ref_shape, ref_dtype, spatial_meta=None)
|
|
492
|
+
return series, None
|
|
493
|
+
|
|
494
|
+
|
|
495
|
+
def _load_nifti_series(path, select=None):
|
|
496
|
+
"""Build a lazy ``_NiftiSeries`` from a directory of NIfTI files.
|
|
497
|
+
|
|
498
|
+
Walks *path* recursively, groups ``.nii``/``.nii.gz`` by immediate parent
|
|
499
|
+
folder (= patient). With *select* (a list of fnmatch patterns), picks one
|
|
500
|
+
file per pattern per patient → 5D ``(*vol, P, M)``. Without *select*,
|
|
501
|
+
requires exactly one NIfTI per patient → 4D ``(*vol, P)``.
|
|
502
|
+
|
|
503
|
+
Returns ``(series, spatial_meta)``.
|
|
504
|
+
"""
|
|
505
|
+
nib = _nib()
|
|
506
|
+
|
|
507
|
+
patients: dict[str, list[str]] = {}
|
|
508
|
+
for root, dirs, files in os.walk(path):
|
|
509
|
+
nii_files = sorted(
|
|
510
|
+
os.path.join(root, f)
|
|
511
|
+
for f in files
|
|
512
|
+
if f.endswith(".nii") or f.endswith(".nii.gz")
|
|
513
|
+
)
|
|
514
|
+
if nii_files:
|
|
515
|
+
patients[root] = nii_files
|
|
516
|
+
# A directory with NIfTI files is the series unit. Do not treat
|
|
517
|
+
# nested mask/derived-output folders as additional patients.
|
|
518
|
+
dirs[:] = []
|
|
519
|
+
|
|
520
|
+
if not patients:
|
|
521
|
+
raise ValueError(
|
|
522
|
+
f"No NIfTI files (.nii/.nii.gz) found under {path!r}. "
|
|
523
|
+
"If the folder contains DICOM (.dcm) files, convert them to NIfTI "
|
|
524
|
+
"first (e.g. `dcm2niix -o <out> <dicom_dir>`)."
|
|
525
|
+
)
|
|
526
|
+
|
|
527
|
+
select_patterns = select or []
|
|
528
|
+
patient_dirs = sorted(patients.keys())
|
|
529
|
+
file_matrix: list[list[str]] = []
|
|
530
|
+
|
|
531
|
+
if select_patterns:
|
|
532
|
+
for pdir in patient_dirs:
|
|
533
|
+
selected: list[str] = []
|
|
534
|
+
for pattern in select_patterns:
|
|
535
|
+
matches = [
|
|
536
|
+
f
|
|
537
|
+
for f in patients[pdir]
|
|
538
|
+
if fnmatch.fnmatch(os.path.basename(f), pattern)
|
|
539
|
+
]
|
|
540
|
+
if not matches:
|
|
541
|
+
raise ValueError(
|
|
542
|
+
f"Patient folder {pdir!r}: no file matches --select "
|
|
543
|
+
f"pattern {pattern!r}. Available: "
|
|
544
|
+
f"{[os.path.basename(f) for f in patients[pdir]]}"
|
|
545
|
+
)
|
|
546
|
+
if len(matches) > 1:
|
|
547
|
+
raise ValueError(
|
|
548
|
+
f"Patient folder {pdir!r}: multiple files match "
|
|
549
|
+
f"--select pattern {pattern!r}: "
|
|
550
|
+
f"{[os.path.basename(f) for f in matches]}. "
|
|
551
|
+
"Make patterns more specific."
|
|
552
|
+
)
|
|
553
|
+
selected.append(matches[0])
|
|
554
|
+
file_matrix.append(selected)
|
|
555
|
+
else:
|
|
556
|
+
for pdir in patient_dirs:
|
|
557
|
+
nii_files = patients[pdir]
|
|
558
|
+
if len(nii_files) == 1:
|
|
559
|
+
file_matrix.append([nii_files[0]])
|
|
560
|
+
else:
|
|
561
|
+
raise ValueError(
|
|
562
|
+
f"Patient folder {pdir!r} contains {len(nii_files)} NIfTI "
|
|
563
|
+
f"files: {[os.path.basename(f) for f in nii_files]}. "
|
|
564
|
+
"Use --select PATTERN to pick one (or more) per patient. "
|
|
565
|
+
"Example: --select '*t1*' --select '*t2*' --select '*flair*'"
|
|
566
|
+
)
|
|
567
|
+
|
|
568
|
+
ref_shape = None
|
|
569
|
+
ref_dtype = None
|
|
570
|
+
for row in file_matrix:
|
|
571
|
+
for fpath in row:
|
|
572
|
+
img = nib.load(fpath)
|
|
573
|
+
shape = tuple(int(s) for s in img.shape)
|
|
574
|
+
dtype = img.get_data_dtype()
|
|
575
|
+
if ref_shape is None:
|
|
576
|
+
ref_shape = shape
|
|
577
|
+
ref_dtype = dtype
|
|
578
|
+
elif shape != ref_shape:
|
|
579
|
+
raise ValueError(
|
|
580
|
+
f"Shape mismatch: {os.path.basename(fpath)!r} has shape "
|
|
581
|
+
f"{shape}, expected {ref_shape}."
|
|
582
|
+
)
|
|
583
|
+
elif dtype != ref_dtype:
|
|
584
|
+
raise ValueError(
|
|
585
|
+
f"Dtype mismatch: {os.path.basename(fpath)!r} has dtype "
|
|
586
|
+
f"{dtype}, expected {ref_dtype}."
|
|
587
|
+
)
|
|
588
|
+
|
|
589
|
+
_, spatial_meta = _load_nifti_with_meta(file_matrix[0][0])
|
|
590
|
+
series = _NiftiSeries(file_matrix, ref_shape, ref_dtype, spatial_meta=spatial_meta)
|
|
591
|
+
return series, spatial_meta
|
|
592
|
+
|
|
593
|
+
|
|
594
|
+
def load_data_with_meta(filepath, key=None, select=None):
|
|
178
595
|
"""Like load_data but also returns spatial metadata for NIfTI files.
|
|
179
596
|
|
|
180
597
|
Returns (array, meta_or_None). meta is None for non-NIfTI formats.
|
|
598
|
+
When *filepath* is a directory, loads it as a file series (see
|
|
599
|
+
``_load_file_series``).
|
|
181
600
|
"""
|
|
601
|
+
if os.path.isdir(filepath):
|
|
602
|
+
return _load_file_series(filepath, select=select)
|
|
182
603
|
if filepath.endswith(".nii") or filepath.endswith(".nii.gz"):
|
|
183
604
|
return _load_nifti_with_meta(filepath)
|
|
184
605
|
return load_data(filepath, key=key), None
|
|
185
606
|
|
|
186
607
|
|
|
187
608
|
def load_data(filepath, key=None):
|
|
609
|
+
if os.path.isdir(filepath):
|
|
610
|
+
series, _meta = _load_file_series(filepath)
|
|
611
|
+
return series
|
|
188
612
|
if filepath.endswith(".npy"):
|
|
189
613
|
# Eager-load small-to-medium files into RAM. mmap_mode="r" on a C-order
|
|
190
614
|
# 4D+ array forces scattered page faults for every orthogonal slice
|
|
@@ -326,6 +750,9 @@ FULL_LOAD_EXTS = frozenset([".pt", ".pth", ".tif", ".tiff", ".mat"])
|
|
|
326
750
|
def _peek_file_shape(fpath: str, ext: str):
|
|
327
751
|
"""Try to return shape quickly without loading the full array. Returns None on failure."""
|
|
328
752
|
try:
|
|
753
|
+
if os.path.isdir(fpath):
|
|
754
|
+
series, _ = _load_file_series(fpath)
|
|
755
|
+
return list(series.shape)
|
|
329
756
|
if ext == ".npy":
|
|
330
757
|
arr = np.load(fpath, mmap_mode="r", allow_pickle=False)
|
|
331
758
|
return list(arr.shape)
|
arrayview/_launch_plan.py
CHANGED
|
@@ -37,7 +37,6 @@ class Invocation(_StrEnum):
|
|
|
37
37
|
PYTHON = "python"
|
|
38
38
|
JUPYTER = "jupyter"
|
|
39
39
|
JULIA = "julia"
|
|
40
|
-
STDIO = "stdio"
|
|
41
40
|
CODEX = "codex"
|
|
42
41
|
|
|
43
42
|
|
|
@@ -52,8 +51,6 @@ class Environment(_StrEnum):
|
|
|
52
51
|
|
|
53
52
|
class Transport(_StrEnum):
|
|
54
53
|
HTTP = "http"
|
|
55
|
-
STDIO_FILE = "stdio_file"
|
|
56
|
-
STDIO_SHM = "stdio_shm"
|
|
57
54
|
NONE = "none"
|
|
58
55
|
|
|
59
56
|
|
|
@@ -77,7 +74,6 @@ class Registration(_StrEnum):
|
|
|
77
74
|
HTTP_LOAD = "http_load"
|
|
78
75
|
DAEMON_STARTUP = "daemon_startup"
|
|
79
76
|
IN_PROCESS_SESSION = "in_process_session"
|
|
80
|
-
STDIO_REGISTER = "stdio_register"
|
|
81
77
|
RELAY = "relay"
|
|
82
78
|
|
|
83
79
|
|