arrayview 0.48.4__py3-none-any.whl → 0.49.1__py3-none-any.whl

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/_analysis.py CHANGED
@@ -60,6 +60,15 @@ def _build_metadata(session) -> dict:
60
60
  }
61
61
  if getattr(session, "initial_view", None):
62
62
  meta["initial_view"] = session.initial_view
63
+ if meta["has_source_file"]:
64
+ from arrayview._config import get_viewer_remember_view
65
+
66
+ if get_viewer_remember_view():
67
+ from arrayview._view_memory import remembered_view
68
+
69
+ remembered = remembered_view(session)
70
+ if remembered:
71
+ meta["remembered_view"] = remembered
63
72
  code_source = _code_source(session)
64
73
  if code_source is not None:
65
74
  meta["code_source"] = code_source
arrayview/_config.py CHANGED
@@ -39,6 +39,8 @@ BUILTIN_PREFERENCES = {
39
39
  "theme": "dark",
40
40
  "rounded_panes": True,
41
41
  "qmri_gapless": False,
42
+ "compare_gapless": False,
43
+ "remember_view": False,
42
44
  "ortho_layout": None,
43
45
  "dimbar_mode": "compact",
44
46
  },
@@ -49,6 +51,8 @@ PREFERENCE_SCHEMA = {
49
51
  "theme": sorted(_VALID_THEMES),
50
52
  "rounded_panes": "boolean",
51
53
  "qmri_gapless": "boolean",
54
+ "compare_gapless": "boolean",
55
+ "remember_view": "boolean",
52
56
  "colormaps": "non-empty list of names",
53
57
  "ortho_layout": sorted(_VALID_ORTHO_LAYOUTS),
54
58
  "dimbar_mode": sorted(_VALID_DIMBAR_MODES),
@@ -120,7 +124,7 @@ def _validate_preference(section: str, key: str, value):
120
124
  )
121
125
  if section == "viewer" and key == "theme":
122
126
  return _normalized_string(value, _VALID_THEMES, "viewer.theme")
123
- if section == "viewer" and key in ("rounded_panes", "qmri_gapless"):
127
+ if section == "viewer" and key in ("rounded_panes", "qmri_gapless", "compare_gapless", "remember_view"):
124
128
  if not isinstance(value, bool):
125
129
  raise InvalidPreferenceError(f"viewer.{key} must be a boolean")
126
130
  return value
@@ -246,6 +250,20 @@ def get_viewer_qmri_gapless() -> bool:
246
250
  return isinstance(viewer_cfg, dict) and viewer_cfg.get("qmri_gapless") is True
247
251
 
248
252
 
253
+ def get_viewer_compare_gapless() -> bool:
254
+ """Whether arrays compared side by side touch, with no space between them."""
255
+ viewer_cfg = load_config().get("viewer", {})
256
+ return isinstance(viewer_cfg, dict) and viewer_cfg.get("compare_gapless") is True
257
+
258
+
259
+ def get_viewer_remember_view() -> bool:
260
+ """Whether reopening a file brings back the view it was last left in."""
261
+ viewer_cfg = load_config().get("viewer", {})
262
+ val = viewer_cfg.get("remember_view") if isinstance(viewer_cfg, dict) else None
263
+ # `arrayview config set` stores the word "true"; the Preferences menu a boolean.
264
+ return val is True or (isinstance(val, str) and val.strip().lower() in ("true", "1", "yes", "on"))
265
+
266
+
249
267
  def get_viewer_ortho_layout() -> str | None:
250
268
  """Return the preferred initial ortho-view layout."""
251
269
  viewer_cfg = load_config().get("viewer", {})
arrayview/_diff.py CHANGED
@@ -135,8 +135,13 @@ def _compute_diff(
135
135
  diff_mode,
136
136
  indices_a=None,
137
137
  indices_b=None,
138
+ stats_out=None,
138
139
  ):
139
- """Return raw diff, display range, colormap, and optional separator mask."""
140
+ """Return raw diff, display range, colormap, and optional separator mask.
141
+
142
+ When *stats_out* is a dict it is filled with summary numbers for the
143
+ compared view (see `_diff_summary`).
144
+ """
140
145
  def _parse_indices(raw_indices):
141
146
  if raw_indices is None:
142
147
  return ()
@@ -170,6 +175,8 @@ def _compute_diff(
170
175
  a = _render_values(session_a, dim_x, dim_y, idx_a, complex_mode, log_scale)
171
176
  b = _render_values(session_b, dim_x, dim_y, idx_b, complex_mode, log_scale)
172
177
  b = _resize_values_like(b, a)
178
+ if stats_out is not None:
179
+ stats_out.update(_diff_summary(a, b, nan_mask))
173
180
  if diff_mode == 1:
174
181
  raw = a - b
175
182
  lo = float(np.nanpercentile(raw, 1))
@@ -192,6 +199,29 @@ def _compute_diff(
192
199
  return raw, vmin, vmax, colormap, nan_mask
193
200
 
194
201
 
202
+ def _diff_summary(a: np.ndarray, b: np.ndarray, nan_mask: np.ndarray | None) -> dict[str, float]:
203
+ """RMS of A−B, largest |A−B|, and RMS of A−B relative to the RMS of A.
204
+
205
+ Pixels that are not finite in either array (or are mosaic separators)
206
+ are left out. Empty or all-zero inputs give NaN for what is undefined.
207
+ """
208
+ a = np.asarray(a, dtype=np.float64)
209
+ b = np.asarray(b, dtype=np.float64)
210
+ valid = np.isfinite(a) & np.isfinite(b)
211
+ if nan_mask is not None and nan_mask.shape == valid.shape:
212
+ valid &= ~nan_mask
213
+ if not valid.any():
214
+ return {"rmse": float("nan"), "max_abs": float("nan"), "rel": float("nan")}
215
+ d = a[valid] - b[valid]
216
+ rmse = float(np.sqrt(np.mean(d * d)))
217
+ rms_a = float(np.sqrt(np.mean(a[valid] ** 2)))
218
+ return {
219
+ "rmse": rmse,
220
+ "max_abs": float(np.max(np.abs(d))),
221
+ "rel": rmse / rms_a if rms_a > 0 else float("nan"),
222
+ }
223
+
224
+
195
225
  def _render_diff_rgba(
196
226
  raw: np.ndarray,
197
227
  vmin: float,
@@ -70,7 +70,10 @@ def runtime_directory() -> Path:
70
70
  return Path(base) / "arrayview"
71
71
  if hasattr(os, "getuid"):
72
72
  return Path(tempfile.gettempdir()) / f"arrayview-{os.getuid()}"
73
- return Path(tempfile.gettempdir()) / f"arrayview-{os.getpid()}"
73
+ # Windows: no uid, but the temp folder is already per user. One shared
74
+ # name lets every command see the servers the others started.
75
+ user = "".join(c for c in os.environ.get("USERNAME", "") if c.isalnum() or c in "-_.")
76
+ return Path(tempfile.gettempdir()) / f"arrayview-{user or 'user'}"
74
77
 
75
78
 
76
79
  def process_start_identity(pid: int) -> str | None:
arrayview/_io.py CHANGED
@@ -503,7 +503,7 @@ def read_gzip_file_parallel(filepath):
503
503
  """
504
504
  try:
505
505
  size = os.path.getsize(filepath)
506
- fd = os.open(filepath, os.O_RDONLY)
506
+ fd = os.open(filepath, _O_RDONLY_BINARY)
507
507
  except OSError:
508
508
  return None
509
509
  buf = bytearray(size)
@@ -518,7 +518,7 @@ def read_gzip_file_parallel(filepath):
518
518
  pos = start
519
519
  try:
520
520
  while pos < stop:
521
- n = os.preadv(fd, [view[pos:stop]], pos)
521
+ n = _pread_into(fd, view[pos:stop], pos)
522
522
  if n <= 0:
523
523
  raise OSError("unexpected end of file")
524
524
  pos += n
@@ -624,10 +624,21 @@ def _mapped_slice_is_scattered(view) -> bool:
624
624
  return nbytes > 4 * view.nbytes and nbytes > _REGION_READ_CHUNK_BYTES
625
625
 
626
626
 
627
+ # Windows has neither pread nor preadv: reads there seek and read under one
628
+ # lock, so they are correct but no longer run in parallel.
629
+ _SEEK_READ_LOCK = threading.Lock()
630
+ _O_RDONLY_BINARY = os.O_RDONLY | getattr(os, "O_BINARY", 0)
631
+
632
+
627
633
  def _pread_into(fd, buf, offset):
628
634
  if hasattr(os, "preadv"):
629
635
  return os.preadv(fd, [buf], offset)
630
- data = os.pread(fd, len(buf), offset)
636
+ if hasattr(os, "pread"):
637
+ data = os.pread(fd, len(buf), offset)
638
+ else:
639
+ with _SEEK_READ_LOCK:
640
+ os.lseek(fd, offset, os.SEEK_SET)
641
+ data = os.read(fd, len(buf))
631
642
  buf[: len(data)] = data
632
643
  return len(data)
633
644
 
@@ -646,7 +657,7 @@ def read_mapped_region(view, progress=None):
646
657
  mv = memoryview(buf)
647
658
  lock = threading.Lock()
648
659
  done = [0]
649
- fd = os.open(filename, os.O_RDONLY)
660
+ fd = os.open(filename, _O_RDONLY_BINARY)
650
661
  try:
651
662
 
652
663
  def _read(start):
@@ -681,9 +692,9 @@ class _ScatteredRegionMixin:
681
692
  map of a 4D one. When a requested slice would be scattered across its
682
693
  region, the region is read in one go (see ``read_mapped_region``) and kept;
683
694
  every later slice of it, in any direction, is then served from memory.
684
- Afterwards the case's other regions and the neighbouring case are read in
685
- the background, so the next step — another map, the next patient — is
686
- usually already there.
695
+ Whichever way the case is first viewed, its regions and the neighbouring
696
+ case's are then read in the background, so the next step — scrolling,
697
+ another map, a crossing view, the next patient — is usually already there.
687
698
  """
688
699
 
689
700
  def _init_region_cache(self):
@@ -793,6 +804,9 @@ class _ScatteredRegionMixin:
793
804
  if region is None:
794
805
  try:
795
806
  if not _mapped_slice_is_scattered(mapped[tuple(spatial_key)]):
807
+ # Cheap to read now, but the next scroll steps and any
808
+ # switch to a crossing view are not: load the case anyway.
809
+ self._schedule_region_warm(p_idx, m_idx, rest, spatial_key)
796
810
  return None
797
811
  view = self._region_view(mapped, rest)
798
812
  if view.nbytes > self._region_cache_max_bytes // 2:
@@ -825,11 +839,6 @@ class _ScatteredRegionMixin:
825
839
  else:
826
840
  step = -1 if last is not None and last[0] > p_idx else 1
827
841
  neighbours = [(p_idx + step, m_idx), (p_idx - step, m_idx)]
828
- # Which axes are on screen is all that carries over to another case;
829
- # the indices along the others may not fit a differently-sized one.
830
- probe = tuple(
831
- slice(None) if isinstance(k, slice) else 0 for k in spatial_key[: min(3, len(spatial_key))]
832
- )
833
842
  plan = [(p_idx, m_idx)] + [
834
843
  (p, m) for p, m in neighbours if 0 <= p < n_p and 0 <= m < n_m
835
844
  ]
@@ -845,7 +854,7 @@ class _ScatteredRegionMixin:
845
854
  if self._file_matrix[p][m] is None:
846
855
  continue
847
856
  mapped = self._mapped_volume(p, m, self._get_volume(p, m))
848
- if mapped is None or not _mapped_slice_is_scattered(mapped[probe + first_rest]):
857
+ if mapped is None:
849
858
  continue
850
859
  n_sp = min(3, mapped.ndim)
851
860
  rests = list(np.ndindex(*mapped.shape[n_sp:])) or [()]
@@ -1106,11 +1115,12 @@ class _FileSeries:
1106
1115
 
1107
1116
  _av_lazy = True
1108
1117
 
1109
- def __init__(self, file_matrix, vol_shape, dtype, spatial_meta=None):
1118
+ def __init__(self, file_matrix, vol_shape, dtype, spatial_meta=None, spacing_matrix=None):
1110
1119
  self._file_matrix = file_matrix
1111
1120
  self._vol_shape = tuple(vol_shape)
1112
1121
  self._dtype = np.dtype(dtype)
1113
1122
  self._spatial_meta = spatial_meta
1123
+ self._spacing_matrix = spacing_matrix
1114
1124
  n_patients = len(file_matrix)
1115
1125
  n_modalities = len(file_matrix[0]) if file_matrix else 0
1116
1126
  if n_modalities <= 1:
@@ -1126,6 +1136,10 @@ class _FileSeries:
1126
1136
  )
1127
1137
  self._cache_lock = threading.Lock()
1128
1138
 
1139
+ @property
1140
+ def case_voxel_spacings(self):
1141
+ return self._spacing_matrix
1142
+
1129
1143
  @property
1130
1144
  def dtype(self):
1131
1145
  return self._dtype
@@ -1378,6 +1392,11 @@ def _is_nifti_path(filepath):
1378
1392
  return filepath.endswith(".nii") or filepath.endswith(".nii.gz")
1379
1393
 
1380
1394
 
1395
+ def _is_gipl_path(filepath):
1396
+ """True if *filepath* ends with .gipl or .gipl.gz."""
1397
+ return filepath.lower().endswith((".gipl", ".gipl.gz"))
1398
+
1399
+
1381
1400
  def _get_ext(filepath):
1382
1401
  """Return the extension of *filepath*, normalising .nii.gz and .zarr.zip."""
1383
1402
  lower = filepath.lower()
@@ -1596,11 +1615,15 @@ def _series_from_file_matrix(
1596
1615
  return series, spatial_meta
1597
1616
 
1598
1617
  spatial_shapes = []
1618
+ # GIPL headers carry a voxel size; use it when every file has one.
1619
+ gipl_metas = [] if all(_is_gipl_path(path) for path in all_paths) else None
1599
1620
  for fpath in all_paths:
1600
1621
  arr = load_data(fpath)
1601
1622
  shape = arr.shape
1602
1623
  dtype = arr.dtype
1603
1624
  spatial_shapes.append(shape)
1625
+ if gipl_metas is not None:
1626
+ gipl_metas.append(_gipl_spatial_meta(fpath, arr))
1604
1627
  if ref_shape is None:
1605
1628
  ref_shape = shape
1606
1629
  ref_dtype = dtype
@@ -1616,30 +1639,47 @@ def _series_from_file_matrix(
1616
1639
  )
1617
1640
  if scan_progress is not None:
1618
1641
  scan_progress(scan_label, fpath)
1642
+ spacing_matrix = None
1643
+ if gipl_metas and all(meta is not None for meta in gipl_metas):
1644
+ spatial_meta = gipl_metas[0]
1645
+ spacing_matrix = []
1619
1646
  shape_matrix = []
1620
1647
  pos = 0
1621
1648
  for row in file_matrix:
1622
1649
  row_shapes = []
1650
+ row_spacings = []
1623
1651
  for path in row:
1624
1652
  if path is None:
1625
1653
  row_shapes.append(ref_shape)
1654
+ if spacing_matrix is not None:
1655
+ row_spacings.append(gipl_metas[0]["voxel_sizes"])
1626
1656
  else:
1627
1657
  row_shapes.append(spatial_shapes[pos])
1658
+ if spacing_matrix is not None:
1659
+ row_spacings.append(gipl_metas[pos]["voxel_sizes"])
1628
1660
  pos += 1
1629
1661
  shape_matrix.append(row_shapes)
1662
+ if spacing_matrix is not None:
1663
+ spacing_matrix.append(row_spacings)
1630
1664
  shapes_differ = any(shape != ref_shape for shape in spatial_shapes)
1631
1665
  if stack == "dense" and shapes_differ:
1632
1666
  raise ValueError("Dense stacking requires every file to have the same shape.")
1633
1667
  if stack == "ragged" or shapes_differ:
1634
- series = _RaggedFileSeries(file_matrix, shape_matrix, ref_dtype, spatial_meta=None)
1668
+ series = _RaggedFileSeries(
1669
+ file_matrix, shape_matrix, ref_dtype,
1670
+ spatial_meta=spatial_meta, spacing_matrix=spacing_matrix,
1671
+ )
1635
1672
  else:
1636
- series = _FileSeries(file_matrix, ref_shape, ref_dtype, spatial_meta=None)
1673
+ series = _FileSeries(
1674
+ file_matrix, ref_shape, ref_dtype,
1675
+ spatial_meta=spatial_meta, spacing_matrix=spacing_matrix,
1676
+ )
1637
1677
  if load == "eager":
1638
1678
  for p, row in enumerate(file_matrix):
1639
1679
  for m, path in enumerate(row):
1640
1680
  if path is not None:
1641
1681
  series._get_volume(p, m)
1642
- return series, None
1682
+ return series, spatial_meta
1643
1683
 
1644
1684
 
1645
1685
  def _collection_scan_workers():
@@ -2182,7 +2222,7 @@ def prefetch_slice_pages(mapped, filepath, dims, index, progress=None) -> bool:
2182
2222
  return False
2183
2223
 
2184
2224
  try:
2185
- fd = os.open(filepath, os.O_RDONLY)
2225
+ fd = os.open(filepath, _O_RDONLY_BINARY)
2186
2226
  except OSError:
2187
2227
  return False
2188
2228
  try:
@@ -2292,13 +2332,13 @@ def read_npy_into_ram(mapped, progress=None):
2292
2332
  lock = threading.Lock()
2293
2333
  done = [0]
2294
2334
 
2295
- fd = os.open(mapped.filename, os.O_RDONLY)
2335
+ fd = os.open(mapped.filename, _O_RDONLY_BINARY)
2296
2336
  try:
2297
2337
  def _read(start):
2298
2338
  stop = min(start + _NPY_READ_CHUNK_BYTES, total)
2299
2339
  pos = start
2300
2340
  while pos < stop:
2301
- n = os.preadv(fd, [dst[pos:stop]], offset + pos)
2341
+ n = _pread_into(fd, dst[pos:stop], offset + pos)
2302
2342
  if n <= 0:
2303
2343
  raise OSError(f"unexpected end of file reading {mapped.filename!r}")
2304
2344
  pos += n
@@ -2350,6 +2390,9 @@ def load_data_with_meta(filepath, key=None, *, load="lazy", stack="auto", select
2350
2390
  return _load_file_series(filepath, load=load, stack=stack)
2351
2391
  if filepath.endswith(".nii") or filepath.endswith(".nii.gz"):
2352
2392
  return _load_nifti_with_meta(filepath)
2393
+ if filepath.lower().endswith((".gipl", ".gipl.gz")):
2394
+ arr = _load_gipl(filepath)
2395
+ return arr, _gipl_spatial_meta(filepath, arr)
2353
2396
  return load_data(
2354
2397
  filepath, key=key, progress=progress, fast_first_frame=fast_first_frame
2355
2398
  ), None
@@ -2546,6 +2589,31 @@ def _load_gipl(filepath):
2546
2589
  return np.ascontiguousarray(arr)
2547
2590
 
2548
2591
 
2592
+ def _gipl_spatial_meta(filepath, arr):
2593
+ """Voxel sizes from a GIPL header, so the viewer shows true proportions.
2594
+
2595
+ GIPL stores no orientation, so axis labels stay empty. Returns None when
2596
+ the header has no usable voxel sizes.
2597
+ """
2598
+ import gzip
2599
+
2600
+ opener = gzip.open if filepath.lower().endswith(".gz") else open
2601
+ with opener(filepath, "rb") as f:
2602
+ header = f.read(_GIPL_HEADER_SIZE)
2603
+ pixdim = np.frombuffer(header, ">f4", count=4, offset=10).astype(float)
2604
+ n = min(arr.ndim, 3)
2605
+ voxel_sizes = tuple(float(v) for v in pixdim[:n])
2606
+ if not all(np.isfinite(v) and v > 0 for v in voxel_sizes):
2607
+ return None
2608
+ return {
2609
+ "voxel_sizes": voxel_sizes,
2610
+ "axis_labels": (),
2611
+ "is_oblique": False,
2612
+ "is_canonical": True,
2613
+ "spatial_shape": tuple(int(v) for v in arr.shape[:n]),
2614
+ }
2615
+
2616
+
2549
2617
  def _tensor_to_numpy(obj, source="object"):
2550
2618
  """Convert a tensor-like object (PyTorch, etc.) to a numpy array."""
2551
2619
  if isinstance(obj, np.ndarray):
@@ -151,7 +151,8 @@ def emit_launch_event(event: str, **attrs: object) -> None:
151
151
  encoded = (json.dumps(payload, separators=(",", ":")) + "\n").encode()
152
152
  fd = os.open(_path, os.O_APPEND | os.O_CREAT | os.O_WRONLY, 0o600)
153
153
  try:
154
- os.fchmod(fd, 0o600)
154
+ if hasattr(os, "fchmod"): # Windows has none before Python 3.13
155
+ os.fchmod(fd, 0o600)
155
156
  os.write(fd, encoded)
156
157
  finally:
157
158
  os.close(fd)
arrayview/_launcher.py CHANGED
@@ -74,10 +74,15 @@ def _detached_child_kwargs() -> dict[str, object]:
74
74
  control event raised for that group reaches *every* process attached to it,
75
75
  so a child being shut down can deliver a KeyboardInterrupt to the process
76
76
  that spawned it — under pytest, to the test runner itself. Both branches
77
- state the same intent: the child gets its own group.
77
+ state the same intent: the child gets its own group. On Windows it also
78
+ gets no console of its own: started from a notebook or a GUI app, a
79
+ background server would otherwise pop up an empty black console window.
78
80
  """
79
81
  if sys.platform == "win32":
80
- return {"creationflags": subprocess.CREATE_NEW_PROCESS_GROUP}
82
+ return {
83
+ "creationflags": subprocess.CREATE_NEW_PROCESS_GROUP
84
+ | subprocess.CREATE_NO_WINDOW
85
+ }
81
86
  return {"start_new_session": True}
82
87
 
83
88
 
@@ -391,6 +396,19 @@ def _build_inline_shell_html(url: str, shell_port: int) -> str | None:
391
396
  return None
392
397
 
393
398
 
399
+ def _set_listen_reuse(sock: "socket.socket") -> None:
400
+ """Let a restarted server rebind its port at once, without sharing it.
401
+
402
+ On Windows ``SO_REUSEADDR`` lets a second process bind a port another
403
+ listener already holds, so two servers could answer on one port; the
404
+ exclusive option there is what the POSIX flag means elsewhere.
405
+ """
406
+ if sys.platform == "win32":
407
+ sock.setsockopt(socket.SOL_SOCKET, socket.SO_EXCLUSIVEADDRUSE, 1)
408
+ else:
409
+ sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
410
+
411
+
394
412
  def _make_loopback_socket(port: int) -> "socket.socket":
395
413
  """Bind a single TCP listener on the same loopback host the viewer URLs use."""
396
414
  for family, socktype, proto, _, sockaddr in socket.getaddrinfo(
@@ -401,7 +419,7 @@ def _make_loopback_socket(port: int) -> "socket.socket":
401
419
  sock = None
402
420
  try:
403
421
  sock = socket.socket(family, socktype, proto)
404
- sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
422
+ _set_listen_reuse(sock)
405
423
  sock.bind(sockaddr)
406
424
  sock.listen(128)
407
425
  sock.set_inheritable(True)
@@ -439,7 +457,7 @@ def _make_loopback_sockets(port: int) -> list["socket.socket"]:
439
457
  sock = None
440
458
  try:
441
459
  sock = socket.socket(family, socktype, proto)
442
- sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
460
+ _set_listen_reuse(sock)
443
461
  # On Linux, binding ::1 first then 127.0.0.1 on the same port is
444
462
  # fine (different addresses). IPV6_V6ONLY stays default (off on
445
463
  # Linux) which is harmless since we bind explicit addresses.
@@ -3539,11 +3557,12 @@ def view(
3539
3557
  vmax: float | None = None,
3540
3558
  log: bool = False,
3541
3559
  complex: str | None = None,
3560
+ voxel_size=None,
3542
3561
  ):
3543
3562
  """
3544
3563
  Launch the viewer. Does not block the main Python process.
3545
3564
 
3546
- Accepts one to four arrays::
3565
+ Accepts one to six arrays (the same limit as the command line)::
3547
3566
 
3548
3567
  handle = arrayview.view(a)
3549
3568
  ha, hb = arrayview.view(a, b)
@@ -3588,6 +3607,10 @@ def view(
3588
3607
  ``"imag"`` (``"mag"`` shows the absolute value of real data). Pressing
3589
3608
  ``E`` in the viewer copies the current view as such a call.
3590
3609
 
3610
+ ``voxel_size`` — physical size of one step along each dimension, e.g.
3611
+ ``(1, 1, 2)`` for 1 mm pixels and 2 mm slices. Slices then show their
3612
+ true proportions. Leading dimensions only; later ones keep size 1.
3613
+
3591
3614
  Returns a ``ViewHandle`` for a single array, or a tuple of ``ViewHandle``
3592
3615
  objects for multiple arrays (one per array). In inline/Jupyter mode with
3593
3616
  multiple arrays, the IFrame is displayed automatically and a uniform tuple
@@ -3601,9 +3624,14 @@ def view(
3601
3624
 
3602
3625
  _initial_view = _session_mod.initial_view_spec(
3603
3626
  mode=mode, dims=dims, index=index, cmap=cmap, vmin=vmin, vmax=vmax, log=log,
3604
- complex=complex,
3627
+ complex=complex, voxel_size=voxel_size,
3605
3628
  )
3606
3629
  _code_name = _caller_variable_name(arrays[0]) if arrays else None
3630
+ # Several arrays side by side need telling apart; the caller's variable
3631
+ # names do that better than identical "Array (shape)" labels.
3632
+ _var_names = (
3633
+ [_caller_variable_name(arr) for arr in arrays] if len(arrays) > 1 else []
3634
+ )
3607
3635
 
3608
3636
  height = _normalize_inline_height(height)
3609
3637
  _inline_mode_heights = _normalize_inline_mode_heights(mode_heights)
@@ -3612,8 +3640,8 @@ def view(
3612
3640
  n_arrays = len(arrays)
3613
3641
  if n_arrays == 0:
3614
3642
  raise ValueError("view() requires at least one array.")
3615
- if n_arrays > 4:
3616
- raise ValueError(f"view() accepts at most 4 arrays, got {n_arrays}.")
3643
+ if n_arrays > 6:
3644
+ raise ValueError(f"view() accepts at most 6 arrays, got {n_arrays}.")
3617
3645
 
3618
3646
  # --- Normalise per-array kwargs (name, rgb) ---
3619
3647
  if isinstance(name, list):
@@ -3664,6 +3692,8 @@ def view(
3664
3692
  arrays = tuple(_convert_array(arr) for arr in arrays)
3665
3693
 
3666
3694
  # --- Apply name defaults after conversion (shape is available now) ---
3695
+ if len(set(filter(None, _var_names))) == n_arrays:
3696
+ names = [names[i] if names[i] is not None else _var_names[i] for i in range(n_arrays)]
3667
3697
  names = [
3668
3698
  (f"Array {getattr(arrays[i], 'shape', '?')}" if names[i] is None else names[i])
3669
3699
  for i in range(n_arrays)
@@ -3680,6 +3710,13 @@ def view(
3680
3710
  f"index has {len(_initial_view['index'])} entries but the array "
3681
3711
  f"has {_ndim} dimensions"
3682
3712
  )
3713
+ if _initial_view and "voxel_size" in _initial_view:
3714
+ _ndim = len(getattr(data, "shape", ()))
3715
+ if len(_initial_view["voxel_size"]) > _ndim:
3716
+ raise ValueError(
3717
+ f"voxel_size has {len(_initial_view['voxel_size'])} entries but the "
3718
+ f"array has {_ndim} dimensions"
3719
+ )
3683
3720
 
3684
3721
  # --- Normalise string window modes ---
3685
3722
  _raw_window = window
@@ -5633,8 +5670,26 @@ def _handle_management_command(argv: list[str]) -> bool:
5633
5670
  return True
5634
5671
 
5635
5672
 
5673
+ def _replace_unprintable_output() -> None:
5674
+ """Keep messages with arrows or ticks from crashing a Windows console.
5675
+
5676
+ Output redirected to a file or pipe on Windows uses an old code page that
5677
+ cannot encode such characters, so printing them raised instead of showing
5678
+ the message. The servers this command starts inherit the same setting.
5679
+ """
5680
+ if os.name != "nt":
5681
+ return
5682
+ os.environ.setdefault("PYTHONIOENCODING", ":replace")
5683
+ for stream in (sys.stdout, sys.stderr):
5684
+ try:
5685
+ stream.reconfigure(errors="replace")
5686
+ except (AttributeError, ValueError):
5687
+ pass
5688
+
5689
+
5636
5690
  def arrayview():
5637
5691
  """Command Line Interface Entry Point."""
5692
+ _replace_unprintable_output()
5638
5693
  # Handle "arrayview config ..." subcommand before argparse
5639
5694
  if len(sys.argv) > 1 and sys.argv[1] == "config":
5640
5695
  _handle_config_command(sys.argv[2:])
arrayview/_platform.py CHANGED
@@ -452,8 +452,17 @@ def _has_vscode_window_registration() -> bool:
452
452
  def _process_is_alive(pid: object) -> bool:
453
453
  try:
454
454
  numeric_pid = int(pid)
455
+ except (TypeError, ValueError):
456
+ return False
457
+ if os.name == "nt":
458
+ # Signal 0 is CTRL_C_EVENT on Windows: os.kill would try to interrupt
459
+ # a console process group rather than test whether the pid exists.
460
+ from arrayview._instance_registry import process_start_identity
461
+
462
+ return process_start_identity(numeric_pid) is not None
463
+ try:
455
464
  os.kill(numeric_pid, 0)
456
- except (OSError, TypeError, ValueError):
465
+ except OSError:
457
466
  return False
458
467
  if sys.platform.startswith("linux"):
459
468
  try:
@@ -1,5 +1,6 @@
1
1
  import asyncio
2
2
  import io
3
+ import re
3
4
 
4
5
  import numpy as np
5
6
  from fastapi import Body, Depends, Request, Response
@@ -8,6 +9,12 @@ from fastapi.responses import JSONResponse
8
9
  from arrayview._render import render_rgba
9
10
 
10
11
 
12
+ def _safe_filename(name: str, default: str) -> str:
13
+ """A bare file name every OS accepts: no folders, no reserved characters."""
14
+ name = re.sub(r'[\\/:*?"<>|\x00-\x1f\s]+', "_", str(name or "")).strip("._")
15
+ return name or default
16
+
17
+
11
18
  def register_export_routes(app, *, get_session_or_404, pil_image) -> None:
12
19
  @app.get("/export_array/{sid}")
13
20
  def export_array(
@@ -20,8 +27,7 @@ def register_export_routes(app, *, get_session_or_404, pil_image) -> None:
20
27
  buf = io.BytesIO()
21
28
  np.save(buf, data)
22
29
  buf.seek(0)
23
- name_stem = (session.name or "array").replace(" ", "_").replace("/", "_")
24
- filename = f"{name_stem}.npy"
30
+ filename = f"{_safe_filename(session.name, 'array')}.npy"
25
31
  if save_to_downloads:
26
32
  import pathlib
27
33
 
@@ -39,7 +45,7 @@ def register_export_routes(app, *, get_session_or_404, pil_image) -> None:
39
45
  async def save_file(request: Request):
40
46
  """Save a client-generated file to the Downloads folder."""
41
47
  body = await request.json()
42
- filename = body.get("filename", "arrayview_export")
48
+ filename = _safe_filename(body.get("filename"), "arrayview_export")
43
49
  data_b64 = body.get("data", "")
44
50
  if "," in data_b64:
45
51
  data_b64 = data_b64.split(",", 1)[1]
@@ -618,6 +618,18 @@ def register_loading_routes(app, *, notify_shells, setup_rgb) -> None:
618
618
  )
619
619
  return {"sid": session.sid, "name": name, "notified": notified}
620
620
 
621
+ @app.post("/reveal/{sid}")
622
+ async def reveal_session_tab(sid: str):
623
+ """Bring the VS Code browser tab already showing *sid* forward."""
624
+ from arrayview._routes_websocket import viewer_tab_reveal_target
625
+ from arrayview._vscode_signal import _request_vscode_tab_reveal
626
+
627
+ target = viewer_tab_reveal_target(sid)
628
+ if target is None:
629
+ return {"sid": sid, "revealed": False}
630
+ revealed = await asyncio.to_thread(_request_vscode_tab_reveal, *target)
631
+ return {"sid": sid, "revealed": revealed}
632
+
621
633
  @app.post("/release/{sid}")
622
634
  def release_viewer_session(sid: str, request: Request):
623
635
  """Release a session when its owning viewer tab/window closes."""
@@ -68,6 +68,28 @@ def register_preferences_routes(app) -> None:
68
68
  return JSONResponse({"error": "session_not_found"}, status_code=404)
69
69
  return _response_payload()
70
70
 
71
+ @app.post("/view-memory/{sid}")
72
+ async def view_memory_post(sid: str, request: Request):
73
+ """Store the view a file was left in (Preferences → Reopen where you left off)."""
74
+ session = SESSIONS.get(sid)
75
+ if session is None:
76
+ return JSONResponse({"error": "session_not_found"}, status_code=404)
77
+ from arrayview._config import get_viewer_remember_view
78
+ from arrayview._view_memory import remember_view
79
+
80
+ if not get_viewer_remember_view():
81
+ return {"saved": False}
82
+ try:
83
+ body = await request.json()
84
+ except Exception:
85
+ return JSONResponse({"error": "invalid_json"}, status_code=400)
86
+ view = body.get("view") if isinstance(body, dict) else None
87
+ try:
88
+ saved = remember_view(session, view)
89
+ except OSError:
90
+ saved = False
91
+ return {"saved": saved}
92
+
71
93
  @app.patch("/preferences/{sid}")
72
94
  async def preferences_patch(sid: str, request: Request):
73
95
  if SESSIONS.get(sid) is None: