arrayview 0.31.2__py3-none-any.whl → 0.32.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/__init__.py +1 -3
- arrayview/_io.py +376 -110
- arrayview/_launcher.py +286 -153
- arrayview/_routes_analysis.py +17 -0
- arrayview/_routes_loading.py +88 -5
- arrayview/_routes_query.py +59 -0
- arrayview/_session.py +10 -0
- arrayview/_session_spec.py +0 -4
- arrayview/_viewer.html +1016 -128
- arrayview/_vscode_browser.py +19 -7
- arrayview/_vscode_extension.py +195 -27
- arrayview/_vscode_signal.py +55 -1
- arrayview/arrayview-opener.vsix +0 -0
- arrayview/todo.md +12 -0
- {arrayview-0.31.2.dist-info → arrayview-0.32.0.dist-info}/METADATA +5 -2
- {arrayview-0.31.2.dist-info → arrayview-0.32.0.dist-info}/RECORD +19 -18
- {arrayview-0.31.2.dist-info → arrayview-0.32.0.dist-info}/WHEEL +0 -0
- {arrayview-0.31.2.dist-info → arrayview-0.32.0.dist-info}/entry_points.txt +0 -0
- {arrayview-0.31.2.dist-info → arrayview-0.32.0.dist-info}/licenses/LICENSE +0 -0
arrayview/__init__.py
CHANGED
|
@@ -7,14 +7,12 @@ __all__ = [
|
|
|
7
7
|
"arrayview",
|
|
8
8
|
"view",
|
|
9
9
|
"view_batch",
|
|
10
|
-
"view_dir",
|
|
11
|
-
"view_dir_patterns",
|
|
12
10
|
"zarr_chunk_preset",
|
|
13
11
|
]
|
|
14
12
|
|
|
15
13
|
|
|
16
14
|
def __getattr__(name: str):
|
|
17
|
-
if name in {"arrayview", "view", "
|
|
15
|
+
if name in {"arrayview", "view", "ViewHandle"}:
|
|
18
16
|
from arrayview import _launcher
|
|
19
17
|
|
|
20
18
|
return getattr(_launcher, name)
|
arrayview/_io.py
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
5
|
import fnmatch
|
|
6
|
+
import gzip
|
|
6
7
|
import glob
|
|
7
8
|
import os
|
|
8
9
|
import re
|
|
@@ -18,6 +19,19 @@ import numpy as np
|
|
|
18
19
|
_nib_mod = None
|
|
19
20
|
|
|
20
21
|
|
|
22
|
+
class MissingOverlayCasesError(ValueError):
|
|
23
|
+
"""A required collection overlay has no file for some image cases."""
|
|
24
|
+
|
|
25
|
+
def __init__(self, overlay_name, missing_cases, total_cases):
|
|
26
|
+
self.overlay_name = str(overlay_name)
|
|
27
|
+
self.missing_cases = tuple(str(case) for case in missing_cases)
|
|
28
|
+
self.total_cases = int(total_cases)
|
|
29
|
+
missing = ", ".join(repr(case) for case in self.missing_cases)
|
|
30
|
+
super().__init__(
|
|
31
|
+
f"Overlay {self.overlay_name!r} is missing case(s): {missing}."
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
|
|
21
35
|
def _nib():
|
|
22
36
|
"""Lazy nibabel import."""
|
|
23
37
|
global _nib_mod
|
|
@@ -170,6 +184,194 @@ def _nifti_display_array(proxy):
|
|
|
170
184
|
return np.asarray(proxy, dtype=display_dtype)
|
|
171
185
|
|
|
172
186
|
|
|
187
|
+
class _ProgressiveLoadCancelled(Exception):
|
|
188
|
+
"""Internal signal used when newer navigation supersedes a cold load."""
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _read_exact(stream, size):
|
|
192
|
+
"""Read exactly *size* bytes, or raise when a gzip stream ends early."""
|
|
193
|
+
chunks = []
|
|
194
|
+
remaining = size
|
|
195
|
+
while remaining:
|
|
196
|
+
chunk = stream.read(remaining)
|
|
197
|
+
if not chunk:
|
|
198
|
+
raise EOFError(f"NIfTI voxel data ended {remaining} bytes early.")
|
|
199
|
+
chunks.append(chunk)
|
|
200
|
+
remaining -= len(chunk)
|
|
201
|
+
return b"".join(chunks)
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _progressive_nifti_spec(filepath):
|
|
205
|
+
"""Describe a gzip volume that can be decoded one canonical Z plane at a time."""
|
|
206
|
+
if not filepath.endswith(".nii.gz"):
|
|
207
|
+
return None
|
|
208
|
+
|
|
209
|
+
nib = _nib()
|
|
210
|
+
img = nib.load(filepath)
|
|
211
|
+
proxy = img.dataobj
|
|
212
|
+
if len(img.shape) != 3 or getattr(proxy, "order", None) != "F":
|
|
213
|
+
return None
|
|
214
|
+
|
|
215
|
+
source_ornt = nib.orientations.io_orientation(img.affine)
|
|
216
|
+
canonical_ornt = nib.orientations.axcodes2ornt(("R", "A", "S"))
|
|
217
|
+
transform = nib.orientations.ornt_transform(source_ornt, canonical_ornt)
|
|
218
|
+
# A source Z plane must remain a canonical Z plane. X/Y may be swapped or
|
|
219
|
+
# flipped inside the plane without requiring any later voxel data.
|
|
220
|
+
if int(transform[2, 0]) != 2:
|
|
221
|
+
return None
|
|
222
|
+
|
|
223
|
+
raw_dtype = np.dtype(proxy.dtype)
|
|
224
|
+
if raw_dtype.kind not in "biufc" or raw_dtype.itemsize <= 0:
|
|
225
|
+
return None
|
|
226
|
+
display_dtype = (
|
|
227
|
+
np.dtype(np.float32)
|
|
228
|
+
if np.issubdtype(raw_dtype, np.floating) and raw_dtype.itemsize > 4
|
|
229
|
+
else raw_dtype
|
|
230
|
+
)
|
|
231
|
+
canonical_shape = tuple(int(img.shape[int(axis)]) for axis in transform[:, 0])
|
|
232
|
+
return {
|
|
233
|
+
"source_shape": tuple(int(size) for size in img.shape),
|
|
234
|
+
"canonical_shape": canonical_shape,
|
|
235
|
+
"transform": transform,
|
|
236
|
+
"raw_dtype": raw_dtype,
|
|
237
|
+
"display_dtype": display_dtype,
|
|
238
|
+
"offset": int(proxy.offset),
|
|
239
|
+
"slope": proxy.slope,
|
|
240
|
+
"inter": proxy.inter,
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
class _ProgressiveNiftiJob:
|
|
245
|
+
"""Decode one gzip stream once, releasing requested planes as they arrive."""
|
|
246
|
+
|
|
247
|
+
def __init__(self, filepath, spec, on_complete, on_finished):
|
|
248
|
+
self.filepath = filepath
|
|
249
|
+
self.spec = spec
|
|
250
|
+
self._on_complete = on_complete
|
|
251
|
+
self._on_finished = on_finished
|
|
252
|
+
self._condition = threading.Condition()
|
|
253
|
+
self._decoded = np.zeros(spec["canonical_shape"][2], dtype=bool)
|
|
254
|
+
# Allocate the large destination only once a worker actually starts.
|
|
255
|
+
# Superseded jobs can otherwise reserve hundreds of MiB while queued.
|
|
256
|
+
self._volume = None
|
|
257
|
+
self._done = False
|
|
258
|
+
self._cancelled = False
|
|
259
|
+
self._error = None
|
|
260
|
+
self.future = None
|
|
261
|
+
|
|
262
|
+
def cancel(self):
|
|
263
|
+
with self._condition:
|
|
264
|
+
self._cancelled = True
|
|
265
|
+
self._condition.notify_all()
|
|
266
|
+
if self.future is not None and self.future.cancel():
|
|
267
|
+
self._finish(cancelled=True)
|
|
268
|
+
|
|
269
|
+
def plane(self, z_index):
|
|
270
|
+
with self._condition:
|
|
271
|
+
while not self._decoded[z_index] and not self._done:
|
|
272
|
+
self._condition.wait()
|
|
273
|
+
if self._decoded[z_index]:
|
|
274
|
+
return self._volume[:, :, z_index]
|
|
275
|
+
if self._cancelled:
|
|
276
|
+
raise _ProgressiveLoadCancelled()
|
|
277
|
+
if self._error is not None:
|
|
278
|
+
raise self._error
|
|
279
|
+
raise RuntimeError("Progressive NIfTI loading stopped before the requested plane.")
|
|
280
|
+
|
|
281
|
+
def volume(self):
|
|
282
|
+
with self._condition:
|
|
283
|
+
while not self._done:
|
|
284
|
+
self._condition.wait()
|
|
285
|
+
if self._cancelled:
|
|
286
|
+
raise _ProgressiveLoadCancelled()
|
|
287
|
+
if self._error is not None:
|
|
288
|
+
raise self._error
|
|
289
|
+
return self._volume
|
|
290
|
+
|
|
291
|
+
def _finish(self, *, error=None, cancelled=False):
|
|
292
|
+
should_notify = False
|
|
293
|
+
with self._condition:
|
|
294
|
+
if self._done:
|
|
295
|
+
return
|
|
296
|
+
self._error = error
|
|
297
|
+
self._cancelled = self._cancelled or cancelled
|
|
298
|
+
self._done = True
|
|
299
|
+
self._condition.notify_all()
|
|
300
|
+
should_notify = True
|
|
301
|
+
if should_notify:
|
|
302
|
+
self._on_finished(self)
|
|
303
|
+
|
|
304
|
+
def run(self):
|
|
305
|
+
spec = self.spec
|
|
306
|
+
source_shape = spec["source_shape"]
|
|
307
|
+
plane_bytes = source_shape[0] * source_shape[1] * spec["raw_dtype"].itemsize
|
|
308
|
+
z_reversed = int(spec["transform"][2, 1]) < 0
|
|
309
|
+
try:
|
|
310
|
+
from nibabel.volumeutils import apply_read_scaling
|
|
311
|
+
|
|
312
|
+
self._volume = np.empty(
|
|
313
|
+
spec["canonical_shape"], dtype=spec["display_dtype"]
|
|
314
|
+
)
|
|
315
|
+
with gzip.open(self.filepath, "rb") as stream:
|
|
316
|
+
stream.seek(spec["offset"])
|
|
317
|
+
for source_z in range(source_shape[2]):
|
|
318
|
+
raw = _read_exact(stream, plane_bytes)
|
|
319
|
+
plane = np.ndarray(
|
|
320
|
+
source_shape[:2],
|
|
321
|
+
dtype=spec["raw_dtype"],
|
|
322
|
+
buffer=raw,
|
|
323
|
+
order="F",
|
|
324
|
+
)
|
|
325
|
+
plane = apply_read_scaling(plane, spec["slope"], spec["inter"])
|
|
326
|
+
plane = np.asarray(plane, dtype=spec["display_dtype"])
|
|
327
|
+
plane = _nib().orientations.apply_orientation(
|
|
328
|
+
plane[:, :, None], spec["transform"]
|
|
329
|
+
)[:, :, 0]
|
|
330
|
+
canonical_z = source_shape[2] - 1 - source_z if z_reversed else source_z
|
|
331
|
+
with self._condition:
|
|
332
|
+
self._volume[:, :, canonical_z] = plane
|
|
333
|
+
self._decoded[canonical_z] = True
|
|
334
|
+
self._condition.notify_all()
|
|
335
|
+
cancelled = self._cancelled
|
|
336
|
+
if cancelled:
|
|
337
|
+
self._finish(cancelled=True)
|
|
338
|
+
return
|
|
339
|
+
with self._condition:
|
|
340
|
+
cancelled = self._cancelled
|
|
341
|
+
if cancelled:
|
|
342
|
+
self._finish(cancelled=True)
|
|
343
|
+
return
|
|
344
|
+
self._on_complete(self._volume)
|
|
345
|
+
self._finish()
|
|
346
|
+
except Exception as exc:
|
|
347
|
+
self._finish(error=exc)
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
_PROGRESSIVE_NIFTI_POOL = None
|
|
351
|
+
_PROGRESSIVE_NIFTI_OVERLAY_POOL = None
|
|
352
|
+
_PROGRESSIVE_NIFTI_POOL_LOCK = threading.Lock()
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
def _get_progressive_nifti_pool(*, overlay_prefetch=False):
|
|
356
|
+
"""Return a bounded gzip pool without mixing base and overlay prefetch."""
|
|
357
|
+
global _PROGRESSIVE_NIFTI_POOL, _PROGRESSIVE_NIFTI_OVERLAY_POOL
|
|
358
|
+
with _PROGRESSIVE_NIFTI_POOL_LOCK:
|
|
359
|
+
if overlay_prefetch:
|
|
360
|
+
if (
|
|
361
|
+
_PROGRESSIVE_NIFTI_OVERLAY_POOL is None
|
|
362
|
+
or _PROGRESSIVE_NIFTI_OVERLAY_POOL._shutdown
|
|
363
|
+
):
|
|
364
|
+
_PROGRESSIVE_NIFTI_OVERLAY_POOL = ThreadPoolExecutor(
|
|
365
|
+
max_workers=2, thread_name_prefix="arrayview-nifti-overlay"
|
|
366
|
+
)
|
|
367
|
+
return _PROGRESSIVE_NIFTI_OVERLAY_POOL
|
|
368
|
+
if _PROGRESSIVE_NIFTI_POOL is None or _PROGRESSIVE_NIFTI_POOL._shutdown:
|
|
369
|
+
_PROGRESSIVE_NIFTI_POOL = ThreadPoolExecutor(
|
|
370
|
+
max_workers=2, thread_name_prefix="arrayview-nifti"
|
|
371
|
+
)
|
|
372
|
+
return _PROGRESSIVE_NIFTI_POOL
|
|
373
|
+
|
|
374
|
+
|
|
173
375
|
def _load_nifti_with_meta(filepath):
|
|
174
376
|
"""Load a NIfTI file, canonical-reorient, return (array, meta).
|
|
175
377
|
|
|
@@ -198,7 +400,74 @@ def _load_nifti_with_meta(filepath):
|
|
|
198
400
|
# ---------------------------------------------------------------------------
|
|
199
401
|
|
|
200
402
|
|
|
201
|
-
class
|
|
403
|
+
class _ProgressiveNiftiSeriesMixin:
|
|
404
|
+
"""Progressive gzip support shared by dense and ragged NIfTI stacks."""
|
|
405
|
+
|
|
406
|
+
def _init_progressive_loading(self):
|
|
407
|
+
self._progressive_jobs = {}
|
|
408
|
+
self._progressive_lock = threading.RLock()
|
|
409
|
+
|
|
410
|
+
def _cache_volume(self, cache_key, vol):
|
|
411
|
+
with self._cache_lock:
|
|
412
|
+
old = self._vol_cache.get(cache_key)
|
|
413
|
+
if old is not None:
|
|
414
|
+
self._vol_cache_bytes -= int(getattr(old, "nbytes", 0))
|
|
415
|
+
self._vol_cache[cache_key] = vol
|
|
416
|
+
self._vol_cache.move_to_end(cache_key)
|
|
417
|
+
self._vol_cache_bytes += int(getattr(vol, "nbytes", 0))
|
|
418
|
+
while self._vol_cache_bytes > self._vol_cache_max_bytes and len(self._vol_cache) > 1:
|
|
419
|
+
_key, evicted = self._vol_cache.popitem(last=False)
|
|
420
|
+
self._vol_cache_bytes -= int(getattr(evicted, "nbytes", 0))
|
|
421
|
+
|
|
422
|
+
def _get_progressive_plane(self, p_idx, m_idx, z_index):
|
|
423
|
+
cache_key = (p_idx, m_idx)
|
|
424
|
+
with self._cache_lock:
|
|
425
|
+
if cache_key in self._vol_cache:
|
|
426
|
+
self._vol_cache.move_to_end(cache_key)
|
|
427
|
+
return self._vol_cache[cache_key][:, :, z_index]
|
|
428
|
+
|
|
429
|
+
filepath = self._file_matrix[p_idx][m_idx]
|
|
430
|
+
if filepath is None:
|
|
431
|
+
return None
|
|
432
|
+
with self._progressive_lock:
|
|
433
|
+
job = self._progressive_jobs.get(cache_key)
|
|
434
|
+
if job is None:
|
|
435
|
+
spec = _progressive_nifti_spec(filepath)
|
|
436
|
+
if spec is None:
|
|
437
|
+
return None
|
|
438
|
+
# Keep the visible patient and one neighbor requested by the
|
|
439
|
+
# existing collection prefetch. A third cold request cancels
|
|
440
|
+
# the oldest unfinished decode instead of growing a queue.
|
|
441
|
+
while len(self._progressive_jobs) >= 2:
|
|
442
|
+
old_key, old_job = next(iter(self._progressive_jobs.items()))
|
|
443
|
+
old_job.cancel()
|
|
444
|
+
self._progressive_jobs.pop(old_key, None)
|
|
445
|
+
|
|
446
|
+
def _complete(volume):
|
|
447
|
+
self._cache_volume(cache_key, volume)
|
|
448
|
+
|
|
449
|
+
def _finished(finished_job):
|
|
450
|
+
with self._progressive_lock:
|
|
451
|
+
if self._progressive_jobs.get(cache_key) is finished_job:
|
|
452
|
+
self._progressive_jobs.pop(cache_key, None)
|
|
453
|
+
|
|
454
|
+
job = _ProgressiveNiftiJob(filepath, spec, _complete, _finished)
|
|
455
|
+
self._progressive_jobs[cache_key] = job
|
|
456
|
+
overlay_prefetch = threading.current_thread().name.startswith(
|
|
457
|
+
"arrayview-overlay-prefetch"
|
|
458
|
+
)
|
|
459
|
+
job.future = _get_progressive_nifti_pool(
|
|
460
|
+
overlay_prefetch=overlay_prefetch
|
|
461
|
+
).submit(job.run)
|
|
462
|
+
try:
|
|
463
|
+
return job.plane(z_index)
|
|
464
|
+
except _ProgressiveLoadCancelled:
|
|
465
|
+
raise
|
|
466
|
+
except Exception:
|
|
467
|
+
return None
|
|
468
|
+
|
|
469
|
+
|
|
470
|
+
class _NiftiSeries(_ProgressiveNiftiSeriesMixin):
|
|
202
471
|
"""Lazy 4D/5D view over a directory of same-shape NIfTI files.
|
|
203
472
|
|
|
204
473
|
``file_matrix`` is a P x M list-of-lists of filepaths (P patients,
|
|
@@ -232,6 +501,7 @@ class _NiftiSeries:
|
|
|
232
501
|
)
|
|
233
502
|
self._cache_lock = threading.Lock()
|
|
234
503
|
self._load_locks = {}
|
|
504
|
+
self._init_progressive_loading()
|
|
235
505
|
|
|
236
506
|
@property
|
|
237
507
|
def dtype(self):
|
|
@@ -249,6 +519,13 @@ class _NiftiSeries:
|
|
|
249
519
|
if cache_key in self._vol_cache:
|
|
250
520
|
self._vol_cache.move_to_end(cache_key)
|
|
251
521
|
return self._vol_cache[cache_key]
|
|
522
|
+
with self._progressive_lock:
|
|
523
|
+
job = self._progressive_jobs.get(cache_key)
|
|
524
|
+
if job is not None:
|
|
525
|
+
try:
|
|
526
|
+
return job.volume()
|
|
527
|
+
except Exception:
|
|
528
|
+
pass
|
|
252
529
|
filepath = self._file_matrix[p_idx][m_idx]
|
|
253
530
|
if filepath is None:
|
|
254
531
|
return np.zeros(self._vol_shape, dtype=self._dtype)
|
|
@@ -259,13 +536,7 @@ class _NiftiSeries:
|
|
|
259
536
|
# only the requested slice. Gzip files must be decompressed once and
|
|
260
537
|
# are therefore materialized into the byte-bounded cache.
|
|
261
538
|
vol = canon.dataobj if filepath.endswith(".nii") else _nifti_display_array(canon.dataobj)
|
|
262
|
-
|
|
263
|
-
self._vol_cache[cache_key] = vol
|
|
264
|
-
self._vol_cache.move_to_end(cache_key)
|
|
265
|
-
self._vol_cache_bytes += int(getattr(vol, "nbytes", 0))
|
|
266
|
-
while self._vol_cache_bytes > self._vol_cache_max_bytes and len(self._vol_cache) > 1:
|
|
267
|
-
_key, evicted = self._vol_cache.popitem(last=False)
|
|
268
|
-
self._vol_cache_bytes -= int(getattr(evicted, "nbytes", 0))
|
|
539
|
+
self._cache_volume(cache_key, vol)
|
|
269
540
|
return vol
|
|
270
541
|
|
|
271
542
|
def __getitem__(self, key):
|
|
@@ -291,6 +562,24 @@ class _NiftiSeries:
|
|
|
291
562
|
m_idx = int(stack_vals[1])
|
|
292
563
|
if m_idx < 0:
|
|
293
564
|
m_idx += n_m
|
|
565
|
+
if (
|
|
566
|
+
len(self._vol_shape) == 3
|
|
567
|
+
and all(
|
|
568
|
+
isinstance(item, slice)
|
|
569
|
+
and item.start is None
|
|
570
|
+
and item.stop is None
|
|
571
|
+
and item.step is None
|
|
572
|
+
for item in spatial_key[:2]
|
|
573
|
+
)
|
|
574
|
+
and isinstance(spatial_key[2], (int, np.integer))
|
|
575
|
+
):
|
|
576
|
+
z_index = int(spatial_key[2])
|
|
577
|
+
if z_index < 0:
|
|
578
|
+
z_index += self._vol_shape[2]
|
|
579
|
+
if 0 <= z_index < self._vol_shape[2]:
|
|
580
|
+
plane = self._get_progressive_plane(p_idx, m_idx, z_index)
|
|
581
|
+
if plane is not None:
|
|
582
|
+
return plane
|
|
294
583
|
vol = self._get_volume(p_idx, m_idx)
|
|
295
584
|
return vol[spatial_key]
|
|
296
585
|
return np.asarray(self)[key]
|
|
@@ -400,7 +689,7 @@ class _FileSeries:
|
|
|
400
689
|
return result
|
|
401
690
|
|
|
402
691
|
|
|
403
|
-
class _RaggedFileSeries:
|
|
692
|
+
class _RaggedFileSeries(_ProgressiveNiftiSeriesMixin):
|
|
404
693
|
"""Lazy collection view over same-rank, same-dtype files with varying shapes.
|
|
405
694
|
|
|
406
695
|
The public ``shape`` is a max-spatial-shape facade so existing viewer
|
|
@@ -445,6 +734,7 @@ class _RaggedFileSeries:
|
|
|
445
734
|
)
|
|
446
735
|
self._cache_lock = threading.Lock()
|
|
447
736
|
self._load_locks = {}
|
|
737
|
+
self._init_progressive_loading()
|
|
448
738
|
|
|
449
739
|
@property
|
|
450
740
|
def dtype(self):
|
|
@@ -497,6 +787,13 @@ class _RaggedFileSeries:
|
|
|
497
787
|
if cache_key in self._vol_cache:
|
|
498
788
|
self._vol_cache.move_to_end(cache_key)
|
|
499
789
|
return self._vol_cache[cache_key]
|
|
790
|
+
with self._progressive_lock:
|
|
791
|
+
job = self._progressive_jobs.get(cache_key)
|
|
792
|
+
if job is not None:
|
|
793
|
+
try:
|
|
794
|
+
return job.volume()
|
|
795
|
+
except Exception:
|
|
796
|
+
pass
|
|
500
797
|
filepath = self._file_matrix[p_idx][m_idx]
|
|
501
798
|
if filepath is None:
|
|
502
799
|
return np.zeros(self._spatial_shapes[p_idx][m_idx], dtype=self._dtype)
|
|
@@ -507,13 +804,7 @@ class _RaggedFileSeries:
|
|
|
507
804
|
vol = proxy if filepath.endswith(".nii") else _nifti_display_array(proxy)
|
|
508
805
|
else:
|
|
509
806
|
vol = load_data(filepath)
|
|
510
|
-
|
|
511
|
-
self._vol_cache[cache_key] = vol
|
|
512
|
-
self._vol_cache.move_to_end(cache_key)
|
|
513
|
-
self._vol_cache_bytes += int(getattr(vol, "nbytes", 0))
|
|
514
|
-
while self._vol_cache_bytes > self._vol_cache_max_bytes and len(self._vol_cache) > 1:
|
|
515
|
-
_key, evicted = self._vol_cache.popitem(last=False)
|
|
516
|
-
self._vol_cache_bytes -= int(getattr(evicted, "nbytes", 0))
|
|
807
|
+
self._cache_volume(cache_key, vol)
|
|
517
808
|
return vol
|
|
518
809
|
|
|
519
810
|
def __getitem__(self, key):
|
|
@@ -540,6 +831,23 @@ class _RaggedFileSeries:
|
|
|
540
831
|
spatial_key.append(idx)
|
|
541
832
|
else:
|
|
542
833
|
spatial_key.append(val)
|
|
834
|
+
if (
|
|
835
|
+
self._all_nifti
|
|
836
|
+
and len(spatial_shape) == 3
|
|
837
|
+
and all(
|
|
838
|
+
isinstance(item, slice)
|
|
839
|
+
and item.start is None
|
|
840
|
+
and item.stop is None
|
|
841
|
+
and item.step is None
|
|
842
|
+
for item in spatial_key[:2]
|
|
843
|
+
)
|
|
844
|
+
and isinstance(spatial_key[2], (int, np.integer))
|
|
845
|
+
):
|
|
846
|
+
z_index = int(spatial_key[2])
|
|
847
|
+
if 0 <= z_index < spatial_shape[2]:
|
|
848
|
+
plane = self._get_progressive_plane(p_idx, m_idx, z_index)
|
|
849
|
+
if plane is not None:
|
|
850
|
+
return plane
|
|
543
851
|
return self._get_volume(p_idx, m_idx)[tuple(spatial_key)]
|
|
544
852
|
raise TypeError("Ragged collections require concrete collection indices.")
|
|
545
853
|
|
|
@@ -808,6 +1116,7 @@ def load_dir_collection(
|
|
|
808
1116
|
load="lazy",
|
|
809
1117
|
stack="auto",
|
|
810
1118
|
scan_progress=None,
|
|
1119
|
+
exclude_cases=None,
|
|
811
1120
|
):
|
|
812
1121
|
"""Load recursive collection patterns as aligned lazy image/overlay stacks.
|
|
813
1122
|
|
|
@@ -816,6 +1125,9 @@ def load_dir_collection(
|
|
|
816
1125
|
sparse overlay whose missing cases should render as empty masks. By
|
|
817
1126
|
default, each pattern's sorted matches are paired by position. With
|
|
818
1127
|
*case_regex*, files are paired by the regex's named ``case`` group instead.
|
|
1128
|
+
*exclude_cases* removes known case ids before any image or overlay data is
|
|
1129
|
+
opened. It is used by the interactive CLI after the user accepts a partial
|
|
1130
|
+
overlay match.
|
|
819
1131
|
"""
|
|
820
1132
|
if not base_patterns:
|
|
821
1133
|
raise ValueError("--stack requires at least one positional image pattern.")
|
|
@@ -865,6 +1177,19 @@ def load_dir_collection(
|
|
|
865
1177
|
for path in base_lists[0]
|
|
866
1178
|
]
|
|
867
1179
|
base_matrix = [list(row) for row in zip(*base_lists)]
|
|
1180
|
+
|
|
1181
|
+
excluded = {str(case) for case in (exclude_cases or ())}
|
|
1182
|
+
if excluded:
|
|
1183
|
+
kept = [
|
|
1184
|
+
(case, row)
|
|
1185
|
+
for case, row in zip(case_ids, base_matrix)
|
|
1186
|
+
if case not in excluded
|
|
1187
|
+
]
|
|
1188
|
+
if not kept:
|
|
1189
|
+
raise ValueError("No image cases remain after excluding missing overlays.")
|
|
1190
|
+
case_ids = [case for case, _row in kept]
|
|
1191
|
+
base_matrix = [row for _case, row in kept]
|
|
1192
|
+
|
|
868
1193
|
data, spatial_meta = _series_from_file_matrix(
|
|
869
1194
|
base_matrix,
|
|
870
1195
|
load=load,
|
|
@@ -884,9 +1209,8 @@ def load_dir_collection(
|
|
|
884
1209
|
by_case = pattern
|
|
885
1210
|
missing_cases = [case for case in case_ids if case not in by_case]
|
|
886
1211
|
if missing_cases and not allow_missing:
|
|
887
|
-
raise
|
|
888
|
-
|
|
889
|
-
f"{', '.join(repr(c) for c in missing_cases)}."
|
|
1212
|
+
raise MissingOverlayCasesError(
|
|
1213
|
+
name, missing_cases, total_cases=len(case_ids)
|
|
890
1214
|
)
|
|
891
1215
|
matrix = [[by_case.get(case)] for case in case_ids]
|
|
892
1216
|
extras = sorted(set(by_case.keys()) - set(case_ids))
|
|
@@ -894,9 +1218,8 @@ def load_dir_collection(
|
|
|
894
1218
|
by_case = _collection_pattern_map(pattern, case_regex=case_regex)
|
|
895
1219
|
missing_cases = [case for case in case_ids if case not in by_case]
|
|
896
1220
|
if missing_cases and not allow_missing:
|
|
897
|
-
raise
|
|
898
|
-
|
|
899
|
-
f"{', '.join(repr(c) for c in missing_cases)}."
|
|
1221
|
+
raise MissingOverlayCasesError(
|
|
1222
|
+
name, missing_cases, total_cases=len(case_ids)
|
|
900
1223
|
)
|
|
901
1224
|
matrix = [[by_case.get(case)] for case in case_ids]
|
|
902
1225
|
extras = sorted(set(by_case.keys()) - set(case_ids))
|
|
@@ -912,9 +1235,8 @@ def load_dir_collection(
|
|
|
912
1235
|
)
|
|
913
1236
|
missing_cases = [case for case in case_ids if case not in by_case]
|
|
914
1237
|
if missing_cases and not allow_missing:
|
|
915
|
-
raise
|
|
916
|
-
|
|
917
|
-
f"{', '.join(repr(c) for c in missing_cases)}."
|
|
1238
|
+
raise MissingOverlayCasesError(
|
|
1239
|
+
name, missing_cases, total_cases=len(case_ids)
|
|
918
1240
|
)
|
|
919
1241
|
matrix = [[by_case.get(case)] for case in case_ids]
|
|
920
1242
|
extras = sorted(set(by_case) - set(case_ids))
|
|
@@ -968,13 +1290,12 @@ def load_dir_collection(
|
|
|
968
1290
|
return data, spatial_meta, overlay_items, summary
|
|
969
1291
|
|
|
970
1292
|
|
|
971
|
-
def _load_file_series(path,
|
|
1293
|
+
def _load_file_series(path, *, load="lazy", stack="auto"):
|
|
972
1294
|
"""Build a lazy series from a directory of supported array files.
|
|
973
1295
|
|
|
974
1296
|
Walks *path* recursively, groups files of any supported format by
|
|
975
|
-
immediate parent folder (= patient).
|
|
976
|
-
|
|
977
|
-
Without *select*, requires exactly one file per patient → 4D ``(*vol, P)``.
|
|
1297
|
+
immediate parent folder (= patient). Requires exactly one file per
|
|
1298
|
+
patient → 4D ``(*vol, P)``.
|
|
978
1299
|
|
|
979
1300
|
If every file is NIfTI (.nii/.nii.gz), delegates to
|
|
980
1301
|
``_load_nifti_series`` for canonical reorientation. Otherwise builds
|
|
@@ -1008,60 +1329,32 @@ def _load_file_series(path, select=None, *, load="lazy", stack="auto"):
|
|
|
1008
1329
|
|
|
1009
1330
|
# NIfTI-only → preserve canonical reorientation + lazy dataobj slicing
|
|
1010
1331
|
if all_nifti:
|
|
1011
|
-
return _load_nifti_series(path,
|
|
1332
|
+
return _load_nifti_series(path, load=load, stack=stack)
|
|
1012
1333
|
|
|
1013
1334
|
# ── build file matrix ──────────────────────────────────────────
|
|
1014
|
-
select_patterns = select or []
|
|
1015
1335
|
patient_dirs = sorted(patients.keys())
|
|
1016
1336
|
file_matrix: list[list[str]] = []
|
|
1017
1337
|
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
raise ValueError(
|
|
1029
|
-
f"Folder {pdir!r}: no file matches --select "
|
|
1030
|
-
f"pattern {pattern!r}. Available: "
|
|
1031
|
-
f"{[os.path.basename(f) for f in patients[pdir]]}"
|
|
1032
|
-
)
|
|
1033
|
-
if len(matches) > 1:
|
|
1034
|
-
raise ValueError(
|
|
1035
|
-
f"Folder {pdir!r}: multiple files match "
|
|
1036
|
-
f"--select pattern {pattern!r}: "
|
|
1037
|
-
f"{[os.path.basename(f) for f in matches]}. "
|
|
1038
|
-
"Make patterns more specific."
|
|
1039
|
-
)
|
|
1040
|
-
selected.append(matches[0])
|
|
1041
|
-
file_matrix.append(selected)
|
|
1042
|
-
else:
|
|
1043
|
-
for pdir in patient_dirs:
|
|
1044
|
-
files = patients[pdir]
|
|
1045
|
-
if len(files) == 1:
|
|
1046
|
-
file_matrix.append([files[0]])
|
|
1047
|
-
else:
|
|
1048
|
-
raise ValueError(
|
|
1049
|
-
f"Folder {pdir!r} contains {len(files)} files: "
|
|
1050
|
-
f"{[os.path.basename(f) for f in files]}. "
|
|
1051
|
-
"Use --select PATTERN to pick one (or more) per folder. "
|
|
1052
|
-
"Example: --select '*t1*' --select '*t2*' --select '*flair*'"
|
|
1053
|
-
)
|
|
1338
|
+
for pdir in patient_dirs:
|
|
1339
|
+
files = patients[pdir]
|
|
1340
|
+
if len(files) == 1:
|
|
1341
|
+
file_matrix.append([files[0]])
|
|
1342
|
+
else:
|
|
1343
|
+
raise ValueError(
|
|
1344
|
+
f"Folder {pdir!r} contains {len(files)} files: "
|
|
1345
|
+
f"{[os.path.basename(f) for f in files]}. "
|
|
1346
|
+
"Use --stack with explicit file patterns for multi-file folders."
|
|
1347
|
+
)
|
|
1054
1348
|
|
|
1055
1349
|
return _series_from_file_matrix(file_matrix, load=load, stack=stack)
|
|
1056
1350
|
|
|
1057
1351
|
|
|
1058
|
-
def _load_nifti_series(path,
|
|
1352
|
+
def _load_nifti_series(path, *, load="lazy", stack="auto"):
|
|
1059
1353
|
"""Build a lazy ``_NiftiSeries`` from a directory of NIfTI files.
|
|
1060
1354
|
|
|
1061
1355
|
Walks *path* recursively, groups ``.nii``/``.nii.gz`` by immediate parent
|
|
1062
|
-
folder (= patient).
|
|
1063
|
-
|
|
1064
|
-
requires exactly one NIfTI per patient → 4D ``(*vol, P)``.
|
|
1356
|
+
folder (= patient). Requires exactly one NIfTI per patient → 4D
|
|
1357
|
+
``(*vol, P)``.
|
|
1065
1358
|
|
|
1066
1359
|
Returns ``(series, spatial_meta)``.
|
|
1067
1360
|
"""
|
|
@@ -1087,51 +1380,24 @@ def _load_nifti_series(path, select=None, *, load="lazy", stack="auto"):
|
|
|
1087
1380
|
"first (e.g. `dcm2niix -o <out> <dicom_dir>`)."
|
|
1088
1381
|
)
|
|
1089
1382
|
|
|
1090
|
-
select_patterns = select or []
|
|
1091
1383
|
patient_dirs = sorted(patients.keys())
|
|
1092
1384
|
file_matrix: list[list[str]] = []
|
|
1093
1385
|
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
raise ValueError(
|
|
1105
|
-
f"Patient folder {pdir!r}: no file matches --select "
|
|
1106
|
-
f"pattern {pattern!r}. Available: "
|
|
1107
|
-
f"{[os.path.basename(f) for f in patients[pdir]]}"
|
|
1108
|
-
)
|
|
1109
|
-
if len(matches) > 1:
|
|
1110
|
-
raise ValueError(
|
|
1111
|
-
f"Patient folder {pdir!r}: multiple files match "
|
|
1112
|
-
f"--select pattern {pattern!r}: "
|
|
1113
|
-
f"{[os.path.basename(f) for f in matches]}. "
|
|
1114
|
-
"Make patterns more specific."
|
|
1115
|
-
)
|
|
1116
|
-
selected.append(matches[0])
|
|
1117
|
-
file_matrix.append(selected)
|
|
1118
|
-
else:
|
|
1119
|
-
for pdir in patient_dirs:
|
|
1120
|
-
nii_files = patients[pdir]
|
|
1121
|
-
if len(nii_files) == 1:
|
|
1122
|
-
file_matrix.append([nii_files[0]])
|
|
1123
|
-
else:
|
|
1124
|
-
raise ValueError(
|
|
1125
|
-
f"Patient folder {pdir!r} contains {len(nii_files)} NIfTI "
|
|
1126
|
-
f"files: {[os.path.basename(f) for f in nii_files]}. "
|
|
1127
|
-
"Use --select PATTERN to pick one (or more) per patient. "
|
|
1128
|
-
"Example: --select '*t1*' --select '*t2*' --select '*flair*'"
|
|
1129
|
-
)
|
|
1386
|
+
for pdir in patient_dirs:
|
|
1387
|
+
nii_files = patients[pdir]
|
|
1388
|
+
if len(nii_files) == 1:
|
|
1389
|
+
file_matrix.append([nii_files[0]])
|
|
1390
|
+
else:
|
|
1391
|
+
raise ValueError(
|
|
1392
|
+
f"Patient folder {pdir!r} contains {len(nii_files)} NIfTI "
|
|
1393
|
+
f"files: {[os.path.basename(f) for f in nii_files]}. "
|
|
1394
|
+
"Use --stack with explicit file patterns for multi-file folders."
|
|
1395
|
+
)
|
|
1130
1396
|
|
|
1131
1397
|
return _series_from_file_matrix(file_matrix, load=load, stack=stack)
|
|
1132
1398
|
|
|
1133
1399
|
|
|
1134
|
-
def load_data_with_meta(filepath, key=None,
|
|
1400
|
+
def load_data_with_meta(filepath, key=None, *, load="lazy", stack="auto"):
|
|
1135
1401
|
"""Like load_data but also returns spatial metadata for NIfTI files.
|
|
1136
1402
|
|
|
1137
1403
|
Returns (array, meta_or_None). meta is None for non-NIfTI formats.
|
|
@@ -1139,7 +1405,7 @@ def load_data_with_meta(filepath, key=None, select=None, *, load="lazy", stack="
|
|
|
1139
1405
|
``_load_file_series``).
|
|
1140
1406
|
"""
|
|
1141
1407
|
if os.path.isdir(filepath):
|
|
1142
|
-
return _load_file_series(filepath,
|
|
1408
|
+
return _load_file_series(filepath, load=load, stack=stack)
|
|
1143
1409
|
if filepath.endswith(".nii") or filepath.endswith(".nii.gz"):
|
|
1144
1410
|
return _load_nifti_with_meta(filepath)
|
|
1145
1411
|
return load_data(filepath, key=key), None
|