arrayview 0.48.2__py3-none-any.whl → 0.48.3__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.
@@ -226,6 +226,16 @@ def register_rendering_routes(app, *, get_session_or_404) -> None:
226
226
  if qmri_role
227
227
  else extract_slice(session, dim_x, dim_y, list(idx_tuple))
228
228
  )
229
+ if qmri_role and (vmin_override is None or vmax_override is None):
230
+ # Colour with the role-adjusted window reported back to the
231
+ # viewer, which locks it for later slices; otherwise the
232
+ # first scroll step visibly changes contrast.
233
+ _, _lo, _hi = _prepare_display(
234
+ session, raw, complex_mode, dr, log_scale
235
+ )
236
+ vmin_override, vmax_override = _qmri_adjust_vmin_vmax(
237
+ _lo, _hi, qmri_role
238
+ )
229
239
  rgba = (
230
240
  render_rgba_from_prepared(
231
241
  session,
@@ -271,9 +271,10 @@ def _render_qmri_rgba(
271
271
  Kept separate from the generic path so the slice read and the window
272
272
  computation happen exactly once, and so multi-pane qMRI/ortho renders run in
273
273
  the render thread pool instead of blocking the event loop serially. The
274
- colour map is applied with the unadjusted window (matching the previous
275
- synchronous path); the returned window is the qMRI-role-adjusted value sent
276
- in the frame header.
274
+ colour map is applied with the qMRI-role-adjusted window, the same one sent
275
+ in the frame header: the viewer locks that window and sends it back on the
276
+ next slice, so colouring with anything else made the first scroll step
277
+ visibly change the contrast while the colorbar labels stayed put.
277
278
  """
278
279
  raw = qmri_display_slice(session, dim_x, dim_y, list(idx_tuple), qmri_role)
279
280
  data, vmin, vmax = _prepare_display(
@@ -285,6 +286,7 @@ def _render_qmri_rgba(
285
286
  vmin_override=vmin_override,
286
287
  vmax_override=vmax_override,
287
288
  )
289
+ vmin, vmax = _qmri_adjust_vmin_vmax(vmin, vmax, qmri_role)
288
290
  if vmax > vmin:
289
291
  normalized = np.clip((data - vmin) / (vmax - vmin), 0, 1)
290
292
  else:
@@ -301,7 +303,6 @@ def _render_qmri_rgba(
301
303
  if transparent.any():
302
304
  rgba = rgba.copy()
303
305
  rgba[transparent, 3] = 0
304
- vmin, vmax = _qmri_adjust_vmin_vmax(vmin, vmax, qmri_role)
305
306
  return rgba, vmin, vmax
306
307
 
307
308
 
arrayview/_viewer.html CHANGED
@@ -8818,19 +8818,36 @@
8818
8818
  return ranked.slice(0, 3).sort((a, b) => a - b);
8819
8819
  }
8820
8820
 
8821
+ /** Anatomical axes of a NIfTI's three spatial dims, from its stored
8822
+ * orientation (any order, e.g. RAS or IAL): {R, A, S} each
8823
+ * {dim, positive}, where positive means the index grows toward R / A / S.
8824
+ * null when the orientation is unknown or the dims are not 0, 1, 2. */
8825
+ function _labelAnatomy(dims) {
8826
+ const labels = spatialMeta && spatialMeta.axis_labels;
8827
+ if (!Array.isArray(labels) || labels.length < 3) return null;
8828
+ if (!(dims[0] === 0 && dims[1] === 1 && dims[2] === 2)) return null;
8829
+ const axes = {};
8830
+ for (let d = 0; d < 3; d++) {
8831
+ const l = String(labels[d]).toUpperCase();
8832
+ const key = (l === 'R' || l === 'L') ? 'R' : (l === 'A' || l === 'P') ? 'A' : (l === 'S' || l === 'I') ? 'S' : null;
8833
+ if (!key || axes[key]) return null;
8834
+ axes[key] = { dim: d, positive: l === key };
8835
+ }
8836
+ return axes;
8837
+ }
8838
+
8821
8839
  function multiViewPlaneDefs(dims) {
8822
- // NIfTI volumes are reoriented to RAS before they reach the viewer.
8823
- // Keep their three clinical planes upright: axial R/L × A/P,
8824
- // coronal R/L × S/I, and sagittal A/P × S/I. Canvas y grows
8825
- // downward, so the two planes containing S/I need a vertical flip.
8826
- const rasSpatialDims = spatialMeta && Array.isArray(spatialMeta.axis_labels)
8827
- && spatialMeta.axis_labels.join(',') === 'R,A,S'
8828
- && dims[0] === 0 && dims[1] === 1 && dims[2] === 2;
8829
- if (rasSpatialDims) {
8840
+ // NIfTI volumes: keep the three clinical planes upright whatever
8841
+ // order the file stores its axes in: axial R/L × A/P, coronal
8842
+ // R/L × S/I, sagittal A/P × S/I. R and A grow toward screen
8843
+ // right/down as in RAS; canvas y grows downward, so S/I axes are
8844
+ // flipped when needed to put superior at the top.
8845
+ const ax = _labelAnatomy(dims);
8846
+ if (ax) {
8830
8847
  return [
8831
- { dimX: dims[0], dimY: dims[1], sliceDir: dims[2], flipX: false, flipY: false },
8832
- { dimX: dims[0], dimY: dims[2], sliceDir: dims[1], flipX: false, flipY: true },
8833
- { dimX: dims[1], dimY: dims[2], sliceDir: dims[0], flipX: false, flipY: true },
8848
+ { dimX: ax.R.dim, dimY: ax.A.dim, sliceDir: ax.S.dim, flipX: !ax.R.positive, flipY: !ax.A.positive },
8849
+ { dimX: ax.R.dim, dimY: ax.S.dim, sliceDir: ax.A.dim, flipX: !ax.R.positive, flipY: ax.S.positive },
8850
+ { dimX: ax.A.dim, dimY: ax.S.dim, sliceDir: ax.R.dim, flipX: !ax.A.positive, flipY: ax.S.positive },
8834
8851
  ];
8835
8852
  }
8836
8853
  // Preserve the established generic-array layout where no anatomical
@@ -8842,6 +8859,215 @@
8842
8859
  ];
8843
8860
  }
8844
8861
 
8862
+ // ── View Cycling & Physical Proportions ──
8863
+ // Shift+P steps a single view through the same three planes the
8864
+ // 3-plane view shows. Per array, the viewer keeps:
8865
+ // - _viewMemory: screen axes + flips last used for each plane, keyed
8866
+ // by the plane's slice dim, so r/t adjustments survive cycling.
8867
+ // - _userAnatomy: {R, A, S} dims for arrays without an orientation
8868
+ // (named via Shift+A), which labels the planes and lays them out
8869
+ // like NIfTI ones.
8870
+ // - _userVoxelSizes: {dim: mm} for arrays without a voxel size.
8871
+ let _viewMemory = {};
8872
+ let _userAnatomy = null;
8873
+ let _userVoxelSizes = null;
8874
+ const _PLANE_NAMES = ['axial', 'coronal', 'sagittal'];
8875
+
8876
+ function _reservedNonSpatialDim() {
8877
+ if (qmriActive) return qmriDim;
8878
+ if (compareQmriActive) return compareQmriDim;
8879
+ return -1;
8880
+ }
8881
+
8882
+ /** The three spatial dims the plane cycle works on, ascending; null if none. */
8883
+ function _cycleSpatialDims() {
8884
+ const reserved = _reservedNonSpatialDim();
8885
+ if (reserved < 0) {
8886
+ const dims = defaultMultiViewDims();
8887
+ return dims.length === 3 && dims.every(d => shape[d] > 1) ? dims : null;
8888
+ }
8889
+ const spatialLimit = collectionSpatialNdim >= 0 ? collectionSpatialNdim : shape.length;
8890
+ const ranked = [...Array(spatialLimit).keys()]
8891
+ .filter(d => d !== reserved)
8892
+ .sort((a, b) => shape[b] - shape[a]);
8893
+ const dims = ranked.slice(0, 3).sort((a, b) => a - b);
8894
+ return dims.length === 3 && dims.every(d => shape[d] > 1) ? dims : null;
8895
+ }
8896
+
8897
+ function _userAnatomyFits(dims) {
8898
+ if (!_userAnatomy) return false;
8899
+ const named = [_userAnatomy.R, _userAnatomy.A, _userAnatomy.S];
8900
+ return named.every(d => dims.includes(d)) && new Set(named).size === 3;
8901
+ }
8902
+
8903
+ /** Planes in cycle order: [{dimX, dimY, sliceDir, flipX, flipY, name}]. */
8904
+ function _cyclePlanes() {
8905
+ const dims = _cycleSpatialDims();
8906
+ if (!dims) return null;
8907
+ if (!spatialMeta && _userAnatomyFits(dims)) {
8908
+ const { R, A, S } = _userAnatomy;
8909
+ // Same screen layout as NIfTI planes; directions are unknown,
8910
+ // so no flips until the user sets them with r/t.
8911
+ return [
8912
+ { dimX: R, dimY: A, sliceDir: S, flipX: false, flipY: false, name: 'axial' },
8913
+ { dimX: R, dimY: S, sliceDir: A, flipX: false, flipY: false, name: 'coronal' },
8914
+ { dimX: A, dimY: S, sliceDir: R, flipX: false, flipY: false, name: 'sagittal' },
8915
+ ];
8916
+ }
8917
+ const named = !!_labelAnatomy(dims);
8918
+ return multiViewPlaneDefs(dims).map((p, i) => ({
8919
+ dimX: p.dimX, dimY: p.dimY, sliceDir: p.sliceDir,
8920
+ flipX: !!p.flipX, flipY: !!p.flipY,
8921
+ name: named ? _PLANE_NAMES[i] : null,
8922
+ }));
8923
+ }
8924
+
8925
+ /** Index of the plane the single view is showing, or -1. */
8926
+ function _currentPlaneIndex(planes) {
8927
+ return planes.findIndex(p =>
8928
+ (p.dimX === dim_x && p.dimY === dim_y) || (p.dimX === dim_y && p.dimY === dim_x));
8929
+ }
8930
+
8931
+ function _planeLabel(planes, i) {
8932
+ return planes[i].name || `view ${i + 1} of 3`;
8933
+ }
8934
+
8935
+ /** Physical size (mm) of one step along `dim`, or null when unknown. */
8936
+ function _voxelSizeOf(dim) {
8937
+ if (_userVoxelSizes && Number.isFinite(_userVoxelSizes[dim]) && _userVoxelSizes[dim] > 0) {
8938
+ return _userVoxelSizes[dim];
8939
+ }
8940
+ const vs = spatialMeta && spatialMeta.voxel_sizes;
8941
+ if (Array.isArray(vs) && dim >= 0 && dim < 3 && Number.isFinite(vs[dim]) && vs[dim] > 0) return vs[dim];
8942
+ return null;
8943
+ }
8944
+
8945
+ /** On-screen height of one pixel relative to its width for a plane
8946
+ * showing dimX across and dimY down: 1 unless both voxel sizes are known. */
8947
+ function _pixelAspect(dimX, dimY) {
8948
+ if (squareStretch) return 1;
8949
+ const sx = _voxelSizeOf(dimX), sy = _voxelSizeOf(dimY);
8950
+ if (!sx || !sy) return 1;
8951
+ return Math.max(0.05, Math.min(20, sy / sx));
8952
+ }
8953
+
8954
+ /** Shift+P: show the plane after the current one, keeping each plane's
8955
+ * remembered screen axes and flips. */
8956
+ function _switchToNextPlane(planes) {
8957
+ const cur = _currentPlaneIndex(planes);
8958
+ if (cur >= 0) {
8959
+ _viewMemory[planes[cur].sliceDir] = { dimX: dim_x, dimY: dim_y, flipX: flip_x, flipY: flip_y };
8960
+ }
8961
+ const next = (cur + 1) % planes.length;
8962
+ const plane = planes[next];
8963
+ const mem = _viewMemory[plane.sliceDir];
8964
+ const pick = (mem && [mem.dimX, mem.dimY].every(d => d === plane.dimX || d === plane.dimY)) ? mem : plane;
8965
+ const wasSliceActive = activeDim === current_slice_dim || activeDim === dim_x || activeDim === dim_y;
8966
+ dim_x = pick.dimX; dim_y = pick.dimY;
8967
+ flip_x = !!pick.flipX; flip_y = !!pick.flipY;
8968
+ current_slice_dim = plane.sliceDir;
8969
+ if (wasSliceActive) activeDim = current_slice_dim;
8970
+ if (projectionMode > 0) projectionDim = current_slice_dim;
8971
+ _roiClearOnDimChange();
8972
+ if (qmriActive || compareQmriActive) qvScaleAllCanvases();
8973
+ updateView();
8974
+ if (!qmriActive && !compareQmriActive) triggerPreload();
8975
+ if (typeof _cropReconcileBindings === 'function') _cropReconcileBindings();
8976
+ renderInfo(); saveState();
8977
+ showStatus(_planeLabel(planes, next));
8978
+ }
8979
+
8980
+ /** Briefly fade the dimbar out, run `change`, and fade it back in, so
8981
+ * its labels don't jump. Runs `change` straight away when the dimbar
8982
+ * is hidden or a fade is already under way. */
8983
+ let _dimbarFadeAnim = null;
8984
+ function _fadeDimbarAround(change) {
8985
+ const info = document.getElementById('info');
8986
+ if (!info || typeof info.animate !== 'function' || _dimbarFadeAnim
8987
+ || parseFloat(getComputedStyle(info).opacity) < 0.5) { change(); return; }
8988
+ const out = info.animate([{ opacity: 1 }, { opacity: 0 }], { duration: 70, easing: 'ease-in', fill: 'forwards' });
8989
+ _dimbarFadeAnim = out;
8990
+ const done = () => { _dimbarFadeAnim = null; };
8991
+ out.oncancel = done;
8992
+ out.onfinish = () => {
8993
+ change();
8994
+ out.oncancel = null;
8995
+ out.cancel();
8996
+ const back = info.animate([{ opacity: 0 }, { opacity: 1 }], { duration: 110, easing: 'ease-out' });
8997
+ _dimbarFadeAnim = back;
8998
+ back.onfinish = done;
8999
+ back.oncancel = done;
9000
+ };
9001
+ }
9002
+
9003
+ /** Shift+A on an array without stored orientation: name the current
9004
+ * view and/or give the voxel size, e.g. "axial 1 1 3". */
9005
+ async function _describeArrayOrientation() {
9006
+ const dims = _cycleSpatialDims();
9007
+ if (!dims) { showStatus('need three spatial dimensions'); return; }
9008
+ const planes = _cyclePlanes();
9009
+ const cur = _currentPlaneIndex(planes);
9010
+ const curName = cur >= 0 ? (planes[cur].name || '') : '';
9011
+ const sizes = dims.map(d => _voxelSizeOf(d));
9012
+ const sizeText = sizes.every(v => v) ? sizes.map(v => String(+v.toFixed(4))).join(' ') : '';
9013
+ const answer = await showInlinePrompt(
9014
+ `Name this view (axial, coronal or sagittal) and/or the voxel size in mm along dims ${dims.join(', ')} — e.g. "axial 1 1 3". Empty clears both.`,
9015
+ `${curName} ${sizeText}`.trim(),
9016
+ );
9017
+ if (answer === null) return;
9018
+ const tokens = answer.trim().toLowerCase().split(/[\s,;×]+/).filter(Boolean);
9019
+ const words = tokens.filter(t => /^[a-z]+$/.test(t));
9020
+ const nums = tokens.filter(t => !/^[a-z]+$/.test(t)).map(Number);
9021
+ if (nums.some(n => !Number.isFinite(n) || n <= 0) || ![0, 1, 3].includes(nums.length) || words.length > 1) {
9022
+ showStatus('expected a view name and/or 1 or 3 positive numbers'); return;
9023
+ }
9024
+ let name = null;
9025
+ if (words.length) {
9026
+ name = _PLANE_NAMES.find(n => n.startsWith(words[0]));
9027
+ if (!name) { showStatus('view name must be axial, coronal or sagittal'); return; }
9028
+ if (cur < 0) { showStatus('show one of the three views first (Shift+P)'); return; }
9029
+ }
9030
+ if (!tokens.length) {
9031
+ _userAnatomy = null; _userVoxelSizes = null;
9032
+ } else {
9033
+ if (name) {
9034
+ const sl = planes[cur].sliceDir;
9035
+ _userAnatomy = name === 'axial' ? { R: dim_x, A: dim_y, S: sl }
9036
+ : name === 'coronal' ? { R: dim_x, S: dim_y, A: sl }
9037
+ : { A: dim_x, S: dim_y, R: sl };
9038
+ // The other two views get the named layout afresh; this
9039
+ // one stays exactly as it is on screen.
9040
+ _viewMemory = { [sl]: { dimX: dim_x, dimY: dim_y, flipX: flip_x, flipY: flip_y } };
9041
+ }
9042
+ if (nums.length) {
9043
+ const v = nums.length === 1 ? [nums[0], nums[0], nums[0]] : nums;
9044
+ _userVoxelSizes = {};
9045
+ dims.forEach((d, i) => { _userVoxelSizes[d] = v[i]; });
9046
+ }
9047
+ }
9048
+ ModeRegistry.scaleAll();
9049
+ renderInfo(); saveState();
9050
+ const parts = [];
9051
+ if (!tokens.length) parts.push('view names and voxel size cleared');
9052
+ if (name) parts.push(`this is the ${name} view`);
9053
+ if (nums.length) parts.push(`voxel size ${dims.map(d => _userVoxelSizes[d]).join(' × ')} mm`);
9054
+ showStatus(parts.join(' · '));
9055
+ }
9056
+
9057
+ /** Layout size of a pane showing dimX × dimY in on-screen proportions.
9058
+ * With both voxel sizes known it is in units of the finest voxel of
9059
+ * the array, so panes laid out side by side share one mm-per-pixel
9060
+ * scale; otherwise it is the raw pixel size. */
9061
+ function _physicalPaneSize(w, h, dimX, dimY) {
9062
+ if (squareStretch) return { w, h };
9063
+ const sx = _voxelSizeOf(dimX), sy = _voxelSizeOf(dimY);
9064
+ if (!sx || !sy) return { w, h };
9065
+ const known = [...Array(shape.length).keys()].map(_voxelSizeOf).filter(Boolean);
9066
+ const ref = Math.min(...known);
9067
+ const k = v => Math.max(0.05, Math.min(20, v / ref));
9068
+ return { w: w * k(sx), h: h * k(sy) };
9069
+ }
9070
+
8845
9071
  /** x/y/z letter for one of the current multiview's 3 spatial dims,
8846
9072
  * by its position in mvDims — a pane's two screen axes aren't always
8847
9073
  * literally x/y (e.g. a sagittal pane is y/z). */
@@ -9150,6 +9376,7 @@
9150
9376
 
9151
9377
  function _calcSinglePaneViewportRect(w, h, zoom, immersive) {
9152
9378
  if (!w || !h) return null;
9379
+ h = h * _pixelAspect(dim_x, dim_y);
9153
9380
  const immersivePad = 12;
9154
9381
  const sidePad = immersive ? immersivePad * 2 : NORMAL_SIDE_RESERVE;
9155
9382
  const bottomReserve = immersive ? 0 : NORMAL_BOTTOM_RESERVE;
@@ -9207,6 +9434,8 @@
9207
9434
  }
9208
9435
 
9209
9436
  function scaleCanvas(w, h) {
9437
+ // Layout works in on-screen proportions: thick voxels draw taller.
9438
+ h = h * _pixelAspect(dim_x, dim_y);
9210
9439
  const immersivePad = 12;
9211
9440
  const cP = _crossfadeCollapseP;
9212
9441
  const bottomReserve = _fullscreenActive ? 0 : NORMAL_BOTTOM_RESERVE * (1 - cP);
@@ -9309,7 +9538,10 @@
9309
9538
  const cbExtra = compareCenterCbReserve;
9310
9539
  const panePadY = (_fullscreenActive ? 0 : 12 * (1 - _crossfadeCollapseP)) + cbExtra;
9311
9540
  const fitSlack = 0.96 + 0.04 * (_fullscreenActive ? 1 : _crossfadeCollapseP);
9312
- const activeFrames = compareFrames.filter(Boolean);
9541
+ // Layout-only sizes in on-screen proportions (thick voxels draw taller).
9542
+ const _cmpAspect = _pixelAspect(dim_x, dim_y);
9543
+ const activeFrames = compareFrames.filter(Boolean)
9544
+ .map(f => _cmpAspect === 1 ? f : { ...f, height: f.height * _cmpAspect });
9313
9545
  const paneCount = activeFrames.length;
9314
9546
  if (!paneCount) return;
9315
9547
  const hasCenterPane = (diffMode > 0 || registrationMode || _wipeActive || _flickerActive || _checkerActive);
@@ -23079,6 +23311,9 @@
23079
23311
  dim_z,
23080
23312
  current_slice_dim,
23081
23313
  activeDim,
23314
+ viewMemory: { ..._viewMemory },
23315
+ userAnatomy: _userAnatomy,
23316
+ userVoxelSizes: _userVoxelSizes,
23082
23317
  compareSid: detachedDimMode ? null : compareSid,
23083
23318
  compareSidList: detachedDimMode ? [] : [...compareSidList],
23084
23319
  compareName,
@@ -23158,6 +23393,27 @@
23158
23393
  { const _dv = modeManager.getFocusedView() || modeManager.currentViews[0]; if (_dv) { _dv.displayState.vmin = manualVmin; _dv.displayState.vmax = manualVmax; } }
23159
23394
  }
23160
23395
 
23396
+ {
23397
+ const okDim = d => Number.isInteger(d) && d >= 0 && d < shape.length;
23398
+ _viewMemory = {};
23399
+ if (s.viewMemory && typeof s.viewMemory === 'object') {
23400
+ for (const [k, m] of Object.entries(s.viewMemory)) {
23401
+ if (m && okDim(Number(k)) && okDim(m.dimX) && okDim(m.dimY)) {
23402
+ _viewMemory[k] = { dimX: m.dimX, dimY: m.dimY, flipX: !!m.flipX, flipY: !!m.flipY };
23403
+ }
23404
+ }
23405
+ }
23406
+ const a = s.userAnatomy;
23407
+ _userAnatomy = (a && okDim(a.R) && okDim(a.A) && okDim(a.S)) ? { R: a.R, A: a.A, S: a.S } : null;
23408
+ _userVoxelSizes = null;
23409
+ if (s.userVoxelSizes && typeof s.userVoxelSizes === 'object') {
23410
+ const vs = {};
23411
+ for (const [k, v] of Object.entries(s.userVoxelSizes)) {
23412
+ if (okDim(Number(k)) && Number.isFinite(v) && v > 0) vs[k] = v;
23413
+ }
23414
+ if (Object.keys(vs).length) _userVoxelSizes = vs;
23415
+ }
23416
+ }
23161
23417
  if (Number.isInteger(s.dim_x) && s.dim_x >= 0 && s.dim_x < shape.length) dim_x = s.dim_x;
23162
23418
  if (Number.isInteger(s.dim_y) && s.dim_y >= 0 && s.dim_y < shape.length) dim_y = s.dim_y;
23163
23419
  if (Number.isInteger(s.dim_z) && s.dim_z >= -1 && s.dim_z < shape.length) dim_z = s.dim_z;
@@ -26847,8 +27103,9 @@
26847
27103
  const vpW = vp ? vp.offsetWidth : cssW;
26848
27104
  const vpH = vp ? vp.offsetHeight : cssH;
26849
27105
  const selW = x1 - x0; // image pixels
26850
- const selH = y1 - y0;
26851
- const baseScale = getBaseScale(canvas.width, canvas.height);
27106
+ const _selAspect = _pixelAspect(dim_x, dim_y);
27107
+ const selH = (y1 - y0) * _selAspect;
27108
+ const baseScale = getBaseScale(canvas.width, canvas.height * _selAspect);
26852
27109
  // fitZoom: scale that maps the selection to fill the viewport
26853
27110
  const fitZoom = Math.min(vpW / (selW * baseScale), vpH / (selH * baseScale));
26854
27111
  userZoom = Math.max(0.1, Math.min(10.0, fitZoom));
@@ -28026,18 +28283,16 @@
28026
28283
  },
28027
28284
  },
28028
28285
  'nifti.toggleRasResample': {
28029
- title: 'Toggle explicit RAS conversion (NIfTI)',
28286
+ title: 'Toggle explicit RAS conversion (NIfTI) / name view and voxel size (other arrays)',
28030
28287
  when: [],
28031
28288
  run: (ctx, e) => {
28032
28289
  if (_rasResampleLoading) return;
28290
+ // Arrays with no stored orientation: describe it instead.
28291
+ if (!spatialMeta) { _describeArrayOrientation(); return; }
28033
28292
  if (compareActive || qmriActive) {
28034
28293
  showToast('RAS resample not supported in compare/qMRI mode');
28035
28294
  return;
28036
28295
  }
28037
- if (!spatialMeta) {
28038
- showToast('RAS resample: not a NIfTI scan');
28039
- return;
28040
- }
28041
28296
  if (!spatialMeta.is_oblique && spatialMeta.is_canonical !== false) {
28042
28297
  const labels = (spatialMeta.axis_labels || []).join('');
28043
28298
  showToast(`already in canonical RAS (${labels || 'axis-aligned'}) — no resample needed`);
@@ -28124,6 +28379,19 @@
28124
28379
  showToast(roundedPanes ? 'rounded panes: on' : 'rounded panes: off');
28125
28380
  },
28126
28381
  },
28382
+ 'view.cyclePlane': {
28383
+ title: 'Next view (axial / coronal / sagittal)',
28384
+ when: [],
28385
+ run: (ctx, e) => {
28386
+ if (multiViewActive || compareMvActive || qmriMvActive) { showStatus('views: the 3-plane view already shows all three'); return; }
28387
+ if (dim_z >= 0 || qmriMosaicActive || stackMosaicActive) { showStatus('views: leave the mosaic first'); return; }
28388
+ if (mipActive || cutawayActive) { showStatus('views: not available in 3-D view'); return; }
28389
+ if (rgbMode || hasVectorfield) { showStatus('views: not available for this array'); return; }
28390
+ const planes = _cyclePlanes();
28391
+ if (!planes) { showStatus('views: need three spatial dimensions'); return; }
28392
+ _fadeDimbarAround(() => _switchToNextPlane(planes));
28393
+ },
28394
+ },
28127
28395
  'view.transpose': {
28128
28396
  title: 'Transpose x/y',
28129
28397
  when: [],
@@ -29682,6 +29950,7 @@
29682
29950
  { key: 'T', command: 'theme.cycle' },
29683
29951
  { key: 'r', command: 'view.rotateOrFlip' },
29684
29952
  { key: 't', command: 'view.transpose' },
29953
+ { key: 'P', shift: true, command: 'view.cyclePlane' },
29685
29954
  { key: 'b', command: 'borders.toggle' },
29686
29955
  { key: 'B', command: 'panes.rounded.toggle' },
29687
29956
  { key: 'a', command: 'view.toggleSquareStretch' },
@@ -29791,6 +30060,7 @@
29791
30060
  entries: [
29792
30061
  { key: '← → ↑ ↓', action: 'Step through slices' },
29793
30062
  { key: 'x / y', action: 'Change viewing dimension', hint: 'choose which axes map to horizontal and vertical' },
30063
+ { key: 'Shift+P', action: 'Next view — axial → coronal → sagittal', hint: 'each view keeps its own r / t rotation and flip · NIfTI views are named; for other arrays Shift+A names the current view and sets the voxel size' },
29794
30064
  { key: 'Shift+D', action: 'Toggle dimbar max indices' },
29795
30065
  { key: 'z', action: 'Mosaic — tile all slices in a grid' },
29796
30066
  { key: 'Shift+;', action: 'Stack mosaic — view N arrays at once', hint: 'asks for N · [ ] move the window · Enter opens a tile · Shift+U shows true physical size' },
@@ -37342,8 +37612,9 @@
37342
37612
  - bottomPad * (1 - _crossfadeCollapseP);
37343
37613
  const absMaxScaleW = (window.innerWidth - sidePad - (cols - 1) * colGap) / cols;
37344
37614
  const absMaxScaleH = Math.max(10, totalH / rows);
37615
+ const _cqAspect = _pixelAspect(dim_x, dim_y);
37345
37616
  const absMaxScale = Math.min(...ready.map(v =>
37346
- Math.min(absMaxScaleW / v.lastW, absMaxScaleH / v.lastH)));
37617
+ Math.min(absMaxScaleW / v.lastW, absMaxScaleH / (v.lastH * _cqAspect))));
37347
37618
  const { capped: cappedScale, atMax: _cqAtMax } = _clampUniformZoom(absMaxScale);
37348
37619
  // Per-pane geometry routed through the shared _scalePane primitive.
37349
37620
  // compare-qMRI has no pan state and no per-pane viewport element —
@@ -37357,7 +37628,7 @@
37357
37628
  const _cqPanStub = { x: 0, y: 0, clamp: () => {}, apply: () => {} };
37358
37629
  for (const v of ready) {
37359
37630
  const r = _scalePane(
37360
- { canvas: v.canvas, w: v.lastW, h: v.lastH, pan: _cqPanStub },
37631
+ { canvas: v.canvas, w: v.lastW, h: v.lastH * _cqAspect, pan: _cqPanStub },
37361
37632
  null,
37362
37633
  {
37363
37634
  maxW: Math.round(absMaxScaleW),
@@ -37966,12 +38237,13 @@
37966
38237
  const absMaxT = Math.max(10, Math.min(absMaxScaleW, absMaxScaleH));
37967
38238
  const { atMax: _cmvAtMax } = _clampUniformZoom(absMaxT);
37968
38239
  const T = Math.min(Math.max(10, Math.floor(absMaxT * userZoom)), absMaxT);
37969
- const maxSide = Math.max(...ready.map(v => Math.max(v.lastW, v.lastH)));
38240
+ const _cmvSizes = new Map(ready.map(v => [v, _physicalPaneSize(v.lastW, v.lastH, v.dimX, v.dimY)]));
38241
+ const maxSide = Math.max(...ready.map(v => Math.max(_cmvSizes.get(v).w, _cmvSizes.get(v).h)));
37970
38242
  const cmvBaseScale = absMaxT / maxSide;
37971
38243
  const _cmvPanStub = { x: 0, y: 0, clamp: () => {}, apply: () => {} };
37972
38244
  for (const v of ready) {
37973
38245
  _scalePane(
37974
- { canvas: v.canvas, w: v.lastW, h: v.lastH, pan: _cmvPanStub },
38246
+ { canvas: v.canvas, w: _cmvSizes.get(v).w, h: _cmvSizes.get(v).h, pan: _cmvPanStub },
37975
38247
  null,
37976
38248
  {
37977
38249
  maxW: T,
@@ -38559,7 +38831,8 @@
38559
38831
  const absMaxT = Math.max(10, Math.min(absMaxScaleW, absMaxScaleH));
38560
38832
  const { atMax } = _clampUniformZoom(absMaxT);
38561
38833
  const T = Math.min(Math.max(10, Math.floor(absMaxT * userZoom)), absMaxT);
38562
- const maxSide = Math.max(...ready.map(v => Math.max(v.lastW, v.lastH)));
38834
+ const _qmvSizes = new Map(ready.map(v => [v, _physicalPaneSize(v.lastW, v.lastH, v.dimX, v.dimY)]));
38835
+ const maxSide = Math.max(...ready.map(v => Math.max(_qmvSizes.get(v).w, _qmvSizes.get(v).h)));
38563
38836
  const baseScale = absMaxT / maxSide;
38564
38837
  const panStub = { x: 0, y: 0, clamp: () => {}, apply: () => {} };
38565
38838
  for (const v of ready) {
@@ -38571,7 +38844,7 @@
38571
38844
  v.clip.classList.toggle('qmv-br', qr === nRows - 1 && qc === 2);
38572
38845
  }
38573
38846
  _scalePane(
38574
- { canvas: v.canvas, w: v.lastW, h: v.lastH, pan: panStub },
38847
+ { canvas: v.canvas, w: _qmvSizes.get(v).w, h: _qmvSizes.get(v).h, pan: panStub },
38575
38848
  v.clip || null,
38576
38849
  {
38577
38850
  maxW: T, maxH: T,
@@ -38984,7 +39257,7 @@
38984
39257
  const layoutSizes = ready.map(v => ({
38985
39258
  v,
38986
39259
  w: Math.max(1, Number(shape[dim_x]) || v.lastW),
38987
- h: Math.max(1, Number(shape[dim_y]) || v.lastH),
39260
+ h: Math.max(1, Number(shape[dim_y]) || v.lastH) * _pixelAspect(dim_x, dim_y),
38988
39261
  }));
38989
39262
  const absMaxScale = Math.min(...layoutSizes.map(({ w, h }) =>
38990
39263
  Math.min(absMaxScaleW / w, absMaxScaleH / h)));
@@ -39986,9 +40259,10 @@
39986
40259
  ready.forEach((v, i) => {
39987
40260
  const rt = rects[i];
39988
40261
  if (!rt || !rt.w || !rt.h) return;
39989
- const fit = Math.min(rt.w / v.lastW, rt.h / v.lastH);
40262
+ const ps = _physicalPaneSize(v.lastW, v.lastH, v.dimX, v.dimY);
40263
+ const fit = Math.min(rt.w / ps.w, rt.h / ps.h);
39990
40264
  _scalePane(
39991
- { canvas: v.canvas, w: v.lastW, h: v.lastH, pan: _presetPanStub },
40265
+ { canvas: v.canvas, w: ps.w, h: ps.h, pan: _presetPanStub },
39992
40266
  v.pane || null,
39993
40267
  {
39994
40268
  maxW: rt.w, maxH: rt.h,
@@ -40059,13 +40333,14 @@
40059
40333
  // multi-view is already handled by the pre-loop overshoot clamp above, so
40060
40334
  // every pane is scaled with updateAutoFit:false / allowOverflowRecalc:false
40061
40335
  // (no reference pane is needed — nothing in the loop mutates userZoom).
40062
- const maxSide = Math.max(...ready.map(v => Math.max(v.lastW, v.lastH)));
40336
+ const _mvSizes = new Map(ready.map(v => [v, _physicalPaneSize(v.lastW, v.lastH, v.dimX, v.dimY)]));
40337
+ const maxSide = Math.max(...ready.map(v => Math.max(_mvSizes.get(v).w, _mvSizes.get(v).h)));
40063
40338
  const mvBaseScale = absMaxT / maxSide;
40064
40339
  const _mvPanStub = { x: 0, y: 0, clamp: () => {}, apply: () => {} };
40065
40340
  for (const v of ready) {
40066
40341
  if (v.pane) v.pane.classList.remove('mv-small-pane');
40067
40342
  _scalePane(
40068
- { canvas: v.canvas, w: v.lastW, h: v.lastH, pan: _mvPanStub },
40343
+ { canvas: v.canvas, w: _mvSizes.get(v).w, h: _mvSizes.get(v).h, pan: _mvPanStub },
40069
40344
  v.pane || null,
40070
40345
  {
40071
40346
  maxW: T,
@@ -41166,6 +41441,7 @@
41166
41441
  // were forImmersive. Mirrors the _autoFitPending branch in scaleCanvas.
41167
41442
  function _calcAutoFitZoom(w, h, forImmersive) {
41168
41443
  if (!w || !h) return 1;
41444
+ h = h * _pixelAspect(dim_x, dim_y);
41169
41445
  if (compareActive) {
41170
41446
  // In compare mode, compareScaleCanvases handles actual sizing.
41171
41447
  // At userZoom=1.0 the base scale already fits each image in its pane,
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: arrayview
3
- Version: 0.48.2
3
+ Version: 0.48.3
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
@@ -28,11 +28,11 @@ arrayview/_routes_persistence.py,sha256=T03csHb7ppGT-8yMDf227XkqhnhjgSLYj0RW2wmg
28
28
  arrayview/_routes_preferences.py,sha256=xoMHbPKliYspgjaclhx2r8HbP9mFo9Td-nTF76eWVMg,4680
29
29
  arrayview/_routes_preload.py,sha256=KOsankNguxdIWmFW9iZcY9p2SvUN1mOKY7KkW1qMGcc,1729
30
30
  arrayview/_routes_query.py,sha256=HEEmMKuFycUTBNVUSWtUFXCnWB7ZrM3hv9IZWbXz11o,25012
31
- arrayview/_routes_rendering.py,sha256=HEA0okhZeiOADOEMdntOYe-mPMP2b_hfBBoAgiyQ-M4,22793
31
+ arrayview/_routes_rendering.py,sha256=6DD8VxPglnPsG0rnE-qk6ogcNRe2S_KAh3K54QYUHFQ,23380
32
32
  arrayview/_routes_segmentation.py,sha256=x2bBjQffZryeTMmOH38YdBWethw1o3KpA8V0chJcZHI,23599
33
33
  arrayview/_routes_state.py,sha256=T_xoYcRlv7L47ydTxxiXb0Fp5M5x9KR2U-wkxaVeuMY,12462
34
34
  arrayview/_routes_vectorfield.py,sha256=OWZtAONwRKQHdYnbDAMbOVf0q6pH7kqV8fa1icDhKHM,9734
35
- arrayview/_routes_websocket.py,sha256=78XmMyuD_-KjG5dHpzbhtN4dOa5m5BeyHfASAFYg85c,43552
35
+ arrayview/_routes_websocket.py,sha256=zfwojLG-c6NaxZanZgmiPpoQUpCmQFqCOglaydjtzJ4,43675
36
36
  arrayview/_segmentation.py,sha256=aENWHmefrKDScXhcZL1X3KgYXE784dpMkmFFo83qnrw,7377
37
37
  arrayview/_server.py,sha256=Ejh-6rWpWo0luhBhtsVq1Z4RFIyOPVv5nzExHtXb7iA,23042
38
38
  arrayview/_session.py,sha256=D4I6FLazLEXbo0IdkyRrzhvMNhvAI6dSyYu5I3tMCaM,42137
@@ -43,7 +43,7 @@ arrayview/_synthetic_mri.py,sha256=KcSYC26dgjBH25bUoqHT0IgJIzOxgXCSLkWr-ZnFq08,7
43
43
  arrayview/_torch.py,sha256=13sqIMCUu_-b9SQ9KfDCExDz2UMStL87EDVjnbJxAEo,7536
44
44
  arrayview/_tutorial.py,sha256=2zZHJGJDa4n30GE1-9ADjKFAifGBH9vvHXP4uDt7BaA,19547
45
45
  arrayview/_vectorfield.py,sha256=eDqMCMEGjO8Jdt-6Auh01cz3WD9dbF7xYUWD9xHAf1E,8115
46
- arrayview/_viewer.html,sha256=KJecxCJXM-dVDb3df9BftEAxiKuiqKfksaWlnj6xHY4,2387023
46
+ arrayview/_viewer.html,sha256=jKI9Ic8YwKYoIKhii_k019xb0ujcf-tgi8kkzfLyyl0,2402661
47
47
  arrayview/_vscode.py,sha256=EKnGN2zrjlybc4FQOmYA8BvYdtkcJbLHDEdgtvO7ul4,837
48
48
  arrayview/_vscode_browser.py,sha256=VCH9JpAAjFuhO0wQe8tIGjAL5837AXg_bpmBFtWOQqc,20573
49
49
  arrayview/_vscode_extension.py,sha256=eYsWAQmik2tt7HMWad9vBnn2kpRyRpLrh42FBGQy0M4,53156
@@ -52,8 +52,8 @@ arrayview/_watch.py,sha256=PxxS_5hcj_S6xQf9Ep4p1HLRkrI5iDMh1aRa4olV7ew,1873
52
52
  arrayview/arrayview-opener.vsix,sha256=44uiTM39qj_RXji3pFQaR8TA1jhNB3LYwVU_dGFbP4c,62284
53
53
  arrayview/gsap.min.js,sha256=VGihm4idI0E1uqrWezWsiScB9F_jMosn7qs6pPSkeac,72304
54
54
  arrayview/todo.md,sha256=KhnRzpn6_jezR0nDDB61-zhox-rbOmEGW-A7Z0_7FnU,622
55
- arrayview-0.48.2.dist-info/METADATA,sha256=iF32fWHFv__muQOvQPW_UDhkozFyTaaj4yGhot_ps3U,2288
56
- arrayview-0.48.2.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
57
- arrayview-0.48.2.dist-info/entry_points.txt,sha256=-0s08axutXkTbINiKs0Q7iv-33aWuTaamUg2q2-NyQ4,60
58
- arrayview-0.48.2.dist-info/licenses/LICENSE,sha256=34OivjM9faB2QaPjZpMRTIiSx31V3U9cEyQ6F2k-bH0,1062
59
- arrayview-0.48.2.dist-info/RECORD,,
55
+ arrayview-0.48.3.dist-info/METADATA,sha256=fYsnZ0m9pQEfwNxFPezS5qk0lTNE6FQQ-jQkaJ1mV5I,2288
56
+ arrayview-0.48.3.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
57
+ arrayview-0.48.3.dist-info/entry_points.txt,sha256=-0s08axutXkTbINiKs0Q7iv-33aWuTaamUg2q2-NyQ4,60
58
+ arrayview-0.48.3.dist-info/licenses/LICENSE,sha256=34OivjM9faB2QaPjZpMRTIiSx31V3U9cEyQ6F2k-bH0,1062
59
+ arrayview-0.48.3.dist-info/RECORD,,