@fideus-labs/fidnii 0.5.0 → 0.7.0
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.
- package/dist/OMEZarrNVImage.d.ts +147 -14
- package/dist/OMEZarrNVImage.d.ts.map +1 -1
- package/dist/OMEZarrNVImage.js +517 -123
- package/dist/OMEZarrNVImage.js.map +1 -1
- package/dist/RegionCoalescer.d.ts +12 -7
- package/dist/RegionCoalescer.d.ts.map +1 -1
- package/dist/RegionCoalescer.js +30 -20
- package/dist/RegionCoalescer.js.map +1 -1
- package/dist/events.d.ts +17 -0
- package/dist/events.d.ts.map +1 -1
- package/dist/events.js.map +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -2
- package/dist/index.js.map +1 -1
- package/dist/types.d.ts +62 -8
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js.map +1 -1
- package/package.json +5 -5
- package/src/OMEZarrNVImage.ts +633 -139
- package/src/RegionCoalescer.ts +33 -14
- package/src/events.ts +18 -0
- package/src/index.ts +6 -1
- package/src/types.ts +88 -12
package/dist/OMEZarrNVImage.js
CHANGED
|
@@ -13,8 +13,8 @@ import { RegionCoalescer } from "./RegionCoalescer.js";
|
|
|
13
13
|
import { getChunkShape, getVolumeShape, select2DResolution, selectResolution, } from "./ResolutionSelector.js";
|
|
14
14
|
import { getBytesPerPixel, getChannelInfo, getNiftiDataType, getRGBNiftiDataType, isRGBImage, NiftiDataType, needsRGBNormalization, parseZarritaDtype, } from "./types.js";
|
|
15
15
|
import { affineToNiftiSrows, calculateWorldBounds, createAffineFromNgffImage, createAffineFromOMEZarr, } from "./utils/affine.js";
|
|
16
|
-
import {
|
|
17
|
-
import {
|
|
16
|
+
import { worldToPixelAffine } from "./utils/coordinates.js";
|
|
17
|
+
import { getOrientationMapping } from "./utils/orientation.js";
|
|
18
18
|
import { boundsApproxEqual, computeViewportBounds2D, computeViewportBounds3D, } from "./ViewportBounds.js";
|
|
19
19
|
const DEFAULT_MAX_PIXELS = 50_000_000;
|
|
20
20
|
const DEFAULT_MAX_CACHE_ENTRIES = 200;
|
|
@@ -41,6 +41,29 @@ export class OMEZarrNVImage extends NVImage {
|
|
|
41
41
|
* OMERO intensity windowing is skipped.
|
|
42
42
|
*/
|
|
43
43
|
isLabelImage;
|
|
44
|
+
// ============================================================
|
|
45
|
+
// Colormap Override
|
|
46
|
+
// ============================================================
|
|
47
|
+
/**
|
|
48
|
+
* The continuous colormap name used for rendering.
|
|
49
|
+
*
|
|
50
|
+
* Overrides the NVImage setter so that changing the colormap on this
|
|
51
|
+
* image automatically propagates to all slab (2D slice) NVImages.
|
|
52
|
+
* Label images are unaffected — they use `setColormapLabel()` instead.
|
|
53
|
+
*/
|
|
54
|
+
get colormap() {
|
|
55
|
+
return this._colormap;
|
|
56
|
+
}
|
|
57
|
+
set colormap(cm) {
|
|
58
|
+
// Use NVImage's setter (calls calMinMax + onColormapChange)
|
|
59
|
+
super.colormap = cm;
|
|
60
|
+
// Propagate to all existing slab NVImages
|
|
61
|
+
if (!this.isLabelImage) {
|
|
62
|
+
for (const slab of this._slabBuffers.values()) {
|
|
63
|
+
slab.nvImage.colormap = cm;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
44
67
|
/** Reference to NiiVue instance */
|
|
45
68
|
niivue;
|
|
46
69
|
/** Buffer manager for dynamically-sized pixel data */
|
|
@@ -78,8 +101,8 @@ export class OMEZarrNVImage extends NVImage {
|
|
|
78
101
|
_volumeBounds;
|
|
79
102
|
/** Current buffer bounds in world space (may differ from full volume when clipped) */
|
|
80
103
|
_currentBufferBounds;
|
|
81
|
-
/**
|
|
82
|
-
|
|
104
|
+
/** AbortController for the clip plane event listener on the primary NV */
|
|
105
|
+
_clipPlaneAbortController;
|
|
83
106
|
/** Debounce delay for clip plane updates (ms) */
|
|
84
107
|
clipPlaneDebounceMs;
|
|
85
108
|
/** Timeout handle for debounced clip plane refetch */
|
|
@@ -98,6 +121,12 @@ export class OMEZarrNVImage extends NVImage {
|
|
|
98
121
|
_eventTarget = new EventTarget();
|
|
99
122
|
/** Pending populate request (latest wins - replaces any previous pending) */
|
|
100
123
|
_pendingPopulateRequest = null;
|
|
124
|
+
/**
|
|
125
|
+
* AbortController for the in-flight `populateVolume` fetch.
|
|
126
|
+
* Aborted when a new `populateVolume` call supersedes the current one,
|
|
127
|
+
* allowing in-flight HTTP requests to be cancelled promptly.
|
|
128
|
+
*/
|
|
129
|
+
_populateAbortController = null;
|
|
101
130
|
/** Current populate trigger (set at start of populateVolume, used by events) */
|
|
102
131
|
_currentPopulateTrigger = "initial";
|
|
103
132
|
// ============================================================
|
|
@@ -129,6 +158,34 @@ export class OMEZarrNVImage extends NVImage {
|
|
|
129
158
|
/** Per-slab AbortController to cancel in-flight progressive loads */
|
|
130
159
|
_slabAbortControllers = new Map();
|
|
131
160
|
// ============================================================
|
|
161
|
+
// Time Dimension State
|
|
162
|
+
// ============================================================
|
|
163
|
+
/**
|
|
164
|
+
* Time axis metadata, or `null` if the dataset has no `"t"` dimension.
|
|
165
|
+
* Populated during construction by inspecting `NgffImage.dims`.
|
|
166
|
+
*/
|
|
167
|
+
_timeAxisInfo = null;
|
|
168
|
+
/** Current time index (0-based). Always 0 for non-time datasets. */
|
|
169
|
+
_timeIndex = 0;
|
|
170
|
+
/** Number of adjacent time frames to pre-fetch in each direction. */
|
|
171
|
+
_timePrefetchCount;
|
|
172
|
+
/**
|
|
173
|
+
* LRU cache of pre-fetched 3D time frames, keyed by time index.
|
|
174
|
+
* Only used when `_timeAxisInfo` is non-null.
|
|
175
|
+
*/
|
|
176
|
+
_timeFrameCache;
|
|
177
|
+
/** Set of time indices currently being pre-fetched (for dedup). */
|
|
178
|
+
_prefetchingTimeIndices = new Set();
|
|
179
|
+
/** AbortController for the most recent pre-fetch batch. */
|
|
180
|
+
_prefetchAbortController = null;
|
|
181
|
+
/**
|
|
182
|
+
* Snapshot of the chunk-aligned region and resolution level used for
|
|
183
|
+
* the last successful 3D volume load. Used to serve cached time frames
|
|
184
|
+
* at the same spatial region. Cleared on clip plane / viewport / resolution
|
|
185
|
+
* changes.
|
|
186
|
+
*/
|
|
187
|
+
_lastLoadedRegion = null;
|
|
188
|
+
// ============================================================
|
|
132
189
|
// 3D Zoom Override
|
|
133
190
|
// ============================================================
|
|
134
191
|
/** Maximum 3D render zoom level for scroll-wheel zoom */
|
|
@@ -141,6 +198,8 @@ export class OMEZarrNVImage extends NVImage {
|
|
|
141
198
|
* continuous zoom/pan interactions.
|
|
142
199
|
*/
|
|
143
200
|
static VIEWPORT_DEBOUNCE_MS = 500;
|
|
201
|
+
/** Default number of adjacent time frames to pre-fetch. */
|
|
202
|
+
static DEFAULT_TIME_PREFETCH_COUNT = 2;
|
|
144
203
|
/**
|
|
145
204
|
* Private constructor. Use OMEZarrNVImage.create() for instantiation.
|
|
146
205
|
*/
|
|
@@ -181,6 +240,34 @@ export class OMEZarrNVImage extends NVImage {
|
|
|
181
240
|
// Detect 2D images (no z axis) and store y-flip preference
|
|
182
241
|
this._is2D = highResImage.dims.indexOf("z") === -1;
|
|
183
242
|
this._flipY2D = options.flipY2D ?? true;
|
|
243
|
+
// Detect time dimension from the zarr axes
|
|
244
|
+
const tDimIndex = highResImage.dims.indexOf("t");
|
|
245
|
+
if (tDimIndex !== -1) {
|
|
246
|
+
const timeCount = highResImage.data.shape[tDimIndex];
|
|
247
|
+
if (timeCount > 0) {
|
|
248
|
+
// Look up time unit from axes metadata
|
|
249
|
+
const timeAxis = this.multiscales.metadata?.axes?.find((a) => a.name === "t");
|
|
250
|
+
this._timeAxisInfo = {
|
|
251
|
+
count: timeCount,
|
|
252
|
+
dimIndex: tDimIndex,
|
|
253
|
+
step: highResImage.scale.t ?? 1,
|
|
254
|
+
origin: highResImage.translation.t ?? 0,
|
|
255
|
+
unit: timeAxis?.unit,
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
// Initialize time state
|
|
260
|
+
this._timePrefetchCount =
|
|
261
|
+
options.timePrefetchCount ?? OMEZarrNVImage.DEFAULT_TIME_PREFETCH_COUNT;
|
|
262
|
+
const defaultTimeIndex = options.timeIndex ?? 0;
|
|
263
|
+
this._timeIndex = this._timeAxisInfo
|
|
264
|
+
? Math.max(0, Math.min(defaultTimeIndex, this._timeAxisInfo.count - 1))
|
|
265
|
+
: 0;
|
|
266
|
+
// Time frame cache: capacity = current frame + both directions + small buffer
|
|
267
|
+
const cacheCapacity = 2 * this._timePrefetchCount + 3;
|
|
268
|
+
this._timeFrameCache = new LRUCache({
|
|
269
|
+
max: cacheCapacity,
|
|
270
|
+
});
|
|
184
271
|
// Calculate volume bounds from highest resolution for most accurate bounds.
|
|
185
272
|
// Use the unadjusted affine (no orientation signs) because volume bounds
|
|
186
273
|
// live in OME-Zarr world space and drive internal clip-plane / viewport math.
|
|
@@ -218,16 +305,12 @@ export class OMEZarrNVImage extends NVImage {
|
|
|
218
305
|
*/
|
|
219
306
|
static async create(options) {
|
|
220
307
|
const image = new OMEZarrNVImage(options);
|
|
221
|
-
//
|
|
222
|
-
|
|
223
|
-
image.
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
}
|
|
228
|
-
// Handle clip plane change
|
|
229
|
-
image.onNiivueClipPlaneChange(clipPlane);
|
|
230
|
-
};
|
|
308
|
+
// Listen for clip plane changes via the browser-native event API
|
|
309
|
+
const clipPlaneController = new AbortController();
|
|
310
|
+
image._clipPlaneAbortController = clipPlaneController;
|
|
311
|
+
image.niivue.addEventListener("clipPlaneChange", (e) => {
|
|
312
|
+
image.onNiivueClipPlaneChange(e.detail.clipPlane);
|
|
313
|
+
}, { signal: clipPlaneController.signal });
|
|
231
314
|
// Auto-attach the primary NV instance for slice type / location tracking
|
|
232
315
|
image.attachNiivue(image.niivue);
|
|
233
316
|
// Auto-load by default (add to NiiVue + start progressive loading)
|
|
@@ -318,8 +401,14 @@ export class OMEZarrNVImage extends NVImage {
|
|
|
318
401
|
}
|
|
319
402
|
// Queue this request (no event - just queuing)
|
|
320
403
|
this._pendingPopulateRequest = { skipPreview, trigger };
|
|
404
|
+
// Abort the in-flight fetch so the queued request runs sooner
|
|
405
|
+
this._populateAbortController?.abort();
|
|
321
406
|
return;
|
|
322
407
|
}
|
|
408
|
+
// Abort any lingering controller from a previous run (defensive)
|
|
409
|
+
this._populateAbortController?.abort();
|
|
410
|
+
const abortController = new AbortController();
|
|
411
|
+
this._populateAbortController = abortController;
|
|
323
412
|
this.isLoading = true;
|
|
324
413
|
this._currentPopulateTrigger = trigger;
|
|
325
414
|
this._pendingPopulateRequest = null; // Clear any stale pending request
|
|
@@ -328,7 +417,9 @@ export class OMEZarrNVImage extends NVImage {
|
|
|
328
417
|
const lowestLevel = numLevels - 1;
|
|
329
418
|
// Quick preview from lowest resolution (if different from target and not skipped)
|
|
330
419
|
if (!skipPreview && lowestLevel !== this.targetLevelIndex) {
|
|
331
|
-
await this.loadResolutionLevel(lowestLevel, "preview");
|
|
420
|
+
await this.loadResolutionLevel(lowestLevel, "preview", undefined, abortController.signal);
|
|
421
|
+
if (abortController.signal.aborted)
|
|
422
|
+
return;
|
|
332
423
|
const prevLevel = this.currentLevelIndex;
|
|
333
424
|
this.currentLevelIndex = lowestLevel;
|
|
334
425
|
// Emit resolutionChange for preview load
|
|
@@ -342,7 +433,9 @@ export class OMEZarrNVImage extends NVImage {
|
|
|
342
433
|
}
|
|
343
434
|
}
|
|
344
435
|
// Final quality at target resolution
|
|
345
|
-
await this.loadResolutionLevel(this.targetLevelIndex, "target");
|
|
436
|
+
await this.loadResolutionLevel(this.targetLevelIndex, "target", undefined, abortController.signal);
|
|
437
|
+
if (abortController.signal.aborted)
|
|
438
|
+
return;
|
|
346
439
|
const prevLevelBeforeTarget = this.currentLevelIndex;
|
|
347
440
|
this.currentLevelIndex = this.targetLevelIndex;
|
|
348
441
|
// Emit resolutionChange for target load
|
|
@@ -364,6 +457,7 @@ export class OMEZarrNVImage extends NVImage {
|
|
|
364
457
|
}
|
|
365
458
|
finally {
|
|
366
459
|
this.isLoading = false;
|
|
460
|
+
this._populateAbortController = null;
|
|
367
461
|
this.handlePendingPopulateRequest();
|
|
368
462
|
}
|
|
369
463
|
}
|
|
@@ -385,6 +479,11 @@ export class OMEZarrNVImage extends NVImage {
|
|
|
385
479
|
targetLevel: this.targetLevelIndex,
|
|
386
480
|
trigger: this._currentPopulateTrigger,
|
|
387
481
|
});
|
|
482
|
+
// Kick off pre-fetching of adjacent time frames now that we have a
|
|
483
|
+
// stable spatial region + resolution level.
|
|
484
|
+
if (this._timeAxisInfo && this._timeAxisInfo.count > 1) {
|
|
485
|
+
this._prefetchAdjacentFrames(this._timeIndex);
|
|
486
|
+
}
|
|
388
487
|
}
|
|
389
488
|
/**
|
|
390
489
|
* Load data at a specific resolution level.
|
|
@@ -397,8 +496,11 @@ export class OMEZarrNVImage extends NVImage {
|
|
|
397
496
|
*
|
|
398
497
|
* @param levelIndex - Resolution level index
|
|
399
498
|
* @param requesterId - ID for request coalescing
|
|
499
|
+
* @param timeIndex - Time point index to fetch (defaults to `this._timeIndex`)
|
|
500
|
+
* @param signal - Optional AbortSignal to cancel the fetch
|
|
400
501
|
*/
|
|
401
|
-
async loadResolutionLevel(levelIndex, requesterId) {
|
|
502
|
+
async loadResolutionLevel(levelIndex, requesterId, timeIndex, signal) {
|
|
503
|
+
const effectiveTimeIndex = timeIndex ?? this._timeIndex;
|
|
402
504
|
// Emit loadingStart event
|
|
403
505
|
this._emitEvent("loadingStart", {
|
|
404
506
|
levelIndex,
|
|
@@ -419,7 +521,7 @@ export class OMEZarrNVImage extends NVImage {
|
|
|
419
521
|
start: alignedRegion.chunkAlignedStart,
|
|
420
522
|
end: alignedRegion.chunkAlignedEnd,
|
|
421
523
|
};
|
|
422
|
-
const result = await this.coalescer.fetchRegion(ngffImage, levelIndex, fetchRegion, requesterId);
|
|
524
|
+
const result = await this.coalescer.fetchRegion(ngffImage, levelIndex, fetchRegion, requesterId, effectiveTimeIndex, signal);
|
|
423
525
|
// Resize buffer to match fetched data exactly (no upsampling!)
|
|
424
526
|
const targetData = this.bufferManager.resize(fetchedShape);
|
|
425
527
|
// For non-uint8 RGB/RGBA, we need OMERO metadata *before* copying
|
|
@@ -442,6 +544,9 @@ export class OMEZarrNVImage extends NVImage {
|
|
|
442
544
|
this.img = this.bufferManager.getTypedArray();
|
|
443
545
|
// Update NVImage header with correct dimensions and transforms
|
|
444
546
|
this.updateHeaderForRegion(ngffImage, alignedRegion, fetchedShape);
|
|
547
|
+
// Snapshot the loaded region so time frame pre-fetch uses the same
|
|
548
|
+
// spatial region / resolution level.
|
|
549
|
+
this._lastLoadedRegion = { region: alignedRegion, levelIndex };
|
|
445
550
|
if (this.isLabelImage) {
|
|
446
551
|
// Label images: apply a discrete colormap instead of OMERO windowing
|
|
447
552
|
this._applyLabelColormap(this, result.data);
|
|
@@ -508,27 +613,36 @@ export class OMEZarrNVImage extends NVImage {
|
|
|
508
613
|
1,
|
|
509
614
|
1,
|
|
510
615
|
];
|
|
511
|
-
//
|
|
512
|
-
//
|
|
513
|
-
// for internal clip-plane / viewport math.
|
|
616
|
+
// Compute buffer bounds in un-oriented OME-Zarr world space.
|
|
617
|
+
// These drive clip-plane / viewport math and must stay un-oriented.
|
|
514
618
|
const regionStart = region.chunkAlignedStart;
|
|
515
|
-
const
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
affine[14] += regionStart[0] * sz; // z offset
|
|
520
|
-
// Update current buffer bounds in OME-Zarr world space
|
|
619
|
+
const translation = ngffImage.translation;
|
|
620
|
+
const tx = (translation.x ?? translation.X ?? 0) + regionStart[2] * sx;
|
|
621
|
+
const ty = (translation.y ?? translation.Y ?? 0) + regionStart[1] * sy;
|
|
622
|
+
const tz = (translation.z ?? translation.Z ?? 0) + regionStart[0] * sz;
|
|
521
623
|
this._currentBufferBounds = {
|
|
522
|
-
min: [
|
|
624
|
+
min: [tx, ty, tz],
|
|
523
625
|
max: [
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
626
|
+
tx + fetchedShape[2] * sx,
|
|
627
|
+
ty + fetchedShape[1] * sy,
|
|
628
|
+
tz + fetchedShape[0] * sz,
|
|
527
629
|
],
|
|
528
630
|
};
|
|
529
|
-
//
|
|
530
|
-
//
|
|
531
|
-
|
|
631
|
+
// Build the fully oriented affine (including orientation permutation
|
|
632
|
+
// and sign flips), then apply the region offset in world space.
|
|
633
|
+
// The offset goes through the oriented 3x3 rotation matrix so it
|
|
634
|
+
// lands on the correct world axis even when NGFF axes are permuted.
|
|
635
|
+
const affine = createAffineFromNgffImage(ngffImage);
|
|
636
|
+
// regionStart is [z, y, x]; affine columns map NIfTI [i=x, j=y, k=z]
|
|
637
|
+
const offsetX = regionStart[2]; // NIfTI i = NGFF x
|
|
638
|
+
const offsetY = regionStart[1]; // NIfTI j = NGFF y
|
|
639
|
+
const offsetZ = regionStart[0]; // NIfTI k = NGFF z
|
|
640
|
+
affine[12] +=
|
|
641
|
+
affine[0] * offsetX + affine[4] * offsetY + affine[8] * offsetZ;
|
|
642
|
+
affine[13] +=
|
|
643
|
+
affine[1] * offsetX + affine[5] * offsetY + affine[9] * offsetZ;
|
|
644
|
+
affine[14] +=
|
|
645
|
+
affine[2] * offsetX + affine[6] * offsetY + affine[10] * offsetZ;
|
|
532
646
|
// For 2D images, flip y so NiiVue's calculateRAS() accounts for
|
|
533
647
|
// top-to-bottom pixel storage order. We shift the translation so
|
|
534
648
|
// the last row maps to where the first row was, then negate the
|
|
@@ -786,6 +900,8 @@ export class OMEZarrNVImage extends NVImage {
|
|
|
786
900
|
// Visual clipping is handled by NiiVue clip planes (already updated in setClipPlanes)
|
|
787
901
|
if (newTargetLevel !== this.targetLevelIndex) {
|
|
788
902
|
this.targetLevelIndex = newTargetLevel;
|
|
903
|
+
// Spatial region changed — cached time frames are stale
|
|
904
|
+
this._invalidateTimeFrameCache();
|
|
789
905
|
this.populateVolume(true, "clipPlanesChanged"); // Skip preview for clip plane updates
|
|
790
906
|
}
|
|
791
907
|
// Emit clipPlanesChange event (after debounce)
|
|
@@ -905,6 +1021,202 @@ export class OMEZarrNVImage extends NVImage {
|
|
|
905
1021
|
};
|
|
906
1022
|
}
|
|
907
1023
|
// ============================================================
|
|
1024
|
+
// Time Navigation
|
|
1025
|
+
// ============================================================
|
|
1026
|
+
/**
|
|
1027
|
+
* Time axis metadata, or `null` if the dataset has no `"t"` dimension.
|
|
1028
|
+
*
|
|
1029
|
+
* @example
|
|
1030
|
+
* ```ts
|
|
1031
|
+
* const info = image.timeAxisInfo
|
|
1032
|
+
* if (info) {
|
|
1033
|
+
* console.log(`${info.count} time points, step=${info.step} ${info.unit}`)
|
|
1034
|
+
* }
|
|
1035
|
+
* ```
|
|
1036
|
+
*/
|
|
1037
|
+
get timeAxisInfo() {
|
|
1038
|
+
return this._timeAxisInfo;
|
|
1039
|
+
}
|
|
1040
|
+
/**
|
|
1041
|
+
* Total number of time points.
|
|
1042
|
+
* Returns 1 for datasets without a `"t"` dimension.
|
|
1043
|
+
*/
|
|
1044
|
+
get timeCount() {
|
|
1045
|
+
return this._timeAxisInfo?.count ?? 1;
|
|
1046
|
+
}
|
|
1047
|
+
/**
|
|
1048
|
+
* Current time index (0-based).
|
|
1049
|
+
* Always 0 for datasets without a `"t"` dimension.
|
|
1050
|
+
*/
|
|
1051
|
+
get timeIndex() {
|
|
1052
|
+
return this._timeIndex;
|
|
1053
|
+
}
|
|
1054
|
+
/**
|
|
1055
|
+
* Compute the physical time value at a given index.
|
|
1056
|
+
*
|
|
1057
|
+
* @param index - Time index (0-based)
|
|
1058
|
+
* @returns Physical time value (`origin + index * step`)
|
|
1059
|
+
*/
|
|
1060
|
+
getTimeValue(index) {
|
|
1061
|
+
if (!this._timeAxisInfo)
|
|
1062
|
+
return 0;
|
|
1063
|
+
return this._timeAxisInfo.origin + index * this._timeAxisInfo.step;
|
|
1064
|
+
}
|
|
1065
|
+
/**
|
|
1066
|
+
* Set the active time index and reload the volume.
|
|
1067
|
+
*
|
|
1068
|
+
* If the requested frame is in the pre-fetch cache, the buffer is
|
|
1069
|
+
* swapped instantly without a network fetch. Otherwise, the frame is
|
|
1070
|
+
* loaded from the zarr store at the current resolution level and
|
|
1071
|
+
* spatial region.
|
|
1072
|
+
*
|
|
1073
|
+
* After the frame is loaded, adjacent frames are pre-fetched in the
|
|
1074
|
+
* background so subsequent scrubbing can serve frames from cache.
|
|
1075
|
+
*
|
|
1076
|
+
* @param index - Time index (0-based)
|
|
1077
|
+
* @throws If `index` is out of range `[0, timeCount)`
|
|
1078
|
+
*
|
|
1079
|
+
* @example
|
|
1080
|
+
* ```ts
|
|
1081
|
+
* await image.setTimeIndex(5)
|
|
1082
|
+
* image.addEventListener('timeChange', (e) => {
|
|
1083
|
+
* console.log(`Frame ${e.detail.index}, cached=${e.detail.cached}`)
|
|
1084
|
+
* })
|
|
1085
|
+
* ```
|
|
1086
|
+
*/
|
|
1087
|
+
async setTimeIndex(index) {
|
|
1088
|
+
if (!this._timeAxisInfo) {
|
|
1089
|
+
if (index !== 0) {
|
|
1090
|
+
throw new Error(`Cannot set time index ${index}: dataset has no time dimension`);
|
|
1091
|
+
}
|
|
1092
|
+
return;
|
|
1093
|
+
}
|
|
1094
|
+
if (index < 0 || index >= this._timeAxisInfo.count) {
|
|
1095
|
+
throw new Error(`Time index ${index} out of range [0, ${this._timeAxisInfo.count})`);
|
|
1096
|
+
}
|
|
1097
|
+
const previousIndex = this._timeIndex;
|
|
1098
|
+
if (index === previousIndex)
|
|
1099
|
+
return;
|
|
1100
|
+
this._timeIndex = index;
|
|
1101
|
+
// Try the pre-fetch cache first
|
|
1102
|
+
const cached = this._timeFrameCache.get(index);
|
|
1103
|
+
if (cached && cached.levelIndex === this.currentLevelIndex) {
|
|
1104
|
+
// Cache hit: instant buffer swap
|
|
1105
|
+
const targetData = this.bufferManager.resize(cached.shape);
|
|
1106
|
+
targetData.set(cached.data);
|
|
1107
|
+
this.img = this.bufferManager.getTypedArray();
|
|
1108
|
+
this.updateHeaderForRegion(this.multiscales.images[cached.levelIndex], cached.region, cached.shape);
|
|
1109
|
+
this.global_min = undefined;
|
|
1110
|
+
this.niivue.updateGLVolume();
|
|
1111
|
+
this._emitEvent("timeChange", {
|
|
1112
|
+
index,
|
|
1113
|
+
timeValue: this.getTimeValue(index),
|
|
1114
|
+
previousIndex,
|
|
1115
|
+
cached: true,
|
|
1116
|
+
});
|
|
1117
|
+
}
|
|
1118
|
+
else {
|
|
1119
|
+
// Cache miss: full load at current resolution + region
|
|
1120
|
+
await this.populateVolume(true, "initial");
|
|
1121
|
+
this._emitEvent("timeChange", {
|
|
1122
|
+
index,
|
|
1123
|
+
timeValue: this.getTimeValue(index),
|
|
1124
|
+
previousIndex,
|
|
1125
|
+
cached: false,
|
|
1126
|
+
});
|
|
1127
|
+
}
|
|
1128
|
+
// Pre-fetch adjacent frames in the background
|
|
1129
|
+
this._prefetchAdjacentFrames(index);
|
|
1130
|
+
}
|
|
1131
|
+
/**
|
|
1132
|
+
* Clear the pre-fetched time frame cache.
|
|
1133
|
+
*
|
|
1134
|
+
* Called internally when the spatial region or resolution changes
|
|
1135
|
+
* (clip planes, viewport, resolution level), since cached frames
|
|
1136
|
+
* were fetched for the previous region and are no longer valid.
|
|
1137
|
+
*/
|
|
1138
|
+
_invalidateTimeFrameCache() {
|
|
1139
|
+
this._timeFrameCache.clear();
|
|
1140
|
+
this._lastLoadedRegion = null;
|
|
1141
|
+
// Cancel any in-flight pre-fetches
|
|
1142
|
+
if (this._prefetchAbortController) {
|
|
1143
|
+
this._prefetchAbortController.abort();
|
|
1144
|
+
this._prefetchAbortController = null;
|
|
1145
|
+
}
|
|
1146
|
+
this._prefetchingTimeIndices.clear();
|
|
1147
|
+
}
|
|
1148
|
+
/**
|
|
1149
|
+
* Pre-fetch adjacent time frames in the background.
|
|
1150
|
+
*
|
|
1151
|
+
* Fetches frames `[index - N, index + N]` (clamped to valid range)
|
|
1152
|
+
* at the current resolution level and spatial region. Already-cached
|
|
1153
|
+
* and currently-in-flight indices are skipped.
|
|
1154
|
+
*
|
|
1155
|
+
* @param centerIndex - The time index to pre-fetch around
|
|
1156
|
+
*/
|
|
1157
|
+
_prefetchAdjacentFrames(centerIndex) {
|
|
1158
|
+
if (!this._timeAxisInfo || this._timePrefetchCount <= 0)
|
|
1159
|
+
return;
|
|
1160
|
+
if (!this._lastLoadedRegion)
|
|
1161
|
+
return;
|
|
1162
|
+
// Cancel any previous pre-fetch batch
|
|
1163
|
+
if (this._prefetchAbortController) {
|
|
1164
|
+
this._prefetchAbortController.abort();
|
|
1165
|
+
}
|
|
1166
|
+
const abortController = new AbortController();
|
|
1167
|
+
this._prefetchAbortController = abortController;
|
|
1168
|
+
const { region, levelIndex } = this._lastLoadedRegion;
|
|
1169
|
+
const ngffImage = this.multiscales.images[levelIndex];
|
|
1170
|
+
// Collect indices to pre-fetch
|
|
1171
|
+
const indices = [];
|
|
1172
|
+
for (let delta = 1; delta <= this._timePrefetchCount; delta++) {
|
|
1173
|
+
const before = centerIndex - delta;
|
|
1174
|
+
const after = centerIndex + delta;
|
|
1175
|
+
if (before >= 0)
|
|
1176
|
+
indices.push(before);
|
|
1177
|
+
if (after < this._timeAxisInfo.count)
|
|
1178
|
+
indices.push(after);
|
|
1179
|
+
}
|
|
1180
|
+
// Filter out already cached and in-flight indices
|
|
1181
|
+
const toFetch = indices.filter((i) => !this._timeFrameCache.has(i) && !this._prefetchingTimeIndices.has(i));
|
|
1182
|
+
if (toFetch.length === 0)
|
|
1183
|
+
return;
|
|
1184
|
+
const fetchRegion = {
|
|
1185
|
+
start: region.chunkAlignedStart,
|
|
1186
|
+
end: region.chunkAlignedEnd,
|
|
1187
|
+
};
|
|
1188
|
+
// Fire-and-forget pre-fetches
|
|
1189
|
+
for (const timeIdx of toFetch) {
|
|
1190
|
+
if (abortController.signal.aborted)
|
|
1191
|
+
break;
|
|
1192
|
+
this._prefetchingTimeIndices.add(timeIdx);
|
|
1193
|
+
void this.coalescer
|
|
1194
|
+
.fetchRegion(ngffImage, levelIndex, fetchRegion, `prefetch-t${timeIdx}`, timeIdx)
|
|
1195
|
+
.then((result) => {
|
|
1196
|
+
if (abortController.signal.aborted)
|
|
1197
|
+
return;
|
|
1198
|
+
const shape = [
|
|
1199
|
+
region.chunkAlignedEnd[0] - region.chunkAlignedStart[0],
|
|
1200
|
+
region.chunkAlignedEnd[1] - region.chunkAlignedStart[1],
|
|
1201
|
+
region.chunkAlignedEnd[2] - region.chunkAlignedStart[2],
|
|
1202
|
+
];
|
|
1203
|
+
// Store a copy so the original fetch result can be GC'd
|
|
1204
|
+
this._timeFrameCache.set(timeIdx, {
|
|
1205
|
+
data: result.data.slice(),
|
|
1206
|
+
shape,
|
|
1207
|
+
levelIndex,
|
|
1208
|
+
region,
|
|
1209
|
+
});
|
|
1210
|
+
})
|
|
1211
|
+
.catch(() => {
|
|
1212
|
+
// Silently ignore pre-fetch failures (non-critical)
|
|
1213
|
+
})
|
|
1214
|
+
.finally(() => {
|
|
1215
|
+
this._prefetchingTimeIndices.delete(timeIdx);
|
|
1216
|
+
});
|
|
1217
|
+
}
|
|
1218
|
+
}
|
|
1219
|
+
// ============================================================
|
|
908
1220
|
// Viewport-Aware Resolution
|
|
909
1221
|
// ============================================================
|
|
910
1222
|
/**
|
|
@@ -944,6 +1256,8 @@ export class OMEZarrNVImage extends NVImage {
|
|
|
944
1256
|
const selection = selectResolution(this.multiscales, this.maxPixels, this._clipPlanes, this._volumeBounds);
|
|
945
1257
|
if (selection.levelIndex !== this.targetLevelIndex) {
|
|
946
1258
|
this.targetLevelIndex = selection.levelIndex;
|
|
1259
|
+
// Spatial region changed — cached time frames are stale
|
|
1260
|
+
this._invalidateTimeFrameCache();
|
|
947
1261
|
this.populateVolume(true, "viewportChanged");
|
|
948
1262
|
}
|
|
949
1263
|
// Also reload slabs without viewport constraint
|
|
@@ -969,49 +1283,33 @@ export class OMEZarrNVImage extends NVImage {
|
|
|
969
1283
|
};
|
|
970
1284
|
}
|
|
971
1285
|
/**
|
|
972
|
-
* Hook viewport events (
|
|
1286
|
+
* Hook viewport events (mouseUp, zoom3DChange, wheel) on a NV instance.
|
|
1287
|
+
* All listeners share a single AbortController so they can be torn down
|
|
1288
|
+
* together via {@link _unhookViewportEvents}.
|
|
973
1289
|
*/
|
|
974
1290
|
_hookViewportEvents(nv, state) {
|
|
975
|
-
// Save and chain onMouseUp (fires at end of any mouse/touch interaction)
|
|
976
|
-
state.previousOnMouseUp = nv.onMouseUp;
|
|
977
|
-
nv.onMouseUp = (data) => {
|
|
978
|
-
if (state.previousOnMouseUp) {
|
|
979
|
-
state.previousOnMouseUp(data);
|
|
980
|
-
}
|
|
981
|
-
this._handleViewportInteractionEnd(nv);
|
|
982
|
-
};
|
|
983
|
-
// Save and chain onZoom3DChange (fires when volScaleMultiplier changes)
|
|
984
|
-
state.previousOnZoom3DChange = nv.onZoom3DChange;
|
|
985
|
-
nv.onZoom3DChange = (zoom) => {
|
|
986
|
-
if (state.previousOnZoom3DChange) {
|
|
987
|
-
state.previousOnZoom3DChange(zoom);
|
|
988
|
-
}
|
|
989
|
-
this._handleViewportInteractionEnd(nv);
|
|
990
|
-
};
|
|
991
|
-
// Add wheel event listener on the canvas for scroll-wheel zoom detection
|
|
992
1291
|
const controller = new AbortController();
|
|
993
1292
|
state.viewportAbortController = controller;
|
|
1293
|
+
const signal = controller.signal;
|
|
1294
|
+
// Detect end of mouse/touch interaction
|
|
1295
|
+
nv.addEventListener("mouseUp", () => {
|
|
1296
|
+
this._handleViewportInteractionEnd(nv);
|
|
1297
|
+
}, { signal });
|
|
1298
|
+
// Detect 3D zoom level changes
|
|
1299
|
+
nv.addEventListener("zoom3DChange", () => {
|
|
1300
|
+
this._handleViewportInteractionEnd(nv);
|
|
1301
|
+
}, { signal });
|
|
1302
|
+
// Detect scroll-wheel zoom on the canvas
|
|
994
1303
|
if (nv.canvas) {
|
|
995
1304
|
nv.canvas.addEventListener("wheel", () => {
|
|
996
1305
|
this._handleViewportInteractionEnd(nv);
|
|
997
|
-
}, { signal
|
|
1306
|
+
}, { signal, passive: true });
|
|
998
1307
|
}
|
|
999
1308
|
}
|
|
1000
1309
|
/**
|
|
1001
1310
|
* Unhook viewport events from a NV instance.
|
|
1002
1311
|
*/
|
|
1003
|
-
_unhookViewportEvents(
|
|
1004
|
-
// Restore onMouseUp
|
|
1005
|
-
if (state.previousOnMouseUp !== undefined) {
|
|
1006
|
-
nv.onMouseUp = state.previousOnMouseUp;
|
|
1007
|
-
state.previousOnMouseUp = undefined;
|
|
1008
|
-
}
|
|
1009
|
-
// Restore onZoom3DChange
|
|
1010
|
-
if (state.previousOnZoom3DChange !== undefined) {
|
|
1011
|
-
nv.onZoom3DChange = state.previousOnZoom3DChange;
|
|
1012
|
-
state.previousOnZoom3DChange = undefined;
|
|
1013
|
-
}
|
|
1014
|
-
// Remove wheel event listener
|
|
1312
|
+
_unhookViewportEvents(_nv, state) {
|
|
1015
1313
|
if (state.viewportAbortController) {
|
|
1016
1314
|
state.viewportAbortController.abort();
|
|
1017
1315
|
state.viewportAbortController = undefined;
|
|
@@ -1175,6 +1473,8 @@ export class OMEZarrNVImage extends NVImage {
|
|
|
1175
1473
|
const selection = selectResolution(this.multiscales, this.maxPixels, this._clipPlanes, this._volumeBounds, this._viewportBounds3D ?? undefined);
|
|
1176
1474
|
if (selection.levelIndex !== this.targetLevelIndex) {
|
|
1177
1475
|
this.targetLevelIndex = selection.levelIndex;
|
|
1476
|
+
// Spatial region changed — cached time frames are stale
|
|
1477
|
+
this._invalidateTimeFrameCache();
|
|
1178
1478
|
this.populateVolume(true, "viewportChanged");
|
|
1179
1479
|
}
|
|
1180
1480
|
}
|
|
@@ -1227,10 +1527,65 @@ export class OMEZarrNVImage extends NVImage {
|
|
|
1227
1527
|
return this.isLoading;
|
|
1228
1528
|
}
|
|
1229
1529
|
/**
|
|
1230
|
-
* Wait for all
|
|
1530
|
+
* Wait for all async work to settle: debounced timers (clip plane
|
|
1531
|
+
* refetch, viewport update, slab reload), the main `populateVolume`
|
|
1532
|
+
* pipeline, all slab loads, and in-flight coalescer fetches.
|
|
1533
|
+
*
|
|
1534
|
+
* The method polls in a loop because debounced timers may fire while
|
|
1535
|
+
* we are waiting, triggering new loads. It only resolves once every
|
|
1536
|
+
* source of async work is idle simultaneously.
|
|
1231
1537
|
*/
|
|
1232
1538
|
async waitForIdle() {
|
|
1233
|
-
|
|
1539
|
+
// Polling interval for active-load checks (ms).
|
|
1540
|
+
const POLL_MS = 50;
|
|
1541
|
+
while (true) {
|
|
1542
|
+
// ---- Debounced timers ----
|
|
1543
|
+
// Wait for pending debounce timers to fire (and potentially
|
|
1544
|
+
// trigger new loads) before checking load state.
|
|
1545
|
+
if (this.clipPlaneRefetchTimeout !== null) {
|
|
1546
|
+
await new Promise((r) => setTimeout(r, this.clipPlaneDebounceMs + POLL_MS));
|
|
1547
|
+
continue;
|
|
1548
|
+
}
|
|
1549
|
+
if (this._viewportUpdateTimeout !== null) {
|
|
1550
|
+
await new Promise((r) => setTimeout(r, OMEZarrNVImage.VIEWPORT_DEBOUNCE_MS + POLL_MS));
|
|
1551
|
+
continue;
|
|
1552
|
+
}
|
|
1553
|
+
if (this._slabReloadTimeouts.size > 0) {
|
|
1554
|
+
// Slab reload debounce is 100 ms (hardcoded in
|
|
1555
|
+
// _debouncedSlabReload).
|
|
1556
|
+
await new Promise((r) => setTimeout(r, 100 + POLL_MS));
|
|
1557
|
+
continue;
|
|
1558
|
+
}
|
|
1559
|
+
// ---- Active loads ----
|
|
1560
|
+
if (this.isLoading || this._pendingPopulateRequest !== null) {
|
|
1561
|
+
await new Promise((r) => setTimeout(r, POLL_MS));
|
|
1562
|
+
continue;
|
|
1563
|
+
}
|
|
1564
|
+
let slabBusy = false;
|
|
1565
|
+
for (const slabState of this._slabBuffers.values()) {
|
|
1566
|
+
if (slabState.isLoading || slabState.pendingReload !== null) {
|
|
1567
|
+
slabBusy = true;
|
|
1568
|
+
break;
|
|
1569
|
+
}
|
|
1570
|
+
}
|
|
1571
|
+
if (slabBusy) {
|
|
1572
|
+
await new Promise((r) => setTimeout(r, POLL_MS));
|
|
1573
|
+
continue;
|
|
1574
|
+
}
|
|
1575
|
+
// ---- In-flight fetches ----
|
|
1576
|
+
await this.coalescer.onIdle();
|
|
1577
|
+
// ---- Convergence check ----
|
|
1578
|
+
// A debounce timer or pending request may have appeared while we
|
|
1579
|
+
// were awaiting the coalescer. If so, loop again.
|
|
1580
|
+
const stillBusy = this.isLoading ||
|
|
1581
|
+
this._pendingPopulateRequest !== null ||
|
|
1582
|
+
this.clipPlaneRefetchTimeout !== null ||
|
|
1583
|
+
this._viewportUpdateTimeout !== null ||
|
|
1584
|
+
this._slabReloadTimeouts.size > 0 ||
|
|
1585
|
+
Array.from(this._slabBuffers.values()).some((s) => s.isLoading || s.pendingReload !== null);
|
|
1586
|
+
if (!stillBusy)
|
|
1587
|
+
break;
|
|
1588
|
+
}
|
|
1234
1589
|
}
|
|
1235
1590
|
// ============================================================
|
|
1236
1591
|
// OMERO Metadata (Visualization Parameters)
|
|
@@ -1305,9 +1660,10 @@ export class OMEZarrNVImage extends NVImage {
|
|
|
1305
1660
|
/**
|
|
1306
1661
|
* Attach a Niivue instance for slice-type-aware rendering.
|
|
1307
1662
|
*
|
|
1308
|
-
* The image auto-detects the NV's current slice type and
|
|
1309
|
-
* `
|
|
1310
|
-
* crosshair/slice
|
|
1663
|
+
* The image auto-detects the NV's current slice type and uses
|
|
1664
|
+
* `addEventListener('sliceTypeChange', ...)` to track mode changes and
|
|
1665
|
+
* `addEventListener('locationChange', ...)` to track crosshair/slice
|
|
1666
|
+
* position changes via Niivue's browser-native EventTarget API.
|
|
1311
1667
|
*
|
|
1312
1668
|
* When the NV is in a 2D slice mode (Axial, Coronal, Sagittal), the image
|
|
1313
1669
|
* loads a slab (one chunk thick in the orthogonal direction) at the current
|
|
@@ -1318,30 +1674,21 @@ export class OMEZarrNVImage extends NVImage {
|
|
|
1318
1674
|
attachNiivue(nv) {
|
|
1319
1675
|
if (this._attachedNiivues.has(nv))
|
|
1320
1676
|
return; // Already attached
|
|
1677
|
+
const eventAbortController = new AbortController();
|
|
1321
1678
|
const state = {
|
|
1322
1679
|
nv,
|
|
1323
1680
|
currentSliceType: this._detectSliceType(nv),
|
|
1324
|
-
|
|
1325
|
-
previousOnOptsChange: nv.onOptsChange,
|
|
1326
|
-
};
|
|
1327
|
-
// Hook onOptsChange to detect slice type changes
|
|
1328
|
-
nv.onOptsChange = (propertyName, newValue, oldValue) => {
|
|
1329
|
-
// Chain to previous handler
|
|
1330
|
-
if (state.previousOnOptsChange) {
|
|
1331
|
-
state.previousOnOptsChange(propertyName, newValue, oldValue);
|
|
1332
|
-
}
|
|
1333
|
-
if (propertyName === "sliceType") {
|
|
1334
|
-
this._handleSliceTypeChange(nv, newValue);
|
|
1335
|
-
}
|
|
1336
|
-
};
|
|
1337
|
-
// Hook onLocationChange to detect slice position changes
|
|
1338
|
-
nv.onLocationChange = (location) => {
|
|
1339
|
-
// Chain to previous handler
|
|
1340
|
-
if (state.previousOnLocationChange) {
|
|
1341
|
-
state.previousOnLocationChange(location);
|
|
1342
|
-
}
|
|
1343
|
-
this._handleLocationChange(nv, location);
|
|
1681
|
+
eventAbortController,
|
|
1344
1682
|
};
|
|
1683
|
+
const signal = eventAbortController.signal;
|
|
1684
|
+
// Listen for slice type changes via the browser-native event API
|
|
1685
|
+
nv.addEventListener("sliceTypeChange", (e) => {
|
|
1686
|
+
this._handleSliceTypeChange(nv, e.detail.sliceType);
|
|
1687
|
+
}, { signal });
|
|
1688
|
+
// Listen for crosshair/slice position changes
|
|
1689
|
+
nv.addEventListener("locationChange", (e) => {
|
|
1690
|
+
this._handleLocationChange(nv, e.detail);
|
|
1691
|
+
}, { signal });
|
|
1345
1692
|
this._attachedNiivues.set(nv, state);
|
|
1346
1693
|
// Hook viewport events if viewport-aware mode is already enabled
|
|
1347
1694
|
if (this._viewportAwareEnabled) {
|
|
@@ -1356,7 +1703,9 @@ export class OMEZarrNVImage extends NVImage {
|
|
|
1356
1703
|
}
|
|
1357
1704
|
}
|
|
1358
1705
|
/**
|
|
1359
|
-
* Detach a Niivue instance,
|
|
1706
|
+
* Detach a Niivue instance, removing all event listeners registered by this
|
|
1707
|
+
* image (viewport, zoom-override, slice-type, location, and clip-plane
|
|
1708
|
+
* listeners are all torn down via their respective `AbortController`s).
|
|
1360
1709
|
*
|
|
1361
1710
|
* @param nv - The Niivue instance to detach
|
|
1362
1711
|
*/
|
|
@@ -1368,10 +1717,13 @@ export class OMEZarrNVImage extends NVImage {
|
|
|
1368
1717
|
this._unhookViewportEvents(nv, state);
|
|
1369
1718
|
// Unhook 3D zoom override
|
|
1370
1719
|
this._unhookZoomOverride(nv, state);
|
|
1371
|
-
//
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
1720
|
+
// Remove niivue event listeners (sliceTypeChange, locationChange)
|
|
1721
|
+
state.eventAbortController.abort();
|
|
1722
|
+
// If detaching the primary NV, also remove the clip plane listener
|
|
1723
|
+
if (nv === this.niivue && this._clipPlaneAbortController) {
|
|
1724
|
+
this._clipPlaneAbortController.abort();
|
|
1725
|
+
this._clipPlaneAbortController = undefined;
|
|
1726
|
+
}
|
|
1375
1727
|
this._attachedNiivues.delete(nv);
|
|
1376
1728
|
}
|
|
1377
1729
|
/**
|
|
@@ -1414,21 +1766,43 @@ export class OMEZarrNVImage extends NVImage {
|
|
|
1414
1766
|
st === SLICE_TYPE.SAGITTAL);
|
|
1415
1767
|
}
|
|
1416
1768
|
/**
|
|
1417
|
-
* Get the
|
|
1418
|
-
*
|
|
1419
|
-
*
|
|
1420
|
-
* -
|
|
1421
|
-
* -
|
|
1769
|
+
* Get the NGFF array axis index that is orthogonal to a slice plane.
|
|
1770
|
+
*
|
|
1771
|
+
* NiiVue slice types refer to anatomical planes:
|
|
1772
|
+
* - Axial: perpendicular to S/I (physicalRow 2)
|
|
1773
|
+
* - Coronal: perpendicular to A/P (physicalRow 1)
|
|
1774
|
+
* - Sagittal: perpendicular to R/L (physicalRow 0)
|
|
1775
|
+
*
|
|
1776
|
+
* When the dataset has permuted axes (e.g. NGFF y encodes S/I instead
|
|
1777
|
+
* of A/P), the NGFF array axis that is orthogonal to a given anatomical
|
|
1778
|
+
* plane differs from the default z/y/x mapping. This method uses the
|
|
1779
|
+
* orientation metadata to find the correct NGFF axis.
|
|
1780
|
+
*
|
|
1781
|
+
* Returns index in [z, y, x] order (0=z, 1=y, 2=x).
|
|
1422
1782
|
*/
|
|
1423
1783
|
_getOrthogonalAxis(sliceType) {
|
|
1784
|
+
// Which physical (RAS) row is perpendicular to this slice plane?
|
|
1785
|
+
let targetRow;
|
|
1424
1786
|
switch (sliceType) {
|
|
1425
1787
|
case SLICE_TYPE.AXIAL:
|
|
1426
|
-
|
|
1788
|
+
targetRow = 2; // S/I
|
|
1789
|
+
break;
|
|
1427
1790
|
case SLICE_TYPE.CORONAL:
|
|
1428
|
-
|
|
1791
|
+
targetRow = 1; // A/P
|
|
1792
|
+
break;
|
|
1429
1793
|
case SLICE_TYPE.SAGITTAL:
|
|
1430
|
-
|
|
1794
|
+
targetRow = 0; // R/L
|
|
1795
|
+
break;
|
|
1431
1796
|
}
|
|
1797
|
+
const orientations = this.multiscales.images[0]?.axesOrientations;
|
|
1798
|
+
const mapping = getOrientationMapping(orientations);
|
|
1799
|
+
// Find which NGFF axis maps to targetRow.
|
|
1800
|
+
// mapping.x/y/z have physicalRow; NGFF indices: x=2, y=1, z=0
|
|
1801
|
+
if (mapping.z.physicalRow === targetRow)
|
|
1802
|
+
return 0;
|
|
1803
|
+
if (mapping.y.physicalRow === targetRow)
|
|
1804
|
+
return 1;
|
|
1805
|
+
return 2;
|
|
1432
1806
|
}
|
|
1433
1807
|
/**
|
|
1434
1808
|
* Handle a slice type change on an attached Niivue instance.
|
|
@@ -1477,9 +1851,12 @@ export class OMEZarrNVImage extends NVImage {
|
|
|
1477
1851
|
catch {
|
|
1478
1852
|
return; // Can't convert coordinates yet
|
|
1479
1853
|
}
|
|
1480
|
-
// Convert world to pixel at the slab's current resolution level
|
|
1854
|
+
// Convert world to pixel at the slab's current resolution level.
|
|
1855
|
+
// Must use the full oriented affine (not the naive scale+translation)
|
|
1856
|
+
// because worldCoord is in oriented (NIfTI RAS) space.
|
|
1481
1857
|
const ngffImage = this.multiscales.images[slabState.levelIndex];
|
|
1482
|
-
const
|
|
1858
|
+
const orientedAffine = createAffineFromNgffImage(ngffImage);
|
|
1859
|
+
const pixelCoord = worldToPixelAffine(worldCoord, orientedAffine);
|
|
1483
1860
|
// Check the orthogonal axis
|
|
1484
1861
|
const orthAxis = this._getOrthogonalAxis(sliceType);
|
|
1485
1862
|
const pixelPos = pixelCoord[orthAxis];
|
|
@@ -1514,10 +1891,11 @@ export class OMEZarrNVImage extends NVImage {
|
|
|
1514
1891
|
slabState = this._createSlabBuffer(sliceType);
|
|
1515
1892
|
this._slabBuffers.set(sliceType, slabState);
|
|
1516
1893
|
}
|
|
1517
|
-
//
|
|
1518
|
-
|
|
1519
|
-
//
|
|
1520
|
-
//
|
|
1894
|
+
// Capture the crosshair world position BEFORE swapping volumes.
|
|
1895
|
+
// frac2mm() uses the current volume's affine, so it must run while
|
|
1896
|
+
// the 3D (or previous slab) NVImage is still attached. After the swap,
|
|
1897
|
+
// the 1×1×1 placeholder's identity affine would produce incorrect
|
|
1898
|
+
// coordinates.
|
|
1521
1899
|
let worldCoord;
|
|
1522
1900
|
try {
|
|
1523
1901
|
const crosshairPos = nv.scene?.crosshairPos;
|
|
@@ -1546,6 +1924,8 @@ export class OMEZarrNVImage extends NVImage {
|
|
|
1546
1924
|
(this._volumeBounds.min[2] + this._volumeBounds.max[2]) / 2,
|
|
1547
1925
|
];
|
|
1548
1926
|
}
|
|
1927
|
+
// Swap the slab's NVImage into this NV instance (after capturing coords)
|
|
1928
|
+
this._swapVolumeInNiivue(nv, slabState.nvImage);
|
|
1549
1929
|
void this._loadSlab(sliceType, worldCoord, "initial").catch((err) => {
|
|
1550
1930
|
console.error(`[fidnii] Error loading slab for ${SLICE_TYPE[sliceType]}:`, err);
|
|
1551
1931
|
});
|
|
@@ -1581,7 +1961,7 @@ export class OMEZarrNVImage extends NVImage {
|
|
|
1581
1961
|
nvImage.name = `${this.name ?? "OME-Zarr"} [${SLICE_TYPE[sliceType]}]`;
|
|
1582
1962
|
nvImage.img = bufferManager.resize([1, 1, 1]);
|
|
1583
1963
|
if (!this.isLabelImage) {
|
|
1584
|
-
nvImage._colormap = "gray";
|
|
1964
|
+
nvImage._colormap = this._colormap || "gray";
|
|
1585
1965
|
}
|
|
1586
1966
|
nvImage._opacity = 1.0;
|
|
1587
1967
|
// Select initial resolution using 2D pixel budget
|
|
@@ -1744,8 +2124,11 @@ export class OMEZarrNVImage extends NVImage {
|
|
|
1744
2124
|
const ngffImage = this.multiscales.images[levelIndex];
|
|
1745
2125
|
const chunkShape = getChunkShape(ngffImage);
|
|
1746
2126
|
const volumeShape = getVolumeShape(ngffImage);
|
|
1747
|
-
// Convert world position to pixel position at this level
|
|
1748
|
-
|
|
2127
|
+
// Convert world position to pixel position at this level.
|
|
2128
|
+
// Must use the full oriented affine because worldCoord is in oriented
|
|
2129
|
+
// (NIfTI RAS) space, not raw NGFF scale+translation space.
|
|
2130
|
+
const orientedAffine = createAffineFromNgffImage(ngffImage);
|
|
2131
|
+
const pixelCoord = worldToPixelAffine(worldCoord, orientedAffine);
|
|
1749
2132
|
const orthPixel = pixelCoord[orthAxis];
|
|
1750
2133
|
// Find the chunk-aligned slab in the orthogonal axis
|
|
1751
2134
|
const chunkSize = chunkShape[orthAxis];
|
|
@@ -1778,7 +2161,7 @@ export class OMEZarrNVImage extends NVImage {
|
|
|
1778
2161
|
];
|
|
1779
2162
|
// Fetch the data
|
|
1780
2163
|
const fetchRegion = { start: fetchStart, end: fetchEnd };
|
|
1781
|
-
const result = await this.coalescer.fetchRegion(ngffImage, levelIndex, fetchRegion, `slab-${SLICE_TYPE[sliceType]}-${levelIndex}
|
|
2164
|
+
const result = await this.coalescer.fetchRegion(ngffImage, levelIndex, fetchRegion, `slab-${SLICE_TYPE[sliceType]}-${levelIndex}`, this._timeIndex);
|
|
1782
2165
|
// Resize buffer and copy data
|
|
1783
2166
|
const targetData = slabState.bufferManager.resize(fetchedShape);
|
|
1784
2167
|
const normalize = needsRGBNormalization(ngffImage, this.dtype);
|
|
@@ -1881,17 +2264,28 @@ export class OMEZarrNVImage extends NVImage {
|
|
|
1881
2264
|
1,
|
|
1882
2265
|
1,
|
|
1883
2266
|
];
|
|
1884
|
-
// Build
|
|
1885
|
-
//
|
|
1886
|
-
//
|
|
1887
|
-
//
|
|
2267
|
+
// Build the fully oriented affine (including orientation permutation
|
|
2268
|
+
// and sign flips), then apply the region offset in world space.
|
|
2269
|
+
//
|
|
2270
|
+
// The offset must be applied AFTER orientation because the fetch region
|
|
2271
|
+
// start is in NGFF voxel space [z, y, x], and the oriented 3x3 rotation
|
|
2272
|
+
// matrix is needed to transform that voxel offset into world coordinates.
|
|
2273
|
+
// Previously, the offset was applied before orientation which broke when
|
|
2274
|
+
// NGFF axes were permuted (e.g. NGFF z → physical A/P axis).
|
|
1888
2275
|
const affine = createAffineFromNgffImage(ngffImage);
|
|
1889
|
-
//
|
|
1890
|
-
|
|
1891
|
-
//
|
|
1892
|
-
|
|
1893
|
-
|
|
1894
|
-
|
|
2276
|
+
// Transform the NGFF voxel offset through the oriented 3x3 rotation.
|
|
2277
|
+
// fetchStart is [z, y, x]; affine columns map NIfTI [i=x, j=y, k=z]
|
|
2278
|
+
// to world, so we need offset in NIfTI [x, y, z] order.
|
|
2279
|
+
const offsetX = fetchStart[2]; // NIfTI i = NGFF x
|
|
2280
|
+
const offsetY = fetchStart[1]; // NIfTI j = NGFF y
|
|
2281
|
+
const offsetZ = fetchStart[0]; // NIfTI k = NGFF z
|
|
2282
|
+
// Multiply the 3x3 rotation by the offset vector and add to translation
|
|
2283
|
+
affine[12] +=
|
|
2284
|
+
affine[0] * offsetX + affine[4] * offsetY + affine[8] * offsetZ;
|
|
2285
|
+
affine[13] +=
|
|
2286
|
+
affine[1] * offsetX + affine[5] * offsetY + affine[9] * offsetZ;
|
|
2287
|
+
affine[14] +=
|
|
2288
|
+
affine[2] * offsetX + affine[6] * offsetY + affine[10] * offsetZ;
|
|
1895
2289
|
// For 2D images, flip y before normalization (composes with orientation)
|
|
1896
2290
|
if (this._flipY2D && this._is2D) {
|
|
1897
2291
|
// Get the y axis orientation mapping to find where the y scale is stored
|