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.
- arrayview/_routes_rendering.py +10 -0
- arrayview/_routes_websocket.py +5 -4
- arrayview/_viewer.html +306 -30
- {arrayview-0.48.2.dist-info → arrayview-0.48.3.dist-info}/METADATA +1 -1
- {arrayview-0.48.2.dist-info → arrayview-0.48.3.dist-info}/RECORD +8 -8
- {arrayview-0.48.2.dist-info → arrayview-0.48.3.dist-info}/WHEEL +0 -0
- {arrayview-0.48.2.dist-info → arrayview-0.48.3.dist-info}/entry_points.txt +0 -0
- {arrayview-0.48.2.dist-info → arrayview-0.48.3.dist-info}/licenses/LICENSE +0 -0
arrayview/_routes_rendering.py
CHANGED
|
@@ -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,
|
arrayview/_routes_websocket.py
CHANGED
|
@@ -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
|
|
275
|
-
|
|
276
|
-
|
|
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
|
|
8823
|
-
//
|
|
8824
|
-
//
|
|
8825
|
-
//
|
|
8826
|
-
|
|
8827
|
-
|
|
8828
|
-
|
|
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:
|
|
8832
|
-
{ dimX:
|
|
8833
|
-
{ dimX:
|
|
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
|
-
|
|
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
|
|
26851
|
-
const
|
|
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
|
|
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.
|
|
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
|
|
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.
|
|
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
|
|
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:
|
|
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
|
|
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.
|
|
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,
|
|
@@ -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=
|
|
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=
|
|
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=
|
|
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.
|
|
56
|
-
arrayview-0.48.
|
|
57
|
-
arrayview-0.48.
|
|
58
|
-
arrayview-0.48.
|
|
59
|
-
arrayview-0.48.
|
|
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,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|