arrayview 0.48.3__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.3 → arrayview-0.49.0}/PKG-INFO +1 -1
  2. {arrayview-0.48.3 → arrayview-0.49.0}/pyproject.toml +1 -1
  3. {arrayview-0.48.3 → arrayview-0.49.0}/src/arrayview/_analysis.py +9 -0
  4. {arrayview-0.48.3 → arrayview-0.49.0}/src/arrayview/_config.py +19 -1
  5. {arrayview-0.48.3 → arrayview-0.49.0}/src/arrayview/_diff.py +91 -45
  6. {arrayview-0.48.3 → arrayview-0.49.0}/src/arrayview/_io.py +63 -4
  7. {arrayview-0.48.3 → arrayview-0.49.0}/src/arrayview/_launcher.py +23 -4
  8. {arrayview-0.48.3 → arrayview-0.49.0}/src/arrayview/_platform.py +10 -1
  9. {arrayview-0.48.3 → arrayview-0.49.0}/src/arrayview/_render.py +49 -0
  10. {arrayview-0.48.3 → arrayview-0.49.0}/src/arrayview/_routes_preferences.py +22 -0
  11. {arrayview-0.48.3 → arrayview-0.49.0}/src/arrayview/_routes_rendering.py +55 -0
  12. {arrayview-0.48.3 → arrayview-0.49.0}/src/arrayview/_routes_websocket.py +77 -0
  13. {arrayview-0.48.3 → arrayview-0.49.0}/src/arrayview/_server.py +4 -0
  14. {arrayview-0.48.3 → arrayview-0.49.0}/src/arrayview/_session.py +12 -0
  15. arrayview-0.49.0/src/arrayview/_view_memory.py +102 -0
  16. {arrayview-0.48.3 → arrayview-0.49.0}/src/arrayview/_viewer.html +1068 -513
  17. {arrayview-0.48.3 → arrayview-0.49.0}/src/arrayview/_vscode_extension.py +1 -1
  18. {arrayview-0.48.3 → arrayview-0.49.0}/src/arrayview/_vscode_signal.py +3 -3
  19. {arrayview-0.48.3 → arrayview-0.49.0}/tests/test_api.py +3 -1
  20. {arrayview-0.48.3 → arrayview-0.49.0}/tests/test_backend_shared.py +37 -1
  21. {arrayview-0.48.3 → arrayview-0.49.0}/tests/test_browser.py +132 -9
  22. {arrayview-0.48.3 → arrayview-0.49.0}/tests/test_config.py +8 -0
  23. {arrayview-0.48.3 → arrayview-0.49.0}/tests/test_instance_registry.py +15 -0
  24. {arrayview-0.48.3 → arrayview-0.49.0}/tests/test_io.py +40 -1
  25. arrayview-0.49.0/tests/test_view_memory.py +32 -0
  26. {arrayview-0.48.3 → arrayview-0.49.0}/.gitignore +0 -0
  27. {arrayview-0.48.3 → arrayview-0.49.0}/LICENSE +0 -0
  28. {arrayview-0.48.3 → arrayview-0.49.0}/README.md +0 -0
  29. {arrayview-0.48.3 → arrayview-0.49.0}/src/arrayview/ARCHITECTURE.md +0 -0
  30. {arrayview-0.48.3 → arrayview-0.49.0}/src/arrayview/__init__.py +0 -0
  31. {arrayview-0.48.3 → arrayview-0.49.0}/src/arrayview/__main__.py +0 -0
  32. {arrayview-0.48.3 → arrayview-0.49.0}/src/arrayview/_app.py +0 -0
  33. {arrayview-0.48.3 → arrayview-0.49.0}/src/arrayview/_bootstrap_app.py +0 -0
  34. {arrayview-0.48.3 → arrayview-0.49.0}/src/arrayview/_diagnostics.py +0 -0
  35. {arrayview-0.48.3 → arrayview-0.49.0}/src/arrayview/_dicom.py +0 -0
  36. {arrayview-0.48.3 → arrayview-0.49.0}/src/arrayview/_extra_ports.py +0 -0
  37. {arrayview-0.48.3 → arrayview-0.49.0}/src/arrayview/_icon.png +0 -0
  38. {arrayview-0.48.3 → arrayview-0.49.0}/src/arrayview/_imaging.py +0 -0
  39. {arrayview-0.48.3 → arrayview-0.49.0}/src/arrayview/_instance_registry.py +0 -0
  40. {arrayview-0.48.3 → arrayview-0.49.0}/src/arrayview/_launch_plan.py +0 -0
  41. {arrayview-0.48.3 → arrayview-0.49.0}/src/arrayview/_launch_trace.py +0 -0
  42. {arrayview-0.48.3 → arrayview-0.49.0}/src/arrayview/_lifecycle.py +0 -0
  43. {arrayview-0.48.3 → arrayview-0.49.0}/src/arrayview/_overlays.py +0 -0
  44. {arrayview-0.48.3 → arrayview-0.49.0}/src/arrayview/_routes_analysis.py +0 -0
  45. {arrayview-0.48.3 → arrayview-0.49.0}/src/arrayview/_routes_drop.py +0 -0
  46. {arrayview-0.48.3 → arrayview-0.49.0}/src/arrayview/_routes_export.py +0 -0
  47. {arrayview-0.48.3 → arrayview-0.49.0}/src/arrayview/_routes_loading.py +0 -0
  48. {arrayview-0.48.3 → arrayview-0.49.0}/src/arrayview/_routes_persistence.py +0 -0
  49. {arrayview-0.48.3 → arrayview-0.49.0}/src/arrayview/_routes_preload.py +0 -0
  50. {arrayview-0.48.3 → arrayview-0.49.0}/src/arrayview/_routes_query.py +0 -0
  51. {arrayview-0.48.3 → arrayview-0.49.0}/src/arrayview/_routes_segmentation.py +0 -0
  52. {arrayview-0.48.3 → arrayview-0.49.0}/src/arrayview/_routes_state.py +0 -0
  53. {arrayview-0.48.3 → arrayview-0.49.0}/src/arrayview/_routes_vectorfield.py +0 -0
  54. {arrayview-0.48.3 → arrayview-0.49.0}/src/arrayview/_segmentation.py +0 -0
  55. {arrayview-0.48.3 → arrayview-0.49.0}/src/arrayview/_session_spec.py +0 -0
  56. {arrayview-0.48.3 → arrayview-0.49.0}/src/arrayview/_shell.html +0 -0
  57. {arrayview-0.48.3 → arrayview-0.49.0}/src/arrayview/_source_safety.py +0 -0
  58. {arrayview-0.48.3 → arrayview-0.49.0}/src/arrayview/_synthetic_mri.py +0 -0
  59. {arrayview-0.48.3 → arrayview-0.49.0}/src/arrayview/_torch.py +0 -0
  60. {arrayview-0.48.3 → arrayview-0.49.0}/src/arrayview/_tutorial.py +0 -0
  61. {arrayview-0.48.3 → arrayview-0.49.0}/src/arrayview/_vectorfield.py +0 -0
  62. {arrayview-0.48.3 → arrayview-0.49.0}/src/arrayview/_vscode.py +0 -0
  63. {arrayview-0.48.3 → arrayview-0.49.0}/src/arrayview/_vscode_browser.py +0 -0
  64. {arrayview-0.48.3 → arrayview-0.49.0}/src/arrayview/_watch.py +0 -0
  65. {arrayview-0.48.3 → arrayview-0.49.0}/src/arrayview/arrayview-opener.vsix +0 -0
  66. {arrayview-0.48.3 → arrayview-0.49.0}/src/arrayview/gsap.min.js +0 -0
  67. {arrayview-0.48.3 → arrayview-0.49.0}/src/arrayview/todo.md +0 -0
  68. {arrayview-0.48.3 → arrayview-0.49.0}/tests/capture_patient_loading.py +0 -0
  69. {arrayview-0.48.3 → arrayview-0.49.0}/tests/capture_v_animation.py +0 -0
  70. {arrayview-0.48.3 → arrayview-0.49.0}/tests/conftest.py +0 -0
  71. {arrayview-0.48.3 → arrayview-0.49.0}/tests/make_vectorfield_test_arrays.py +0 -0
  72. {arrayview-0.48.3 → arrayview-0.49.0}/tests/test_1d_arrays.py +0 -0
  73. {arrayview-0.48.3 → arrayview-0.49.0}/tests/test_cli.py +0 -0
  74. {arrayview-0.48.3 → arrayview-0.49.0}/tests/test_dicom.py +0 -0
  75. {arrayview-0.48.3 → arrayview-0.49.0}/tests/test_drop_import.py +0 -0
  76. {arrayview-0.48.3 → arrayview-0.49.0}/tests/test_large_arrays.py +0 -0
  77. {arrayview-0.48.3 → arrayview-0.49.0}/tests/test_launch_blackbox.py +0 -0
  78. {arrayview-0.48.3 → arrayview-0.49.0}/tests/test_mode_roundtrip.py +0 -0
  79. {arrayview-0.48.3 → arrayview-0.49.0}/tests/test_nifti_meta.py +0 -0
  80. {arrayview-0.48.3 → arrayview-0.49.0}/tests/test_nifti_series.py +0 -0
  81. {arrayview-0.48.3 → arrayview-0.49.0}/tests/test_rgb_pixel_art.py +0 -0
  82. {arrayview-0.48.3 → arrayview-0.49.0}/tests/test_safe_server_stop.py +0 -0
  83. {arrayview-0.48.3 → arrayview-0.49.0}/tests/test_source_safety.py +0 -0
  84. {arrayview-0.48.3 → arrayview-0.49.0}/tests/test_torch.py +0 -0
  85. {arrayview-0.48.3 → arrayview-0.49.0}/tests/test_tutorial.py +0 -0
  86. {arrayview-0.48.3 → arrayview-0.49.0}/tests/test_vsix_bundle.py +0 -0
  87. {arrayview-0.48.3 → arrayview-0.49.0}/tests/ui_audit.py +0 -0
  88. {arrayview-0.48.3 → arrayview-0.49.0}/tests/v_anim_frames/.gitkeep +0 -0
  89. {arrayview-0.48.3 → 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.3
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.3"
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", {})
@@ -85,6 +85,43 @@ def _resize_normalized_like(source: np.ndarray, target: np.ndarray) -> np.ndarra
85
85
  return np.array(image, dtype=np.float32) / 255.0
86
86
 
87
87
 
88
+ def _render_values(session, dim_x, dim_y, idx_tuple, complex_mode, log_scale):
89
+ """Extract a slice in the array's own units (complex/log applied, no rescale)."""
90
+ raw = extract_slice(session, dim_x, dim_y, list(idx_tuple))
91
+ data = apply_complex_mode(raw, complex_mode)
92
+ if log_scale:
93
+ data = np.log1p(np.abs(data))
94
+ return np.asarray(data, dtype=np.float32)
95
+
96
+
97
+ def _render_values_mosaic(session, dim_x, dim_y, dim_z, idx_tuple, complex_mode, log_scale):
98
+ """Mosaic of every dim_z slice in the array's own units, plus separator mask."""
99
+ idx_list = list(idx_tuple)
100
+ frames = [
101
+ _render_values(
102
+ session,
103
+ dim_x,
104
+ dim_y,
105
+ [i if j == dim_z else idx_list[j] for j in range(len(session.shape))],
106
+ complex_mode,
107
+ log_scale,
108
+ )
109
+ for i in range(session.shape[dim_z])
110
+ ]
111
+ all_data = np.stack(frames)
112
+ grid, _, _ = _build_mosaic_grid(all_data, len(frames))
113
+ return grid.astype(np.float32), np.isnan(grid)
114
+
115
+
116
+ def _resize_values_like(source: np.ndarray, target: np.ndarray) -> np.ndarray:
117
+ """Resize float source data to target shape without quantising it."""
118
+ if source.shape == target.shape:
119
+ return source
120
+ image = _pil_image().fromarray(np.nan_to_num(source).astype(np.float32), mode="F")
121
+ image = image.resize((target.shape[1], target.shape[0]), _pil_image().BILINEAR)
122
+ return np.array(image, dtype=np.float32)
123
+
124
+
88
125
  def _compute_diff(
89
126
  session_a,
90
127
  session_b,
@@ -98,8 +135,13 @@ def _compute_diff(
98
135
  diff_mode,
99
136
  indices_a=None,
100
137
  indices_b=None,
138
+ stats_out=None,
101
139
  ):
102
- """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
+ """
103
145
  def _parse_indices(raw_indices):
104
146
  if raw_indices is None:
105
147
  return ()
@@ -114,71 +156,72 @@ def _compute_diff(
114
156
  idx_b = _parse_indices(indices_b)[:ndim_b] if indices_b is not None else idx_tuple[:ndim_b]
115
157
  nan_mask = None
116
158
 
159
+ # True A−B in the arrays' own units. Each side used to be rescaled to its
160
+ # own per-slice display range first, which cancelled real differences
161
+ # (B = 0.9·A showed as zero) and made the map flicker while scrolling.
117
162
  if dim_z >= 0:
118
- a, nan_mask_a = _render_normalized_mosaic(
119
- session_a,
120
- dim_x,
121
- dim_y,
122
- dim_z,
123
- idx_a,
124
- dr,
125
- complex_mode,
126
- log_scale,
163
+ a, nan_mask_a = _render_values_mosaic(
164
+ session_a, dim_x, dim_y, dim_z, idx_a, complex_mode, log_scale
127
165
  )
128
- b, nan_mask_b = _render_normalized_mosaic(
129
- session_b,
130
- dim_x,
131
- dim_y,
132
- dim_z,
133
- idx_b,
134
- dr,
135
- complex_mode,
136
- log_scale,
166
+ b, nan_mask_b = _render_values_mosaic(
167
+ session_b, dim_x, dim_y, dim_z, idx_b, complex_mode, log_scale
137
168
  )
138
- nan_mask = nan_mask_a | nan_mask_b
169
+ b = _resize_values_like(b, a)
170
+ if nan_mask_b.shape == nan_mask_a.shape:
171
+ nan_mask = nan_mask_a | nan_mask_b
172
+ else:
173
+ nan_mask = nan_mask_a
139
174
  else:
140
- a = _render_normalized(
141
- session_a,
142
- dim_x,
143
- dim_y,
144
- idx_a,
145
- dr,
146
- complex_mode,
147
- log_scale,
148
- )
149
- b = _render_normalized(
150
- session_b,
151
- dim_x,
152
- dim_y,
153
- idx_b,
154
- dr,
155
- complex_mode,
156
- log_scale,
157
- )
158
-
159
- b = _resize_normalized_like(b, a)
175
+ a = _render_values(session_a, dim_x, dim_y, idx_a, complex_mode, log_scale)
176
+ b = _render_values(session_b, dim_x, dim_y, idx_b, complex_mode, log_scale)
177
+ b = _resize_values_like(b, a)
178
+ if stats_out is not None:
179
+ stats_out.update(_diff_summary(a, b, nan_mask))
160
180
  if diff_mode == 1:
161
181
  raw = a - b
162
182
  lo = float(np.nanpercentile(raw, 1))
163
183
  hi = float(np.nanpercentile(raw, 99))
164
184
  vmax = max(abs(lo), abs(hi)) or 1.0
165
185
  vmin = -vmax
166
- colormap = "RdBu_r"
186
+ colormap = "icefire"
167
187
  elif diff_mode == 2:
168
188
  raw = np.abs(a - b)
169
- vmax = float(raw.max()) or 1.0
189
+ vmax = float(np.nanmax(raw)) or 1.0
170
190
  vmin = 0.0
171
191
  colormap = "afmhot"
172
192
  else:
173
193
  raw = np.abs(a - b) / np.maximum(np.abs(a), 1e-6)
174
194
  raw = np.clip(raw, 0.0, 2.0).astype(np.float32)
175
- vmax = float(raw.max()) or 1.0
195
+ vmax = float(np.nanmax(raw)) or 1.0
176
196
  vmin = 0.0
177
197
  colormap = "afmhot"
178
198
 
179
199
  return raw, vmin, vmax, colormap, nan_mask
180
200
 
181
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
+
182
225
  def _render_diff_rgba(
183
226
  raw: np.ndarray,
184
227
  vmin: float,
@@ -188,7 +231,7 @@ def _render_diff_rgba(
188
231
  ) -> np.ndarray:
189
232
  """Map raw diff data to RGBA."""
190
233
  if vmax > vmin:
191
- normalized = np.clip((raw - vmin) / (vmax - vmin), 0, 1)
234
+ normalized = np.clip(np.nan_to_num((raw - vmin) / (vmax - vmin)), 0, 1)
192
235
  else:
193
236
  normalized = np.zeros_like(raw)
194
237
  _ensure_lut(colormap)
@@ -201,9 +244,12 @@ def _render_diff_rgba(
201
244
 
202
245
  def _diff_histogram(raw: np.ndarray, bins: int) -> dict[str, object]:
203
246
  """Return histogram payload for raw diff data."""
247
+ raw = raw[np.isfinite(raw)]
248
+ if raw.size == 0:
249
+ raw = np.zeros(1, dtype=np.float32)
204
250
  vmin = float(raw.min())
205
251
  vmax = float(raw.max())
206
- counts, edges = np.histogram(raw.ravel(), bins=bins)
252
+ counts, edges = np.histogram(raw, bins=bins)
207
253
  return {
208
254
  "counts": counts.tolist(),
209
255
  "edges": edges.tolist(),
@@ -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:
@@ -34,6 +34,43 @@ _mpl_colormaps = None
34
34
  _luts_lock = threading.Lock()
35
35
 
36
36
 
37
+ _ICEFIRE_STOPS = [
38
+ (0.7394, 0.9044, 0.8576),
39
+ (0.6645, 0.8497, 0.8387),
40
+ (0.5749, 0.7908, 0.8202),
41
+ (0.4818, 0.734, 0.8084),
42
+ (0.3863, 0.6778, 0.8043),
43
+ (0.3052, 0.6184, 0.8053),
44
+ (0.2418, 0.5555, 0.8106),
45
+ (0.2141, 0.4873, 0.8157),
46
+ (0.2487, 0.4134, 0.7887),
47
+ (0.2831, 0.3444, 0.715),
48
+ (0.2846, 0.2908, 0.6028),
49
+ (0.2577, 0.2497, 0.4821),
50
+ (0.2205, 0.2121, 0.3737),
51
+ (0.182, 0.1769, 0.2809),
52
+ (0.1492, 0.1465, 0.2053),
53
+ (0.1276, 0.1254, 0.1501),
54
+ (0.1213, 0.1183, 0.1188),
55
+ (0.1538, 0.1167, 0.1227),
56
+ (0.2063, 0.1255, 0.1416),
57
+ (0.2734, 0.1407, 0.1703),
58
+ (0.3514, 0.157, 0.2039),
59
+ (0.4375, 0.1703, 0.2357),
60
+ (0.5298, 0.1784, 0.2601),
61
+ (0.6251, 0.1832, 0.2686),
62
+ (0.7138, 0.2028, 0.2533),
63
+ (0.7898, 0.2469, 0.2268),
64
+ (0.85, 0.3142, 0.2058),
65
+ (0.8971, 0.3932, 0.2012),
66
+ (0.9279, 0.482, 0.2415),
67
+ (0.9479, 0.5722, 0.3318),
68
+ (0.9659, 0.659, 0.4438),
69
+ (0.9829, 0.7447, 0.5597),
70
+ (0.9992, 0.831, 0.6764),
71
+ ]
72
+
73
+
37
74
  def _init_luts():
38
75
  """Build colourmap LUTs on first use (defers matplotlib + qmricolors import)."""
39
76
  global _mpl_colormaps
@@ -46,6 +83,12 @@ def _init_luts():
46
83
  from matplotlib import colormaps
47
84
  from matplotlib.colors import LinearSegmentedColormap
48
85
 
86
+ if "icefire" not in colormaps:
87
+ # seaborn's icefire (blue → near-black → red), sampled at 33 stops so
88
+ # we don't depend on seaborn. Default for the A−B compare center.
89
+ colormaps.register(
90
+ LinearSegmentedColormap.from_list("icefire", _ICEFIRE_STOPS)
91
+ )
49
92
  _mpl_colormaps = colormaps
50
93
  for name in COLORMAPS:
51
94
  lut = np.concatenate(
@@ -210,6 +253,12 @@ def _read_slice(session, dim_x, dim_y, idx_list, progress):
210
253
  if progress is None:
211
254
  progress = getattr(session, 'progress_sink', None)
212
255
  data = session.data
256
+ if not getattr(session, "_ram_upgrade_started", False):
257
+ # Not every loading route asks for this (compare arrays, for one), and
258
+ # without it a scattered plane is re-read from the file on every step.
259
+ from ._session import upgrade_memmap_in_background
260
+
261
+ upgrade_memmap_in_background(session)
213
262
  if isinstance(data, np.memmap) and data.filename:
214
263
  # A slice scattered one element per page reads far faster as many
215
264
  # parallel small reads than by faulting through the map (local disks).
@@ -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: