arrayview 0.47.3__tar.gz → 0.47.5__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 (89) hide show
  1. {arrayview-0.47.3 → arrayview-0.47.5}/PKG-INFO +1 -1
  2. {arrayview-0.47.3 → arrayview-0.47.5}/pyproject.toml +1 -1
  3. {arrayview-0.47.3 → arrayview-0.47.5}/src/arrayview/_io.py +418 -48
  4. {arrayview-0.47.3 → arrayview-0.47.5}/src/arrayview/_launcher.py +13 -11
  5. {arrayview-0.47.3 → arrayview-0.47.5}/src/arrayview/_render.py +11 -1
  6. {arrayview-0.47.3 → arrayview-0.47.5}/src/arrayview/_routes_loading.py +12 -11
  7. {arrayview-0.47.3 → arrayview-0.47.5}/src/arrayview/_routes_query.py +1 -0
  8. {arrayview-0.47.3 → arrayview-0.47.5}/src/arrayview/_session.py +10 -7
  9. {arrayview-0.47.3 → arrayview-0.47.5}/src/arrayview/_source_safety.py +2 -0
  10. {arrayview-0.47.3 → arrayview-0.47.5}/src/arrayview/_viewer.html +83 -13
  11. {arrayview-0.47.3 → arrayview-0.47.5}/src/arrayview/_vscode_extension.py +1 -1
  12. arrayview-0.47.5/src/arrayview/arrayview-opener.vsix +0 -0
  13. {arrayview-0.47.3 → arrayview-0.47.5}/tests/test_api.py +26 -2
  14. {arrayview-0.47.3 → arrayview-0.47.5}/tests/test_cli.py +28 -0
  15. arrayview-0.47.5/tests/test_io.py +264 -0
  16. arrayview-0.47.3/src/arrayview/arrayview-opener.vsix +0 -0
  17. arrayview-0.47.3/tests/test_io.py +0 -53
  18. {arrayview-0.47.3 → arrayview-0.47.5}/.gitignore +0 -0
  19. {arrayview-0.47.3 → arrayview-0.47.5}/LICENSE +0 -0
  20. {arrayview-0.47.3 → arrayview-0.47.5}/README.md +0 -0
  21. {arrayview-0.47.3 → arrayview-0.47.5}/src/arrayview/ARCHITECTURE.md +0 -0
  22. {arrayview-0.47.3 → arrayview-0.47.5}/src/arrayview/__init__.py +0 -0
  23. {arrayview-0.47.3 → arrayview-0.47.5}/src/arrayview/__main__.py +0 -0
  24. {arrayview-0.47.3 → arrayview-0.47.5}/src/arrayview/_analysis.py +0 -0
  25. {arrayview-0.47.3 → arrayview-0.47.5}/src/arrayview/_app.py +0 -0
  26. {arrayview-0.47.3 → arrayview-0.47.5}/src/arrayview/_bootstrap_app.py +0 -0
  27. {arrayview-0.47.3 → arrayview-0.47.5}/src/arrayview/_config.py +0 -0
  28. {arrayview-0.47.3 → arrayview-0.47.5}/src/arrayview/_diagnostics.py +0 -0
  29. {arrayview-0.47.3 → arrayview-0.47.5}/src/arrayview/_dicom.py +0 -0
  30. {arrayview-0.47.3 → arrayview-0.47.5}/src/arrayview/_diff.py +0 -0
  31. {arrayview-0.47.3 → arrayview-0.47.5}/src/arrayview/_extra_ports.py +0 -0
  32. {arrayview-0.47.3 → arrayview-0.47.5}/src/arrayview/_icon.png +0 -0
  33. {arrayview-0.47.3 → arrayview-0.47.5}/src/arrayview/_imaging.py +0 -0
  34. {arrayview-0.47.3 → arrayview-0.47.5}/src/arrayview/_instance_registry.py +0 -0
  35. {arrayview-0.47.3 → arrayview-0.47.5}/src/arrayview/_launch_plan.py +0 -0
  36. {arrayview-0.47.3 → arrayview-0.47.5}/src/arrayview/_launch_trace.py +0 -0
  37. {arrayview-0.47.3 → arrayview-0.47.5}/src/arrayview/_lifecycle.py +0 -0
  38. {arrayview-0.47.3 → arrayview-0.47.5}/src/arrayview/_overlays.py +0 -0
  39. {arrayview-0.47.3 → arrayview-0.47.5}/src/arrayview/_platform.py +0 -0
  40. {arrayview-0.47.3 → arrayview-0.47.5}/src/arrayview/_routes_analysis.py +0 -0
  41. {arrayview-0.47.3 → arrayview-0.47.5}/src/arrayview/_routes_drop.py +0 -0
  42. {arrayview-0.47.3 → arrayview-0.47.5}/src/arrayview/_routes_export.py +0 -0
  43. {arrayview-0.47.3 → arrayview-0.47.5}/src/arrayview/_routes_persistence.py +0 -0
  44. {arrayview-0.47.3 → arrayview-0.47.5}/src/arrayview/_routes_preferences.py +0 -0
  45. {arrayview-0.47.3 → arrayview-0.47.5}/src/arrayview/_routes_preload.py +0 -0
  46. {arrayview-0.47.3 → arrayview-0.47.5}/src/arrayview/_routes_rendering.py +0 -0
  47. {arrayview-0.47.3 → arrayview-0.47.5}/src/arrayview/_routes_segmentation.py +0 -0
  48. {arrayview-0.47.3 → arrayview-0.47.5}/src/arrayview/_routes_state.py +0 -0
  49. {arrayview-0.47.3 → arrayview-0.47.5}/src/arrayview/_routes_vectorfield.py +0 -0
  50. {arrayview-0.47.3 → arrayview-0.47.5}/src/arrayview/_routes_websocket.py +0 -0
  51. {arrayview-0.47.3 → arrayview-0.47.5}/src/arrayview/_segmentation.py +0 -0
  52. {arrayview-0.47.3 → arrayview-0.47.5}/src/arrayview/_server.py +0 -0
  53. {arrayview-0.47.3 → arrayview-0.47.5}/src/arrayview/_session_spec.py +0 -0
  54. {arrayview-0.47.3 → arrayview-0.47.5}/src/arrayview/_shell.html +0 -0
  55. {arrayview-0.47.3 → arrayview-0.47.5}/src/arrayview/_synthetic_mri.py +0 -0
  56. {arrayview-0.47.3 → arrayview-0.47.5}/src/arrayview/_torch.py +0 -0
  57. {arrayview-0.47.3 → arrayview-0.47.5}/src/arrayview/_tutorial.py +0 -0
  58. {arrayview-0.47.3 → arrayview-0.47.5}/src/arrayview/_vectorfield.py +0 -0
  59. {arrayview-0.47.3 → arrayview-0.47.5}/src/arrayview/_vscode.py +0 -0
  60. {arrayview-0.47.3 → arrayview-0.47.5}/src/arrayview/_vscode_browser.py +0 -0
  61. {arrayview-0.47.3 → arrayview-0.47.5}/src/arrayview/_vscode_signal.py +0 -0
  62. {arrayview-0.47.3 → arrayview-0.47.5}/src/arrayview/_watch.py +0 -0
  63. {arrayview-0.47.3 → arrayview-0.47.5}/src/arrayview/gsap.min.js +0 -0
  64. {arrayview-0.47.3 → arrayview-0.47.5}/src/arrayview/todo.md +0 -0
  65. {arrayview-0.47.3 → arrayview-0.47.5}/tests/capture_patient_loading.py +0 -0
  66. {arrayview-0.47.3 → arrayview-0.47.5}/tests/capture_v_animation.py +0 -0
  67. {arrayview-0.47.3 → arrayview-0.47.5}/tests/conftest.py +0 -0
  68. {arrayview-0.47.3 → arrayview-0.47.5}/tests/make_vectorfield_test_arrays.py +0 -0
  69. {arrayview-0.47.3 → arrayview-0.47.5}/tests/test_1d_arrays.py +0 -0
  70. {arrayview-0.47.3 → arrayview-0.47.5}/tests/test_backend_shared.py +0 -0
  71. {arrayview-0.47.3 → arrayview-0.47.5}/tests/test_browser.py +0 -0
  72. {arrayview-0.47.3 → arrayview-0.47.5}/tests/test_config.py +0 -0
  73. {arrayview-0.47.3 → arrayview-0.47.5}/tests/test_dicom.py +0 -0
  74. {arrayview-0.47.3 → arrayview-0.47.5}/tests/test_drop_import.py +0 -0
  75. {arrayview-0.47.3 → arrayview-0.47.5}/tests/test_instance_registry.py +0 -0
  76. {arrayview-0.47.3 → arrayview-0.47.5}/tests/test_large_arrays.py +0 -0
  77. {arrayview-0.47.3 → arrayview-0.47.5}/tests/test_launch_blackbox.py +0 -0
  78. {arrayview-0.47.3 → arrayview-0.47.5}/tests/test_mode_roundtrip.py +0 -0
  79. {arrayview-0.47.3 → arrayview-0.47.5}/tests/test_nifti_meta.py +0 -0
  80. {arrayview-0.47.3 → arrayview-0.47.5}/tests/test_nifti_series.py +0 -0
  81. {arrayview-0.47.3 → arrayview-0.47.5}/tests/test_rgb_pixel_art.py +0 -0
  82. {arrayview-0.47.3 → arrayview-0.47.5}/tests/test_safe_server_stop.py +0 -0
  83. {arrayview-0.47.3 → arrayview-0.47.5}/tests/test_source_safety.py +0 -0
  84. {arrayview-0.47.3 → arrayview-0.47.5}/tests/test_torch.py +0 -0
  85. {arrayview-0.47.3 → arrayview-0.47.5}/tests/test_tutorial.py +0 -0
  86. {arrayview-0.47.3 → arrayview-0.47.5}/tests/test_vsix_bundle.py +0 -0
  87. {arrayview-0.47.3 → arrayview-0.47.5}/tests/ui_audit.py +0 -0
  88. {arrayview-0.47.3 → arrayview-0.47.5}/tests/v_anim_frames/.gitkeep +0 -0
  89. {arrayview-0.47.3 → arrayview-0.47.5}/tests/visual_smoke.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: arrayview
3
- Version: 0.47.3
3
+ Version: 0.47.5
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.47.3"
7
+ version = "0.47.5"
8
8
  description = "Fast multi-dimensional array viewer"
9
9
  readme = { file = "README.md", content-type = "text/markdown" }
10
10
  requires-python = ">=3.12"
@@ -3,11 +3,14 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import fnmatch
6
+ import functools
6
7
  import gzip
7
8
  import glob
8
9
  import os
9
10
  import re
11
+ import sys
10
12
  import threading
13
+ import zlib
11
14
  from concurrent.futures import ThreadPoolExecutor
12
15
  from collections import OrderedDict
13
16
 
@@ -114,6 +117,23 @@ def list_npz_keys(filepath):
114
117
  return keys
115
118
 
116
119
 
120
+ # MATLAB class -> dtype scipy.io.loadmat returns for it (complex is not
121
+ # visible in the header; the picker only shows this when several arrays exist).
122
+ _MAT_CLASS_DTYPES = {
123
+ "double": "float64",
124
+ "single": "float32",
125
+ "int8": "int8",
126
+ "uint8": "uint8",
127
+ "int16": "int16",
128
+ "uint16": "uint16",
129
+ "int32": "int32",
130
+ "uint32": "uint32",
131
+ "int64": "int64",
132
+ "uint64": "uint64",
133
+ "logical": "uint8",
134
+ }
135
+
136
+
117
137
  def list_mat_keys(filepath):
118
138
  """Return [{key, shape, dtype}] for each array in a .mat file.
119
139
 
@@ -123,6 +143,17 @@ def list_mat_keys(filepath):
123
143
  try:
124
144
  import scipy.io
125
145
 
146
+ # The headers alone settle the common single-array file, whose array
147
+ # is loaded right after this anyway; reading it here too doubled the
148
+ # wait on network shares.
149
+ numeric = [
150
+ (name, shape, cls)
151
+ for name, shape, cls in scipy.io.whosmat(filepath)
152
+ if cls in _MAT_CLASS_DTYPES
153
+ ]
154
+ if len(numeric) == 1:
155
+ name, shape, cls = numeric[0]
156
+ return [{"key": name, "shape": list(shape), "dtype": _MAT_CLASS_DTYPES[cls]}]
126
157
  mat = scipy.io.loadmat(filepath)
127
158
  keys = []
128
159
  for k, v in mat.items():
@@ -435,12 +466,100 @@ def _load_nifti_with_meta(filepath):
435
466
  random-access, but it is no longer silently reordered.
436
467
  """
437
468
  img, meta = _nifti_header_with_meta(filepath, native=True)
438
- arr = (
439
- img.dataobj
440
- if filepath.endswith(".nii")
441
- else _nifti_display_array(img.dataobj)
442
- )
443
- return arr, meta
469
+ if filepath.endswith(".nii"):
470
+ return img.dataobj, meta
471
+ source = img
472
+ if _nifti_voxel_bytes(img) <= npy_eager_limit():
473
+ raw = read_gzip_file_parallel(filepath)
474
+ if raw is not None:
475
+ try:
476
+ source = type(img).from_bytes(raw)
477
+ except Exception:
478
+ source = img
479
+ return _nifti_display_array(source.dataobj), meta
480
+
481
+
482
+ def _nifti_voxel_bytes(img) -> int:
483
+ try:
484
+ return int(np.prod(img.shape)) * np.dtype(img.get_data_dtype()).itemsize
485
+ except Exception:
486
+ return 1 << 62
487
+
488
+
489
+ _GZIP_READ_CHUNK_BYTES = 8 << 20
490
+ _GZIP_READ_THREADS = 8
491
+
492
+
493
+ def read_gzip_file_parallel(filepath):
494
+ """Decompress a whole .gz file, reading it with several reads in flight.
495
+
496
+ A gzip stream can only be decoded front to back, so the decode is one
497
+ thread; but the bytes can arrive out of order. Reading 8 MB chunks in
498
+ parallel and decoding each as soon as its turn comes overlaps the two, so
499
+ on an SMB share the wait is close to the parallel read alone (~1.3 s for a
500
+ 110 MB file) instead of a single slow reader followed by the decode.
501
+ Handles multi-member files. Returns None on any read or decode error so
502
+ the caller can fall back to its ordinary reader.
503
+ """
504
+ try:
505
+ size = os.path.getsize(filepath)
506
+ fd = os.open(filepath, os.O_RDONLY)
507
+ except OSError:
508
+ return None
509
+ buf = bytearray(size)
510
+ view = memoryview(buf)
511
+ starts = list(range(0, size, _GZIP_READ_CHUNK_BYTES))
512
+ ready = [threading.Event() for _ in starts]
513
+ failed = []
514
+
515
+ def _read(i):
516
+ start = starts[i]
517
+ stop = min(start + _GZIP_READ_CHUNK_BYTES, size)
518
+ pos = start
519
+ try:
520
+ while pos < stop:
521
+ n = os.preadv(fd, [view[pos:stop]], pos)
522
+ if n <= 0:
523
+ raise OSError("unexpected end of file")
524
+ pos += n
525
+ except Exception as exc: # noqa: BLE001 - reported to the decoder
526
+ failed.append(exc)
527
+ finally:
528
+ ready[i].set()
529
+
530
+ out = []
531
+ try:
532
+ with ThreadPoolExecutor(
533
+ max_workers=max(1, min(_GZIP_READ_THREADS, len(starts))),
534
+ thread_name_prefix="arrayview-gzip-read",
535
+ ) as pool:
536
+ for i in range(len(starts)):
537
+ pool.submit(_read, i)
538
+ decoder = zlib.decompressobj(wbits=31)
539
+ fed = False
540
+ for i, start in enumerate(starts):
541
+ ready[i].wait()
542
+ if failed:
543
+ return None
544
+ chunk = view[start:min(start + _GZIP_READ_CHUNK_BYTES, size)]
545
+ while chunk:
546
+ fed = True
547
+ out.append(decoder.decompress(chunk))
548
+ if not decoder.eof:
549
+ break
550
+ # End of one gzip member: any rest starts the next one.
551
+ chunk = decoder.unused_data
552
+ decoder = zlib.decompressobj(wbits=31)
553
+ fed = False
554
+ if fed and not decoder.eof:
555
+ return None # truncated: let the ordinary reader report it
556
+ if not out:
557
+ return None
558
+ except (zlib.error, OSError):
559
+ return None
560
+ finally:
561
+ os.close(fd)
562
+ return b"".join(out)
444
563
 
445
564
 
446
565
  # ---------------------------------------------------------------------------
@@ -667,7 +786,7 @@ class _FileSeries:
667
786
  """Lazy 4D/5D view over a directory of same-shape array files.
668
787
 
669
788
  Like ``_NiftiSeries`` but works with any supported file format
670
- (.npy, .npz, .zarr, .pt/.pth, .h5/.hdf5, .tif/.tiff, .mat).
789
+ (.npy, .npz, .zarr, .pt/.pth, .h5/.hdf5, .tif/.tiff, .mat, .gipl).
671
790
  Uses ``load_data`` for each volume — no nibabel reorientation.
672
791
  """
673
792
 
@@ -940,6 +1059,8 @@ def _get_ext(filepath):
940
1059
  return ".nii.gz"
941
1060
  if lower.endswith(".zarr.zip"):
942
1061
  return ".zarr.zip"
1062
+ if lower.endswith(".gipl.gz"):
1063
+ return ".gipl.gz"
943
1064
  return os.path.splitext(lower)[1]
944
1065
 
945
1066
 
@@ -950,7 +1071,7 @@ def _is_supported_collection_path(filepath):
950
1071
  def _strip_array_ext(filepath):
951
1072
  base = os.path.basename(filepath)
952
1073
  lower = base.lower()
953
- for suffix in (".nii.gz", ".zarr.zip"):
1074
+ for suffix in (".nii.gz", ".zarr.zip", ".gipl.gz"):
954
1075
  if lower.endswith(suffix):
955
1076
  return base[: -len(suffix)]
956
1077
  return os.path.splitext(base)[0]
@@ -1599,41 +1720,220 @@ def mmap_first_frame_is_cheap(arr, total_bytes) -> bool:
1599
1720
  return runs <= max_runs and faulted <= fraction * total_bytes
1600
1721
 
1601
1722
 
1602
- def _read_npy_reporting_progress(mapped, progress):
1603
- """Copy a memmapped .npy into RAM in chunks, saying how far along it is.
1723
+ # Fetching the first frame's pages one by one beats reading the whole file
1724
+ # only on a local disk: on an SMB share the same 65k small reads of a 1.6 GB
1725
+ # file took 11 s against 0.7 s locally.
1726
+ _FIRST_FRAME_PREFETCH_MAX_PAGES = 1 << 17
1727
+ _FIRST_FRAME_PREFETCH_THREADS = 16
1604
1728
 
1605
- For a large array this read is the whole wait between a click and the
1606
- first frame — 9 s for a 1.6 GB file on a cold cache — and ``np.load``
1607
- reports nothing while it runs. Copying in pieces costs the same reads
1608
- and makes the wait explainable.
1609
1729
 
1610
- The flat view follows the array's own memory order, so the copy stays
1611
- sequential whether the file is C- or Fortran-ordered. A .npy memmap is
1612
- always contiguous in one of the two, so both reshapes are views.
1730
+ def _on_known_local_disk(filepath) -> bool:
1731
+ if not sys.platform.startswith("linux"):
1732
+ return False
1733
+ try:
1734
+ from ._source_safety import _KNOWN_LOCAL_STAGE_FILESYSTEMS, mount_for_path
1735
+
1736
+ mount = mount_for_path(os.path.realpath(filepath))
1737
+ except Exception:
1738
+ return False
1739
+ return mount is not None and mount.filesystem in _KNOWN_LOCAL_STAGE_FILESYSTEMS
1740
+
1741
+
1742
+ def _viewer_start_index(shape, dims):
1743
+ """The slice the viewer opens on; mirrors its ``_initialIndices``."""
1744
+ ref = max(shape[dims[0]], shape[dims[1]])
1745
+ return tuple(
1746
+ size // 2 if (i in dims or size >= 0.25 * ref) else 0
1747
+ for i, size in enumerate(shape)
1748
+ )
1749
+
1750
+
1751
+ def prefetch_first_frame_pages(mapped, filepath) -> bool:
1752
+ """Pull just the pages of the viewer's first frame into memory, in parallel.
1753
+
1754
+ When the first frame is one element per page across the whole file (a
1755
+ C-order array viewed along its leading axes), reading the file first made
1756
+ a 1.6 GB array wait 3-4 s cold for its picture. Fetching only the pages the
1757
+ frame touches, many at a time, takes ~0.7 s; the memmap then renders the
1758
+ frame from memory while the full read continues in the background.
1759
+
1760
+ Returns False, having read nothing, when this would not pay off: not a
1761
+ known local disk, the viewer would start elsewhere, or too many pages.
1613
1762
  """
1614
- order = "F" if mapped.flags.f_contiguous and not mapped.flags.c_contiguous else "C"
1615
- out = np.empty(mapped.shape, dtype=mapped.dtype, order=order)
1616
- src = mapped.reshape(-1, order="A")
1617
- dst = out.reshape(-1, order="A")
1618
- total = int(src.shape[0])
1619
- itemsize = int(mapped.dtype.itemsize)
1620
- # ~200 updates: smooth enough to read as motion, coarse enough that the
1621
- # per-chunk overhead stays lost in the read itself.
1622
- step = max(1, total // 200)
1623
- done = 0
1624
- while done < total:
1625
- end = min(done + step, total)
1626
- dst[done:end] = src[done:end]
1627
- done = end
1763
+ from ._session import _default_start_dims_for_data, _viewer_start_dims_for_shape
1764
+
1765
+ shape = tuple(int(s) for s in mapped.shape)
1766
+ if len(shape) < 3 or _default_start_dims_for_data(mapped) is not None:
1767
+ return False
1768
+ dims = _viewer_start_dims_for_shape(shape)
1769
+ if dims is None:
1770
+ return False
1771
+ return prefetch_slice_pages(mapped, filepath, dims, _viewer_start_index(shape, dims))
1772
+
1773
+
1774
+ @functools.lru_cache(maxsize=64)
1775
+ def _on_known_local_disk_cached(filepath) -> bool:
1776
+ return _on_known_local_disk(filepath)
1777
+
1778
+
1779
+ def prefetch_slice_pages(mapped, filepath, dims, index) -> bool:
1780
+ """Fetch the pages of one 2-D slice of a memmap with parallel reads.
1781
+
1782
+ Faulting such a slice in through the map is the slow way: readahead drags
1783
+ in the pages between the wanted elements (6 s for one slice of a 1.6 GB
1784
+ C-order file), and without readahead each page is its own wait (7.7 s).
1785
+ Many small reads in flight take ~0.7 s on a local disk. Only worth it when
1786
+ the slice is scattered one element per page; returns False otherwise, or
1787
+ off a known local disk, having read nothing.
1788
+ """
1789
+ shape = tuple(int(s) for s in mapped.shape)
1790
+ strides = tuple(int(s) for s in mapped.strides)
1791
+ runs, _faulted = first_frame_fault_cost(
1792
+ shape, mapped.dtype.itemsize, strides, dims, mapped.nbytes
1793
+ )
1794
+ if runs <= 1024 or not _on_known_local_disk_cached(str(filepath)):
1795
+ return False
1796
+ base = int(mapped.offset) + sum(
1797
+ int(index[i]) * strides[i] for i in range(len(shape)) if i not in dims
1798
+ )
1799
+ rows = np.arange(shape[dims[0]], dtype=np.int64) * strides[dims[0]]
1800
+ cols = np.arange(shape[dims[1]], dtype=np.int64) * strides[dims[1]]
1801
+ starts = (base + rows[:, None] + cols[None, :]).ravel()
1802
+ ends = starts + (mapped.dtype.itemsize - 1)
1803
+ pages = np.unique(np.concatenate((starts, ends)) // _MMAP_PAGE_BYTES)
1804
+ if pages.size > _FIRST_FRAME_PREFETCH_MAX_PAGES:
1805
+ return False
1806
+
1807
+ try:
1808
+ fd = os.open(filepath, os.O_RDONLY)
1809
+ except OSError:
1810
+ return False
1811
+ try:
1812
+ # Only the pages asked for: readahead around each would multiply the I/O.
1628
1813
  try:
1629
- progress(done * itemsize, total * itemsize)
1630
- except Exception:
1814
+ os.posix_fadvise(fd, 0, 0, os.POSIX_FADV_RANDOM)
1815
+ except (AttributeError, OSError):
1631
1816
  pass
1817
+ offsets = (pages * _MMAP_PAGE_BYTES).tolist()
1818
+ n = _FIRST_FRAME_PREFETCH_THREADS
1819
+
1820
+ def _warm(part):
1821
+ for offset in part:
1822
+ os.pread(fd, _MMAP_PAGE_BYTES, offset)
1823
+
1824
+ with ThreadPoolExecutor(max_workers=n, thread_name_prefix="arrayview-slice-prefetch") as pool:
1825
+ for _ in pool.map(_warm, [offsets[k::n] for k in range(n)]):
1826
+ pass
1827
+ except OSError:
1828
+ return False
1829
+ finally:
1830
+ os.close(fd)
1831
+ return True
1832
+
1833
+
1834
+ def _h5_contiguous_memmap(filepath, ds):
1835
+ """Map an HDF5 dataset straight from the file, or None if it can't be.
1836
+
1837
+ MATLAB v7.3 writes large arrays as one uncompressed contiguous block, so
1838
+ the same first-frame shortcut as .npy applies: the viewer can draw a slice
1839
+ without reading the whole file first. Chunked, compressed or filtered
1840
+ datasets are not one block and return None.
1841
+ """
1842
+ try:
1843
+ if ds.chunks is not None or ds.compression is not None:
1844
+ return None
1845
+ offset = ds.id.get_offset()
1846
+ if offset is None:
1847
+ return None
1848
+ dtype = ds.dtype
1849
+ if dtype.names:
1850
+ # MATLAB complex: {real, imag} of one float type, packed, which is
1851
+ # byte-for-byte numpy's complex layout.
1852
+ if set(dtype.names) != {"real", "imag"}:
1853
+ return None
1854
+ real_t, real_off = dtype.fields["real"][:2]
1855
+ imag_t, imag_off = dtype.fields["imag"][:2]
1856
+ # Only single precision: the eager path downcasts complex to
1857
+ # complex64, and the mapped array must come out the same.
1858
+ if (
1859
+ real_t != imag_t
1860
+ or real_t.kind != "f"
1861
+ or real_t.itemsize != 4
1862
+ or real_off != 0
1863
+ or imag_off != real_t.itemsize
1864
+ or dtype.itemsize != 2 * real_t.itemsize
1865
+ ):
1866
+ return None
1867
+ dtype = np.dtype(f"{real_t.byteorder}c{2 * real_t.itemsize}")
1868
+ elif dtype.kind not in "biuf":
1869
+ return None
1870
+ if offset + int(np.prod(ds.shape)) * dtype.itemsize > os.path.getsize(filepath):
1871
+ return None
1872
+ return np.memmap(filepath, dtype=dtype, mode="r", offset=offset, shape=ds.shape)
1873
+ except Exception:
1874
+ return None
1875
+
1876
+
1877
+ _NPY_READ_CHUNK_BYTES = 8 << 20
1878
+ _NPY_READ_THREADS = 8
1879
+
1880
+
1881
+ def read_npy_into_ram(mapped, progress=None):
1882
+ """Read a memmapped .npy into RAM with several reads in flight.
1883
+
1884
+ For a large array this read is the whole wait between a click and the
1885
+ first frame. One sequential reader leaves a network share mostly idle:
1886
+ on SMB a 220 MB file took 5.5 s that way and ~3 s with eight readers.
1887
+ Local disks are not slowed by it.
1888
+
1889
+ The reads land directly in the result's memory, in the file's own byte
1890
+ order: a .npy body is contiguous in either C or Fortran order, so the
1891
+ result is allocated in that order and filled byte for byte. *progress*,
1892
+ if given, is called with ``(done_bytes, total_bytes)`` as chunks land.
1893
+ """
1894
+ order = "F" if mapped.flags.f_contiguous and not mapped.flags.c_contiguous else "C"
1895
+ out = np.empty(mapped.shape, dtype=mapped.dtype, order=order)
1896
+ total = int(out.nbytes)
1897
+ if total == 0:
1898
+ return out
1899
+ dst = memoryview(out.reshape(-1, order="A").view(np.uint8))
1900
+ offset = int(mapped.offset)
1901
+ lock = threading.Lock()
1902
+ done = [0]
1903
+
1904
+ fd = os.open(mapped.filename, os.O_RDONLY)
1905
+ try:
1906
+ def _read(start):
1907
+ stop = min(start + _NPY_READ_CHUNK_BYTES, total)
1908
+ pos = start
1909
+ while pos < stop:
1910
+ n = os.preadv(fd, [dst[pos:stop]], offset + pos)
1911
+ if n <= 0:
1912
+ raise OSError(f"unexpected end of file reading {mapped.filename!r}")
1913
+ pos += n
1914
+ if progress is not None:
1915
+ with lock:
1916
+ done[0] += stop - start
1917
+ finished = done[0]
1918
+ try:
1919
+ progress(finished, total)
1920
+ except Exception:
1921
+ pass
1922
+
1923
+ starts = range(0, total, _NPY_READ_CHUNK_BYTES)
1924
+ with ThreadPoolExecutor(
1925
+ max_workers=min(_NPY_READ_THREADS, len(starts)),
1926
+ thread_name_prefix="arrayview-npy-read",
1927
+ ) as pool:
1928
+ for _ in pool.map(_read, starts):
1929
+ pass
1930
+ finally:
1931
+ os.close(fd)
1632
1932
  return out
1633
1933
 
1634
1934
 
1635
1935
  def load_data_with_meta(filepath, key=None, *, load="lazy", stack="auto", select=None,
1636
- progress=None):
1936
+ progress=None, fast_first_frame=False):
1637
1937
  """Like load_data but also returns spatial metadata for NIfTI files.
1638
1938
 
1639
1939
  Returns (array, meta_or_None). meta is None for non-NIfTI formats.
@@ -1659,10 +1959,12 @@ def load_data_with_meta(filepath, key=None, *, load="lazy", stack="auto", select
1659
1959
  return _load_file_series(filepath, load=load, stack=stack)
1660
1960
  if filepath.endswith(".nii") or filepath.endswith(".nii.gz"):
1661
1961
  return _load_nifti_with_meta(filepath)
1662
- return load_data(filepath, key=key, progress=progress), None
1962
+ return load_data(
1963
+ filepath, key=key, progress=progress, fast_first_frame=fast_first_frame
1964
+ ), None
1663
1965
 
1664
1966
 
1665
- def load_data(filepath, key=None, *, progress=None):
1967
+ def load_data(filepath, key=None, *, progress=None, fast_first_frame=False):
1666
1968
  if filepath.lower().endswith(".dcm"):
1667
1969
  from ._dicom import load_dicom_series
1668
1970
 
@@ -1683,16 +1985,19 @@ def load_data(filepath, key=None, *, progress=None):
1683
1985
  # read runs behind it (see mmap_first_frame_is_cheap) or the viewer shows
1684
1986
  # a spinner until the read completes.
1685
1987
  size = os.path.getsize(filepath)
1686
- if size >= npy_eager_limit():
1687
- return np.load(filepath, mmap_mode="r")
1688
1988
  mapped = np.load(filepath, mmap_mode="r")
1989
+ if size >= npy_eager_limit():
1990
+ if fast_first_frame:
1991
+ prefetch_first_frame_pages(mapped, filepath)
1992
+ return mapped
1689
1993
  if mmap_first_frame_is_cheap(mapped, size):
1690
1994
  # Session creation upgrades this to an in-RAM array in the background.
1691
1995
  return mapped
1692
- if progress is not None:
1693
- return _read_npy_reporting_progress(mapped, progress)
1694
- del mapped
1695
- return np.load(filepath)
1996
+ if fast_first_frame and prefetch_first_frame_pages(mapped, filepath):
1997
+ # Same as above: the first frame's pages are already in memory, and
1998
+ # the full read runs behind the viewer.
1999
+ return mapped
2000
+ return read_npy_into_ram(mapped, progress)
1696
2001
  elif filepath.endswith(".npz"):
1697
2002
  npz = np.load(filepath)
1698
2003
  try:
@@ -1738,6 +2043,8 @@ def load_data(filepath, key=None, *, progress=None):
1738
2043
  import tifffile
1739
2044
 
1740
2045
  return tifffile.imread(filepath)
2046
+ elif filepath.lower().endswith((".gipl", ".gipl.gz")):
2047
+ return _load_gipl(filepath)
1741
2048
  elif filepath.endswith(".mat"):
1742
2049
  try:
1743
2050
  import scipy.io
@@ -1762,10 +2069,13 @@ def load_data(filepath, key=None, *, progress=None):
1762
2069
 
1763
2070
  f = h5py.File(filepath, "r")
1764
2071
  arrays = {k: f[k] for k in f.keys() if _is_viewable_h5_dataset(f[k])}
1765
- if key is not None:
1766
- return _fix_mat_complex(np.array(arrays[key]))
1767
- if len(arrays) == 1:
1768
- return _fix_mat_complex(np.array(next(iter(arrays.values()))))
2072
+ if key is not None or len(arrays) == 1:
2073
+ ds = arrays[key] if key is not None else next(iter(arrays.values()))
2074
+ mapped = _h5_contiguous_memmap(filepath, ds)
2075
+ if mapped is not None and mmap_first_frame_is_cheap(mapped, mapped.nbytes):
2076
+ # Session creation upgrades this to an in-RAM array in the background.
2077
+ return mapped
2078
+ return _fix_mat_complex(np.array(ds))
1769
2079
  raise ValueError(
1770
2080
  f".mat (v7.3) file contains multiple datasets: {list(arrays.keys())}. "
1771
2081
  "Select one in the viewer or pass a key."
@@ -1783,10 +2093,68 @@ def load_data(filepath, key=None, *, progress=None):
1783
2093
  return data
1784
2094
  raise ValueError(
1785
2095
  "Unsupported format. Supported: .npy, .npz, .nii/.nii.gz, .zarr, "
1786
- ".pt/.pth, .h5/.hdf5, .tif/.tiff, .mat"
2096
+ ".pt/.pth, .h5/.hdf5, .tif/.tiff, .mat, .gipl/.gipl.gz"
1787
2097
  )
1788
2098
 
1789
2099
 
2100
+ # GIPL (Guys Image Processing Lab) image types -> numpy dtypes. Data is
2101
+ # big-endian; complex types store interleaved real/imaginary components.
2102
+ _GIPL_DTYPES = {
2103
+ 1: ">u1", # binary, one value per byte
2104
+ 7: ">i1",
2105
+ 8: ">u1",
2106
+ 15: ">i2",
2107
+ 16: ">u2",
2108
+ 31: ">u4",
2109
+ 32: ">i4",
2110
+ 64: ">f4",
2111
+ 65: ">f8",
2112
+ 144: ">i2", # complex short
2113
+ 160: ">i4", # complex int
2114
+ 192: ">f4", # complex float
2115
+ 193: ">f8", # complex double
2116
+ }
2117
+ _GIPL_COMPLEX = frozenset([144, 160, 192, 193])
2118
+ _GIPL_MAGIC = (4026526128, 719555000)
2119
+ _GIPL_HEADER_SIZE = 256
2120
+
2121
+
2122
+ def _load_gipl(filepath):
2123
+ """Read a .gipl/.gipl.gz file into an array indexed (x, y, z, t).
2124
+
2125
+ Trailing singleton dimensions are dropped, matching how a NIfTI volume
2126
+ of the same data is shown.
2127
+ """
2128
+ import gzip
2129
+
2130
+ opener = gzip.open if filepath.lower().endswith(".gz") else open
2131
+ with opener(filepath, "rb") as f:
2132
+ raw = f.read()
2133
+ if len(raw) < _GIPL_HEADER_SIZE:
2134
+ raise ValueError(f"{filepath!r} is too short to be a GIPL file.")
2135
+ header = raw[:_GIPL_HEADER_SIZE]
2136
+ magic = int(np.frombuffer(header, ">u4", count=1, offset=252)[0])
2137
+ if magic not in _GIPL_MAGIC:
2138
+ raise ValueError(f"{filepath!r} is not a GIPL file (bad magic number).")
2139
+ dims = [int(d) for d in np.frombuffer(header, ">u2", count=4, offset=0)]
2140
+ image_type = int(np.frombuffer(header, ">u2", count=1, offset=8)[0])
2141
+ if image_type not in _GIPL_DTYPES:
2142
+ raise ValueError(f"{filepath!r} has unsupported GIPL image type {image_type}.")
2143
+ dims = [max(d, 1) for d in dims]
2144
+ is_complex = image_type in _GIPL_COMPLEX
2145
+ count = int(np.prod(dims)) * (2 if is_complex else 1)
2146
+ data = np.frombuffer(raw, _GIPL_DTYPES[image_type], count=count, offset=_GIPL_HEADER_SIZE)
2147
+ data = data.astype(data.dtype.newbyteorder("="))
2148
+ if is_complex:
2149
+ data = data.astype(np.float64 if data.itemsize == 8 else np.float32)
2150
+ data = data[0::2] + 1j * data[1::2]
2151
+ # x varies fastest on disk.
2152
+ arr = data.reshape(dims[::-1]).transpose(3, 2, 1, 0)
2153
+ while arr.ndim > 2 and arr.shape[-1] == 1:
2154
+ arr = arr[..., 0]
2155
+ return np.ascontiguousarray(arr)
2156
+
2157
+
1790
2158
  def _tensor_to_numpy(obj, source="object"):
1791
2159
  """Convert a tensor-like object (PyTorch, etc.) to a numpy array."""
1792
2160
  if isinstance(obj, np.ndarray):
@@ -1823,13 +2191,15 @@ _SUPPORTED_EXTS = frozenset(
1823
2191
  ".tif",
1824
2192
  ".tiff",
1825
2193
  ".mat",
2194
+ ".gipl",
2195
+ ".gipl.gz",
1826
2196
  ".dcm",
1827
2197
  ]
1828
2198
  )
1829
2199
 
1830
2200
  # Formats that load the entire array into RAM (no mmap/lazy access).
1831
2201
  # RAM guard only applies to these.
1832
- FULL_LOAD_EXTS = frozenset([".pt", ".pth", ".tif", ".tiff", ".mat"])
2202
+ FULL_LOAD_EXTS = frozenset([".pt", ".pth", ".tif", ".tiff", ".mat", ".gipl", ".gipl.gz"])
1833
2203
 
1834
2204
 
1835
2205
  def _peek_file_shape(fpath: str, ext: str):
@@ -4853,6 +4853,7 @@ def _serve_daemon(
4853
4853
  key=_load_key,
4854
4854
  load=collection_load,
4855
4855
  stack=collection_stack,
4856
+ fast_first_frame=True,
4856
4857
  )
4857
4858
  dir_overlay_items = None
4858
4859
  collection_spatial_ndim = None
@@ -6211,17 +6212,18 @@ def arrayview():
6211
6212
  pattern for _name, pattern in _normalize_dir_overlay_specs(args.overlay or [])
6212
6213
  )
6213
6214
  collection_inputs.extend(args.overlay_dir or [])
6214
- for pattern in collection_inputs:
6215
- pattern = lexical_abspath(pattern)
6216
- probe_prefix = scan_root_before_magic(pattern)
6217
- mount = direct_network_mount(probe_prefix)
6218
- nested_mount = network_mount_below(probe_prefix)
6219
- if mount is not None or nested_mount is not None:
6220
- _print_failure(
6221
- "network directory collections are not accessed directly. "
6222
- "Copy the collection to local storage before using --match."
6223
- )
6224
- sys.exit(1)
6215
+ if os.environ.get("ARRAYVIEW_SKIP_SOURCE_STAGING", "1") == "0":
6216
+ for pattern in collection_inputs:
6217
+ pattern = lexical_abspath(pattern)
6218
+ probe_prefix = scan_root_before_magic(pattern)
6219
+ mount = direct_network_mount(probe_prefix)
6220
+ nested_mount = network_mount_below(probe_prefix)
6221
+ if mount is not None or nested_mount is not None:
6222
+ _print_failure(
6223
+ "network directory collections are not accessed directly. "
6224
+ "Copy the collection to local storage before using --match."
6225
+ )
6226
+ sys.exit(1)
6225
6227
  if len(dir_patterns) == 1 and os.path.isdir(dir_patterns[0]):
6226
6228
  dir_patterns = [os.path.join(dir_patterns[0], "**", "*")]
6227
6229
  dir_overlay_specs = [
@@ -207,7 +207,17 @@ def extract_slice(session, dim_x, dim_y, idx_list, progress=None):
207
207
  # already served from cache.
208
208
  if progress is None:
209
209
  progress = getattr(session, 'progress_sink', None)
210
- extracted = _extract_with_progress(session.data, slicer, progress)
210
+ data = session.data
211
+ if isinstance(data, np.memmap) and data.filename:
212
+ # A slice scattered one element per page reads far faster as many
213
+ # parallel small reads than by faulting through the map (local disks).
214
+ from ._io import prefetch_slice_pages
215
+
216
+ try:
217
+ prefetch_slice_pages(data, data.filename, (dim_x, dim_y), idx_list)
218
+ except Exception:
219
+ pass
220
+ extracted = _extract_with_progress(data, slicer, progress)
211
221
  if dim_x < dim_y:
212
222
  extracted = extracted.T
213
223
  # scipy.io.loadmat returns complex arrays as structured dtypes; convert here.