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.
Files changed (87) hide show
  1. {arrayview-0.48.0 → arrayview-0.48.1}/PKG-INFO +1 -1
  2. {arrayview-0.48.0 → arrayview-0.48.1}/pyproject.toml +1 -1
  3. {arrayview-0.48.0 → arrayview-0.48.1}/src/arrayview/_config.py +10 -2
  4. {arrayview-0.48.0 → arrayview-0.48.1}/src/arrayview/_io.py +382 -9
  5. {arrayview-0.48.0 → arrayview-0.48.1}/src/arrayview/_render.py +6 -1
  6. {arrayview-0.48.0 → arrayview-0.48.1}/src/arrayview/_server.py +2 -0
  7. {arrayview-0.48.0 → arrayview-0.48.1}/src/arrayview/_viewer.html +144 -14
  8. {arrayview-0.48.0 → arrayview-0.48.1}/tests/test_config.py +8 -0
  9. {arrayview-0.48.0 → arrayview-0.48.1}/tests/test_nifti_series.py +75 -2
  10. {arrayview-0.48.0 → arrayview-0.48.1}/.gitignore +0 -0
  11. {arrayview-0.48.0 → arrayview-0.48.1}/LICENSE +0 -0
  12. {arrayview-0.48.0 → arrayview-0.48.1}/README.md +0 -0
  13. {arrayview-0.48.0 → arrayview-0.48.1}/src/arrayview/ARCHITECTURE.md +0 -0
  14. {arrayview-0.48.0 → arrayview-0.48.1}/src/arrayview/__init__.py +0 -0
  15. {arrayview-0.48.0 → arrayview-0.48.1}/src/arrayview/__main__.py +0 -0
  16. {arrayview-0.48.0 → arrayview-0.48.1}/src/arrayview/_analysis.py +0 -0
  17. {arrayview-0.48.0 → arrayview-0.48.1}/src/arrayview/_app.py +0 -0
  18. {arrayview-0.48.0 → arrayview-0.48.1}/src/arrayview/_bootstrap_app.py +0 -0
  19. {arrayview-0.48.0 → arrayview-0.48.1}/src/arrayview/_diagnostics.py +0 -0
  20. {arrayview-0.48.0 → arrayview-0.48.1}/src/arrayview/_dicom.py +0 -0
  21. {arrayview-0.48.0 → arrayview-0.48.1}/src/arrayview/_diff.py +0 -0
  22. {arrayview-0.48.0 → arrayview-0.48.1}/src/arrayview/_extra_ports.py +0 -0
  23. {arrayview-0.48.0 → arrayview-0.48.1}/src/arrayview/_icon.png +0 -0
  24. {arrayview-0.48.0 → arrayview-0.48.1}/src/arrayview/_imaging.py +0 -0
  25. {arrayview-0.48.0 → arrayview-0.48.1}/src/arrayview/_instance_registry.py +0 -0
  26. {arrayview-0.48.0 → arrayview-0.48.1}/src/arrayview/_launch_plan.py +0 -0
  27. {arrayview-0.48.0 → arrayview-0.48.1}/src/arrayview/_launch_trace.py +0 -0
  28. {arrayview-0.48.0 → arrayview-0.48.1}/src/arrayview/_launcher.py +0 -0
  29. {arrayview-0.48.0 → arrayview-0.48.1}/src/arrayview/_lifecycle.py +0 -0
  30. {arrayview-0.48.0 → arrayview-0.48.1}/src/arrayview/_overlays.py +0 -0
  31. {arrayview-0.48.0 → arrayview-0.48.1}/src/arrayview/_platform.py +0 -0
  32. {arrayview-0.48.0 → arrayview-0.48.1}/src/arrayview/_routes_analysis.py +0 -0
  33. {arrayview-0.48.0 → arrayview-0.48.1}/src/arrayview/_routes_drop.py +0 -0
  34. {arrayview-0.48.0 → arrayview-0.48.1}/src/arrayview/_routes_export.py +0 -0
  35. {arrayview-0.48.0 → arrayview-0.48.1}/src/arrayview/_routes_loading.py +0 -0
  36. {arrayview-0.48.0 → arrayview-0.48.1}/src/arrayview/_routes_persistence.py +0 -0
  37. {arrayview-0.48.0 → arrayview-0.48.1}/src/arrayview/_routes_preferences.py +0 -0
  38. {arrayview-0.48.0 → arrayview-0.48.1}/src/arrayview/_routes_preload.py +0 -0
  39. {arrayview-0.48.0 → arrayview-0.48.1}/src/arrayview/_routes_query.py +0 -0
  40. {arrayview-0.48.0 → arrayview-0.48.1}/src/arrayview/_routes_rendering.py +0 -0
  41. {arrayview-0.48.0 → arrayview-0.48.1}/src/arrayview/_routes_segmentation.py +0 -0
  42. {arrayview-0.48.0 → arrayview-0.48.1}/src/arrayview/_routes_state.py +0 -0
  43. {arrayview-0.48.0 → arrayview-0.48.1}/src/arrayview/_routes_vectorfield.py +0 -0
  44. {arrayview-0.48.0 → arrayview-0.48.1}/src/arrayview/_routes_websocket.py +0 -0
  45. {arrayview-0.48.0 → arrayview-0.48.1}/src/arrayview/_segmentation.py +0 -0
  46. {arrayview-0.48.0 → arrayview-0.48.1}/src/arrayview/_session.py +0 -0
  47. {arrayview-0.48.0 → arrayview-0.48.1}/src/arrayview/_session_spec.py +0 -0
  48. {arrayview-0.48.0 → arrayview-0.48.1}/src/arrayview/_shell.html +0 -0
  49. {arrayview-0.48.0 → arrayview-0.48.1}/src/arrayview/_source_safety.py +0 -0
  50. {arrayview-0.48.0 → arrayview-0.48.1}/src/arrayview/_synthetic_mri.py +0 -0
  51. {arrayview-0.48.0 → arrayview-0.48.1}/src/arrayview/_torch.py +0 -0
  52. {arrayview-0.48.0 → arrayview-0.48.1}/src/arrayview/_tutorial.py +0 -0
  53. {arrayview-0.48.0 → arrayview-0.48.1}/src/arrayview/_vectorfield.py +0 -0
  54. {arrayview-0.48.0 → arrayview-0.48.1}/src/arrayview/_vscode.py +0 -0
  55. {arrayview-0.48.0 → arrayview-0.48.1}/src/arrayview/_vscode_browser.py +0 -0
  56. {arrayview-0.48.0 → arrayview-0.48.1}/src/arrayview/_vscode_extension.py +0 -0
  57. {arrayview-0.48.0 → arrayview-0.48.1}/src/arrayview/_vscode_signal.py +0 -0
  58. {arrayview-0.48.0 → arrayview-0.48.1}/src/arrayview/_watch.py +0 -0
  59. {arrayview-0.48.0 → arrayview-0.48.1}/src/arrayview/arrayview-opener.vsix +0 -0
  60. {arrayview-0.48.0 → arrayview-0.48.1}/src/arrayview/gsap.min.js +0 -0
  61. {arrayview-0.48.0 → arrayview-0.48.1}/src/arrayview/todo.md +0 -0
  62. {arrayview-0.48.0 → arrayview-0.48.1}/tests/capture_patient_loading.py +0 -0
  63. {arrayview-0.48.0 → arrayview-0.48.1}/tests/capture_v_animation.py +0 -0
  64. {arrayview-0.48.0 → arrayview-0.48.1}/tests/conftest.py +0 -0
  65. {arrayview-0.48.0 → arrayview-0.48.1}/tests/make_vectorfield_test_arrays.py +0 -0
  66. {arrayview-0.48.0 → arrayview-0.48.1}/tests/test_1d_arrays.py +0 -0
  67. {arrayview-0.48.0 → arrayview-0.48.1}/tests/test_api.py +0 -0
  68. {arrayview-0.48.0 → arrayview-0.48.1}/tests/test_backend_shared.py +0 -0
  69. {arrayview-0.48.0 → arrayview-0.48.1}/tests/test_browser.py +0 -0
  70. {arrayview-0.48.0 → arrayview-0.48.1}/tests/test_cli.py +0 -0
  71. {arrayview-0.48.0 → arrayview-0.48.1}/tests/test_dicom.py +0 -0
  72. {arrayview-0.48.0 → arrayview-0.48.1}/tests/test_drop_import.py +0 -0
  73. {arrayview-0.48.0 → arrayview-0.48.1}/tests/test_instance_registry.py +0 -0
  74. {arrayview-0.48.0 → arrayview-0.48.1}/tests/test_io.py +0 -0
  75. {arrayview-0.48.0 → arrayview-0.48.1}/tests/test_large_arrays.py +0 -0
  76. {arrayview-0.48.0 → arrayview-0.48.1}/tests/test_launch_blackbox.py +0 -0
  77. {arrayview-0.48.0 → arrayview-0.48.1}/tests/test_mode_roundtrip.py +0 -0
  78. {arrayview-0.48.0 → arrayview-0.48.1}/tests/test_nifti_meta.py +0 -0
  79. {arrayview-0.48.0 → arrayview-0.48.1}/tests/test_rgb_pixel_art.py +0 -0
  80. {arrayview-0.48.0 → arrayview-0.48.1}/tests/test_safe_server_stop.py +0 -0
  81. {arrayview-0.48.0 → arrayview-0.48.1}/tests/test_source_safety.py +0 -0
  82. {arrayview-0.48.0 → arrayview-0.48.1}/tests/test_torch.py +0 -0
  83. {arrayview-0.48.0 → arrayview-0.48.1}/tests/test_tutorial.py +0 -0
  84. {arrayview-0.48.0 → arrayview-0.48.1}/tests/test_vsix_bundle.py +0 -0
  85. {arrayview-0.48.0 → arrayview-0.48.1}/tests/ui_audit.py +0 -0
  86. {arrayview-0.48.0 → arrayview-0.48.1}/tests/v_anim_frames/.gitkeep +0 -0
  87. {arrayview-0.48.0 → arrayview-0.48.1}/tests/visual_smoke.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: arrayview
3
- Version: 0.48.0
3
+ Version: 0.48.1
4
4
  Summary: Fast multi-dimensional array viewer
5
5
  Project-URL: Home, https://github.com/oscarvanderheide/arrayview
6
6
  Project-URL: Source, https://github.com/oscarvanderheide/arrayview
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "arrayview"
7
- version = "0.48.0"
7
+ version = "0.48.1"
8
8
  description = "Fast multi-dimensional array viewer"
9
9
  readme = { file = "README.md", content-type = "text/markdown" }
10
10
  requires-python = ">=3.12"
@@ -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 == "rounded_panes":
123
+ if section == "viewer" and key in ("rounded_panes", "qmri_gapless"):
122
124
  if not isinstance(value, bool):
123
- raise InvalidPreferenceError("viewer.rounded_panes must be a boolean")
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
- return self._get_volume(p_idx, m_idx)[tuple(spatial_key)]
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
- base_lists = [_collection_pattern_paths(pattern) for pattern in base_patterns]
1358
- inferred_maps, inferred_case_depth = _infer_collection_layout_maps(
1359
- base_lists, base_patterns
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
- if inferred_maps is not None:
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": list(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
- extracted = _extract_with_progress(data, slicer, progress)
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 "")