arrayview 0.48.4__tar.gz → 0.49.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.48.4 → arrayview-0.49.0}/PKG-INFO +1 -1
  2. {arrayview-0.48.4 → arrayview-0.49.0}/pyproject.toml +1 -1
  3. {arrayview-0.48.4 → arrayview-0.49.0}/src/arrayview/_analysis.py +9 -0
  4. {arrayview-0.48.4 → arrayview-0.49.0}/src/arrayview/_config.py +19 -1
  5. {arrayview-0.48.4 → arrayview-0.49.0}/src/arrayview/_diff.py +31 -1
  6. {arrayview-0.48.4 → arrayview-0.49.0}/src/arrayview/_io.py +63 -4
  7. {arrayview-0.48.4 → arrayview-0.49.0}/src/arrayview/_launcher.py +23 -4
  8. {arrayview-0.48.4 → arrayview-0.49.0}/src/arrayview/_platform.py +10 -1
  9. {arrayview-0.48.4 → arrayview-0.49.0}/src/arrayview/_routes_preferences.py +22 -0
  10. {arrayview-0.48.4 → arrayview-0.49.0}/src/arrayview/_routes_rendering.py +7 -0
  11. {arrayview-0.48.4 → arrayview-0.49.0}/src/arrayview/_routes_websocket.py +29 -0
  12. {arrayview-0.48.4 → arrayview-0.49.0}/src/arrayview/_server.py +4 -0
  13. {arrayview-0.48.4 → arrayview-0.49.0}/src/arrayview/_session.py +9 -0
  14. arrayview-0.49.0/src/arrayview/_view_memory.py +102 -0
  15. {arrayview-0.48.4 → arrayview-0.49.0}/src/arrayview/_viewer.html +768 -433
  16. {arrayview-0.48.4 → arrayview-0.49.0}/src/arrayview/_vscode_extension.py +1 -1
  17. {arrayview-0.48.4 → arrayview-0.49.0}/src/arrayview/_vscode_signal.py +3 -3
  18. {arrayview-0.48.4 → arrayview-0.49.0}/tests/test_api.py +3 -1
  19. {arrayview-0.48.4 → arrayview-0.49.0}/tests/test_backend_shared.py +14 -0
  20. {arrayview-0.48.4 → arrayview-0.49.0}/tests/test_browser.py +132 -9
  21. {arrayview-0.48.4 → arrayview-0.49.0}/tests/test_config.py +8 -0
  22. {arrayview-0.48.4 → arrayview-0.49.0}/tests/test_instance_registry.py +15 -0
  23. {arrayview-0.48.4 → arrayview-0.49.0}/tests/test_io.py +40 -1
  24. arrayview-0.49.0/tests/test_view_memory.py +32 -0
  25. {arrayview-0.48.4 → arrayview-0.49.0}/.gitignore +0 -0
  26. {arrayview-0.48.4 → arrayview-0.49.0}/LICENSE +0 -0
  27. {arrayview-0.48.4 → arrayview-0.49.0}/README.md +0 -0
  28. {arrayview-0.48.4 → arrayview-0.49.0}/src/arrayview/ARCHITECTURE.md +0 -0
  29. {arrayview-0.48.4 → arrayview-0.49.0}/src/arrayview/__init__.py +0 -0
  30. {arrayview-0.48.4 → arrayview-0.49.0}/src/arrayview/__main__.py +0 -0
  31. {arrayview-0.48.4 → arrayview-0.49.0}/src/arrayview/_app.py +0 -0
  32. {arrayview-0.48.4 → arrayview-0.49.0}/src/arrayview/_bootstrap_app.py +0 -0
  33. {arrayview-0.48.4 → arrayview-0.49.0}/src/arrayview/_diagnostics.py +0 -0
  34. {arrayview-0.48.4 → arrayview-0.49.0}/src/arrayview/_dicom.py +0 -0
  35. {arrayview-0.48.4 → arrayview-0.49.0}/src/arrayview/_extra_ports.py +0 -0
  36. {arrayview-0.48.4 → arrayview-0.49.0}/src/arrayview/_icon.png +0 -0
  37. {arrayview-0.48.4 → arrayview-0.49.0}/src/arrayview/_imaging.py +0 -0
  38. {arrayview-0.48.4 → arrayview-0.49.0}/src/arrayview/_instance_registry.py +0 -0
  39. {arrayview-0.48.4 → arrayview-0.49.0}/src/arrayview/_launch_plan.py +0 -0
  40. {arrayview-0.48.4 → arrayview-0.49.0}/src/arrayview/_launch_trace.py +0 -0
  41. {arrayview-0.48.4 → arrayview-0.49.0}/src/arrayview/_lifecycle.py +0 -0
  42. {arrayview-0.48.4 → arrayview-0.49.0}/src/arrayview/_overlays.py +0 -0
  43. {arrayview-0.48.4 → arrayview-0.49.0}/src/arrayview/_render.py +0 -0
  44. {arrayview-0.48.4 → arrayview-0.49.0}/src/arrayview/_routes_analysis.py +0 -0
  45. {arrayview-0.48.4 → arrayview-0.49.0}/src/arrayview/_routes_drop.py +0 -0
  46. {arrayview-0.48.4 → arrayview-0.49.0}/src/arrayview/_routes_export.py +0 -0
  47. {arrayview-0.48.4 → arrayview-0.49.0}/src/arrayview/_routes_loading.py +0 -0
  48. {arrayview-0.48.4 → arrayview-0.49.0}/src/arrayview/_routes_persistence.py +0 -0
  49. {arrayview-0.48.4 → arrayview-0.49.0}/src/arrayview/_routes_preload.py +0 -0
  50. {arrayview-0.48.4 → arrayview-0.49.0}/src/arrayview/_routes_query.py +0 -0
  51. {arrayview-0.48.4 → arrayview-0.49.0}/src/arrayview/_routes_segmentation.py +0 -0
  52. {arrayview-0.48.4 → arrayview-0.49.0}/src/arrayview/_routes_state.py +0 -0
  53. {arrayview-0.48.4 → arrayview-0.49.0}/src/arrayview/_routes_vectorfield.py +0 -0
  54. {arrayview-0.48.4 → arrayview-0.49.0}/src/arrayview/_segmentation.py +0 -0
  55. {arrayview-0.48.4 → arrayview-0.49.0}/src/arrayview/_session_spec.py +0 -0
  56. {arrayview-0.48.4 → arrayview-0.49.0}/src/arrayview/_shell.html +0 -0
  57. {arrayview-0.48.4 → arrayview-0.49.0}/src/arrayview/_source_safety.py +0 -0
  58. {arrayview-0.48.4 → arrayview-0.49.0}/src/arrayview/_synthetic_mri.py +0 -0
  59. {arrayview-0.48.4 → arrayview-0.49.0}/src/arrayview/_torch.py +0 -0
  60. {arrayview-0.48.4 → arrayview-0.49.0}/src/arrayview/_tutorial.py +0 -0
  61. {arrayview-0.48.4 → arrayview-0.49.0}/src/arrayview/_vectorfield.py +0 -0
  62. {arrayview-0.48.4 → arrayview-0.49.0}/src/arrayview/_vscode.py +0 -0
  63. {arrayview-0.48.4 → arrayview-0.49.0}/src/arrayview/_vscode_browser.py +0 -0
  64. {arrayview-0.48.4 → arrayview-0.49.0}/src/arrayview/_watch.py +0 -0
  65. {arrayview-0.48.4 → arrayview-0.49.0}/src/arrayview/arrayview-opener.vsix +0 -0
  66. {arrayview-0.48.4 → arrayview-0.49.0}/src/arrayview/gsap.min.js +0 -0
  67. {arrayview-0.48.4 → arrayview-0.49.0}/src/arrayview/todo.md +0 -0
  68. {arrayview-0.48.4 → arrayview-0.49.0}/tests/capture_patient_loading.py +0 -0
  69. {arrayview-0.48.4 → arrayview-0.49.0}/tests/capture_v_animation.py +0 -0
  70. {arrayview-0.48.4 → arrayview-0.49.0}/tests/conftest.py +0 -0
  71. {arrayview-0.48.4 → arrayview-0.49.0}/tests/make_vectorfield_test_arrays.py +0 -0
  72. {arrayview-0.48.4 → arrayview-0.49.0}/tests/test_1d_arrays.py +0 -0
  73. {arrayview-0.48.4 → arrayview-0.49.0}/tests/test_cli.py +0 -0
  74. {arrayview-0.48.4 → arrayview-0.49.0}/tests/test_dicom.py +0 -0
  75. {arrayview-0.48.4 → arrayview-0.49.0}/tests/test_drop_import.py +0 -0
  76. {arrayview-0.48.4 → arrayview-0.49.0}/tests/test_large_arrays.py +0 -0
  77. {arrayview-0.48.4 → arrayview-0.49.0}/tests/test_launch_blackbox.py +0 -0
  78. {arrayview-0.48.4 → arrayview-0.49.0}/tests/test_mode_roundtrip.py +0 -0
  79. {arrayview-0.48.4 → arrayview-0.49.0}/tests/test_nifti_meta.py +0 -0
  80. {arrayview-0.48.4 → arrayview-0.49.0}/tests/test_nifti_series.py +0 -0
  81. {arrayview-0.48.4 → arrayview-0.49.0}/tests/test_rgb_pixel_art.py +0 -0
  82. {arrayview-0.48.4 → arrayview-0.49.0}/tests/test_safe_server_stop.py +0 -0
  83. {arrayview-0.48.4 → arrayview-0.49.0}/tests/test_source_safety.py +0 -0
  84. {arrayview-0.48.4 → arrayview-0.49.0}/tests/test_torch.py +0 -0
  85. {arrayview-0.48.4 → arrayview-0.49.0}/tests/test_tutorial.py +0 -0
  86. {arrayview-0.48.4 → arrayview-0.49.0}/tests/test_vsix_bundle.py +0 -0
  87. {arrayview-0.48.4 → arrayview-0.49.0}/tests/ui_audit.py +0 -0
  88. {arrayview-0.48.4 → arrayview-0.49.0}/tests/v_anim_frames/.gitkeep +0 -0
  89. {arrayview-0.48.4 → arrayview-0.49.0}/tests/visual_smoke.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: arrayview
3
- Version: 0.48.4
3
+ Version: 0.49.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.48.4"
7
+ version = "0.49.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"
@@ -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
@@ -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", {})
@@ -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,
@@ -1106,11 +1106,12 @@ class _FileSeries:
1106
1106
 
1107
1107
  _av_lazy = True
1108
1108
 
1109
- def __init__(self, file_matrix, vol_shape, dtype, spatial_meta=None):
1109
+ def __init__(self, file_matrix, vol_shape, dtype, spatial_meta=None, spacing_matrix=None):
1110
1110
  self._file_matrix = file_matrix
1111
1111
  self._vol_shape = tuple(vol_shape)
1112
1112
  self._dtype = np.dtype(dtype)
1113
1113
  self._spatial_meta = spatial_meta
1114
+ self._spacing_matrix = spacing_matrix
1114
1115
  n_patients = len(file_matrix)
1115
1116
  n_modalities = len(file_matrix[0]) if file_matrix else 0
1116
1117
  if n_modalities <= 1:
@@ -1126,6 +1127,10 @@ class _FileSeries:
1126
1127
  )
1127
1128
  self._cache_lock = threading.Lock()
1128
1129
 
1130
+ @property
1131
+ def case_voxel_spacings(self):
1132
+ return self._spacing_matrix
1133
+
1129
1134
  @property
1130
1135
  def dtype(self):
1131
1136
  return self._dtype
@@ -1378,6 +1383,11 @@ def _is_nifti_path(filepath):
1378
1383
  return filepath.endswith(".nii") or filepath.endswith(".nii.gz")
1379
1384
 
1380
1385
 
1386
+ def _is_gipl_path(filepath):
1387
+ """True if *filepath* ends with .gipl or .gipl.gz."""
1388
+ return filepath.lower().endswith((".gipl", ".gipl.gz"))
1389
+
1390
+
1381
1391
  def _get_ext(filepath):
1382
1392
  """Return the extension of *filepath*, normalising .nii.gz and .zarr.zip."""
1383
1393
  lower = filepath.lower()
@@ -1596,11 +1606,15 @@ def _series_from_file_matrix(
1596
1606
  return series, spatial_meta
1597
1607
 
1598
1608
  spatial_shapes = []
1609
+ # GIPL headers carry a voxel size; use it when every file has one.
1610
+ gipl_metas = [] if all(_is_gipl_path(path) for path in all_paths) else None
1599
1611
  for fpath in all_paths:
1600
1612
  arr = load_data(fpath)
1601
1613
  shape = arr.shape
1602
1614
  dtype = arr.dtype
1603
1615
  spatial_shapes.append(shape)
1616
+ if gipl_metas is not None:
1617
+ gipl_metas.append(_gipl_spatial_meta(fpath, arr))
1604
1618
  if ref_shape is None:
1605
1619
  ref_shape = shape
1606
1620
  ref_dtype = dtype
@@ -1616,30 +1630,47 @@ def _series_from_file_matrix(
1616
1630
  )
1617
1631
  if scan_progress is not None:
1618
1632
  scan_progress(scan_label, fpath)
1633
+ spacing_matrix = None
1634
+ if gipl_metas and all(meta is not None for meta in gipl_metas):
1635
+ spatial_meta = gipl_metas[0]
1636
+ spacing_matrix = []
1619
1637
  shape_matrix = []
1620
1638
  pos = 0
1621
1639
  for row in file_matrix:
1622
1640
  row_shapes = []
1641
+ row_spacings = []
1623
1642
  for path in row:
1624
1643
  if path is None:
1625
1644
  row_shapes.append(ref_shape)
1645
+ if spacing_matrix is not None:
1646
+ row_spacings.append(gipl_metas[0]["voxel_sizes"])
1626
1647
  else:
1627
1648
  row_shapes.append(spatial_shapes[pos])
1649
+ if spacing_matrix is not None:
1650
+ row_spacings.append(gipl_metas[pos]["voxel_sizes"])
1628
1651
  pos += 1
1629
1652
  shape_matrix.append(row_shapes)
1653
+ if spacing_matrix is not None:
1654
+ spacing_matrix.append(row_spacings)
1630
1655
  shapes_differ = any(shape != ref_shape for shape in spatial_shapes)
1631
1656
  if stack == "dense" and shapes_differ:
1632
1657
  raise ValueError("Dense stacking requires every file to have the same shape.")
1633
1658
  if stack == "ragged" or shapes_differ:
1634
- series = _RaggedFileSeries(file_matrix, shape_matrix, ref_dtype, spatial_meta=None)
1659
+ series = _RaggedFileSeries(
1660
+ file_matrix, shape_matrix, ref_dtype,
1661
+ spatial_meta=spatial_meta, spacing_matrix=spacing_matrix,
1662
+ )
1635
1663
  else:
1636
- series = _FileSeries(file_matrix, ref_shape, ref_dtype, spatial_meta=None)
1664
+ series = _FileSeries(
1665
+ file_matrix, ref_shape, ref_dtype,
1666
+ spatial_meta=spatial_meta, spacing_matrix=spacing_matrix,
1667
+ )
1637
1668
  if load == "eager":
1638
1669
  for p, row in enumerate(file_matrix):
1639
1670
  for m, path in enumerate(row):
1640
1671
  if path is not None:
1641
1672
  series._get_volume(p, m)
1642
- return series, None
1673
+ return series, spatial_meta
1643
1674
 
1644
1675
 
1645
1676
  def _collection_scan_workers():
@@ -2350,6 +2381,9 @@ def load_data_with_meta(filepath, key=None, *, load="lazy", stack="auto", select
2350
2381
  return _load_file_series(filepath, load=load, stack=stack)
2351
2382
  if filepath.endswith(".nii") or filepath.endswith(".nii.gz"):
2352
2383
  return _load_nifti_with_meta(filepath)
2384
+ if filepath.lower().endswith((".gipl", ".gipl.gz")):
2385
+ arr = _load_gipl(filepath)
2386
+ return arr, _gipl_spatial_meta(filepath, arr)
2353
2387
  return load_data(
2354
2388
  filepath, key=key, progress=progress, fast_first_frame=fast_first_frame
2355
2389
  ), None
@@ -2546,6 +2580,31 @@ def _load_gipl(filepath):
2546
2580
  return np.ascontiguousarray(arr)
2547
2581
 
2548
2582
 
2583
+ def _gipl_spatial_meta(filepath, arr):
2584
+ """Voxel sizes from a GIPL header, so the viewer shows true proportions.
2585
+
2586
+ GIPL stores no orientation, so axis labels stay empty. Returns None when
2587
+ the header has no usable voxel sizes.
2588
+ """
2589
+ import gzip
2590
+
2591
+ opener = gzip.open if filepath.lower().endswith(".gz") else open
2592
+ with opener(filepath, "rb") as f:
2593
+ header = f.read(_GIPL_HEADER_SIZE)
2594
+ pixdim = np.frombuffer(header, ">f4", count=4, offset=10).astype(float)
2595
+ n = min(arr.ndim, 3)
2596
+ voxel_sizes = tuple(float(v) for v in pixdim[:n])
2597
+ if not all(np.isfinite(v) and v > 0 for v in voxel_sizes):
2598
+ return None
2599
+ return {
2600
+ "voxel_sizes": voxel_sizes,
2601
+ "axis_labels": (),
2602
+ "is_oblique": False,
2603
+ "is_canonical": True,
2604
+ "spatial_shape": tuple(int(v) for v in arr.shape[:n]),
2605
+ }
2606
+
2607
+
2549
2608
  def _tensor_to_numpy(obj, source="object"):
2550
2609
  """Convert a tensor-like object (PyTorch, etc.) to a numpy array."""
2551
2610
  if isinstance(obj, np.ndarray):
@@ -3539,11 +3539,12 @@ def view(
3539
3539
  vmax: float | None = None,
3540
3540
  log: bool = False,
3541
3541
  complex: str | None = None,
3542
+ voxel_size=None,
3542
3543
  ):
3543
3544
  """
3544
3545
  Launch the viewer. Does not block the main Python process.
3545
3546
 
3546
- Accepts one to four arrays::
3547
+ Accepts one to six arrays (the same limit as the command line)::
3547
3548
 
3548
3549
  handle = arrayview.view(a)
3549
3550
  ha, hb = arrayview.view(a, b)
@@ -3588,6 +3589,10 @@ def view(
3588
3589
  ``"imag"`` (``"mag"`` shows the absolute value of real data). Pressing
3589
3590
  ``E`` in the viewer copies the current view as such a call.
3590
3591
 
3592
+ ``voxel_size`` — physical size of one step along each dimension, e.g.
3593
+ ``(1, 1, 2)`` for 1 mm pixels and 2 mm slices. Slices then show their
3594
+ true proportions. Leading dimensions only; later ones keep size 1.
3595
+
3591
3596
  Returns a ``ViewHandle`` for a single array, or a tuple of ``ViewHandle``
3592
3597
  objects for multiple arrays (one per array). In inline/Jupyter mode with
3593
3598
  multiple arrays, the IFrame is displayed automatically and a uniform tuple
@@ -3601,9 +3606,14 @@ def view(
3601
3606
 
3602
3607
  _initial_view = _session_mod.initial_view_spec(
3603
3608
  mode=mode, dims=dims, index=index, cmap=cmap, vmin=vmin, vmax=vmax, log=log,
3604
- complex=complex,
3609
+ complex=complex, voxel_size=voxel_size,
3605
3610
  )
3606
3611
  _code_name = _caller_variable_name(arrays[0]) if arrays else None
3612
+ # Several arrays side by side need telling apart; the caller's variable
3613
+ # names do that better than identical "Array (shape)" labels.
3614
+ _var_names = (
3615
+ [_caller_variable_name(arr) for arr in arrays] if len(arrays) > 1 else []
3616
+ )
3607
3617
 
3608
3618
  height = _normalize_inline_height(height)
3609
3619
  _inline_mode_heights = _normalize_inline_mode_heights(mode_heights)
@@ -3612,8 +3622,8 @@ def view(
3612
3622
  n_arrays = len(arrays)
3613
3623
  if n_arrays == 0:
3614
3624
  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}.")
3625
+ if n_arrays > 6:
3626
+ raise ValueError(f"view() accepts at most 6 arrays, got {n_arrays}.")
3617
3627
 
3618
3628
  # --- Normalise per-array kwargs (name, rgb) ---
3619
3629
  if isinstance(name, list):
@@ -3664,6 +3674,8 @@ def view(
3664
3674
  arrays = tuple(_convert_array(arr) for arr in arrays)
3665
3675
 
3666
3676
  # --- Apply name defaults after conversion (shape is available now) ---
3677
+ if len(set(filter(None, _var_names))) == n_arrays:
3678
+ names = [names[i] if names[i] is not None else _var_names[i] for i in range(n_arrays)]
3667
3679
  names = [
3668
3680
  (f"Array {getattr(arrays[i], 'shape', '?')}" if names[i] is None else names[i])
3669
3681
  for i in range(n_arrays)
@@ -3680,6 +3692,13 @@ def view(
3680
3692
  f"index has {len(_initial_view['index'])} entries but the array "
3681
3693
  f"has {_ndim} dimensions"
3682
3694
  )
3695
+ if _initial_view and "voxel_size" in _initial_view:
3696
+ _ndim = len(getattr(data, "shape", ()))
3697
+ if len(_initial_view["voxel_size"]) > _ndim:
3698
+ raise ValueError(
3699
+ f"voxel_size has {len(_initial_view['voxel_size'])} entries but the "
3700
+ f"array has {_ndim} dimensions"
3701
+ )
3683
3702
 
3684
3703
  # --- Normalise string window modes ---
3685
3704
  _raw_window = window
@@ -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:
@@ -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:
@@ -360,6 +360,7 @@ def register_rendering_routes(app, *, get_session_or_404) -> None:
360
360
  session_b = SESSIONS.get(sid_b)
361
361
  if not session_a or not session_b:
362
362
  return Response(status_code=404)
363
+ stats: dict[str, float] = {}
363
364
  try:
364
365
  raw, vmin, vmax, colormap, nan_mask = _compute_diff(
365
366
  session_a,
@@ -374,6 +375,7 @@ def register_rendering_routes(app, *, get_session_or_404) -> None:
374
375
  diff_mode,
375
376
  indices_a=indices_a,
376
377
  indices_b=indices_b,
378
+ stats_out=stats,
377
379
  )
378
380
  except Exception:
379
381
  return Response(status_code=422)
@@ -399,6 +401,11 @@ def register_rendering_routes(app, *, get_session_or_404) -> None:
399
401
  "X-ArrayView-Vmin": str(vmin),
400
402
  "X-ArrayView-Vmax": str(vmax),
401
403
  "X-ArrayView-Colormap": colormap,
404
+ # Numbers for the A−B title: RMS difference, largest
405
+ # difference, RMS difference relative to A.
406
+ "X-ArrayView-Diff-Rmse": str(stats.get("rmse", "nan")),
407
+ "X-ArrayView-Diff-Max": str(stats.get("max_abs", "nan")),
408
+ "X-ArrayView-Diff-Rel": str(stats.get("rel", "nan")),
402
409
  },
403
410
  )
404
411
 
@@ -284,6 +284,29 @@ async def _send_json_quietly(ws, payload):
284
284
  pass
285
285
 
286
286
 
287
+ # Viewer tabs whose slice position follows the other linked tabs (Shift+L).
288
+ # Rides on each viewer's existing socket: VS Code tabs share one channel with
289
+ # a small socket budget, so linking must not open connections of its own.
290
+ _LINKED_VIEWER_SOCKETS: set = set()
291
+
292
+
293
+ def _handle_link_message(ws, msg: dict) -> None:
294
+ action = msg.get("action")
295
+ if action == "join":
296
+ _LINKED_VIEWER_SOCKETS.add(ws)
297
+ elif action == "leave":
298
+ _LINKED_VIEWER_SOCKETS.discard(ws)
299
+ elif action == "position" and ws in _LINKED_VIEWER_SOCKETS:
300
+ indices = msg.get("indices")
301
+ shape = msg.get("shape")
302
+ if not isinstance(indices, list) or not isinstance(shape, list):
303
+ return
304
+ payload = {"type": "link", "indices": indices, "shape": shape}
305
+ for other in list(_LINKED_VIEWER_SOCKETS):
306
+ if other is not ws:
307
+ asyncio.ensure_future(_send_json_quietly(other, payload))
308
+
309
+
287
310
  def _render_rgba_from_data(data, colormap, vmin, vmax):
288
311
  if vmax > vmin:
289
312
  normalized = np.clip((data - vmin) / (vmax - vmin), 0, 1)
@@ -503,6 +526,11 @@ def register_websocket_routes(app) -> None:
503
526
  try:
504
527
  while True:
505
528
  msg = await ws.receive_json()
529
+ if isinstance(msg, dict) and msg.get("type") == "link":
530
+ # Not a frame request: answer here so it never
531
+ # displaces (or is displaced by) a pending render.
532
+ _handle_link_message(ws, msg)
533
+ continue
506
534
  try:
507
535
  _pending.get_nowait()
508
536
  except asyncio.QueueEmpty:
@@ -904,6 +932,7 @@ def register_websocket_routes(app) -> None:
904
932
  _session_mod.LIVE_VIEWER_SOCKETS.remove(ws)
905
933
  except ValueError:
906
934
  pass
935
+ _LINKED_VIEWER_SOCKETS.discard(ws)
907
936
  try:
908
937
  from arrayview import _extra_ports
909
938
 
@@ -55,6 +55,8 @@ from arrayview._config import (
55
55
  get_viewer_ortho_layout,
56
56
  get_viewer_rounded_panes,
57
57
  get_viewer_qmri_gapless,
58
+ get_viewer_compare_gapless,
59
+ get_viewer_remember_view,
58
60
  get_viewer_theme,
59
61
  )
60
62
 
@@ -484,6 +486,8 @@ def _viewer_ui_response(
484
486
  .replace("__DEFAULT_THEME_IDX__", str(_default_theme_idx))
485
487
  .replace("__DEFAULT_ROUNDED_PANES__", _default_rounded_panes)
486
488
  .replace("__DEFAULT_QMRI_GAPLESS__", "true" if get_viewer_qmri_gapless() else "false")
489
+ .replace("__DEFAULT_COMPARE_GAPLESS__", "true" if get_viewer_compare_gapless() else "false")
490
+ .replace("__DEFAULT_REMEMBER_VIEW__", "true" if get_viewer_remember_view() else "false")
487
491
  .replace("__DEFAULT_ORTHO_LAYOUT__", _default_ortho_layout)
488
492
  .replace("__DEFAULT_DIMBAR_MODE__", _default_dimbar_mode)
489
493
  .replace("__BODY_CLASS__", "av-loading" if sid else "")
@@ -690,6 +690,7 @@ def initial_view_spec(
690
690
  vmax=None,
691
691
  log=False,
692
692
  complex=None,
693
+ voxel_size=None,
693
694
  ) -> dict | None:
694
695
  """Validate how a caller asks the viewer to open; None when nothing is set.
695
696
 
@@ -734,6 +735,14 @@ def initial_view_spec(
734
735
  if complex not in COMPLEX_PARTS:
735
736
  raise ValueError(f"complex must be one of: {', '.join(COMPLEX_PARTS)}")
736
737
  spec["complex"] = complex
738
+ if voxel_size is not None:
739
+ try:
740
+ sizes = [float(v) for v in voxel_size]
741
+ except (TypeError, ValueError):
742
+ raise ValueError("voxel_size must be one size per dimension, e.g. (1, 1, 2)")
743
+ if not sizes or not all(np.isfinite(v) and v > 0 for v in sizes):
744
+ raise ValueError("voxel_size must be positive numbers, e.g. (1, 1, 2)")
745
+ spec["voxel_size"] = sizes
737
746
  return spec or None
738
747
 
739
748
 
@@ -0,0 +1,102 @@
1
+ """Remember the last view of each file, for Preferences → Reopen where you left off.
2
+
3
+ Stored in ~/.arrayview/view_memory.json, keyed by the file's real path (plus the
4
+ array name inside .npz/.mat files). A remembered view is only handed back for
5
+ an array of the same shape, so a file rewritten with new dimensions starts fresh.
6
+ A file rewritten with the same shape keeps its slice, view and colormap, but not
7
+ its display range, which no longer fits the new values.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ import os
14
+ import tempfile
15
+ import threading
16
+ import time
17
+
18
+ VIEW_MEMORY_PATH = os.path.expanduser("~/.arrayview/view_memory.json")
19
+ _MAX_ENTRIES = 200
20
+ _RANGE_KEYS = ("manualVmin", "manualVmax")
21
+ _LOCK = threading.Lock()
22
+
23
+
24
+ def view_memory_key(session) -> str | None:
25
+ filepath = getattr(session, "filepath", None)
26
+ if not filepath:
27
+ return None
28
+ try:
29
+ key = os.path.realpath(str(filepath))
30
+ except (OSError, ValueError):
31
+ return None
32
+ array_key = getattr(session, "array_key", None)
33
+ return f"{key}#{array_key}" if array_key else key
34
+
35
+
36
+ def _file_signature(session) -> list | None:
37
+ from arrayview._session import file_signature
38
+
39
+ sig = file_signature(str(session.filepath))
40
+ return list(sig) if sig else None
41
+
42
+
43
+ def _load() -> dict:
44
+ try:
45
+ with open(VIEW_MEMORY_PATH, encoding="utf-8") as f:
46
+ data = json.load(f)
47
+ except (OSError, ValueError):
48
+ return {}
49
+ return data if isinstance(data, dict) else {}
50
+
51
+
52
+ def _save(entries: dict) -> None:
53
+ folder = os.path.dirname(VIEW_MEMORY_PATH)
54
+ os.makedirs(folder, exist_ok=True)
55
+ fd, temp_path = tempfile.mkstemp(prefix=".view_memory.", suffix=".tmp", dir=folder)
56
+ try:
57
+ with os.fdopen(fd, "w", encoding="utf-8") as f:
58
+ json.dump(entries, f)
59
+ os.replace(temp_path, VIEW_MEMORY_PATH)
60
+ except Exception:
61
+ try:
62
+ os.unlink(temp_path)
63
+ except FileNotFoundError:
64
+ pass
65
+ raise
66
+
67
+
68
+ def remembered_view(session) -> dict | None:
69
+ key = view_memory_key(session)
70
+ if key is None:
71
+ return None
72
+ with _LOCK:
73
+ entry = _load().get(key)
74
+ if not isinstance(entry, dict) or not isinstance(entry.get("view"), dict):
75
+ return None
76
+ if entry.get("shape") != [int(s) for s in session.shape]:
77
+ return None
78
+ view = dict(entry["view"])
79
+ if entry.get("signature") is None or entry.get("signature") != _file_signature(session):
80
+ for k in _RANGE_KEYS:
81
+ view.pop(k, None)
82
+ return view
83
+
84
+
85
+ def remember_view(session, view: dict) -> bool:
86
+ key = view_memory_key(session)
87
+ if key is None or not isinstance(view, dict):
88
+ return False
89
+ with _LOCK:
90
+ entries = _load()
91
+ entries[key] = {
92
+ "shape": [int(s) for s in session.shape],
93
+ "view": view,
94
+ "signature": _file_signature(session),
95
+ "updated": time.time(),
96
+ }
97
+ if len(entries) > _MAX_ENTRIES:
98
+ oldest = sorted(entries, key=lambda k: entries[k].get("updated", 0) if isinstance(entries[k], dict) else 0)
99
+ for stale in oldest[: len(entries) - _MAX_ENTRIES]:
100
+ entries.pop(stale, None)
101
+ _save(entries)
102
+ return True