arrayview 0.47.4__tar.gz → 0.48.0__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.4 → arrayview-0.48.0}/PKG-INFO +1 -1
  2. {arrayview-0.47.4 → arrayview-0.48.0}/pyproject.toml +1 -1
  3. {arrayview-0.47.4 → arrayview-0.48.0}/src/arrayview/_io.py +368 -44
  4. {arrayview-0.47.4 → arrayview-0.48.0}/src/arrayview/_launcher.py +1 -0
  5. {arrayview-0.47.4 → arrayview-0.48.0}/src/arrayview/_render.py +13 -1
  6. {arrayview-0.47.4 → arrayview-0.48.0}/src/arrayview/_routes_loading.py +1 -0
  7. {arrayview-0.47.4 → arrayview-0.48.0}/src/arrayview/_routes_query.py +1 -0
  8. {arrayview-0.47.4 → arrayview-0.48.0}/src/arrayview/_routes_websocket.py +24 -6
  9. {arrayview-0.47.4 → arrayview-0.48.0}/src/arrayview/_session.py +10 -7
  10. {arrayview-0.47.4 → arrayview-0.48.0}/src/arrayview/_viewer.html +181 -66
  11. {arrayview-0.47.4 → arrayview-0.48.0}/src/arrayview/_vscode_extension.py +1 -1
  12. arrayview-0.48.0/src/arrayview/arrayview-opener.vsix +0 -0
  13. {arrayview-0.47.4 → arrayview-0.48.0}/tests/test_api.py +2 -2
  14. arrayview-0.48.0/tests/test_io.py +264 -0
  15. arrayview-0.47.4/src/arrayview/arrayview-opener.vsix +0 -0
  16. arrayview-0.47.4/tests/test_io.py +0 -91
  17. {arrayview-0.47.4 → arrayview-0.48.0}/.gitignore +0 -0
  18. {arrayview-0.47.4 → arrayview-0.48.0}/LICENSE +0 -0
  19. {arrayview-0.47.4 → arrayview-0.48.0}/README.md +0 -0
  20. {arrayview-0.47.4 → arrayview-0.48.0}/src/arrayview/ARCHITECTURE.md +0 -0
  21. {arrayview-0.47.4 → arrayview-0.48.0}/src/arrayview/__init__.py +0 -0
  22. {arrayview-0.47.4 → arrayview-0.48.0}/src/arrayview/__main__.py +0 -0
  23. {arrayview-0.47.4 → arrayview-0.48.0}/src/arrayview/_analysis.py +0 -0
  24. {arrayview-0.47.4 → arrayview-0.48.0}/src/arrayview/_app.py +0 -0
  25. {arrayview-0.47.4 → arrayview-0.48.0}/src/arrayview/_bootstrap_app.py +0 -0
  26. {arrayview-0.47.4 → arrayview-0.48.0}/src/arrayview/_config.py +0 -0
  27. {arrayview-0.47.4 → arrayview-0.48.0}/src/arrayview/_diagnostics.py +0 -0
  28. {arrayview-0.47.4 → arrayview-0.48.0}/src/arrayview/_dicom.py +0 -0
  29. {arrayview-0.47.4 → arrayview-0.48.0}/src/arrayview/_diff.py +0 -0
  30. {arrayview-0.47.4 → arrayview-0.48.0}/src/arrayview/_extra_ports.py +0 -0
  31. {arrayview-0.47.4 → arrayview-0.48.0}/src/arrayview/_icon.png +0 -0
  32. {arrayview-0.47.4 → arrayview-0.48.0}/src/arrayview/_imaging.py +0 -0
  33. {arrayview-0.47.4 → arrayview-0.48.0}/src/arrayview/_instance_registry.py +0 -0
  34. {arrayview-0.47.4 → arrayview-0.48.0}/src/arrayview/_launch_plan.py +0 -0
  35. {arrayview-0.47.4 → arrayview-0.48.0}/src/arrayview/_launch_trace.py +0 -0
  36. {arrayview-0.47.4 → arrayview-0.48.0}/src/arrayview/_lifecycle.py +0 -0
  37. {arrayview-0.47.4 → arrayview-0.48.0}/src/arrayview/_overlays.py +0 -0
  38. {arrayview-0.47.4 → arrayview-0.48.0}/src/arrayview/_platform.py +0 -0
  39. {arrayview-0.47.4 → arrayview-0.48.0}/src/arrayview/_routes_analysis.py +0 -0
  40. {arrayview-0.47.4 → arrayview-0.48.0}/src/arrayview/_routes_drop.py +0 -0
  41. {arrayview-0.47.4 → arrayview-0.48.0}/src/arrayview/_routes_export.py +0 -0
  42. {arrayview-0.47.4 → arrayview-0.48.0}/src/arrayview/_routes_persistence.py +0 -0
  43. {arrayview-0.47.4 → arrayview-0.48.0}/src/arrayview/_routes_preferences.py +0 -0
  44. {arrayview-0.47.4 → arrayview-0.48.0}/src/arrayview/_routes_preload.py +0 -0
  45. {arrayview-0.47.4 → arrayview-0.48.0}/src/arrayview/_routes_rendering.py +0 -0
  46. {arrayview-0.47.4 → arrayview-0.48.0}/src/arrayview/_routes_segmentation.py +0 -0
  47. {arrayview-0.47.4 → arrayview-0.48.0}/src/arrayview/_routes_state.py +0 -0
  48. {arrayview-0.47.4 → arrayview-0.48.0}/src/arrayview/_routes_vectorfield.py +0 -0
  49. {arrayview-0.47.4 → arrayview-0.48.0}/src/arrayview/_segmentation.py +0 -0
  50. {arrayview-0.47.4 → arrayview-0.48.0}/src/arrayview/_server.py +0 -0
  51. {arrayview-0.47.4 → arrayview-0.48.0}/src/arrayview/_session_spec.py +0 -0
  52. {arrayview-0.47.4 → arrayview-0.48.0}/src/arrayview/_shell.html +0 -0
  53. {arrayview-0.47.4 → arrayview-0.48.0}/src/arrayview/_source_safety.py +0 -0
  54. {arrayview-0.47.4 → arrayview-0.48.0}/src/arrayview/_synthetic_mri.py +0 -0
  55. {arrayview-0.47.4 → arrayview-0.48.0}/src/arrayview/_torch.py +0 -0
  56. {arrayview-0.47.4 → arrayview-0.48.0}/src/arrayview/_tutorial.py +0 -0
  57. {arrayview-0.47.4 → arrayview-0.48.0}/src/arrayview/_vectorfield.py +0 -0
  58. {arrayview-0.47.4 → arrayview-0.48.0}/src/arrayview/_vscode.py +0 -0
  59. {arrayview-0.47.4 → arrayview-0.48.0}/src/arrayview/_vscode_browser.py +0 -0
  60. {arrayview-0.47.4 → arrayview-0.48.0}/src/arrayview/_vscode_signal.py +0 -0
  61. {arrayview-0.47.4 → arrayview-0.48.0}/src/arrayview/_watch.py +0 -0
  62. {arrayview-0.47.4 → arrayview-0.48.0}/src/arrayview/gsap.min.js +0 -0
  63. {arrayview-0.47.4 → arrayview-0.48.0}/src/arrayview/todo.md +0 -0
  64. {arrayview-0.47.4 → arrayview-0.48.0}/tests/capture_patient_loading.py +0 -0
  65. {arrayview-0.47.4 → arrayview-0.48.0}/tests/capture_v_animation.py +0 -0
  66. {arrayview-0.47.4 → arrayview-0.48.0}/tests/conftest.py +0 -0
  67. {arrayview-0.47.4 → arrayview-0.48.0}/tests/make_vectorfield_test_arrays.py +0 -0
  68. {arrayview-0.47.4 → arrayview-0.48.0}/tests/test_1d_arrays.py +0 -0
  69. {arrayview-0.47.4 → arrayview-0.48.0}/tests/test_backend_shared.py +0 -0
  70. {arrayview-0.47.4 → arrayview-0.48.0}/tests/test_browser.py +0 -0
  71. {arrayview-0.47.4 → arrayview-0.48.0}/tests/test_cli.py +0 -0
  72. {arrayview-0.47.4 → arrayview-0.48.0}/tests/test_config.py +0 -0
  73. {arrayview-0.47.4 → arrayview-0.48.0}/tests/test_dicom.py +0 -0
  74. {arrayview-0.47.4 → arrayview-0.48.0}/tests/test_drop_import.py +0 -0
  75. {arrayview-0.47.4 → arrayview-0.48.0}/tests/test_instance_registry.py +0 -0
  76. {arrayview-0.47.4 → arrayview-0.48.0}/tests/test_large_arrays.py +0 -0
  77. {arrayview-0.47.4 → arrayview-0.48.0}/tests/test_launch_blackbox.py +0 -0
  78. {arrayview-0.47.4 → arrayview-0.48.0}/tests/test_mode_roundtrip.py +0 -0
  79. {arrayview-0.47.4 → arrayview-0.48.0}/tests/test_nifti_meta.py +0 -0
  80. {arrayview-0.47.4 → arrayview-0.48.0}/tests/test_nifti_series.py +0 -0
  81. {arrayview-0.47.4 → arrayview-0.48.0}/tests/test_rgb_pixel_art.py +0 -0
  82. {arrayview-0.47.4 → arrayview-0.48.0}/tests/test_safe_server_stop.py +0 -0
  83. {arrayview-0.47.4 → arrayview-0.48.0}/tests/test_source_safety.py +0 -0
  84. {arrayview-0.47.4 → arrayview-0.48.0}/tests/test_torch.py +0 -0
  85. {arrayview-0.47.4 → arrayview-0.48.0}/tests/test_tutorial.py +0 -0
  86. {arrayview-0.47.4 → arrayview-0.48.0}/tests/test_vsix_bundle.py +0 -0
  87. {arrayview-0.47.4 → arrayview-0.48.0}/tests/ui_audit.py +0 -0
  88. {arrayview-0.47.4 → arrayview-0.48.0}/tests/v_anim_frames/.gitkeep +0 -0
  89. {arrayview-0.47.4 → arrayview-0.48.0}/tests/visual_smoke.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: arrayview
3
- Version: 0.47.4
3
+ Version: 0.48.0
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.4"
7
+ version = "0.48.0"
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
  # ---------------------------------------------------------------------------
@@ -1601,41 +1720,238 @@ def mmap_first_frame_is_cheap(arr, total_bytes) -> bool:
1601
1720
  return runs <= max_runs and faulted <= fraction * total_bytes
1602
1721
 
1603
1722
 
1604
- def _read_npy_reporting_progress(mapped, progress):
1605
- """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
1606
1728
 
1607
- For a large array this read is the whole wait between a click and the
1608
- first frame — 9 s for a 1.6 GB file on a cold cache — and ``np.load``
1609
- reports nothing while it runs. Copying in pieces costs the same reads
1610
- and makes the wait explainable.
1611
1729
 
1612
- The flat view follows the array's own memory order, so the copy stays
1613
- sequential whether the file is C- or Fortran-ordered. A .npy memmap is
1614
- 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, progress=None) -> 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.
1615
1762
  """
1616
- order = "F" if mapped.flags.f_contiguous and not mapped.flags.c_contiguous else "C"
1617
- out = np.empty(mapped.shape, dtype=mapped.dtype, order=order)
1618
- src = mapped.reshape(-1, order="A")
1619
- dst = out.reshape(-1, order="A")
1620
- total = int(src.shape[0])
1621
- itemsize = int(mapped.dtype.itemsize)
1622
- # ~200 updates: smooth enough to read as motion, coarse enough that the
1623
- # per-chunk overhead stays lost in the read itself.
1624
- step = max(1, total // 200)
1625
- done = 0
1626
- while done < total:
1627
- end = min(done + step, total)
1628
- dst[done:end] = src[done:end]
1629
- 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(
1772
+ mapped, filepath, dims, _viewer_start_index(shape, dims), progress=progress
1773
+ )
1774
+
1775
+
1776
+ @functools.lru_cache(maxsize=64)
1777
+ def _on_known_local_disk_cached(filepath) -> bool:
1778
+ return _on_known_local_disk(filepath)
1779
+
1780
+
1781
+ def prefetch_slice_pages(mapped, filepath, dims, index, progress=None) -> bool:
1782
+ """Fetch the pages of one 2-D slice of a memmap with parallel reads.
1783
+
1784
+ Faulting such a slice in through the map is the slow way: readahead drags
1785
+ in the pages between the wanted elements (6 s for one slice of a 1.6 GB
1786
+ C-order file), and without readahead each page is its own wait (7.7 s).
1787
+ Many small reads in flight take ~0.7 s on a local disk. Only worth it when
1788
+ the slice is scattered one element per page; returns False otherwise, or
1789
+ off a known local disk, having read nothing. *progress*, if given, is
1790
+ called with ``(pages_done, pages_total)`` as batches of pages land — this
1791
+ fetch is the whole wait, so it is what a progress line should follow.
1792
+ """
1793
+ shape = tuple(int(s) for s in mapped.shape)
1794
+ strides = tuple(int(s) for s in mapped.strides)
1795
+ runs, _faulted = first_frame_fault_cost(
1796
+ shape, mapped.dtype.itemsize, strides, dims, mapped.nbytes
1797
+ )
1798
+ if runs <= 1024 or not _on_known_local_disk_cached(str(filepath)):
1799
+ return False
1800
+ base = int(mapped.offset) + sum(
1801
+ int(index[i]) * strides[i] for i in range(len(shape)) if i not in dims
1802
+ )
1803
+ rows = np.arange(shape[dims[0]], dtype=np.int64) * strides[dims[0]]
1804
+ cols = np.arange(shape[dims[1]], dtype=np.int64) * strides[dims[1]]
1805
+ starts = (base + rows[:, None] + cols[None, :]).ravel()
1806
+ ends = starts + (mapped.dtype.itemsize - 1)
1807
+ pages = np.unique(np.concatenate((starts, ends)) // _MMAP_PAGE_BYTES)
1808
+ if pages.size > _FIRST_FRAME_PREFETCH_MAX_PAGES:
1809
+ return False
1810
+
1811
+ try:
1812
+ fd = os.open(filepath, os.O_RDONLY)
1813
+ except OSError:
1814
+ return False
1815
+ try:
1816
+ # Only the pages asked for: readahead around each would multiply the I/O.
1630
1817
  try:
1631
- progress(done * itemsize, total * itemsize)
1632
- except Exception:
1818
+ os.posix_fadvise(fd, 0, 0, os.POSIX_FADV_RANDOM)
1819
+ except (AttributeError, OSError):
1633
1820
  pass
1821
+ offsets = (pages * _MMAP_PAGE_BYTES).tolist()
1822
+ total = len(offsets)
1823
+ n = _FIRST_FRAME_PREFETCH_THREADS
1824
+ # Interleaved batches: each one spans the file, so batches finish at
1825
+ # a steady rate and the progress they report moves evenly.
1826
+ batches = max(n, 64)
1827
+ lock = threading.Lock()
1828
+ done = [0]
1829
+
1830
+ def _warm(part):
1831
+ for offset in part:
1832
+ os.pread(fd, _MMAP_PAGE_BYTES, offset)
1833
+ if progress is not None:
1834
+ with lock:
1835
+ done[0] += len(part)
1836
+ finished = done[0]
1837
+ try:
1838
+ progress(finished, total)
1839
+ except Exception:
1840
+ pass
1841
+
1842
+ with ThreadPoolExecutor(max_workers=n, thread_name_prefix="arrayview-slice-prefetch") as pool:
1843
+ for _ in pool.map(_warm, [offsets[k::batches] for k in range(batches)]):
1844
+ pass
1845
+ except OSError:
1846
+ return False
1847
+ finally:
1848
+ os.close(fd)
1849
+ return True
1850
+
1851
+
1852
+ def _h5_contiguous_memmap(filepath, ds):
1853
+ """Map an HDF5 dataset straight from the file, or None if it can't be.
1854
+
1855
+ MATLAB v7.3 writes large arrays as one uncompressed contiguous block, so
1856
+ the same first-frame shortcut as .npy applies: the viewer can draw a slice
1857
+ without reading the whole file first. Chunked, compressed or filtered
1858
+ datasets are not one block and return None.
1859
+ """
1860
+ try:
1861
+ if ds.chunks is not None or ds.compression is not None:
1862
+ return None
1863
+ offset = ds.id.get_offset()
1864
+ if offset is None:
1865
+ return None
1866
+ dtype = ds.dtype
1867
+ if dtype.names:
1868
+ # MATLAB complex: {real, imag} of one float type, packed, which is
1869
+ # byte-for-byte numpy's complex layout.
1870
+ if set(dtype.names) != {"real", "imag"}:
1871
+ return None
1872
+ real_t, real_off = dtype.fields["real"][:2]
1873
+ imag_t, imag_off = dtype.fields["imag"][:2]
1874
+ # Only single precision: the eager path downcasts complex to
1875
+ # complex64, and the mapped array must come out the same.
1876
+ if (
1877
+ real_t != imag_t
1878
+ or real_t.kind != "f"
1879
+ or real_t.itemsize != 4
1880
+ or real_off != 0
1881
+ or imag_off != real_t.itemsize
1882
+ or dtype.itemsize != 2 * real_t.itemsize
1883
+ ):
1884
+ return None
1885
+ dtype = np.dtype(f"{real_t.byteorder}c{2 * real_t.itemsize}")
1886
+ elif dtype.kind not in "biuf":
1887
+ return None
1888
+ if offset + int(np.prod(ds.shape)) * dtype.itemsize > os.path.getsize(filepath):
1889
+ return None
1890
+ return np.memmap(filepath, dtype=dtype, mode="r", offset=offset, shape=ds.shape)
1891
+ except Exception:
1892
+ return None
1893
+
1894
+
1895
+ _NPY_READ_CHUNK_BYTES = 8 << 20
1896
+ _NPY_READ_THREADS = 8
1897
+
1898
+
1899
+ def read_npy_into_ram(mapped, progress=None):
1900
+ """Read a memmapped .npy into RAM with several reads in flight.
1901
+
1902
+ For a large array this read is the whole wait between a click and the
1903
+ first frame. One sequential reader leaves a network share mostly idle:
1904
+ on SMB a 220 MB file took 5.5 s that way and ~3 s with eight readers.
1905
+ Local disks are not slowed by it.
1906
+
1907
+ The reads land directly in the result's memory, in the file's own byte
1908
+ order: a .npy body is contiguous in either C or Fortran order, so the
1909
+ result is allocated in that order and filled byte for byte. *progress*,
1910
+ if given, is called with ``(done_bytes, total_bytes)`` as chunks land.
1911
+ """
1912
+ order = "F" if mapped.flags.f_contiguous and not mapped.flags.c_contiguous else "C"
1913
+ out = np.empty(mapped.shape, dtype=mapped.dtype, order=order)
1914
+ total = int(out.nbytes)
1915
+ if total == 0:
1916
+ return out
1917
+ dst = memoryview(out.reshape(-1, order="A").view(np.uint8))
1918
+ offset = int(mapped.offset)
1919
+ lock = threading.Lock()
1920
+ done = [0]
1921
+
1922
+ fd = os.open(mapped.filename, os.O_RDONLY)
1923
+ try:
1924
+ def _read(start):
1925
+ stop = min(start + _NPY_READ_CHUNK_BYTES, total)
1926
+ pos = start
1927
+ while pos < stop:
1928
+ n = os.preadv(fd, [dst[pos:stop]], offset + pos)
1929
+ if n <= 0:
1930
+ raise OSError(f"unexpected end of file reading {mapped.filename!r}")
1931
+ pos += n
1932
+ if progress is not None:
1933
+ with lock:
1934
+ done[0] += stop - start
1935
+ finished = done[0]
1936
+ try:
1937
+ progress(finished, total)
1938
+ except Exception:
1939
+ pass
1940
+
1941
+ starts = range(0, total, _NPY_READ_CHUNK_BYTES)
1942
+ with ThreadPoolExecutor(
1943
+ max_workers=min(_NPY_READ_THREADS, len(starts)),
1944
+ thread_name_prefix="arrayview-npy-read",
1945
+ ) as pool:
1946
+ for _ in pool.map(_read, starts):
1947
+ pass
1948
+ finally:
1949
+ os.close(fd)
1634
1950
  return out
1635
1951
 
1636
1952
 
1637
1953
  def load_data_with_meta(filepath, key=None, *, load="lazy", stack="auto", select=None,
1638
- progress=None):
1954
+ progress=None, fast_first_frame=False):
1639
1955
  """Like load_data but also returns spatial metadata for NIfTI files.
1640
1956
 
1641
1957
  Returns (array, meta_or_None). meta is None for non-NIfTI formats.
@@ -1661,10 +1977,12 @@ def load_data_with_meta(filepath, key=None, *, load="lazy", stack="auto", select
1661
1977
  return _load_file_series(filepath, load=load, stack=stack)
1662
1978
  if filepath.endswith(".nii") or filepath.endswith(".nii.gz"):
1663
1979
  return _load_nifti_with_meta(filepath)
1664
- return load_data(filepath, key=key, progress=progress), None
1980
+ return load_data(
1981
+ filepath, key=key, progress=progress, fast_first_frame=fast_first_frame
1982
+ ), None
1665
1983
 
1666
1984
 
1667
- def load_data(filepath, key=None, *, progress=None):
1985
+ def load_data(filepath, key=None, *, progress=None, fast_first_frame=False):
1668
1986
  if filepath.lower().endswith(".dcm"):
1669
1987
  from ._dicom import load_dicom_series
1670
1988
 
@@ -1685,16 +2003,19 @@ def load_data(filepath, key=None, *, progress=None):
1685
2003
  # read runs behind it (see mmap_first_frame_is_cheap) or the viewer shows
1686
2004
  # a spinner until the read completes.
1687
2005
  size = os.path.getsize(filepath)
1688
- if size >= npy_eager_limit():
1689
- return np.load(filepath, mmap_mode="r")
1690
2006
  mapped = np.load(filepath, mmap_mode="r")
2007
+ if size >= npy_eager_limit():
2008
+ if fast_first_frame:
2009
+ prefetch_first_frame_pages(mapped, filepath, progress)
2010
+ return mapped
1691
2011
  if mmap_first_frame_is_cheap(mapped, size):
1692
2012
  # Session creation upgrades this to an in-RAM array in the background.
1693
2013
  return mapped
1694
- if progress is not None:
1695
- return _read_npy_reporting_progress(mapped, progress)
1696
- del mapped
1697
- return np.load(filepath)
2014
+ if fast_first_frame and prefetch_first_frame_pages(mapped, filepath, progress):
2015
+ # Same as above: the first frame's pages are already in memory, and
2016
+ # the full read runs behind the viewer.
2017
+ return mapped
2018
+ return read_npy_into_ram(mapped, progress)
1698
2019
  elif filepath.endswith(".npz"):
1699
2020
  npz = np.load(filepath)
1700
2021
  try:
@@ -1766,10 +2087,13 @@ def load_data(filepath, key=None, *, progress=None):
1766
2087
 
1767
2088
  f = h5py.File(filepath, "r")
1768
2089
  arrays = {k: f[k] for k in f.keys() if _is_viewable_h5_dataset(f[k])}
1769
- if key is not None:
1770
- return _fix_mat_complex(np.array(arrays[key]))
1771
- if len(arrays) == 1:
1772
- return _fix_mat_complex(np.array(next(iter(arrays.values()))))
2090
+ if key is not None or len(arrays) == 1:
2091
+ ds = arrays[key] if key is not None else next(iter(arrays.values()))
2092
+ mapped = _h5_contiguous_memmap(filepath, ds)
2093
+ if mapped is not None and mmap_first_frame_is_cheap(mapped, mapped.nbytes):
2094
+ # Session creation upgrades this to an in-RAM array in the background.
2095
+ return mapped
2096
+ return _fix_mat_complex(np.array(ds))
1773
2097
  raise ValueError(
1774
2098
  f".mat (v7.3) file contains multiple datasets: {list(arrays.keys())}. "
1775
2099
  "Select one in the viewer or pass a key."
@@ -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
@@ -207,7 +207,19 @@ 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(
218
+ data, data.filename, (dim_x, dim_y), idx_list, progress=progress
219
+ )
220
+ except Exception:
221
+ pass
222
+ extracted = _extract_with_progress(data, slicer, progress)
211
223
  if dim_x < dim_y:
212
224
  extracted = extracted.T
213
225
  # scipy.io.loadmat returns complex arrays as structured dtypes; convert here.
@@ -341,6 +341,7 @@ def register_loading_routes(app, *, notify_shells, setup_rgb) -> None:
341
341
  filepath,
342
342
  select=body.get("select"),
343
343
  progress=_report_read_progress,
344
+ fast_first_frame=True,
344
345
  )
345
346
  session = Session(data, filepath=filepath, name=name)
346
347
  session.sid = sid
@@ -247,6 +247,7 @@ def register_query_routes(app, *, get_session_or_404, pil_image, pil_imageops) -
247
247
  request_id=request_id,
248
248
  navigation_attempt=journal.get("navigation_attempt"),
249
249
  phase=phase,
250
+ client_ms=body.get("client_ms"),
250
251
  )
251
252
  except Exception:
252
253
  pass
@@ -81,9 +81,11 @@ def _render_progress_reporter(
81
81
  Anything finishing inside `quiet_for_s` therefore shows nothing at all,
82
82
  which is what keeps a fast load from flashing a bar on its way past.
83
83
  """
84
- state = {"started": time.perf_counter(), "last": 0.0}
84
+ state = {"started": time.perf_counter(), "last": 0.0, "closed": False}
85
85
 
86
86
  def report(done, total):
87
+ if state["closed"]:
88
+ return
87
89
  now = time.perf_counter()
88
90
  if now - state["started"] < quiet_for_s:
89
91
  return
@@ -98,15 +100,24 @@ def _render_progress_reporter(
98
100
  "total": int(total),
99
101
  "label": label,
100
102
  }
103
+ def _send():
104
+ # Checked again on the loop: an update queued just before the
105
+ # frame was sent would otherwise arrive after it, and the viewer
106
+ # (which clears its bar when a frame lands) kept the bar up.
107
+ if not state["closed"]:
108
+ asyncio.ensure_future(_send_json_quietly(ws, payload))
109
+
101
110
  try:
102
- loop.call_soon_threadsafe(
103
- lambda: asyncio.ensure_future(_send_json_quietly(ws, payload))
104
- )
111
+ loop.call_soon_threadsafe(_send)
105
112
  except RuntimeError:
106
113
  # Loop already closed (viewer went away mid-render); progress is
107
114
  # never worth raising over.
108
115
  pass
109
116
 
117
+ def close():
118
+ state["closed"] = True
119
+
120
+ report.close = close
110
121
  return report
111
122
 
112
123
 
@@ -118,9 +129,11 @@ async def _await_session_reporting_read_progress(ws, sid):
118
129
  holding a line to the viewer while the wait happens.
119
130
 
120
131
  Silent for the first stretch, so an ordinary fast open never flashes a
121
- bar on its way past — the same rule the render progress reporter uses.
132
+ bar on its way past. Shorter than the viewer's own 0.6 s before it shows
133
+ its loading screen (counted from page load, which starts later), so the
134
+ line is ready when the screen appears rather than coming after it.
122
135
  """
123
- quiet_for_s = 0.6
136
+ quiet_for_s = 0.3
124
137
  started = time.perf_counter()
125
138
  waiter = asyncio.ensure_future(wait_for_session_ready(sid))
126
139
  last = None
@@ -694,7 +707,12 @@ def register_websocket_routes(app) -> None:
694
707
 
695
708
  # The pixels exist; nothing after this point reads the array,
696
709
  # and the viewer clears its indicator when the frame lands.
710
+ # Closing the reporter also drops updates still queued for
711
+ # the socket, which would otherwise land after the frame.
712
+ sink = session.progress_sink
697
713
  session.progress_sink = None
714
+ if hasattr(sink, "close"):
715
+ sink.close()
698
716
  render_ms = (time.perf_counter() - render_t0) * 1000.0
699
717
  post_t0 = time.perf_counter()
700
718
  header = np.array([seq, w, h], dtype=np.uint32).tobytes()
@@ -743,12 +743,9 @@ def upgrade_memmap_in_background(session) -> bool:
743
743
  return False
744
744
 
745
745
  if is_memmap:
746
- if not filepath.endswith(".npy"):
747
- return False
748
- try:
749
- nbytes = os.path.getsize(filepath)
750
- except OSError:
746
+ if not filepath.endswith((".npy", ".mat")):
751
747
  return False
748
+ nbytes = int(data.nbytes)
752
749
  else:
753
750
  nbytes = _nifti_proxy_nbytes(data)
754
751
  if nbytes >= npy_eager_limit():
@@ -759,8 +756,14 @@ def upgrade_memmap_in_background(session) -> bool:
759
756
 
760
757
  def _upgrade():
761
758
  try:
762
- if is_memmap:
763
- loaded = np.load(filepath)
759
+ if is_memmap and filepath.endswith(".npy"):
760
+ from ._io import read_npy_into_ram
761
+
762
+ loaded = read_npy_into_ram(data)
763
+ elif is_memmap:
764
+ # A block mapped out of a larger file (.mat v7.3): copy it in
765
+ # file order, which is one sequential read.
766
+ loaded = np.array(data, copy=True, order="K")
764
767
  else:
765
768
  from ._io import _nifti_display_array
766
769