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.
- {arrayview-0.47.4 → arrayview-0.48.0}/PKG-INFO +1 -1
- {arrayview-0.47.4 → arrayview-0.48.0}/pyproject.toml +1 -1
- {arrayview-0.47.4 → arrayview-0.48.0}/src/arrayview/_io.py +368 -44
- {arrayview-0.47.4 → arrayview-0.48.0}/src/arrayview/_launcher.py +1 -0
- {arrayview-0.47.4 → arrayview-0.48.0}/src/arrayview/_render.py +13 -1
- {arrayview-0.47.4 → arrayview-0.48.0}/src/arrayview/_routes_loading.py +1 -0
- {arrayview-0.47.4 → arrayview-0.48.0}/src/arrayview/_routes_query.py +1 -0
- {arrayview-0.47.4 → arrayview-0.48.0}/src/arrayview/_routes_websocket.py +24 -6
- {arrayview-0.47.4 → arrayview-0.48.0}/src/arrayview/_session.py +10 -7
- {arrayview-0.47.4 → arrayview-0.48.0}/src/arrayview/_viewer.html +181 -66
- {arrayview-0.47.4 → arrayview-0.48.0}/src/arrayview/_vscode_extension.py +1 -1
- arrayview-0.48.0/src/arrayview/arrayview-opener.vsix +0 -0
- {arrayview-0.47.4 → arrayview-0.48.0}/tests/test_api.py +2 -2
- arrayview-0.48.0/tests/test_io.py +264 -0
- arrayview-0.47.4/src/arrayview/arrayview-opener.vsix +0 -0
- arrayview-0.47.4/tests/test_io.py +0 -91
- {arrayview-0.47.4 → arrayview-0.48.0}/.gitignore +0 -0
- {arrayview-0.47.4 → arrayview-0.48.0}/LICENSE +0 -0
- {arrayview-0.47.4 → arrayview-0.48.0}/README.md +0 -0
- {arrayview-0.47.4 → arrayview-0.48.0}/src/arrayview/ARCHITECTURE.md +0 -0
- {arrayview-0.47.4 → arrayview-0.48.0}/src/arrayview/__init__.py +0 -0
- {arrayview-0.47.4 → arrayview-0.48.0}/src/arrayview/__main__.py +0 -0
- {arrayview-0.47.4 → arrayview-0.48.0}/src/arrayview/_analysis.py +0 -0
- {arrayview-0.47.4 → arrayview-0.48.0}/src/arrayview/_app.py +0 -0
- {arrayview-0.47.4 → arrayview-0.48.0}/src/arrayview/_bootstrap_app.py +0 -0
- {arrayview-0.47.4 → arrayview-0.48.0}/src/arrayview/_config.py +0 -0
- {arrayview-0.47.4 → arrayview-0.48.0}/src/arrayview/_diagnostics.py +0 -0
- {arrayview-0.47.4 → arrayview-0.48.0}/src/arrayview/_dicom.py +0 -0
- {arrayview-0.47.4 → arrayview-0.48.0}/src/arrayview/_diff.py +0 -0
- {arrayview-0.47.4 → arrayview-0.48.0}/src/arrayview/_extra_ports.py +0 -0
- {arrayview-0.47.4 → arrayview-0.48.0}/src/arrayview/_icon.png +0 -0
- {arrayview-0.47.4 → arrayview-0.48.0}/src/arrayview/_imaging.py +0 -0
- {arrayview-0.47.4 → arrayview-0.48.0}/src/arrayview/_instance_registry.py +0 -0
- {arrayview-0.47.4 → arrayview-0.48.0}/src/arrayview/_launch_plan.py +0 -0
- {arrayview-0.47.4 → arrayview-0.48.0}/src/arrayview/_launch_trace.py +0 -0
- {arrayview-0.47.4 → arrayview-0.48.0}/src/arrayview/_lifecycle.py +0 -0
- {arrayview-0.47.4 → arrayview-0.48.0}/src/arrayview/_overlays.py +0 -0
- {arrayview-0.47.4 → arrayview-0.48.0}/src/arrayview/_platform.py +0 -0
- {arrayview-0.47.4 → arrayview-0.48.0}/src/arrayview/_routes_analysis.py +0 -0
- {arrayview-0.47.4 → arrayview-0.48.0}/src/arrayview/_routes_drop.py +0 -0
- {arrayview-0.47.4 → arrayview-0.48.0}/src/arrayview/_routes_export.py +0 -0
- {arrayview-0.47.4 → arrayview-0.48.0}/src/arrayview/_routes_persistence.py +0 -0
- {arrayview-0.47.4 → arrayview-0.48.0}/src/arrayview/_routes_preferences.py +0 -0
- {arrayview-0.47.4 → arrayview-0.48.0}/src/arrayview/_routes_preload.py +0 -0
- {arrayview-0.47.4 → arrayview-0.48.0}/src/arrayview/_routes_rendering.py +0 -0
- {arrayview-0.47.4 → arrayview-0.48.0}/src/arrayview/_routes_segmentation.py +0 -0
- {arrayview-0.47.4 → arrayview-0.48.0}/src/arrayview/_routes_state.py +0 -0
- {arrayview-0.47.4 → arrayview-0.48.0}/src/arrayview/_routes_vectorfield.py +0 -0
- {arrayview-0.47.4 → arrayview-0.48.0}/src/arrayview/_segmentation.py +0 -0
- {arrayview-0.47.4 → arrayview-0.48.0}/src/arrayview/_server.py +0 -0
- {arrayview-0.47.4 → arrayview-0.48.0}/src/arrayview/_session_spec.py +0 -0
- {arrayview-0.47.4 → arrayview-0.48.0}/src/arrayview/_shell.html +0 -0
- {arrayview-0.47.4 → arrayview-0.48.0}/src/arrayview/_source_safety.py +0 -0
- {arrayview-0.47.4 → arrayview-0.48.0}/src/arrayview/_synthetic_mri.py +0 -0
- {arrayview-0.47.4 → arrayview-0.48.0}/src/arrayview/_torch.py +0 -0
- {arrayview-0.47.4 → arrayview-0.48.0}/src/arrayview/_tutorial.py +0 -0
- {arrayview-0.47.4 → arrayview-0.48.0}/src/arrayview/_vectorfield.py +0 -0
- {arrayview-0.47.4 → arrayview-0.48.0}/src/arrayview/_vscode.py +0 -0
- {arrayview-0.47.4 → arrayview-0.48.0}/src/arrayview/_vscode_browser.py +0 -0
- {arrayview-0.47.4 → arrayview-0.48.0}/src/arrayview/_vscode_signal.py +0 -0
- {arrayview-0.47.4 → arrayview-0.48.0}/src/arrayview/_watch.py +0 -0
- {arrayview-0.47.4 → arrayview-0.48.0}/src/arrayview/gsap.min.js +0 -0
- {arrayview-0.47.4 → arrayview-0.48.0}/src/arrayview/todo.md +0 -0
- {arrayview-0.47.4 → arrayview-0.48.0}/tests/capture_patient_loading.py +0 -0
- {arrayview-0.47.4 → arrayview-0.48.0}/tests/capture_v_animation.py +0 -0
- {arrayview-0.47.4 → arrayview-0.48.0}/tests/conftest.py +0 -0
- {arrayview-0.47.4 → arrayview-0.48.0}/tests/make_vectorfield_test_arrays.py +0 -0
- {arrayview-0.47.4 → arrayview-0.48.0}/tests/test_1d_arrays.py +0 -0
- {arrayview-0.47.4 → arrayview-0.48.0}/tests/test_backend_shared.py +0 -0
- {arrayview-0.47.4 → arrayview-0.48.0}/tests/test_browser.py +0 -0
- {arrayview-0.47.4 → arrayview-0.48.0}/tests/test_cli.py +0 -0
- {arrayview-0.47.4 → arrayview-0.48.0}/tests/test_config.py +0 -0
- {arrayview-0.47.4 → arrayview-0.48.0}/tests/test_dicom.py +0 -0
- {arrayview-0.47.4 → arrayview-0.48.0}/tests/test_drop_import.py +0 -0
- {arrayview-0.47.4 → arrayview-0.48.0}/tests/test_instance_registry.py +0 -0
- {arrayview-0.47.4 → arrayview-0.48.0}/tests/test_large_arrays.py +0 -0
- {arrayview-0.47.4 → arrayview-0.48.0}/tests/test_launch_blackbox.py +0 -0
- {arrayview-0.47.4 → arrayview-0.48.0}/tests/test_mode_roundtrip.py +0 -0
- {arrayview-0.47.4 → arrayview-0.48.0}/tests/test_nifti_meta.py +0 -0
- {arrayview-0.47.4 → arrayview-0.48.0}/tests/test_nifti_series.py +0 -0
- {arrayview-0.47.4 → arrayview-0.48.0}/tests/test_rgb_pixel_art.py +0 -0
- {arrayview-0.47.4 → arrayview-0.48.0}/tests/test_safe_server_stop.py +0 -0
- {arrayview-0.47.4 → arrayview-0.48.0}/tests/test_source_safety.py +0 -0
- {arrayview-0.47.4 → arrayview-0.48.0}/tests/test_torch.py +0 -0
- {arrayview-0.47.4 → arrayview-0.48.0}/tests/test_tutorial.py +0 -0
- {arrayview-0.47.4 → arrayview-0.48.0}/tests/test_vsix_bundle.py +0 -0
- {arrayview-0.47.4 → arrayview-0.48.0}/tests/ui_audit.py +0 -0
- {arrayview-0.47.4 → arrayview-0.48.0}/tests/v_anim_frames/.gitkeep +0 -0
- {arrayview-0.47.4 → arrayview-0.48.0}/tests/visual_smoke.py +0 -0
|
@@ -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
|
-
|
|
439
|
-
img.dataobj
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
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
|
-
|
|
1605
|
-
|
|
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
|
-
|
|
1613
|
-
|
|
1614
|
-
|
|
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
|
-
|
|
1617
|
-
|
|
1618
|
-
|
|
1619
|
-
|
|
1620
|
-
|
|
1621
|
-
|
|
1622
|
-
|
|
1623
|
-
|
|
1624
|
-
|
|
1625
|
-
|
|
1626
|
-
|
|
1627
|
-
|
|
1628
|
-
|
|
1629
|
-
|
|
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
|
-
|
|
1632
|
-
except
|
|
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(
|
|
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
|
|
1695
|
-
|
|
1696
|
-
|
|
1697
|
-
|
|
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
|
-
|
|
1771
|
-
|
|
1772
|
-
|
|
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."
|
|
@@ -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
|
-
|
|
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
|
|
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.
|
|
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
|
-
|
|
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
|
|