arrayview 0.47.4__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 (88) hide show
  1. {arrayview-0.47.4 → arrayview-0.47.5}/PKG-INFO +1 -1
  2. {arrayview-0.47.4 → arrayview-0.47.5}/pyproject.toml +1 -1
  3. {arrayview-0.47.4 → arrayview-0.47.5}/src/arrayview/_io.py +350 -44
  4. {arrayview-0.47.4 → arrayview-0.47.5}/src/arrayview/_launcher.py +1 -0
  5. {arrayview-0.47.4 → arrayview-0.47.5}/src/arrayview/_render.py +11 -1
  6. {arrayview-0.47.4 → arrayview-0.47.5}/src/arrayview/_routes_loading.py +1 -0
  7. {arrayview-0.47.4 → arrayview-0.47.5}/src/arrayview/_routes_query.py +1 -0
  8. {arrayview-0.47.4 → arrayview-0.47.5}/src/arrayview/_session.py +10 -7
  9. {arrayview-0.47.4 → arrayview-0.47.5}/src/arrayview/_viewer.html +83 -13
  10. {arrayview-0.47.4 → arrayview-0.47.5}/tests/test_api.py +2 -2
  11. arrayview-0.47.5/tests/test_io.py +264 -0
  12. arrayview-0.47.4/tests/test_io.py +0 -91
  13. {arrayview-0.47.4 → arrayview-0.47.5}/.gitignore +0 -0
  14. {arrayview-0.47.4 → arrayview-0.47.5}/LICENSE +0 -0
  15. {arrayview-0.47.4 → arrayview-0.47.5}/README.md +0 -0
  16. {arrayview-0.47.4 → arrayview-0.47.5}/src/arrayview/ARCHITECTURE.md +0 -0
  17. {arrayview-0.47.4 → arrayview-0.47.5}/src/arrayview/__init__.py +0 -0
  18. {arrayview-0.47.4 → arrayview-0.47.5}/src/arrayview/__main__.py +0 -0
  19. {arrayview-0.47.4 → arrayview-0.47.5}/src/arrayview/_analysis.py +0 -0
  20. {arrayview-0.47.4 → arrayview-0.47.5}/src/arrayview/_app.py +0 -0
  21. {arrayview-0.47.4 → arrayview-0.47.5}/src/arrayview/_bootstrap_app.py +0 -0
  22. {arrayview-0.47.4 → arrayview-0.47.5}/src/arrayview/_config.py +0 -0
  23. {arrayview-0.47.4 → arrayview-0.47.5}/src/arrayview/_diagnostics.py +0 -0
  24. {arrayview-0.47.4 → arrayview-0.47.5}/src/arrayview/_dicom.py +0 -0
  25. {arrayview-0.47.4 → arrayview-0.47.5}/src/arrayview/_diff.py +0 -0
  26. {arrayview-0.47.4 → arrayview-0.47.5}/src/arrayview/_extra_ports.py +0 -0
  27. {arrayview-0.47.4 → arrayview-0.47.5}/src/arrayview/_icon.png +0 -0
  28. {arrayview-0.47.4 → arrayview-0.47.5}/src/arrayview/_imaging.py +0 -0
  29. {arrayview-0.47.4 → arrayview-0.47.5}/src/arrayview/_instance_registry.py +0 -0
  30. {arrayview-0.47.4 → arrayview-0.47.5}/src/arrayview/_launch_plan.py +0 -0
  31. {arrayview-0.47.4 → arrayview-0.47.5}/src/arrayview/_launch_trace.py +0 -0
  32. {arrayview-0.47.4 → arrayview-0.47.5}/src/arrayview/_lifecycle.py +0 -0
  33. {arrayview-0.47.4 → arrayview-0.47.5}/src/arrayview/_overlays.py +0 -0
  34. {arrayview-0.47.4 → arrayview-0.47.5}/src/arrayview/_platform.py +0 -0
  35. {arrayview-0.47.4 → arrayview-0.47.5}/src/arrayview/_routes_analysis.py +0 -0
  36. {arrayview-0.47.4 → arrayview-0.47.5}/src/arrayview/_routes_drop.py +0 -0
  37. {arrayview-0.47.4 → arrayview-0.47.5}/src/arrayview/_routes_export.py +0 -0
  38. {arrayview-0.47.4 → arrayview-0.47.5}/src/arrayview/_routes_persistence.py +0 -0
  39. {arrayview-0.47.4 → arrayview-0.47.5}/src/arrayview/_routes_preferences.py +0 -0
  40. {arrayview-0.47.4 → arrayview-0.47.5}/src/arrayview/_routes_preload.py +0 -0
  41. {arrayview-0.47.4 → arrayview-0.47.5}/src/arrayview/_routes_rendering.py +0 -0
  42. {arrayview-0.47.4 → arrayview-0.47.5}/src/arrayview/_routes_segmentation.py +0 -0
  43. {arrayview-0.47.4 → arrayview-0.47.5}/src/arrayview/_routes_state.py +0 -0
  44. {arrayview-0.47.4 → arrayview-0.47.5}/src/arrayview/_routes_vectorfield.py +0 -0
  45. {arrayview-0.47.4 → arrayview-0.47.5}/src/arrayview/_routes_websocket.py +0 -0
  46. {arrayview-0.47.4 → arrayview-0.47.5}/src/arrayview/_segmentation.py +0 -0
  47. {arrayview-0.47.4 → arrayview-0.47.5}/src/arrayview/_server.py +0 -0
  48. {arrayview-0.47.4 → arrayview-0.47.5}/src/arrayview/_session_spec.py +0 -0
  49. {arrayview-0.47.4 → arrayview-0.47.5}/src/arrayview/_shell.html +0 -0
  50. {arrayview-0.47.4 → arrayview-0.47.5}/src/arrayview/_source_safety.py +0 -0
  51. {arrayview-0.47.4 → arrayview-0.47.5}/src/arrayview/_synthetic_mri.py +0 -0
  52. {arrayview-0.47.4 → arrayview-0.47.5}/src/arrayview/_torch.py +0 -0
  53. {arrayview-0.47.4 → arrayview-0.47.5}/src/arrayview/_tutorial.py +0 -0
  54. {arrayview-0.47.4 → arrayview-0.47.5}/src/arrayview/_vectorfield.py +0 -0
  55. {arrayview-0.47.4 → arrayview-0.47.5}/src/arrayview/_vscode.py +0 -0
  56. {arrayview-0.47.4 → arrayview-0.47.5}/src/arrayview/_vscode_browser.py +0 -0
  57. {arrayview-0.47.4 → arrayview-0.47.5}/src/arrayview/_vscode_extension.py +0 -0
  58. {arrayview-0.47.4 → arrayview-0.47.5}/src/arrayview/_vscode_signal.py +0 -0
  59. {arrayview-0.47.4 → arrayview-0.47.5}/src/arrayview/_watch.py +0 -0
  60. {arrayview-0.47.4 → arrayview-0.47.5}/src/arrayview/arrayview-opener.vsix +0 -0
  61. {arrayview-0.47.4 → arrayview-0.47.5}/src/arrayview/gsap.min.js +0 -0
  62. {arrayview-0.47.4 → arrayview-0.47.5}/src/arrayview/todo.md +0 -0
  63. {arrayview-0.47.4 → arrayview-0.47.5}/tests/capture_patient_loading.py +0 -0
  64. {arrayview-0.47.4 → arrayview-0.47.5}/tests/capture_v_animation.py +0 -0
  65. {arrayview-0.47.4 → arrayview-0.47.5}/tests/conftest.py +0 -0
  66. {arrayview-0.47.4 → arrayview-0.47.5}/tests/make_vectorfield_test_arrays.py +0 -0
  67. {arrayview-0.47.4 → arrayview-0.47.5}/tests/test_1d_arrays.py +0 -0
  68. {arrayview-0.47.4 → arrayview-0.47.5}/tests/test_backend_shared.py +0 -0
  69. {arrayview-0.47.4 → arrayview-0.47.5}/tests/test_browser.py +0 -0
  70. {arrayview-0.47.4 → arrayview-0.47.5}/tests/test_cli.py +0 -0
  71. {arrayview-0.47.4 → arrayview-0.47.5}/tests/test_config.py +0 -0
  72. {arrayview-0.47.4 → arrayview-0.47.5}/tests/test_dicom.py +0 -0
  73. {arrayview-0.47.4 → arrayview-0.47.5}/tests/test_drop_import.py +0 -0
  74. {arrayview-0.47.4 → arrayview-0.47.5}/tests/test_instance_registry.py +0 -0
  75. {arrayview-0.47.4 → arrayview-0.47.5}/tests/test_large_arrays.py +0 -0
  76. {arrayview-0.47.4 → arrayview-0.47.5}/tests/test_launch_blackbox.py +0 -0
  77. {arrayview-0.47.4 → arrayview-0.47.5}/tests/test_mode_roundtrip.py +0 -0
  78. {arrayview-0.47.4 → arrayview-0.47.5}/tests/test_nifti_meta.py +0 -0
  79. {arrayview-0.47.4 → arrayview-0.47.5}/tests/test_nifti_series.py +0 -0
  80. {arrayview-0.47.4 → arrayview-0.47.5}/tests/test_rgb_pixel_art.py +0 -0
  81. {arrayview-0.47.4 → arrayview-0.47.5}/tests/test_safe_server_stop.py +0 -0
  82. {arrayview-0.47.4 → arrayview-0.47.5}/tests/test_source_safety.py +0 -0
  83. {arrayview-0.47.4 → arrayview-0.47.5}/tests/test_torch.py +0 -0
  84. {arrayview-0.47.4 → arrayview-0.47.5}/tests/test_tutorial.py +0 -0
  85. {arrayview-0.47.4 → arrayview-0.47.5}/tests/test_vsix_bundle.py +0 -0
  86. {arrayview-0.47.4 → arrayview-0.47.5}/tests/ui_audit.py +0 -0
  87. {arrayview-0.47.4 → arrayview-0.47.5}/tests/v_anim_frames/.gitkeep +0 -0
  88. {arrayview-0.47.4 → 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.4
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.4"
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
  # ---------------------------------------------------------------------------
@@ -1601,41 +1720,220 @@ 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
1728
+
1729
+
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
1606
1740
 
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
1741
 
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.
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.
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(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.
1630
1813
  try:
1631
- progress(done * itemsize, total * itemsize)
1632
- except Exception:
1814
+ os.posix_fadvise(fd, 0, 0, os.POSIX_FADV_RANDOM)
1815
+ except (AttributeError, OSError):
1633
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)
1634
1932
  return out
1635
1933
 
1636
1934
 
1637
1935
  def load_data_with_meta(filepath, key=None, *, load="lazy", stack="auto", select=None,
1638
- progress=None):
1936
+ progress=None, fast_first_frame=False):
1639
1937
  """Like load_data but also returns spatial metadata for NIfTI files.
1640
1938
 
1641
1939
  Returns (array, meta_or_None). meta is None for non-NIfTI formats.
@@ -1661,10 +1959,12 @@ def load_data_with_meta(filepath, key=None, *, load="lazy", stack="auto", select
1661
1959
  return _load_file_series(filepath, load=load, stack=stack)
1662
1960
  if filepath.endswith(".nii") or filepath.endswith(".nii.gz"):
1663
1961
  return _load_nifti_with_meta(filepath)
1664
- 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
1665
1965
 
1666
1966
 
1667
- def load_data(filepath, key=None, *, progress=None):
1967
+ def load_data(filepath, key=None, *, progress=None, fast_first_frame=False):
1668
1968
  if filepath.lower().endswith(".dcm"):
1669
1969
  from ._dicom import load_dicom_series
1670
1970
 
@@ -1685,16 +1985,19 @@ def load_data(filepath, key=None, *, progress=None):
1685
1985
  # read runs behind it (see mmap_first_frame_is_cheap) or the viewer shows
1686
1986
  # a spinner until the read completes.
1687
1987
  size = os.path.getsize(filepath)
1688
- if size >= npy_eager_limit():
1689
- return np.load(filepath, mmap_mode="r")
1690
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
1691
1993
  if mmap_first_frame_is_cheap(mapped, size):
1692
1994
  # Session creation upgrades this to an in-RAM array in the background.
1693
1995
  return mapped
1694
- if progress is not None:
1695
- return _read_npy_reporting_progress(mapped, progress)
1696
- del mapped
1697
- 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)
1698
2001
  elif filepath.endswith(".npz"):
1699
2002
  npz = np.load(filepath)
1700
2003
  try:
@@ -1766,10 +2069,13 @@ def load_data(filepath, key=None, *, progress=None):
1766
2069
 
1767
2070
  f = h5py.File(filepath, "r")
1768
2071
  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()))))
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))
1773
2079
  raise ValueError(
1774
2080
  f".mat (v7.3) file contains multiple datasets: {list(arrays.keys())}. "
1775
2081
  "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,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.
@@ -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
@@ -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