arrayview 0.48.0__tar.gz → 0.48.1__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.0 → arrayview-0.48.1}/PKG-INFO +1 -1
- {arrayview-0.48.0 → arrayview-0.48.1}/pyproject.toml +1 -1
- {arrayview-0.48.0 → arrayview-0.48.1}/src/arrayview/_config.py +10 -2
- {arrayview-0.48.0 → arrayview-0.48.1}/src/arrayview/_io.py +382 -9
- {arrayview-0.48.0 → arrayview-0.48.1}/src/arrayview/_render.py +6 -1
- {arrayview-0.48.0 → arrayview-0.48.1}/src/arrayview/_server.py +2 -0
- {arrayview-0.48.0 → arrayview-0.48.1}/src/arrayview/_viewer.html +144 -14
- {arrayview-0.48.0 → arrayview-0.48.1}/tests/test_config.py +8 -0
- {arrayview-0.48.0 → arrayview-0.48.1}/tests/test_nifti_series.py +75 -2
- {arrayview-0.48.0 → arrayview-0.48.1}/.gitignore +0 -0
- {arrayview-0.48.0 → arrayview-0.48.1}/LICENSE +0 -0
- {arrayview-0.48.0 → arrayview-0.48.1}/README.md +0 -0
- {arrayview-0.48.0 → arrayview-0.48.1}/src/arrayview/ARCHITECTURE.md +0 -0
- {arrayview-0.48.0 → arrayview-0.48.1}/src/arrayview/__init__.py +0 -0
- {arrayview-0.48.0 → arrayview-0.48.1}/src/arrayview/__main__.py +0 -0
- {arrayview-0.48.0 → arrayview-0.48.1}/src/arrayview/_analysis.py +0 -0
- {arrayview-0.48.0 → arrayview-0.48.1}/src/arrayview/_app.py +0 -0
- {arrayview-0.48.0 → arrayview-0.48.1}/src/arrayview/_bootstrap_app.py +0 -0
- {arrayview-0.48.0 → arrayview-0.48.1}/src/arrayview/_diagnostics.py +0 -0
- {arrayview-0.48.0 → arrayview-0.48.1}/src/arrayview/_dicom.py +0 -0
- {arrayview-0.48.0 → arrayview-0.48.1}/src/arrayview/_diff.py +0 -0
- {arrayview-0.48.0 → arrayview-0.48.1}/src/arrayview/_extra_ports.py +0 -0
- {arrayview-0.48.0 → arrayview-0.48.1}/src/arrayview/_icon.png +0 -0
- {arrayview-0.48.0 → arrayview-0.48.1}/src/arrayview/_imaging.py +0 -0
- {arrayview-0.48.0 → arrayview-0.48.1}/src/arrayview/_instance_registry.py +0 -0
- {arrayview-0.48.0 → arrayview-0.48.1}/src/arrayview/_launch_plan.py +0 -0
- {arrayview-0.48.0 → arrayview-0.48.1}/src/arrayview/_launch_trace.py +0 -0
- {arrayview-0.48.0 → arrayview-0.48.1}/src/arrayview/_launcher.py +0 -0
- {arrayview-0.48.0 → arrayview-0.48.1}/src/arrayview/_lifecycle.py +0 -0
- {arrayview-0.48.0 → arrayview-0.48.1}/src/arrayview/_overlays.py +0 -0
- {arrayview-0.48.0 → arrayview-0.48.1}/src/arrayview/_platform.py +0 -0
- {arrayview-0.48.0 → arrayview-0.48.1}/src/arrayview/_routes_analysis.py +0 -0
- {arrayview-0.48.0 → arrayview-0.48.1}/src/arrayview/_routes_drop.py +0 -0
- {arrayview-0.48.0 → arrayview-0.48.1}/src/arrayview/_routes_export.py +0 -0
- {arrayview-0.48.0 → arrayview-0.48.1}/src/arrayview/_routes_loading.py +0 -0
- {arrayview-0.48.0 → arrayview-0.48.1}/src/arrayview/_routes_persistence.py +0 -0
- {arrayview-0.48.0 → arrayview-0.48.1}/src/arrayview/_routes_preferences.py +0 -0
- {arrayview-0.48.0 → arrayview-0.48.1}/src/arrayview/_routes_preload.py +0 -0
- {arrayview-0.48.0 → arrayview-0.48.1}/src/arrayview/_routes_query.py +0 -0
- {arrayview-0.48.0 → arrayview-0.48.1}/src/arrayview/_routes_rendering.py +0 -0
- {arrayview-0.48.0 → arrayview-0.48.1}/src/arrayview/_routes_segmentation.py +0 -0
- {arrayview-0.48.0 → arrayview-0.48.1}/src/arrayview/_routes_state.py +0 -0
- {arrayview-0.48.0 → arrayview-0.48.1}/src/arrayview/_routes_vectorfield.py +0 -0
- {arrayview-0.48.0 → arrayview-0.48.1}/src/arrayview/_routes_websocket.py +0 -0
- {arrayview-0.48.0 → arrayview-0.48.1}/src/arrayview/_segmentation.py +0 -0
- {arrayview-0.48.0 → arrayview-0.48.1}/src/arrayview/_session.py +0 -0
- {arrayview-0.48.0 → arrayview-0.48.1}/src/arrayview/_session_spec.py +0 -0
- {arrayview-0.48.0 → arrayview-0.48.1}/src/arrayview/_shell.html +0 -0
- {arrayview-0.48.0 → arrayview-0.48.1}/src/arrayview/_source_safety.py +0 -0
- {arrayview-0.48.0 → arrayview-0.48.1}/src/arrayview/_synthetic_mri.py +0 -0
- {arrayview-0.48.0 → arrayview-0.48.1}/src/arrayview/_torch.py +0 -0
- {arrayview-0.48.0 → arrayview-0.48.1}/src/arrayview/_tutorial.py +0 -0
- {arrayview-0.48.0 → arrayview-0.48.1}/src/arrayview/_vectorfield.py +0 -0
- {arrayview-0.48.0 → arrayview-0.48.1}/src/arrayview/_vscode.py +0 -0
- {arrayview-0.48.0 → arrayview-0.48.1}/src/arrayview/_vscode_browser.py +0 -0
- {arrayview-0.48.0 → arrayview-0.48.1}/src/arrayview/_vscode_extension.py +0 -0
- {arrayview-0.48.0 → arrayview-0.48.1}/src/arrayview/_vscode_signal.py +0 -0
- {arrayview-0.48.0 → arrayview-0.48.1}/src/arrayview/_watch.py +0 -0
- {arrayview-0.48.0 → arrayview-0.48.1}/src/arrayview/arrayview-opener.vsix +0 -0
- {arrayview-0.48.0 → arrayview-0.48.1}/src/arrayview/gsap.min.js +0 -0
- {arrayview-0.48.0 → arrayview-0.48.1}/src/arrayview/todo.md +0 -0
- {arrayview-0.48.0 → arrayview-0.48.1}/tests/capture_patient_loading.py +0 -0
- {arrayview-0.48.0 → arrayview-0.48.1}/tests/capture_v_animation.py +0 -0
- {arrayview-0.48.0 → arrayview-0.48.1}/tests/conftest.py +0 -0
- {arrayview-0.48.0 → arrayview-0.48.1}/tests/make_vectorfield_test_arrays.py +0 -0
- {arrayview-0.48.0 → arrayview-0.48.1}/tests/test_1d_arrays.py +0 -0
- {arrayview-0.48.0 → arrayview-0.48.1}/tests/test_api.py +0 -0
- {arrayview-0.48.0 → arrayview-0.48.1}/tests/test_backend_shared.py +0 -0
- {arrayview-0.48.0 → arrayview-0.48.1}/tests/test_browser.py +0 -0
- {arrayview-0.48.0 → arrayview-0.48.1}/tests/test_cli.py +0 -0
- {arrayview-0.48.0 → arrayview-0.48.1}/tests/test_dicom.py +0 -0
- {arrayview-0.48.0 → arrayview-0.48.1}/tests/test_drop_import.py +0 -0
- {arrayview-0.48.0 → arrayview-0.48.1}/tests/test_instance_registry.py +0 -0
- {arrayview-0.48.0 → arrayview-0.48.1}/tests/test_io.py +0 -0
- {arrayview-0.48.0 → arrayview-0.48.1}/tests/test_large_arrays.py +0 -0
- {arrayview-0.48.0 → arrayview-0.48.1}/tests/test_launch_blackbox.py +0 -0
- {arrayview-0.48.0 → arrayview-0.48.1}/tests/test_mode_roundtrip.py +0 -0
- {arrayview-0.48.0 → arrayview-0.48.1}/tests/test_nifti_meta.py +0 -0
- {arrayview-0.48.0 → arrayview-0.48.1}/tests/test_rgb_pixel_art.py +0 -0
- {arrayview-0.48.0 → arrayview-0.48.1}/tests/test_safe_server_stop.py +0 -0
- {arrayview-0.48.0 → arrayview-0.48.1}/tests/test_source_safety.py +0 -0
- {arrayview-0.48.0 → arrayview-0.48.1}/tests/test_torch.py +0 -0
- {arrayview-0.48.0 → arrayview-0.48.1}/tests/test_tutorial.py +0 -0
- {arrayview-0.48.0 → arrayview-0.48.1}/tests/test_vsix_bundle.py +0 -0
- {arrayview-0.48.0 → arrayview-0.48.1}/tests/ui_audit.py +0 -0
- {arrayview-0.48.0 → arrayview-0.48.1}/tests/v_anim_frames/.gitkeep +0 -0
- {arrayview-0.48.0 → arrayview-0.48.1}/tests/visual_smoke.py +0 -0
|
@@ -38,6 +38,7 @@ BUILTIN_PREFERENCES = {
|
|
|
38
38
|
"viewer": {
|
|
39
39
|
"theme": "dark",
|
|
40
40
|
"rounded_panes": True,
|
|
41
|
+
"qmri_gapless": False,
|
|
41
42
|
"ortho_layout": None,
|
|
42
43
|
"dimbar_mode": "compact",
|
|
43
44
|
},
|
|
@@ -47,6 +48,7 @@ PREFERENCE_SCHEMA = {
|
|
|
47
48
|
"viewer": {
|
|
48
49
|
"theme": sorted(_VALID_THEMES),
|
|
49
50
|
"rounded_panes": "boolean",
|
|
51
|
+
"qmri_gapless": "boolean",
|
|
50
52
|
"colormaps": "non-empty list of names",
|
|
51
53
|
"ortho_layout": sorted(_VALID_ORTHO_LAYOUTS),
|
|
52
54
|
"dimbar_mode": sorted(_VALID_DIMBAR_MODES),
|
|
@@ -118,9 +120,9 @@ def _validate_preference(section: str, key: str, value):
|
|
|
118
120
|
)
|
|
119
121
|
if section == "viewer" and key == "theme":
|
|
120
122
|
return _normalized_string(value, _VALID_THEMES, "viewer.theme")
|
|
121
|
-
if section == "viewer" and key
|
|
123
|
+
if section == "viewer" and key in ("rounded_panes", "qmri_gapless"):
|
|
122
124
|
if not isinstance(value, bool):
|
|
123
|
-
raise InvalidPreferenceError("viewer.
|
|
125
|
+
raise InvalidPreferenceError(f"viewer.{key} must be a boolean")
|
|
124
126
|
return value
|
|
125
127
|
if section == "viewer" and key == "colormaps":
|
|
126
128
|
if (
|
|
@@ -238,6 +240,12 @@ def get_viewer_rounded_panes() -> bool | None:
|
|
|
238
240
|
return None
|
|
239
241
|
|
|
240
242
|
|
|
243
|
+
def get_viewer_qmri_gapless() -> bool:
|
|
244
|
+
"""Whether qMRI maps are laid out touching, with no space between them."""
|
|
245
|
+
viewer_cfg = load_config().get("viewer", {})
|
|
246
|
+
return isinstance(viewer_cfg, dict) and viewer_cfg.get("qmri_gapless") is True
|
|
247
|
+
|
|
248
|
+
|
|
241
249
|
def get_viewer_ortho_layout() -> str | None:
|
|
242
250
|
"""Return the preferred initial ortho-view layout."""
|
|
243
251
|
viewer_cfg = load_config().get("viewer", {})
|
|
@@ -11,7 +11,7 @@ import re
|
|
|
11
11
|
import sys
|
|
12
12
|
import threading
|
|
13
13
|
import zlib
|
|
14
|
-
from concurrent.futures import ThreadPoolExecutor
|
|
14
|
+
from concurrent.futures import Future, ThreadPoolExecutor
|
|
15
15
|
from collections import OrderedDict
|
|
16
16
|
|
|
17
17
|
import numpy as np
|
|
@@ -562,6 +562,310 @@ def read_gzip_file_parallel(filepath):
|
|
|
562
562
|
return b"".join(out)
|
|
563
563
|
|
|
564
564
|
|
|
565
|
+
# ---------------------------------------------------------------------------
|
|
566
|
+
# Scattered slices of mapped files — read the whole region instead
|
|
567
|
+
# ---------------------------------------------------------------------------
|
|
568
|
+
|
|
569
|
+
# A slice that cuts across a file's storage order touches every page of the
|
|
570
|
+
# volume it lives in. Faulted in through the map that is a long chain of small
|
|
571
|
+
# waits: one map of a (224,240,204,6) float32 file took 2.1 s off an SMB share,
|
|
572
|
+
# where 4 MB reads, several in flight, fetched the same 42 MB in 0.45 s (the
|
|
573
|
+
# link's ~100 MB/s). Small targeted reads lost too: 204 single-row reads took
|
|
574
|
+
# 0.46 s even 64 at a time, because each one is a ~13 ms round trip.
|
|
575
|
+
_REGION_READ_CHUNK_BYTES = 4 * 1024 * 1024
|
|
576
|
+
_REGION_READ_THREADS = 8
|
|
577
|
+
_REGION_READ_POOL = None
|
|
578
|
+
_REGION_WARM_POOL = None
|
|
579
|
+
_REGION_POOL_LOCK = threading.Lock()
|
|
580
|
+
|
|
581
|
+
|
|
582
|
+
def _region_pools():
|
|
583
|
+
global _REGION_READ_POOL, _REGION_WARM_POOL
|
|
584
|
+
with _REGION_POOL_LOCK:
|
|
585
|
+
if _REGION_READ_POOL is None:
|
|
586
|
+
_REGION_READ_POOL = ThreadPoolExecutor(
|
|
587
|
+
max_workers=_REGION_READ_THREADS, thread_name_prefix="arrayview-region-read"
|
|
588
|
+
)
|
|
589
|
+
# One background lane, so warming never competes with itself for
|
|
590
|
+
# the link the visible frame is waiting on.
|
|
591
|
+
_REGION_WARM_POOL = ThreadPoolExecutor(
|
|
592
|
+
max_workers=1, thread_name_prefix="arrayview-region-warm"
|
|
593
|
+
)
|
|
594
|
+
return _REGION_READ_POOL, _REGION_WARM_POOL
|
|
595
|
+
|
|
596
|
+
|
|
597
|
+
def _mapped_file_span(view):
|
|
598
|
+
"""Where the bytes behind a view of an ``np.memmap`` sit in its file.
|
|
599
|
+
|
|
600
|
+
Returns ``(filename, file_offset, nbytes, first_element_offset)``, or None
|
|
601
|
+
when *view* is not backed by a file map.
|
|
602
|
+
"""
|
|
603
|
+
if not isinstance(view, np.ndarray) or view.size == 0:
|
|
604
|
+
return None
|
|
605
|
+
root = view
|
|
606
|
+
while isinstance(root.base, np.ndarray):
|
|
607
|
+
root = root.base
|
|
608
|
+
if not isinstance(root, np.memmap) or not getattr(root, "filename", None):
|
|
609
|
+
return None
|
|
610
|
+
ptr = view.__array_interface__["data"][0]
|
|
611
|
+
lo = ptr + sum(min(0, (n - 1) * s) for n, s in zip(view.shape, view.strides))
|
|
612
|
+
hi = ptr + sum(max(0, (n - 1) * s) for n, s in zip(view.shape, view.strides))
|
|
613
|
+
hi += view.itemsize
|
|
614
|
+
root_ptr = root.__array_interface__["data"][0]
|
|
615
|
+
return root.filename, int(root.offset) + (lo - root_ptr), hi - lo, ptr - lo
|
|
616
|
+
|
|
617
|
+
|
|
618
|
+
def _mapped_slice_is_scattered(view) -> bool:
|
|
619
|
+
"""Whether reading *view* through its map drags in far more than it holds."""
|
|
620
|
+
span = _mapped_file_span(view)
|
|
621
|
+
if span is None:
|
|
622
|
+
return False
|
|
623
|
+
nbytes = span[2]
|
|
624
|
+
return nbytes > 4 * view.nbytes and nbytes > _REGION_READ_CHUNK_BYTES
|
|
625
|
+
|
|
626
|
+
|
|
627
|
+
def _pread_into(fd, buf, offset):
|
|
628
|
+
if hasattr(os, "preadv"):
|
|
629
|
+
return os.preadv(fd, [buf], offset)
|
|
630
|
+
data = os.pread(fd, len(buf), offset)
|
|
631
|
+
buf[: len(data)] = data
|
|
632
|
+
return len(data)
|
|
633
|
+
|
|
634
|
+
|
|
635
|
+
def read_mapped_region(view, progress=None):
|
|
636
|
+
"""Read the file bytes behind memmap view *view* into RAM with large parallel reads.
|
|
637
|
+
|
|
638
|
+
Returns an in-memory array equal to *view*. *progress*, if given, is called
|
|
639
|
+
with ``(bytes_done, bytes_total)`` as chunks land.
|
|
640
|
+
"""
|
|
641
|
+
span = _mapped_file_span(view)
|
|
642
|
+
if span is None:
|
|
643
|
+
return np.array(view)
|
|
644
|
+
filename, file_lo, nbytes, first = span
|
|
645
|
+
buf = np.empty(nbytes, dtype=np.uint8)
|
|
646
|
+
mv = memoryview(buf)
|
|
647
|
+
lock = threading.Lock()
|
|
648
|
+
done = [0]
|
|
649
|
+
fd = os.open(filename, os.O_RDONLY)
|
|
650
|
+
try:
|
|
651
|
+
|
|
652
|
+
def _read(start):
|
|
653
|
+
stop = min(start + _REGION_READ_CHUNK_BYTES, nbytes)
|
|
654
|
+
pos = start
|
|
655
|
+
while pos < stop:
|
|
656
|
+
n = _pread_into(fd, mv[pos:stop], file_lo + pos)
|
|
657
|
+
if n <= 0:
|
|
658
|
+
raise OSError(f"short read from {filename}")
|
|
659
|
+
pos += n
|
|
660
|
+
if progress is not None:
|
|
661
|
+
with lock:
|
|
662
|
+
done[0] += stop - start
|
|
663
|
+
finished = done[0]
|
|
664
|
+
try:
|
|
665
|
+
progress(finished, nbytes)
|
|
666
|
+
except Exception:
|
|
667
|
+
pass
|
|
668
|
+
|
|
669
|
+
pool, _warm = _region_pools()
|
|
670
|
+
for _ in pool.map(_read, range(0, nbytes, _REGION_READ_CHUNK_BYTES)):
|
|
671
|
+
pass
|
|
672
|
+
finally:
|
|
673
|
+
os.close(fd)
|
|
674
|
+
return np.ndarray(view.shape, view.dtype, buffer=buf, offset=first, strides=view.strides)
|
|
675
|
+
|
|
676
|
+
|
|
677
|
+
class _ScatteredRegionMixin:
|
|
678
|
+
"""Serve scattered slices of mapped NIfTI files from regions held in RAM.
|
|
679
|
+
|
|
680
|
+
A region is one whole spatial volume of a file: all of a 3D file, or one
|
|
681
|
+
map of a 4D one. When a requested slice would be scattered across its
|
|
682
|
+
region, the region is read in one go (see ``read_mapped_region``) and kept;
|
|
683
|
+
every later slice of it, in any direction, is then served from memory.
|
|
684
|
+
Afterwards the case's other regions and the neighbouring case are read in
|
|
685
|
+
the background, so the next step — another map, the next patient — is
|
|
686
|
+
usually already there.
|
|
687
|
+
"""
|
|
688
|
+
|
|
689
|
+
def _init_region_cache(self):
|
|
690
|
+
self._region_cache: OrderedDict = OrderedDict()
|
|
691
|
+
self._region_cache_bytes = 0
|
|
692
|
+
self._region_cache_max_bytes = int(
|
|
693
|
+
os.environ.get("ARRAYVIEW_NIFTI_REGION_CACHE_BYTES", 2 * 1024**3)
|
|
694
|
+
)
|
|
695
|
+
self._region_lock = threading.Lock()
|
|
696
|
+
self._region_jobs = {}
|
|
697
|
+
self._region_generation = 0
|
|
698
|
+
self._region_last_case = None
|
|
699
|
+
self._mapped_vols = {}
|
|
700
|
+
|
|
701
|
+
def _mapped_volume(self, p_idx, m_idx, vol):
|
|
702
|
+
"""*vol* as a memmap-backed array, or None if it is not one."""
|
|
703
|
+
if isinstance(vol, np.ndarray):
|
|
704
|
+
return vol if _mapped_file_span(vol) is not None else None
|
|
705
|
+
key = (p_idx, m_idx)
|
|
706
|
+
with self._region_lock:
|
|
707
|
+
if key in self._mapped_vols:
|
|
708
|
+
return self._mapped_vols[key]
|
|
709
|
+
mapped = None
|
|
710
|
+
# An uncompressed, unscaled proxy maps without reading anything.
|
|
711
|
+
slope = getattr(vol, "slope", None)
|
|
712
|
+
inter = getattr(vol, "inter", None)
|
|
713
|
+
if (
|
|
714
|
+
getattr(vol, "is_proxy", False)
|
|
715
|
+
and slope == 1
|
|
716
|
+
and inter == 0
|
|
717
|
+
and str(getattr(vol, "file_like", "")).endswith(".nii")
|
|
718
|
+
):
|
|
719
|
+
try:
|
|
720
|
+
arr = np.asanyarray(vol)
|
|
721
|
+
if _mapped_file_span(arr) is not None:
|
|
722
|
+
mapped = arr
|
|
723
|
+
except Exception:
|
|
724
|
+
mapped = None
|
|
725
|
+
with self._region_lock:
|
|
726
|
+
self._mapped_vols[key] = mapped
|
|
727
|
+
return mapped
|
|
728
|
+
|
|
729
|
+
@staticmethod
|
|
730
|
+
def _region_split(mapped, spatial_key):
|
|
731
|
+
"""Split *spatial_key* into the region's key and the key within it."""
|
|
732
|
+
n_sp = min(3, mapped.ndim)
|
|
733
|
+
rest = tuple(spatial_key[n_sp:])
|
|
734
|
+
if not all(isinstance(r, (int, np.integer)) for r in rest):
|
|
735
|
+
return None
|
|
736
|
+
return tuple(int(r) for r in rest), tuple(spatial_key[:n_sp])
|
|
737
|
+
|
|
738
|
+
def _region_view(self, mapped, rest):
|
|
739
|
+
n_sp = min(3, mapped.ndim)
|
|
740
|
+
return mapped[(slice(None),) * n_sp + rest]
|
|
741
|
+
|
|
742
|
+
def _cached_region(self, key):
|
|
743
|
+
with self._region_lock:
|
|
744
|
+
arr = self._region_cache.get(key)
|
|
745
|
+
if arr is not None:
|
|
746
|
+
self._region_cache.move_to_end(key)
|
|
747
|
+
return arr
|
|
748
|
+
|
|
749
|
+
def _load_region(self, key, view, progress=None):
|
|
750
|
+
with self._region_lock:
|
|
751
|
+
arr = self._region_cache.get(key)
|
|
752
|
+
if arr is not None:
|
|
753
|
+
self._region_cache.move_to_end(key)
|
|
754
|
+
return arr
|
|
755
|
+
fut = self._region_jobs.get(key)
|
|
756
|
+
owner = fut is None
|
|
757
|
+
if owner:
|
|
758
|
+
fut = Future()
|
|
759
|
+
self._region_jobs[key] = fut
|
|
760
|
+
if not owner:
|
|
761
|
+
return fut.result()
|
|
762
|
+
try:
|
|
763
|
+
arr = read_mapped_region(view, progress)
|
|
764
|
+
except BaseException as exc:
|
|
765
|
+
fut.set_exception(exc)
|
|
766
|
+
raise
|
|
767
|
+
finally:
|
|
768
|
+
with self._region_lock:
|
|
769
|
+
self._region_jobs.pop(key, None)
|
|
770
|
+
with self._region_lock:
|
|
771
|
+
self._region_cache[key] = arr
|
|
772
|
+
self._region_cache_bytes += arr.nbytes
|
|
773
|
+
while (
|
|
774
|
+
self._region_cache_bytes > self._region_cache_max_bytes
|
|
775
|
+
and len(self._region_cache) > 1
|
|
776
|
+
):
|
|
777
|
+
_old, evicted = self._region_cache.popitem(last=False)
|
|
778
|
+
self._region_cache_bytes -= evicted.nbytes
|
|
779
|
+
fut.set_result(arr)
|
|
780
|
+
return arr
|
|
781
|
+
|
|
782
|
+
def _scattered_region_slice(self, p_idx, m_idx, vol, spatial_key, progress=None):
|
|
783
|
+
"""``vol[spatial_key]`` from a region in RAM, or None to read it as usual."""
|
|
784
|
+
mapped = self._mapped_volume(p_idx, m_idx, vol)
|
|
785
|
+
if mapped is None:
|
|
786
|
+
return None
|
|
787
|
+
split = self._region_split(mapped, spatial_key)
|
|
788
|
+
if split is None:
|
|
789
|
+
return None
|
|
790
|
+
rest, inner = split
|
|
791
|
+
key = (p_idx, m_idx, rest)
|
|
792
|
+
region = self._cached_region(key)
|
|
793
|
+
if region is None:
|
|
794
|
+
try:
|
|
795
|
+
if not _mapped_slice_is_scattered(mapped[tuple(spatial_key)]):
|
|
796
|
+
return None
|
|
797
|
+
view = self._region_view(mapped, rest)
|
|
798
|
+
if view.nbytes > self._region_cache_max_bytes // 2:
|
|
799
|
+
return None
|
|
800
|
+
region = self._load_region(key, view, progress)
|
|
801
|
+
except Exception:
|
|
802
|
+
return None
|
|
803
|
+
self._schedule_region_warm(p_idx, m_idx, rest, spatial_key)
|
|
804
|
+
return region[inner]
|
|
805
|
+
|
|
806
|
+
def _schedule_region_warm(self, p_idx, m_idx, rest, spatial_key):
|
|
807
|
+
case = (p_idx, m_idx)
|
|
808
|
+
with self._region_lock:
|
|
809
|
+
last = self._region_last_case
|
|
810
|
+
if last == case:
|
|
811
|
+
return
|
|
812
|
+
self._region_last_case = case
|
|
813
|
+
self._region_generation += 1
|
|
814
|
+
generation = self._region_generation
|
|
815
|
+
n_p = len(self._file_matrix)
|
|
816
|
+
n_m = len(self._file_matrix[0]) if self._file_matrix else 0
|
|
817
|
+
# Neighbours along whichever stack axis was stepped: patients, or
|
|
818
|
+
# channels when separate files were stacked that way.
|
|
819
|
+
along_m = (n_p == 1 and n_m > 1) or (
|
|
820
|
+
last is not None and last[0] == p_idx and last[1] != m_idx
|
|
821
|
+
)
|
|
822
|
+
if along_m:
|
|
823
|
+
step = -1 if last is not None and last[1] > m_idx else 1
|
|
824
|
+
neighbours = [(p_idx, m_idx + step), (p_idx, m_idx - step)]
|
|
825
|
+
else:
|
|
826
|
+
step = -1 if last is not None and last[0] > p_idx else 1
|
|
827
|
+
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
|
+
plan = [(p_idx, m_idx)] + [
|
|
834
|
+
(p, m) for p, m in neighbours if 0 <= p < n_p and 0 <= m < n_m
|
|
835
|
+
]
|
|
836
|
+
first_rest = rest
|
|
837
|
+
|
|
838
|
+
def _warm():
|
|
839
|
+
budget = self._region_cache_max_bytes // 2
|
|
840
|
+
for p, m in plan:
|
|
841
|
+
with self._region_lock:
|
|
842
|
+
if generation != self._region_generation:
|
|
843
|
+
return
|
|
844
|
+
try:
|
|
845
|
+
if self._file_matrix[p][m] is None:
|
|
846
|
+
continue
|
|
847
|
+
mapped = self._mapped_volume(p, m, self._get_volume(p, m))
|
|
848
|
+
if mapped is None or not _mapped_slice_is_scattered(mapped[probe + first_rest]):
|
|
849
|
+
continue
|
|
850
|
+
n_sp = min(3, mapped.ndim)
|
|
851
|
+
rests = list(np.ndindex(*mapped.shape[n_sp:])) or [()]
|
|
852
|
+
# The visible map first, then the case's others.
|
|
853
|
+
rests.sort(key=lambda r: r != first_rest)
|
|
854
|
+
for r in rests:
|
|
855
|
+
with self._region_lock:
|
|
856
|
+
if generation != self._region_generation:
|
|
857
|
+
return
|
|
858
|
+
view = self._region_view(mapped, tuple(int(i) for i in r))
|
|
859
|
+
budget -= view.nbytes
|
|
860
|
+
if budget < 0:
|
|
861
|
+
return
|
|
862
|
+
self._load_region((p, m, tuple(int(i) for i in r)), view)
|
|
863
|
+
except Exception:
|
|
864
|
+
continue
|
|
865
|
+
|
|
866
|
+
_region_pools()[1].submit(_warm)
|
|
867
|
+
|
|
868
|
+
|
|
565
869
|
# ---------------------------------------------------------------------------
|
|
566
870
|
# File series — lazy 4D/5D view over a directory of same-shape volumes
|
|
567
871
|
# ---------------------------------------------------------------------------
|
|
@@ -646,7 +950,7 @@ class _ProgressiveNiftiSeriesMixin:
|
|
|
646
950
|
return None
|
|
647
951
|
|
|
648
952
|
|
|
649
|
-
class _NiftiSeries(_ProgressiveNiftiSeriesMixin):
|
|
953
|
+
class _NiftiSeries(_ProgressiveNiftiSeriesMixin, _ScatteredRegionMixin):
|
|
650
954
|
"""Lazy 4D/5D view over a directory of same-shape NIfTI files.
|
|
651
955
|
|
|
652
956
|
``file_matrix`` is a P x M list-of-lists of filepaths (P patients,
|
|
@@ -682,6 +986,7 @@ class _NiftiSeries(_ProgressiveNiftiSeriesMixin):
|
|
|
682
986
|
self._cache_lock = threading.Lock()
|
|
683
987
|
self._load_locks = {}
|
|
684
988
|
self._init_progressive_loading()
|
|
989
|
+
self._init_region_cache()
|
|
685
990
|
|
|
686
991
|
@property
|
|
687
992
|
def dtype(self):
|
|
@@ -724,6 +1029,10 @@ class _NiftiSeries(_ProgressiveNiftiSeriesMixin):
|
|
|
724
1029
|
return vol
|
|
725
1030
|
|
|
726
1031
|
def __getitem__(self, key):
|
|
1032
|
+
return self.read_slice(key)
|
|
1033
|
+
|
|
1034
|
+
def read_slice(self, key, progress=None):
|
|
1035
|
+
"""``self[key]``; *progress* follows any whole-region read it needs."""
|
|
727
1036
|
if not isinstance(key, tuple):
|
|
728
1037
|
key = (key,)
|
|
729
1038
|
has_special = any(k is None or k is Ellipsis for k in key)
|
|
@@ -765,6 +1074,11 @@ class _NiftiSeries(_ProgressiveNiftiSeriesMixin):
|
|
|
765
1074
|
if plane is not None:
|
|
766
1075
|
return plane
|
|
767
1076
|
vol = self._get_volume(p_idx, m_idx)
|
|
1077
|
+
region = self._scattered_region_slice(
|
|
1078
|
+
p_idx, m_idx, vol, spatial_key, progress
|
|
1079
|
+
)
|
|
1080
|
+
if region is not None:
|
|
1081
|
+
return region
|
|
768
1082
|
return vol[spatial_key]
|
|
769
1083
|
return np.asarray(self)[key]
|
|
770
1084
|
|
|
@@ -873,7 +1187,7 @@ class _FileSeries:
|
|
|
873
1187
|
return result
|
|
874
1188
|
|
|
875
1189
|
|
|
876
|
-
class _RaggedFileSeries(_ProgressiveNiftiSeriesMixin):
|
|
1190
|
+
class _RaggedFileSeries(_ProgressiveNiftiSeriesMixin, _ScatteredRegionMixin):
|
|
877
1191
|
"""Lazy collection view over same-rank, same-dtype files with varying shapes.
|
|
878
1192
|
|
|
879
1193
|
The public ``shape`` is a max-spatial-shape facade so existing viewer
|
|
@@ -923,6 +1237,7 @@ class _RaggedFileSeries(_ProgressiveNiftiSeriesMixin):
|
|
|
923
1237
|
self._cache_lock = threading.Lock()
|
|
924
1238
|
self._load_locks = {}
|
|
925
1239
|
self._init_progressive_loading()
|
|
1240
|
+
self._init_region_cache()
|
|
926
1241
|
|
|
927
1242
|
@property
|
|
928
1243
|
def dtype(self):
|
|
@@ -1000,6 +1315,10 @@ class _RaggedFileSeries(_ProgressiveNiftiSeriesMixin):
|
|
|
1000
1315
|
return vol
|
|
1001
1316
|
|
|
1002
1317
|
def __getitem__(self, key):
|
|
1318
|
+
return self.read_slice(key)
|
|
1319
|
+
|
|
1320
|
+
def read_slice(self, key, progress=None):
|
|
1321
|
+
"""``self[key]``; *progress* follows any whole-region read it needs."""
|
|
1003
1322
|
if not isinstance(key, tuple):
|
|
1004
1323
|
key = (key,)
|
|
1005
1324
|
has_special = any(k is None or k is Ellipsis for k in key)
|
|
@@ -1040,7 +1359,14 @@ class _RaggedFileSeries(_ProgressiveNiftiSeriesMixin):
|
|
|
1040
1359
|
plane = self._get_progressive_plane(p_idx, m_idx, z_index)
|
|
1041
1360
|
if plane is not None:
|
|
1042
1361
|
return plane
|
|
1043
|
-
|
|
1362
|
+
vol = self._get_volume(p_idx, m_idx)
|
|
1363
|
+
if self._all_nifti:
|
|
1364
|
+
region = self._scattered_region_slice(
|
|
1365
|
+
p_idx, m_idx, vol, tuple(spatial_key), progress
|
|
1366
|
+
)
|
|
1367
|
+
if region is not None:
|
|
1368
|
+
return region
|
|
1369
|
+
return vol[tuple(spatial_key)]
|
|
1044
1370
|
raise TypeError("Ragged collections require concrete collection indices.")
|
|
1045
1371
|
|
|
1046
1372
|
def __array__(self, dtype=None):
|
|
@@ -1107,6 +1433,38 @@ def _collection_pattern_paths(pattern):
|
|
|
1107
1433
|
return paths
|
|
1108
1434
|
|
|
1109
1435
|
|
|
1436
|
+
def _explicit_case_files(patterns):
|
|
1437
|
+
"""Plain file paths given as several patterns, or None.
|
|
1438
|
+
|
|
1439
|
+
An unquoted ``--match dir/*/maps.nii`` reaches us already expanded by the
|
|
1440
|
+
shell: one literal path per case. Read as separate image patterns, that
|
|
1441
|
+
became one case with a channel per file — every patient on the channel
|
|
1442
|
+
axis, where patient navigation and prefetch never see them.
|
|
1443
|
+
"""
|
|
1444
|
+
if len(patterns) < 2:
|
|
1445
|
+
return None
|
|
1446
|
+
if any(_GLOB_MAGIC.search(str(p)) or not os.path.isfile(p) for p in patterns):
|
|
1447
|
+
return None
|
|
1448
|
+
paths = [os.path.abspath(p) for p in patterns if _is_supported_collection_path(p)]
|
|
1449
|
+
if len(paths) != len(patterns) or len(set(paths)) != len(paths):
|
|
1450
|
+
return None
|
|
1451
|
+
return sorted(paths)
|
|
1452
|
+
|
|
1453
|
+
|
|
1454
|
+
def _distinguishing_path_parts(paths):
|
|
1455
|
+
"""Name each path by the first path component in which the paths differ."""
|
|
1456
|
+
split = [os.path.normpath(p).split(os.sep) for p in paths]
|
|
1457
|
+
if len({len(parts) for parts in split}) == 1:
|
|
1458
|
+
for i in range(len(split[0])):
|
|
1459
|
+
names = [parts[i] for parts in split]
|
|
1460
|
+
if len(set(names)) == len(names):
|
|
1461
|
+
return [_strip_array_ext(n) if i == len(split[0]) - 1 else n for n in names]
|
|
1462
|
+
return [_default_collection_case_key(p) for p in paths]
|
|
1463
|
+
|
|
1464
|
+
|
|
1465
|
+
_GLOB_MAGIC = re.compile(r"[*?[]")
|
|
1466
|
+
|
|
1467
|
+
|
|
1110
1468
|
def _collection_pattern_map(pattern, case_regex):
|
|
1111
1469
|
paths = _collection_pattern_paths(pattern)
|
|
1112
1470
|
by_case = {}
|
|
@@ -1339,6 +1697,7 @@ def load_dir_collection(
|
|
|
1339
1697
|
overlays = overlays or []
|
|
1340
1698
|
|
|
1341
1699
|
inferred_case_depth = None
|
|
1700
|
+
explicit_cases = None
|
|
1342
1701
|
if case_regex:
|
|
1343
1702
|
base_maps = [
|
|
1344
1703
|
_collection_pattern_map(pattern, case_regex=case_regex)
|
|
@@ -1354,11 +1713,21 @@ def load_dir_collection(
|
|
|
1354
1713
|
raise ValueError("; ".join(missing))
|
|
1355
1714
|
base_matrix = [[by_case[case] for by_case in base_maps] for case in case_ids]
|
|
1356
1715
|
else:
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1716
|
+
explicit_cases = _explicit_case_files(base_patterns)
|
|
1717
|
+
base_lists = (
|
|
1718
|
+
[explicit_cases]
|
|
1719
|
+
if explicit_cases is not None
|
|
1720
|
+
else [_collection_pattern_paths(pattern) for pattern in base_patterns]
|
|
1360
1721
|
)
|
|
1361
|
-
|
|
1722
|
+
inferred_maps, inferred_case_depth = (
|
|
1723
|
+
(None, None)
|
|
1724
|
+
if explicit_cases is not None
|
|
1725
|
+
else _infer_collection_layout_maps(base_lists, base_patterns)
|
|
1726
|
+
)
|
|
1727
|
+
if explicit_cases is not None:
|
|
1728
|
+
case_ids = _distinguishing_path_parts(explicit_cases)
|
|
1729
|
+
base_matrix = [[path] for path in explicit_cases]
|
|
1730
|
+
elif inferred_maps is not None:
|
|
1362
1731
|
case_ids = sorted(inferred_maps[0])
|
|
1363
1732
|
base_matrix = [
|
|
1364
1733
|
[by_case[case] for by_case in inferred_maps] for case in case_ids
|
|
@@ -1490,7 +1859,11 @@ def load_dir_collection(
|
|
|
1490
1859
|
|
|
1491
1860
|
summary = {
|
|
1492
1861
|
"cases": case_ids,
|
|
1493
|
-
"base_patterns":
|
|
1862
|
+
"base_patterns": (
|
|
1863
|
+
[f"{len(explicit_cases)} files, one per case"]
|
|
1864
|
+
if not case_regex and explicit_cases is not None
|
|
1865
|
+
else list(base_patterns)
|
|
1866
|
+
),
|
|
1494
1867
|
"overlays": overlay_reports,
|
|
1495
1868
|
"shape": tuple(int(s) for s in data.shape),
|
|
1496
1869
|
"spatial_shape": tuple(int(s) for s in spatial_shape),
|
|
@@ -219,7 +219,12 @@ def extract_slice(session, dim_x, dim_y, idx_list, progress=None):
|
|
|
219
219
|
)
|
|
220
220
|
except Exception:
|
|
221
221
|
pass
|
|
222
|
-
|
|
222
|
+
if hasattr(data, "read_slice"):
|
|
223
|
+
# NIfTI collections read scattered slices as whole regions and report
|
|
224
|
+
# that read themselves; blocking it here would hide the wait.
|
|
225
|
+
extracted = np.array(data.read_slice(tuple(slicer), progress))
|
|
226
|
+
else:
|
|
227
|
+
extracted = _extract_with_progress(data, slicer, progress)
|
|
223
228
|
if dim_x < dim_y:
|
|
224
229
|
extracted = extracted.T
|
|
225
230
|
# scipy.io.loadmat returns complex arrays as structured dtypes; convert here.
|
|
@@ -54,6 +54,7 @@ from arrayview._config import (
|
|
|
54
54
|
get_viewer_dimbar_mode,
|
|
55
55
|
get_viewer_ortho_layout,
|
|
56
56
|
get_viewer_rounded_panes,
|
|
57
|
+
get_viewer_qmri_gapless,
|
|
57
58
|
get_viewer_theme,
|
|
58
59
|
)
|
|
59
60
|
|
|
@@ -482,6 +483,7 @@ def _viewer_ui_response(
|
|
|
482
483
|
.replace("__ARRAYVIEW_BOOT_METADATA__", _boot_metadata_json(sid))
|
|
483
484
|
.replace("__DEFAULT_THEME_IDX__", str(_default_theme_idx))
|
|
484
485
|
.replace("__DEFAULT_ROUNDED_PANES__", _default_rounded_panes)
|
|
486
|
+
.replace("__DEFAULT_QMRI_GAPLESS__", "true" if get_viewer_qmri_gapless() else "false")
|
|
485
487
|
.replace("__DEFAULT_ORTHO_LAYOUT__", _default_ortho_layout)
|
|
486
488
|
.replace("__DEFAULT_DIMBAR_MODE__", _default_dimbar_mode)
|
|
487
489
|
.replace("__BODY_CLASS__", "av-loading" if sid else "")
|