@fideus-labs/fidnii 0.1.0 → 0.2.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/BufferManager.d.ts.map +1 -1
- package/dist/BufferManager.js +2 -5
- package/dist/BufferManager.js.map +1 -1
- package/dist/ClipPlanes.d.ts.map +1 -1
- package/dist/ClipPlanes.js +11 -15
- package/dist/ClipPlanes.js.map +1 -1
- package/dist/OMEZarrNVImage.d.ts +33 -3
- package/dist/OMEZarrNVImage.d.ts.map +1 -1
- package/dist/OMEZarrNVImage.js +129 -50
- package/dist/OMEZarrNVImage.js.map +1 -1
- package/dist/RegionCoalescer.d.ts.map +1 -1
- package/dist/RegionCoalescer.js +1 -1
- package/dist/RegionCoalescer.js.map +1 -1
- package/dist/ResolutionSelector.d.ts.map +1 -1
- package/dist/ResolutionSelector.js +2 -4
- package/dist/ResolutionSelector.js.map +1 -1
- package/dist/ViewportBounds.d.ts.map +1 -1
- package/dist/ViewportBounds.js +7 -5
- package/dist/ViewportBounds.js.map +1 -1
- package/dist/events.d.ts.map +1 -1
- package/dist/events.js.map +1 -1
- package/dist/index.d.ts +11 -11
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +17 -16
- package/dist/index.js.map +1 -1
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js.map +1 -1
- package/dist/utils/affine.d.ts +1 -1
- package/dist/utils/affine.d.ts.map +1 -1
- package/dist/utils/affine.js.map +1 -1
- package/dist/utils/coordinates.d.ts +1 -1
- package/dist/utils/coordinates.d.ts.map +1 -1
- package/dist/utils/coordinates.js.map +1 -1
- package/package.json +1 -1
- package/src/BufferManager.ts +45 -45
- package/src/ClipPlanes.ts +131 -130
- package/src/OMEZarrNVImage.ts +685 -606
- package/src/RegionCoalescer.ts +48 -47
- package/src/ResolutionSelector.ts +66 -67
- package/src/ViewportBounds.ts +120 -118
- package/src/events.ts +36 -35
- package/src/index.ts +59 -69
- package/src/types.ts +95 -94
- package/src/utils/affine.ts +65 -65
- package/src/utils/coordinates.ts +70 -70
package/src/OMEZarrNVImage.ts
CHANGED
|
@@ -1,13 +1,42 @@
|
|
|
1
1
|
// SPDX-FileCopyrightText: Copyright (c) Fideus Labs LLC
|
|
2
2
|
// SPDX-License-Identifier: MIT
|
|
3
3
|
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
import
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
4
|
+
import type { Multiscales, NgffImage, Omero } from "@fideus-labs/ngff-zarr"
|
|
5
|
+
import { Methods } from "@fideus-labs/ngff-zarr"
|
|
6
|
+
import {
|
|
7
|
+
computeOmeroFromNgffImage,
|
|
8
|
+
GLASBEY_COLORS,
|
|
9
|
+
} from "@fideus-labs/ngff-zarr/browser"
|
|
10
|
+
import type { Niivue } from "@niivue/niivue"
|
|
11
|
+
import { NVImage, SLICE_TYPE } from "@niivue/niivue"
|
|
12
|
+
import { LRUCache } from "lru-cache"
|
|
13
|
+
import { NIFTI1 } from "nifti-reader-js"
|
|
14
|
+
|
|
15
|
+
import { BufferManager } from "./BufferManager.js"
|
|
16
|
+
import {
|
|
17
|
+
alignToChunks,
|
|
18
|
+
clipPlanesToNiivue,
|
|
19
|
+
clipPlanesToPixelRegion,
|
|
20
|
+
createDefaultClipPlanes,
|
|
21
|
+
MAX_CLIP_PLANES,
|
|
22
|
+
normalizeVector,
|
|
23
|
+
validateClipPlanes,
|
|
24
|
+
} from "./ClipPlanes.js"
|
|
25
|
+
import {
|
|
26
|
+
OMEZarrNVImageEvent,
|
|
27
|
+
type OMEZarrNVImageEventListener,
|
|
28
|
+
type OMEZarrNVImageEventListenerOptions,
|
|
29
|
+
type OMEZarrNVImageEventMap,
|
|
30
|
+
type PopulateTrigger,
|
|
31
|
+
} from "./events.js"
|
|
32
|
+
import { RegionCoalescer } from "./RegionCoalescer.js"
|
|
33
|
+
import type { OrthogonalAxis } from "./ResolutionSelector.js"
|
|
34
|
+
import {
|
|
35
|
+
getChunkShape,
|
|
36
|
+
getVolumeShape,
|
|
37
|
+
select2DResolution,
|
|
38
|
+
selectResolution,
|
|
39
|
+
} from "./ResolutionSelector.js"
|
|
11
40
|
import type {
|
|
12
41
|
AttachedNiivueState,
|
|
13
42
|
ChunkAlignedRegion,
|
|
@@ -18,53 +47,29 @@ import type {
|
|
|
18
47
|
PixelRegion,
|
|
19
48
|
SlabBufferState,
|
|
20
49
|
SlabSliceType,
|
|
50
|
+
TypedArray,
|
|
21
51
|
VolumeBounds,
|
|
22
52
|
ZarrDtype,
|
|
23
|
-
} from "./types.js"
|
|
53
|
+
} from "./types.js"
|
|
24
54
|
import {
|
|
25
55
|
getBytesPerPixel,
|
|
26
56
|
getNiftiDataType,
|
|
27
57
|
parseZarritaDtype,
|
|
28
|
-
} from "./types.js"
|
|
29
|
-
import { BufferManager } from "./BufferManager.js";
|
|
30
|
-
import { RegionCoalescer } from "./RegionCoalescer.js";
|
|
31
|
-
import {
|
|
32
|
-
getChunkShape,
|
|
33
|
-
getVolumeShape,
|
|
34
|
-
select2DResolution,
|
|
35
|
-
selectResolution,
|
|
36
|
-
} from "./ResolutionSelector.js";
|
|
37
|
-
import type { OrthogonalAxis } from "./ResolutionSelector.js";
|
|
38
|
-
import {
|
|
39
|
-
alignToChunks,
|
|
40
|
-
clipPlanesToNiivue,
|
|
41
|
-
clipPlanesToPixelRegion,
|
|
42
|
-
createDefaultClipPlanes,
|
|
43
|
-
MAX_CLIP_PLANES,
|
|
44
|
-
normalizeVector,
|
|
45
|
-
validateClipPlanes,
|
|
46
|
-
} from "./ClipPlanes.js";
|
|
58
|
+
} from "./types.js"
|
|
47
59
|
import {
|
|
48
60
|
affineToNiftiSrows,
|
|
49
61
|
calculateWorldBounds,
|
|
50
62
|
createAffineFromNgffImage,
|
|
51
|
-
} from "./utils/affine.js"
|
|
52
|
-
import { worldToPixel } from "./utils/coordinates.js"
|
|
63
|
+
} from "./utils/affine.js"
|
|
64
|
+
import { worldToPixel } from "./utils/coordinates.js"
|
|
53
65
|
import {
|
|
54
66
|
boundsApproxEqual,
|
|
55
67
|
computeViewportBounds2D,
|
|
56
68
|
computeViewportBounds3D,
|
|
57
|
-
} from "./ViewportBounds.js"
|
|
58
|
-
import {
|
|
59
|
-
OMEZarrNVImageEvent,
|
|
60
|
-
OMEZarrNVImageEventListener,
|
|
61
|
-
OMEZarrNVImageEventListenerOptions,
|
|
62
|
-
OMEZarrNVImageEventMap,
|
|
63
|
-
PopulateTrigger,
|
|
64
|
-
} from "./events.js";
|
|
69
|
+
} from "./ViewportBounds.js"
|
|
65
70
|
|
|
66
|
-
const DEFAULT_MAX_PIXELS = 50_000_000
|
|
67
|
-
const DEFAULT_MAX_CACHE_ENTRIES = 200
|
|
71
|
+
const DEFAULT_MAX_PIXELS = 50_000_000
|
|
72
|
+
const DEFAULT_MAX_CACHE_ENTRIES = 200
|
|
68
73
|
|
|
69
74
|
/**
|
|
70
75
|
* OMEZarrNVImage extends NVImage to support rendering OME-Zarr images in NiiVue.
|
|
@@ -78,179 +83,188 @@ const DEFAULT_MAX_CACHE_ENTRIES = 200;
|
|
|
78
83
|
*/
|
|
79
84
|
export class OMEZarrNVImage extends NVImage {
|
|
80
85
|
/** The OME-Zarr multiscales data */
|
|
81
|
-
readonly multiscales: Multiscales
|
|
86
|
+
readonly multiscales: Multiscales
|
|
82
87
|
|
|
83
88
|
/** Maximum number of pixels to use */
|
|
84
|
-
readonly maxPixels: number
|
|
89
|
+
readonly maxPixels: number
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* True when `multiscales.method` is `Methods.ITKWASM_LABEL_IMAGE`.
|
|
93
|
+
*
|
|
94
|
+
* Label images are rendered with a discrete colormap
|
|
95
|
+
* (`setColormapLabel()`) instead of a continuous colormap, and
|
|
96
|
+
* OMERO intensity windowing is skipped.
|
|
97
|
+
*/
|
|
98
|
+
readonly isLabelImage: boolean
|
|
85
99
|
|
|
86
100
|
/** Reference to NiiVue instance */
|
|
87
|
-
private readonly niivue: Niivue
|
|
101
|
+
private readonly niivue: Niivue
|
|
88
102
|
|
|
89
103
|
/** Buffer manager for dynamically-sized pixel data */
|
|
90
|
-
private readonly bufferManager: BufferManager
|
|
104
|
+
private readonly bufferManager: BufferManager
|
|
91
105
|
|
|
92
106
|
/** Region coalescer for efficient chunk fetching */
|
|
93
|
-
private readonly coalescer: RegionCoalescer
|
|
107
|
+
private readonly coalescer: RegionCoalescer
|
|
94
108
|
|
|
95
109
|
/** Decoded-chunk cache shared across 3D and 2D slab loads. */
|
|
96
|
-
private readonly _chunkCache: ChunkCache | undefined
|
|
110
|
+
private readonly _chunkCache: ChunkCache | undefined
|
|
97
111
|
|
|
98
112
|
/** Current clip planes in world space */
|
|
99
|
-
private _clipPlanes: ClipPlanes
|
|
113
|
+
private _clipPlanes: ClipPlanes
|
|
100
114
|
|
|
101
115
|
/** Target resolution level index (based on maxPixels) */
|
|
102
|
-
private targetLevelIndex: number
|
|
116
|
+
private targetLevelIndex: number
|
|
103
117
|
|
|
104
118
|
/** Current resolution level index during progressive loading */
|
|
105
|
-
private currentLevelIndex: number
|
|
119
|
+
private currentLevelIndex: number
|
|
106
120
|
|
|
107
121
|
/** True if currently loading data */
|
|
108
|
-
private isLoading: boolean = false
|
|
122
|
+
private isLoading: boolean = false
|
|
109
123
|
|
|
110
124
|
/** Data type of the volume */
|
|
111
|
-
private readonly dtype: ZarrDtype
|
|
125
|
+
private readonly dtype: ZarrDtype
|
|
112
126
|
|
|
113
127
|
/** Full volume bounds in world space */
|
|
114
|
-
private readonly _volumeBounds: VolumeBounds
|
|
128
|
+
private readonly _volumeBounds: VolumeBounds
|
|
115
129
|
|
|
116
130
|
/** Current buffer bounds in world space (may differ from full volume when clipped) */
|
|
117
|
-
private _currentBufferBounds: VolumeBounds
|
|
131
|
+
private _currentBufferBounds: VolumeBounds
|
|
118
132
|
|
|
119
133
|
/** Previous clip plane change handler (to restore later) */
|
|
120
|
-
private previousOnClipPlaneChange?: (clipPlane: number[]) => void
|
|
134
|
+
private previousOnClipPlaneChange?: (clipPlane: number[]) => void
|
|
121
135
|
|
|
122
136
|
/** Debounce delay for clip plane updates (ms) */
|
|
123
|
-
private readonly clipPlaneDebounceMs: number
|
|
137
|
+
private readonly clipPlaneDebounceMs: number
|
|
124
138
|
|
|
125
139
|
/** Timeout handle for debounced clip plane refetch */
|
|
126
|
-
private clipPlaneRefetchTimeout: ReturnType<typeof setTimeout> | null = null
|
|
140
|
+
private clipPlaneRefetchTimeout: ReturnType<typeof setTimeout> | null = null
|
|
127
141
|
|
|
128
142
|
/** Previous clip planes state for direction comparison */
|
|
129
|
-
private _previousClipPlanes: ClipPlanes = []
|
|
143
|
+
private _previousClipPlanes: ClipPlanes = []
|
|
130
144
|
|
|
131
145
|
/** Previous pixel count at current resolution (for direction comparison) */
|
|
132
|
-
private _previousPixelCount: number = 0
|
|
146
|
+
private _previousPixelCount: number = 0
|
|
133
147
|
|
|
134
148
|
/** Cached/computed OMERO metadata for visualization (cal_min/cal_max) */
|
|
135
|
-
private _omero: Omero | undefined
|
|
149
|
+
private _omero: Omero | undefined
|
|
136
150
|
|
|
137
151
|
/** Active channel index for OMERO window selection (default: 0) */
|
|
138
|
-
private _activeChannel: number = 0
|
|
152
|
+
private _activeChannel: number = 0
|
|
139
153
|
|
|
140
154
|
/** Resolution level at which OMERO was last computed (to track recomputation) */
|
|
141
|
-
private _omeroComputedForLevel: number = -1
|
|
155
|
+
private _omeroComputedForLevel: number = -1
|
|
142
156
|
|
|
143
157
|
/** Internal EventTarget for event dispatching (composition pattern) */
|
|
144
|
-
private readonly _eventTarget = new EventTarget()
|
|
158
|
+
private readonly _eventTarget = new EventTarget()
|
|
145
159
|
|
|
146
160
|
/** Pending populate request (latest wins - replaces any previous pending) */
|
|
147
161
|
private _pendingPopulateRequest: {
|
|
148
|
-
skipPreview: boolean
|
|
149
|
-
trigger: PopulateTrigger
|
|
150
|
-
} | null = null
|
|
162
|
+
skipPreview: boolean
|
|
163
|
+
trigger: PopulateTrigger
|
|
164
|
+
} | null = null
|
|
151
165
|
|
|
152
166
|
/** Current populate trigger (set at start of populateVolume, used by events) */
|
|
153
|
-
private _currentPopulateTrigger: PopulateTrigger = "initial"
|
|
167
|
+
private _currentPopulateTrigger: PopulateTrigger = "initial"
|
|
154
168
|
|
|
155
169
|
// ============================================================
|
|
156
170
|
// Multi-NV / Slab Buffer State
|
|
157
171
|
// ============================================================
|
|
158
172
|
|
|
159
173
|
/** Attached Niivue instances and their state */
|
|
160
|
-
private _attachedNiivues: Map<Niivue, AttachedNiivueState> = new Map()
|
|
174
|
+
private _attachedNiivues: Map<Niivue, AttachedNiivueState> = new Map()
|
|
161
175
|
|
|
162
176
|
/** Per-slice-type slab buffers (lazily created) */
|
|
163
|
-
private _slabBuffers: Map<SlabSliceType, SlabBufferState> = new Map()
|
|
177
|
+
private _slabBuffers: Map<SlabSliceType, SlabBufferState> = new Map()
|
|
164
178
|
|
|
165
179
|
/** Debounce timeout for slab reload per slice type */
|
|
166
180
|
private _slabReloadTimeouts: Map<
|
|
167
181
|
SlabSliceType,
|
|
168
182
|
ReturnType<typeof setTimeout>
|
|
169
|
-
> = new Map()
|
|
183
|
+
> = new Map()
|
|
170
184
|
|
|
171
185
|
// ============================================================
|
|
172
186
|
// Viewport-Aware Resolution State
|
|
173
187
|
// ============================================================
|
|
174
188
|
|
|
175
189
|
/** Whether viewport-aware resolution selection is enabled */
|
|
176
|
-
private _viewportAwareEnabled: boolean = false
|
|
190
|
+
private _viewportAwareEnabled: boolean = false
|
|
177
191
|
|
|
178
192
|
/**
|
|
179
193
|
* Viewport bounds for the 3D render volume (union of all RENDER/MULTIPLANAR NVs).
|
|
180
194
|
* Null = full volume, no viewport constraint.
|
|
181
195
|
*/
|
|
182
|
-
private _viewportBounds3D: VolumeBounds | null = null
|
|
196
|
+
private _viewportBounds3D: VolumeBounds | null = null
|
|
183
197
|
|
|
184
198
|
/**
|
|
185
199
|
* Per-slab viewport bounds (from the NV instance that displays each slab).
|
|
186
200
|
* Null entry = full volume, no viewport constraint for that slab.
|
|
187
201
|
*/
|
|
188
202
|
private _viewportBoundsPerSlab: Map<SlabSliceType, VolumeBounds | null> =
|
|
189
|
-
new Map()
|
|
203
|
+
new Map()
|
|
190
204
|
|
|
191
205
|
/** Timeout handle for debounced viewport update */
|
|
192
|
-
private _viewportUpdateTimeout: ReturnType<typeof setTimeout> | null = null
|
|
206
|
+
private _viewportUpdateTimeout: ReturnType<typeof setTimeout> | null = null
|
|
193
207
|
|
|
194
208
|
/** Per-slab AbortController to cancel in-flight progressive loads */
|
|
195
|
-
private _slabAbortControllers: Map<SlabSliceType, AbortController> =
|
|
196
|
-
new Map();
|
|
209
|
+
private _slabAbortControllers: Map<SlabSliceType, AbortController> = new Map()
|
|
197
210
|
|
|
198
211
|
// ============================================================
|
|
199
212
|
// 3D Zoom Override
|
|
200
213
|
// ============================================================
|
|
201
214
|
|
|
202
215
|
/** Maximum 3D render zoom level for scroll-wheel zoom */
|
|
203
|
-
private readonly _max3DZoom: number
|
|
216
|
+
private readonly _max3DZoom: number
|
|
204
217
|
|
|
205
218
|
/** Minimum 3D render zoom level for scroll-wheel zoom */
|
|
206
|
-
private readonly _min3DZoom: number
|
|
219
|
+
private readonly _min3DZoom: number
|
|
207
220
|
|
|
208
221
|
/**
|
|
209
222
|
* Debounce delay for viewport-aware reloads (ms).
|
|
210
223
|
* Higher than clip plane debounce to avoid excessive reloads during
|
|
211
224
|
* continuous zoom/pan interactions.
|
|
212
225
|
*/
|
|
213
|
-
private static readonly VIEWPORT_DEBOUNCE_MS = 500
|
|
226
|
+
private static readonly VIEWPORT_DEBOUNCE_MS = 500
|
|
214
227
|
|
|
215
228
|
/**
|
|
216
229
|
* Private constructor. Use OMEZarrNVImage.create() for instantiation.
|
|
217
230
|
*/
|
|
218
231
|
private constructor(options: OMEZarrNVImageOptions) {
|
|
219
232
|
// Call NVImage constructor with no data buffer
|
|
220
|
-
super()
|
|
233
|
+
super()
|
|
221
234
|
|
|
222
|
-
this.multiscales = options.multiscales
|
|
223
|
-
this.maxPixels = options.maxPixels ?? DEFAULT_MAX_PIXELS
|
|
224
|
-
this.
|
|
225
|
-
this.
|
|
235
|
+
this.multiscales = options.multiscales
|
|
236
|
+
this.maxPixels = options.maxPixels ?? DEFAULT_MAX_PIXELS
|
|
237
|
+
this.isLabelImage = this.multiscales.method === Methods.ITKWASM_LABEL_IMAGE
|
|
238
|
+
this.niivue = options.niivue
|
|
239
|
+
this.clipPlaneDebounceMs = options.clipPlaneDebounceMs ?? 300
|
|
226
240
|
|
|
227
241
|
// Initialize chunk cache: user-provided > LRU(maxCacheEntries) > disabled
|
|
228
|
-
const maxEntries = options.maxCacheEntries ?? DEFAULT_MAX_CACHE_ENTRIES
|
|
242
|
+
const maxEntries = options.maxCacheEntries ?? DEFAULT_MAX_CACHE_ENTRIES
|
|
229
243
|
if (options.cache) {
|
|
230
|
-
this._chunkCache = options.cache
|
|
244
|
+
this._chunkCache = options.cache
|
|
231
245
|
} else if (maxEntries > 0) {
|
|
232
|
-
this._chunkCache = new LRUCache({ max: maxEntries })
|
|
246
|
+
this._chunkCache = new LRUCache({ max: maxEntries })
|
|
233
247
|
}
|
|
234
248
|
|
|
235
|
-
this.coalescer = new RegionCoalescer(this._chunkCache)
|
|
236
|
-
this._max3DZoom = options.max3DZoom ?? 10.0
|
|
237
|
-
this._min3DZoom = options.min3DZoom ?? 0.3
|
|
238
|
-
this._viewportAwareEnabled = options.viewportAware ?? true
|
|
249
|
+
this.coalescer = new RegionCoalescer(this._chunkCache)
|
|
250
|
+
this._max3DZoom = options.max3DZoom ?? 10.0
|
|
251
|
+
this._min3DZoom = options.min3DZoom ?? 0.3
|
|
252
|
+
this._viewportAwareEnabled = options.viewportAware ?? true
|
|
239
253
|
|
|
240
254
|
// Initialize clip planes to empty (full volume visible)
|
|
241
|
-
this._clipPlanes = createDefaultClipPlanes(this.multiscales)
|
|
255
|
+
this._clipPlanes = createDefaultClipPlanes(this.multiscales)
|
|
242
256
|
|
|
243
257
|
// Get data type from highest resolution image
|
|
244
|
-
const highResImage = this.multiscales.images[0]
|
|
245
|
-
this.dtype = parseZarritaDtype(highResImage.data.dtype)
|
|
258
|
+
const highResImage = this.multiscales.images[0]
|
|
259
|
+
this.dtype = parseZarritaDtype(highResImage.data.dtype)
|
|
246
260
|
|
|
247
261
|
// Calculate volume bounds from highest resolution for most accurate bounds
|
|
248
|
-
const highResAffine = createAffineFromNgffImage(highResImage)
|
|
249
|
-
const highResShape = getVolumeShape(highResImage)
|
|
250
|
-
this._volumeBounds = calculateWorldBounds(highResAffine, highResShape)
|
|
262
|
+
const highResAffine = createAffineFromNgffImage(highResImage)
|
|
263
|
+
const highResShape = getVolumeShape(highResImage)
|
|
264
|
+
this._volumeBounds = calculateWorldBounds(highResAffine, highResShape)
|
|
251
265
|
|
|
252
266
|
// Initially, buffer bounds = full volume bounds (no clipping yet)
|
|
253
|
-
this._currentBufferBounds = { ...this._volumeBounds }
|
|
267
|
+
this._currentBufferBounds = { ...this._volumeBounds }
|
|
254
268
|
|
|
255
269
|
// Calculate target resolution based on pixel budget
|
|
256
270
|
const selection = selectResolution(
|
|
@@ -258,16 +272,16 @@ export class OMEZarrNVImage extends NVImage {
|
|
|
258
272
|
this.maxPixels,
|
|
259
273
|
this._clipPlanes,
|
|
260
274
|
this._volumeBounds,
|
|
261
|
-
)
|
|
262
|
-
this.targetLevelIndex = selection.levelIndex
|
|
263
|
-
this.currentLevelIndex = this.multiscales.images.length - 1
|
|
275
|
+
)
|
|
276
|
+
this.targetLevelIndex = selection.levelIndex
|
|
277
|
+
this.currentLevelIndex = this.multiscales.images.length - 1
|
|
264
278
|
|
|
265
279
|
// Create buffer manager (dynamic sizing, no pre-allocation)
|
|
266
|
-
this.bufferManager = new BufferManager(this.maxPixels, this.dtype)
|
|
280
|
+
this.bufferManager = new BufferManager(this.maxPixels, this.dtype)
|
|
267
281
|
|
|
268
282
|
// Initialize NVImage properties with placeholder values
|
|
269
283
|
// Actual values will be set when data is first loaded
|
|
270
|
-
this.initializeNVImageProperties()
|
|
284
|
+
this.initializeNVImageProperties()
|
|
271
285
|
}
|
|
272
286
|
|
|
273
287
|
/**
|
|
@@ -284,30 +298,30 @@ export class OMEZarrNVImage extends NVImage {
|
|
|
284
298
|
* @returns Promise resolving to the OMEZarrNVImage instance
|
|
285
299
|
*/
|
|
286
300
|
static async create(options: OMEZarrNVImageOptions): Promise<OMEZarrNVImage> {
|
|
287
|
-
const image = new OMEZarrNVImage(options)
|
|
301
|
+
const image = new OMEZarrNVImage(options)
|
|
288
302
|
|
|
289
303
|
// Store and replace the clip plane change handler
|
|
290
|
-
image.previousOnClipPlaneChange = image.niivue.onClipPlaneChange
|
|
304
|
+
image.previousOnClipPlaneChange = image.niivue.onClipPlaneChange
|
|
291
305
|
image.niivue.onClipPlaneChange = (clipPlane: number[]) => {
|
|
292
306
|
// Call original handler if it exists
|
|
293
307
|
if (image.previousOnClipPlaneChange) {
|
|
294
|
-
image.previousOnClipPlaneChange(clipPlane)
|
|
308
|
+
image.previousOnClipPlaneChange(clipPlane)
|
|
295
309
|
}
|
|
296
310
|
// Handle clip plane change
|
|
297
|
-
image.onNiivueClipPlaneChange(clipPlane)
|
|
298
|
-
}
|
|
311
|
+
image.onNiivueClipPlaneChange(clipPlane)
|
|
312
|
+
}
|
|
299
313
|
|
|
300
314
|
// Auto-attach the primary NV instance for slice type / location tracking
|
|
301
|
-
image.attachNiivue(image.niivue)
|
|
315
|
+
image.attachNiivue(image.niivue)
|
|
302
316
|
|
|
303
317
|
// Auto-load by default (add to NiiVue + start progressive loading)
|
|
304
|
-
const autoLoad = options.autoLoad ?? true
|
|
318
|
+
const autoLoad = options.autoLoad ?? true
|
|
305
319
|
if (autoLoad) {
|
|
306
|
-
image.niivue.addVolume(image)
|
|
307
|
-
void image.populateVolume()
|
|
320
|
+
image.niivue.addVolume(image)
|
|
321
|
+
void image.populateVolume() // Fire-and-forget, returns immediately
|
|
308
322
|
}
|
|
309
323
|
|
|
310
|
-
return image
|
|
324
|
+
return image
|
|
311
325
|
}
|
|
312
326
|
|
|
313
327
|
/**
|
|
@@ -316,18 +330,18 @@ export class OMEZarrNVImage extends NVImage {
|
|
|
316
330
|
*/
|
|
317
331
|
private initializeNVImageProperties(): void {
|
|
318
332
|
// Create NIfTI header with placeholder values
|
|
319
|
-
const hdr = new NIFTI1()
|
|
320
|
-
this.hdr = hdr
|
|
333
|
+
const hdr = new NIFTI1()
|
|
334
|
+
this.hdr = hdr
|
|
321
335
|
|
|
322
336
|
// Placeholder dimensions (will be updated when data loads)
|
|
323
|
-
hdr.dims = [3, 1, 1, 1, 1, 1, 1, 1]
|
|
337
|
+
hdr.dims = [3, 1, 1, 1, 1, 1, 1, 1]
|
|
324
338
|
|
|
325
339
|
// Set data type
|
|
326
|
-
hdr.datatypeCode = getNiftiDataType(this.dtype)
|
|
327
|
-
hdr.numBitsPerVoxel = getBytesPerPixel(this.dtype) * 8
|
|
340
|
+
hdr.datatypeCode = getNiftiDataType(this.dtype)
|
|
341
|
+
hdr.numBitsPerVoxel = getBytesPerPixel(this.dtype) * 8
|
|
328
342
|
|
|
329
343
|
// Placeholder pixel dimensions
|
|
330
|
-
hdr.pixDims = [1, 1, 1, 1, 0, 0, 0, 0]
|
|
344
|
+
hdr.pixDims = [1, 1, 1, 1, 0, 0, 0, 0]
|
|
331
345
|
|
|
332
346
|
// Placeholder affine (identity)
|
|
333
347
|
hdr.affine = [
|
|
@@ -335,20 +349,22 @@ export class OMEZarrNVImage extends NVImage {
|
|
|
335
349
|
[0, 1, 0, 0],
|
|
336
350
|
[0, 0, 1, 0],
|
|
337
351
|
[0, 0, 0, 1],
|
|
338
|
-
]
|
|
352
|
+
]
|
|
339
353
|
|
|
340
|
-
hdr.sform_code = 1
|
|
354
|
+
hdr.sform_code = 1 // Scanner coordinates
|
|
341
355
|
|
|
342
356
|
// Set name
|
|
343
|
-
this.name = this.multiscales.metadata?.name ?? "OME-Zarr"
|
|
357
|
+
this.name = this.multiscales.metadata?.name ?? "OME-Zarr"
|
|
344
358
|
|
|
345
359
|
// Initialize with empty typed array (will be replaced when data loads)
|
|
346
360
|
// We need at least 1 element to avoid issues
|
|
347
|
-
this.img = this.bufferManager.resize([1, 1, 1]) as
|
|
361
|
+
this.img = this.bufferManager.resize([1, 1, 1]) as NVImage["img"]
|
|
348
362
|
|
|
349
|
-
// Set default colormap
|
|
350
|
-
this.
|
|
351
|
-
|
|
363
|
+
// Set default colormap (label images use setColormapLabel() instead)
|
|
364
|
+
if (!this.isLabelImage) {
|
|
365
|
+
this._colormap = "gray"
|
|
366
|
+
}
|
|
367
|
+
this._opacity = 1.0
|
|
352
368
|
}
|
|
353
369
|
|
|
354
370
|
/**
|
|
@@ -376,26 +392,26 @@ export class OMEZarrNVImage extends NVImage {
|
|
|
376
392
|
this._emitEvent("loadingSkipped", {
|
|
377
393
|
reason: "queued-replaced",
|
|
378
394
|
trigger: this._pendingPopulateRequest.trigger,
|
|
379
|
-
})
|
|
395
|
+
})
|
|
380
396
|
}
|
|
381
397
|
// Queue this request (no event - just queuing)
|
|
382
|
-
this._pendingPopulateRequest = { skipPreview, trigger }
|
|
383
|
-
return
|
|
398
|
+
this._pendingPopulateRequest = { skipPreview, trigger }
|
|
399
|
+
return
|
|
384
400
|
}
|
|
385
401
|
|
|
386
|
-
this.isLoading = true
|
|
387
|
-
this._currentPopulateTrigger = trigger
|
|
388
|
-
this._pendingPopulateRequest = null
|
|
402
|
+
this.isLoading = true
|
|
403
|
+
this._currentPopulateTrigger = trigger
|
|
404
|
+
this._pendingPopulateRequest = null // Clear any stale pending request
|
|
389
405
|
|
|
390
406
|
try {
|
|
391
|
-
const numLevels = this.multiscales.images.length
|
|
392
|
-
const lowestLevel = numLevels - 1
|
|
407
|
+
const numLevels = this.multiscales.images.length
|
|
408
|
+
const lowestLevel = numLevels - 1
|
|
393
409
|
|
|
394
410
|
// Quick preview from lowest resolution (if different from target and not skipped)
|
|
395
411
|
if (!skipPreview && lowestLevel !== this.targetLevelIndex) {
|
|
396
|
-
await this.loadResolutionLevel(lowestLevel, "preview")
|
|
397
|
-
const prevLevel = this.currentLevelIndex
|
|
398
|
-
this.currentLevelIndex = lowestLevel
|
|
412
|
+
await this.loadResolutionLevel(lowestLevel, "preview")
|
|
413
|
+
const prevLevel = this.currentLevelIndex
|
|
414
|
+
this.currentLevelIndex = lowestLevel
|
|
399
415
|
|
|
400
416
|
// Emit resolutionChange for preview load
|
|
401
417
|
if (prevLevel !== lowestLevel) {
|
|
@@ -404,14 +420,14 @@ export class OMEZarrNVImage extends NVImage {
|
|
|
404
420
|
targetLevel: this.targetLevelIndex,
|
|
405
421
|
previousLevel: prevLevel,
|
|
406
422
|
trigger: this._currentPopulateTrigger,
|
|
407
|
-
})
|
|
423
|
+
})
|
|
408
424
|
}
|
|
409
425
|
}
|
|
410
426
|
|
|
411
427
|
// Final quality at target resolution
|
|
412
|
-
await this.loadResolutionLevel(this.targetLevelIndex, "target")
|
|
413
|
-
const prevLevelBeforeTarget = this.currentLevelIndex
|
|
414
|
-
this.currentLevelIndex = this.targetLevelIndex
|
|
428
|
+
await this.loadResolutionLevel(this.targetLevelIndex, "target")
|
|
429
|
+
const prevLevelBeforeTarget = this.currentLevelIndex
|
|
430
|
+
this.currentLevelIndex = this.targetLevelIndex
|
|
415
431
|
|
|
416
432
|
// Emit resolutionChange for target load
|
|
417
433
|
if (prevLevelBeforeTarget !== this.targetLevelIndex) {
|
|
@@ -420,23 +436,23 @@ export class OMEZarrNVImage extends NVImage {
|
|
|
420
436
|
targetLevel: this.targetLevelIndex,
|
|
421
437
|
previousLevel: prevLevelBeforeTarget,
|
|
422
438
|
trigger: this._currentPopulateTrigger,
|
|
423
|
-
})
|
|
439
|
+
})
|
|
424
440
|
}
|
|
425
441
|
|
|
426
442
|
// Update previous state for direction-aware resolution selection
|
|
427
443
|
// Always calculate at level 0 for consistent comparison across resolution changes
|
|
428
|
-
this._previousClipPlanes = this.copyClipPlanes(this._clipPlanes)
|
|
429
|
-
const referenceImage = this.multiscales.images[0]
|
|
444
|
+
this._previousClipPlanes = this.copyClipPlanes(this._clipPlanes)
|
|
445
|
+
const referenceImage = this.multiscales.images[0]
|
|
430
446
|
const region = clipPlanesToPixelRegion(
|
|
431
447
|
this._clipPlanes,
|
|
432
448
|
this._volumeBounds,
|
|
433
449
|
referenceImage,
|
|
434
|
-
)
|
|
435
|
-
const aligned = alignToChunks(region, referenceImage)
|
|
436
|
-
this._previousPixelCount = this.calculateAlignedPixelCount(aligned)
|
|
450
|
+
)
|
|
451
|
+
const aligned = alignToChunks(region, referenceImage)
|
|
452
|
+
this._previousPixelCount = this.calculateAlignedPixelCount(aligned)
|
|
437
453
|
} finally {
|
|
438
|
-
this.isLoading = false
|
|
439
|
-
this.handlePendingPopulateRequest()
|
|
454
|
+
this.isLoading = false
|
|
455
|
+
this.handlePendingPopulateRequest()
|
|
440
456
|
}
|
|
441
457
|
}
|
|
442
458
|
|
|
@@ -445,12 +461,12 @@ export class OMEZarrNVImage extends NVImage {
|
|
|
445
461
|
* If no pending request, emits populateComplete.
|
|
446
462
|
*/
|
|
447
463
|
private handlePendingPopulateRequest(): void {
|
|
448
|
-
const pending = this._pendingPopulateRequest
|
|
464
|
+
const pending = this._pendingPopulateRequest
|
|
449
465
|
if (pending !== null) {
|
|
450
|
-
this._pendingPopulateRequest = null
|
|
466
|
+
this._pendingPopulateRequest = null
|
|
451
467
|
// Use void to indicate we're intentionally not awaiting
|
|
452
|
-
void this.populateVolume(pending.skipPreview, pending.trigger)
|
|
453
|
-
return
|
|
468
|
+
void this.populateVolume(pending.skipPreview, pending.trigger)
|
|
469
|
+
return
|
|
454
470
|
}
|
|
455
471
|
|
|
456
472
|
// No more pending requests - emit populateComplete
|
|
@@ -458,7 +474,7 @@ export class OMEZarrNVImage extends NVImage {
|
|
|
458
474
|
currentLevel: this.currentLevelIndex,
|
|
459
475
|
targetLevel: this.targetLevelIndex,
|
|
460
476
|
trigger: this._currentPopulateTrigger,
|
|
461
|
-
})
|
|
477
|
+
})
|
|
462
478
|
}
|
|
463
479
|
|
|
464
480
|
/**
|
|
@@ -481,9 +497,9 @@ export class OMEZarrNVImage extends NVImage {
|
|
|
481
497
|
this._emitEvent("loadingStart", {
|
|
482
498
|
levelIndex,
|
|
483
499
|
trigger: this._currentPopulateTrigger,
|
|
484
|
-
})
|
|
500
|
+
})
|
|
485
501
|
|
|
486
|
-
const ngffImage = this.multiscales.images[levelIndex]
|
|
502
|
+
const ngffImage = this.multiscales.images[levelIndex]
|
|
487
503
|
|
|
488
504
|
// Get the pixel region for current clip planes (+ 3D viewport bounds if active)
|
|
489
505
|
const pixelRegion = clipPlanesToPixelRegion(
|
|
@@ -491,69 +507,76 @@ export class OMEZarrNVImage extends NVImage {
|
|
|
491
507
|
this._volumeBounds,
|
|
492
508
|
ngffImage,
|
|
493
509
|
this._viewportBounds3D ?? undefined,
|
|
494
|
-
)
|
|
495
|
-
const alignedRegion = alignToChunks(pixelRegion, ngffImage)
|
|
510
|
+
)
|
|
511
|
+
const alignedRegion = alignToChunks(pixelRegion, ngffImage)
|
|
496
512
|
|
|
497
513
|
// Calculate the shape of data to fetch
|
|
498
514
|
const fetchedShape: [number, number, number] = [
|
|
499
515
|
alignedRegion.chunkAlignedEnd[0] - alignedRegion.chunkAlignedStart[0],
|
|
500
516
|
alignedRegion.chunkAlignedEnd[1] - alignedRegion.chunkAlignedStart[1],
|
|
501
517
|
alignedRegion.chunkAlignedEnd[2] - alignedRegion.chunkAlignedStart[2],
|
|
502
|
-
]
|
|
518
|
+
]
|
|
503
519
|
|
|
504
520
|
// Fetch the data
|
|
505
521
|
const fetchRegion: PixelRegion = {
|
|
506
522
|
start: alignedRegion.chunkAlignedStart,
|
|
507
523
|
end: alignedRegion.chunkAlignedEnd,
|
|
508
|
-
}
|
|
524
|
+
}
|
|
509
525
|
|
|
510
526
|
const result = await this.coalescer.fetchRegion(
|
|
511
527
|
ngffImage,
|
|
512
528
|
levelIndex,
|
|
513
529
|
fetchRegion,
|
|
514
530
|
requesterId,
|
|
515
|
-
)
|
|
531
|
+
)
|
|
516
532
|
|
|
517
533
|
// Resize buffer to match fetched data exactly (no upsampling!)
|
|
518
|
-
const targetData = this.bufferManager.resize(fetchedShape)
|
|
534
|
+
const targetData = this.bufferManager.resize(fetchedShape)
|
|
519
535
|
|
|
520
536
|
// Direct copy of fetched data
|
|
521
|
-
targetData.set(result.data)
|
|
537
|
+
targetData.set(result.data)
|
|
522
538
|
|
|
523
539
|
// Update this.img to point to the (possibly new) buffer
|
|
524
|
-
this.img = this.bufferManager.getTypedArray() as
|
|
540
|
+
this.img = this.bufferManager.getTypedArray() as NVImage["img"]
|
|
525
541
|
|
|
526
542
|
// Update NVImage header with correct dimensions and transforms
|
|
527
|
-
this.updateHeaderForRegion(ngffImage, alignedRegion, fetchedShape)
|
|
543
|
+
this.updateHeaderForRegion(ngffImage, alignedRegion, fetchedShape)
|
|
528
544
|
|
|
529
|
-
|
|
530
|
-
|
|
545
|
+
if (this.isLabelImage) {
|
|
546
|
+
// Label images: apply a discrete colormap instead of OMERO windowing
|
|
547
|
+
this._applyLabelColormap(this, result.data)
|
|
548
|
+
} else {
|
|
549
|
+
// Compute or apply OMERO metadata for cal_min/cal_max
|
|
550
|
+
await this.ensureOmeroMetadata(ngffImage, levelIndex)
|
|
551
|
+
}
|
|
531
552
|
|
|
532
553
|
// Reset global_min so NiiVue's refreshLayers() re-runs calMinMax() on real data.
|
|
533
554
|
// Without this, if calMinMax() was previously called on placeholder/empty data
|
|
534
555
|
// (e.g., when setting colormap before loading), global_min would already be set
|
|
535
556
|
// and NiiVue would skip recalculating intensity ranges, leaving cal_min/cal_max
|
|
536
557
|
// at stale values (typically 0/0), causing an all-white render.
|
|
537
|
-
this.global_min = undefined
|
|
558
|
+
this.global_min = undefined
|
|
538
559
|
|
|
539
560
|
// Update NiiVue clip planes
|
|
540
|
-
this.updateNiivueClipPlanes()
|
|
561
|
+
this.updateNiivueClipPlanes()
|
|
541
562
|
|
|
542
563
|
// Refresh NiiVue
|
|
543
|
-
this.niivue.updateGLVolume()
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
564
|
+
this.niivue.updateGLVolume()
|
|
565
|
+
|
|
566
|
+
if (!this.isLabelImage) {
|
|
567
|
+
// Widen the display window if actual data exceeds the OMERO range.
|
|
568
|
+
// At higher resolutions, individual bright/dark voxels that were averaged
|
|
569
|
+
// out at lower resolutions can exceed the OMERO-specified window, causing
|
|
570
|
+
// clipping artifacts. This preserves the OMERO lower bound but widens the
|
|
571
|
+
// ceiling to encompass the full data range when needed.
|
|
572
|
+
this._widenCalRangeIfNeeded(this)
|
|
573
|
+
}
|
|
551
574
|
|
|
552
575
|
// Emit loadingComplete event
|
|
553
576
|
this._emitEvent("loadingComplete", {
|
|
554
577
|
levelIndex,
|
|
555
578
|
trigger: this._currentPopulateTrigger,
|
|
556
|
-
})
|
|
579
|
+
})
|
|
557
580
|
}
|
|
558
581
|
|
|
559
582
|
/**
|
|
@@ -572,16 +595,16 @@ export class OMEZarrNVImage extends NVImage {
|
|
|
572
595
|
region: ChunkAlignedRegion,
|
|
573
596
|
fetchedShape: [number, number, number],
|
|
574
597
|
): void {
|
|
575
|
-
if (!this.hdr) return
|
|
598
|
+
if (!this.hdr) return
|
|
576
599
|
|
|
577
600
|
// Get voxel size from this resolution level (no upsampling adjustment needed!)
|
|
578
|
-
const scale = ngffImage.scale
|
|
579
|
-
const sx = scale.x ?? scale.X ?? 1
|
|
580
|
-
const sy = scale.y ?? scale.Y ?? 1
|
|
581
|
-
const sz = scale.z ?? scale.Z ?? 1
|
|
601
|
+
const scale = ngffImage.scale
|
|
602
|
+
const sx = scale.x ?? scale.X ?? 1
|
|
603
|
+
const sy = scale.y ?? scale.Y ?? 1
|
|
604
|
+
const sz = scale.z ?? scale.Z ?? 1
|
|
582
605
|
|
|
583
606
|
// Set pixDims directly from resolution's voxel size
|
|
584
|
-
this.hdr.pixDims = [1, sx, sy, sz, 0, 0, 0, 0]
|
|
607
|
+
this.hdr.pixDims = [1, sx, sy, sz, 0, 0, 0, 0]
|
|
585
608
|
|
|
586
609
|
// Set dims to match fetched data (buffer now equals fetched size)
|
|
587
610
|
// NIfTI dims: [ndim, x, y, z, t, ...]
|
|
@@ -594,27 +617,22 @@ export class OMEZarrNVImage extends NVImage {
|
|
|
594
617
|
1,
|
|
595
618
|
1,
|
|
596
619
|
1,
|
|
597
|
-
]
|
|
620
|
+
]
|
|
598
621
|
|
|
599
622
|
// Build affine with offset for region start
|
|
600
|
-
const affine = createAffineFromNgffImage(ngffImage)
|
|
623
|
+
const affine = createAffineFromNgffImage(ngffImage)
|
|
601
624
|
|
|
602
625
|
// Adjust translation for region offset
|
|
603
626
|
// Buffer pixel [0,0,0] corresponds to source pixel region.chunkAlignedStart
|
|
604
|
-
const regionStart = region.chunkAlignedStart
|
|
627
|
+
const regionStart = region.chunkAlignedStart
|
|
605
628
|
// regionStart is [z, y, x], affine translation is [x, y, z] (indices 12, 13, 14)
|
|
606
|
-
affine[12] += regionStart[2] * sx
|
|
607
|
-
affine[13] += regionStart[1] * sy
|
|
608
|
-
affine[14] += regionStart[0] * sz
|
|
629
|
+
affine[12] += regionStart[2] * sx // x offset
|
|
630
|
+
affine[13] += regionStart[1] * sy // y offset
|
|
631
|
+
affine[14] += regionStart[0] * sz // z offset
|
|
609
632
|
|
|
610
633
|
// Update affine in header
|
|
611
|
-
const srows = affineToNiftiSrows(affine)
|
|
612
|
-
this.hdr.affine = [
|
|
613
|
-
srows.srow_x,
|
|
614
|
-
srows.srow_y,
|
|
615
|
-
srows.srow_z,
|
|
616
|
-
[0, 0, 0, 1],
|
|
617
|
-
];
|
|
634
|
+
const srows = affineToNiftiSrows(affine)
|
|
635
|
+
this.hdr.affine = [srows.srow_x, srows.srow_y, srows.srow_z, [0, 0, 0, 1]]
|
|
618
636
|
|
|
619
637
|
// Update current buffer bounds
|
|
620
638
|
// Buffer starts at region.chunkAlignedStart and has extent fetchedShape
|
|
@@ -629,10 +647,10 @@ export class OMEZarrNVImage extends NVImage {
|
|
|
629
647
|
affine[13] + fetchedShape[1] * sy,
|
|
630
648
|
affine[14] + fetchedShape[0] * sz,
|
|
631
649
|
],
|
|
632
|
-
}
|
|
650
|
+
}
|
|
633
651
|
|
|
634
652
|
// Recalculate RAS orientation
|
|
635
|
-
this.calculateRAS()
|
|
653
|
+
this.calculateRAS()
|
|
636
654
|
}
|
|
637
655
|
|
|
638
656
|
/**
|
|
@@ -648,13 +666,13 @@ export class OMEZarrNVImage extends NVImage {
|
|
|
648
666
|
const niivueClipPlanes = clipPlanesToNiivue(
|
|
649
667
|
this._clipPlanes,
|
|
650
668
|
this._currentBufferBounds,
|
|
651
|
-
)
|
|
669
|
+
)
|
|
652
670
|
|
|
653
671
|
if (niivueClipPlanes.length > 0) {
|
|
654
|
-
this.niivue.scene.clipPlaneDepthAziElevs = niivueClipPlanes
|
|
672
|
+
this.niivue.scene.clipPlaneDepthAziElevs = niivueClipPlanes
|
|
655
673
|
} else {
|
|
656
674
|
// Clear clip planes - set to "disabled" state (depth > 1.8)
|
|
657
|
-
this.niivue.scene.clipPlaneDepthAziElevs = [[2, 0, 0]]
|
|
675
|
+
this.niivue.scene.clipPlaneDepthAziElevs = [[2, 0, 0]]
|
|
658
676
|
}
|
|
659
677
|
}
|
|
660
678
|
|
|
@@ -665,26 +683,74 @@ export class OMEZarrNVImage extends NVImage {
|
|
|
665
683
|
* This sets the display intensity range for NiiVue rendering.
|
|
666
684
|
*/
|
|
667
685
|
private applyOmeroToHeader(): void {
|
|
668
|
-
if (!this.hdr || !this._omero?.channels?.length) return
|
|
686
|
+
if (!this.hdr || !this._omero?.channels?.length) return
|
|
669
687
|
|
|
670
688
|
// Clamp active channel to valid range
|
|
671
689
|
const channelIndex = Math.min(
|
|
672
690
|
this._activeChannel,
|
|
673
691
|
this._omero.channels.length - 1,
|
|
674
|
-
)
|
|
675
|
-
const channel = this._omero.channels[channelIndex]
|
|
676
|
-
const window = channel?.window
|
|
692
|
+
)
|
|
693
|
+
const channel = this._omero.channels[channelIndex]
|
|
694
|
+
const window = channel?.window
|
|
677
695
|
|
|
678
696
|
if (window) {
|
|
679
697
|
// Prefer start/end (display window based on quantiles) over min/max (data range)
|
|
680
|
-
const calMin = window.start ?? window.min
|
|
681
|
-
const calMax = window.end ?? window.max
|
|
698
|
+
const calMin = window.start ?? window.min
|
|
699
|
+
const calMax = window.end ?? window.max
|
|
682
700
|
|
|
683
|
-
if (calMin !== undefined) this.hdr.cal_min = calMin
|
|
684
|
-
if (calMax !== undefined) this.hdr.cal_max = calMax
|
|
701
|
+
if (calMin !== undefined) this.hdr.cal_min = calMin
|
|
702
|
+
if (calMax !== undefined) this.hdr.cal_max = calMax
|
|
685
703
|
}
|
|
686
704
|
}
|
|
687
705
|
|
|
706
|
+
/**
|
|
707
|
+
* Build and apply a discrete NiiVue label colormap to an NVImage.
|
|
708
|
+
*
|
|
709
|
+
* Scans the pixel data for unique integer values and assigns each a
|
|
710
|
+
* distinct color from the Glasbey palette (via `@fideus-labs/ngff-zarr`).
|
|
711
|
+
* Label 0 is treated as background (fully transparent).
|
|
712
|
+
*
|
|
713
|
+
* @param nvImage - The NVImage to apply the label colormap to
|
|
714
|
+
* @param data - The pixel data to scan for unique labels
|
|
715
|
+
*/
|
|
716
|
+
private _applyLabelColormap(nvImage: NVImage, data: TypedArray): void {
|
|
717
|
+
const uniqueLabels = [...new Set(data as Iterable<number>)].sort(
|
|
718
|
+
(a, b) => a - b,
|
|
719
|
+
)
|
|
720
|
+
|
|
721
|
+
const R: number[] = []
|
|
722
|
+
const G: number[] = []
|
|
723
|
+
const B: number[] = []
|
|
724
|
+
const A: number[] = []
|
|
725
|
+
const I: number[] = []
|
|
726
|
+
const labels: string[] = []
|
|
727
|
+
|
|
728
|
+
for (let i = 0; i < uniqueLabels.length; i++) {
|
|
729
|
+
const label = uniqueLabels[i]
|
|
730
|
+
I.push(label)
|
|
731
|
+
|
|
732
|
+
if (label === 0) {
|
|
733
|
+
// Background: transparent
|
|
734
|
+
R.push(0)
|
|
735
|
+
G.push(0)
|
|
736
|
+
B.push(0)
|
|
737
|
+
A.push(0)
|
|
738
|
+
labels.push("background")
|
|
739
|
+
} else {
|
|
740
|
+
// Use Glasbey color palette (cycling if >256 labels)
|
|
741
|
+
const hex = GLASBEY_COLORS[(i - 1) % GLASBEY_COLORS.length] ?? "FFFFFF"
|
|
742
|
+
R.push(parseInt(hex.slice(0, 2), 16))
|
|
743
|
+
G.push(parseInt(hex.slice(2, 4), 16))
|
|
744
|
+
B.push(parseInt(hex.slice(4, 6), 16))
|
|
745
|
+
A.push(255)
|
|
746
|
+
labels.push(String(label))
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
// NiiVue's setColormapLabel expects a ColorMap-shaped object
|
|
751
|
+
nvImage.setColormapLabel({ R, G, B, A, I, labels })
|
|
752
|
+
}
|
|
753
|
+
|
|
688
754
|
/**
|
|
689
755
|
* Ensure OMERO metadata is available and applied.
|
|
690
756
|
*
|
|
@@ -702,30 +768,30 @@ export class OMEZarrNVImage extends NVImage {
|
|
|
702
768
|
ngffImage: NgffImage,
|
|
703
769
|
levelIndex: number,
|
|
704
770
|
): Promise<void> {
|
|
705
|
-
const existingOmero = this.multiscales.metadata?.omero
|
|
771
|
+
const existingOmero = this.multiscales.metadata?.omero
|
|
706
772
|
|
|
707
773
|
if (existingOmero && !this._omero) {
|
|
708
774
|
// Use existing OMERO metadata from the file (first time)
|
|
709
|
-
this._omero = existingOmero
|
|
710
|
-
this.applyOmeroToHeader()
|
|
711
|
-
return
|
|
775
|
+
this._omero = existingOmero
|
|
776
|
+
this.applyOmeroToHeader()
|
|
777
|
+
return
|
|
712
778
|
}
|
|
713
779
|
|
|
714
780
|
if (!existingOmero) {
|
|
715
781
|
// No OMERO in file - compute dynamically
|
|
716
782
|
// Compute at preview (lowest) and target levels, then keep for consistency
|
|
717
|
-
const lowestLevel = this.multiscales.images.length - 1
|
|
718
|
-
const isPreviewLevel = levelIndex === lowestLevel
|
|
719
|
-
const isTargetLevel = levelIndex === this.targetLevelIndex
|
|
720
|
-
const needsCompute =
|
|
721
|
-
|
|
722
|
-
|
|
783
|
+
const lowestLevel = this.multiscales.images.length - 1
|
|
784
|
+
const isPreviewLevel = levelIndex === lowestLevel
|
|
785
|
+
const isTargetLevel = levelIndex === this.targetLevelIndex
|
|
786
|
+
const needsCompute =
|
|
787
|
+
isPreviewLevel ||
|
|
788
|
+
(isTargetLevel && this._omeroComputedForLevel !== this.targetLevelIndex)
|
|
723
789
|
|
|
724
790
|
if (needsCompute) {
|
|
725
|
-
const computedOmero = await computeOmeroFromNgffImage(ngffImage)
|
|
726
|
-
this._omero = computedOmero
|
|
727
|
-
this._omeroComputedForLevel = levelIndex
|
|
728
|
-
this.applyOmeroToHeader()
|
|
791
|
+
const computedOmero = await computeOmeroFromNgffImage(ngffImage)
|
|
792
|
+
this._omero = computedOmero
|
|
793
|
+
this._omeroComputedForLevel = levelIndex
|
|
794
|
+
this.applyOmeroToHeader()
|
|
729
795
|
}
|
|
730
796
|
}
|
|
731
797
|
}
|
|
@@ -752,31 +818,31 @@ export class OMEZarrNVImage extends NVImage {
|
|
|
752
818
|
*/
|
|
753
819
|
setClipPlanes(planes: ClipPlanes): void {
|
|
754
820
|
// Validate the planes
|
|
755
|
-
validateClipPlanes(planes)
|
|
821
|
+
validateClipPlanes(planes)
|
|
756
822
|
|
|
757
823
|
// Check if this is a "reset" operation (clearing all planes)
|
|
758
|
-
const isReset = planes.length === 0 && this._previousClipPlanes.length > 0
|
|
824
|
+
const isReset = planes.length === 0 && this._previousClipPlanes.length > 0
|
|
759
825
|
|
|
760
826
|
// Store new clip planes
|
|
761
827
|
this._clipPlanes = planes.map((p) => ({
|
|
762
828
|
point: [...p.point] as [number, number, number],
|
|
763
829
|
normal: normalizeVector([...p.normal] as [number, number, number]),
|
|
764
|
-
}))
|
|
830
|
+
}))
|
|
765
831
|
|
|
766
832
|
// Always update NiiVue clip planes immediately (visual feedback)
|
|
767
|
-
this.updateNiivueClipPlanes()
|
|
768
|
-
this.niivue.drawScene()
|
|
833
|
+
this.updateNiivueClipPlanes()
|
|
834
|
+
this.niivue.drawScene()
|
|
769
835
|
|
|
770
836
|
// Clear any pending debounced refetch
|
|
771
837
|
if (this.clipPlaneRefetchTimeout) {
|
|
772
|
-
clearTimeout(this.clipPlaneRefetchTimeout)
|
|
773
|
-
this.clipPlaneRefetchTimeout = null
|
|
838
|
+
clearTimeout(this.clipPlaneRefetchTimeout)
|
|
839
|
+
this.clipPlaneRefetchTimeout = null
|
|
774
840
|
}
|
|
775
841
|
|
|
776
842
|
// Debounce the data refetch decision
|
|
777
843
|
this.clipPlaneRefetchTimeout = setTimeout(() => {
|
|
778
|
-
this.handleDebouncedClipPlaneUpdate(isReset)
|
|
779
|
-
}, this.clipPlaneDebounceMs)
|
|
844
|
+
this.handleDebouncedClipPlaneUpdate(isReset)
|
|
845
|
+
}, this.clipPlaneDebounceMs)
|
|
780
846
|
}
|
|
781
847
|
|
|
782
848
|
/**
|
|
@@ -787,23 +853,23 @@ export class OMEZarrNVImage extends NVImage {
|
|
|
787
853
|
* Visual clipping is handled by NiiVue clip planes (updated immediately in setClipPlanes).
|
|
788
854
|
*/
|
|
789
855
|
private handleDebouncedClipPlaneUpdate(isReset: boolean): void {
|
|
790
|
-
this.clipPlaneRefetchTimeout = null
|
|
856
|
+
this.clipPlaneRefetchTimeout = null
|
|
791
857
|
|
|
792
858
|
// Always use level 0 for consistent pixel count comparison across resolution changes
|
|
793
|
-
const referenceImage = this.multiscales.images[0]
|
|
859
|
+
const referenceImage = this.multiscales.images[0]
|
|
794
860
|
|
|
795
861
|
// Calculate current region at reference resolution
|
|
796
862
|
const currentRegion = clipPlanesToPixelRegion(
|
|
797
863
|
this._clipPlanes,
|
|
798
864
|
this._volumeBounds,
|
|
799
865
|
referenceImage,
|
|
800
|
-
)
|
|
801
|
-
const currentAligned = alignToChunks(currentRegion, referenceImage)
|
|
802
|
-
const currentPixelCount = this.calculateAlignedPixelCount(currentAligned)
|
|
866
|
+
)
|
|
867
|
+
const currentAligned = alignToChunks(currentRegion, referenceImage)
|
|
868
|
+
const currentPixelCount = this.calculateAlignedPixelCount(currentAligned)
|
|
803
869
|
|
|
804
870
|
// Determine volume change direction (comparing at consistent reference level)
|
|
805
|
-
const volumeReduced = currentPixelCount < this._previousPixelCount
|
|
806
|
-
const volumeIncreased = currentPixelCount > this._previousPixelCount
|
|
871
|
+
const volumeReduced = currentPixelCount < this._previousPixelCount
|
|
872
|
+
const volumeIncreased = currentPixelCount > this._previousPixelCount
|
|
807
873
|
|
|
808
874
|
// Get optimal resolution for new region (3D viewport bounds)
|
|
809
875
|
const selection = selectResolution(
|
|
@@ -812,36 +878,37 @@ export class OMEZarrNVImage extends NVImage {
|
|
|
812
878
|
this._clipPlanes,
|
|
813
879
|
this._volumeBounds,
|
|
814
880
|
this._viewportBounds3D ?? undefined,
|
|
815
|
-
)
|
|
881
|
+
)
|
|
816
882
|
|
|
817
883
|
// Direction-aware resolution change
|
|
818
|
-
let newTargetLevel = this.targetLevelIndex
|
|
884
|
+
let newTargetLevel = this.targetLevelIndex
|
|
819
885
|
|
|
820
886
|
if (isReset) {
|
|
821
887
|
// Reset/clear: always recalculate optimal resolution
|
|
822
|
-
newTargetLevel = selection.levelIndex
|
|
888
|
+
newTargetLevel = selection.levelIndex
|
|
823
889
|
} else if (volumeReduced && selection.levelIndex < this.targetLevelIndex) {
|
|
824
890
|
// Volume reduced → allow higher resolution (lower level index)
|
|
825
|
-
newTargetLevel = selection.levelIndex
|
|
891
|
+
newTargetLevel = selection.levelIndex
|
|
826
892
|
} else if (
|
|
827
|
-
volumeIncreased &&
|
|
893
|
+
volumeIncreased &&
|
|
894
|
+
selection.levelIndex > this.targetLevelIndex
|
|
828
895
|
) {
|
|
829
896
|
// Volume increased → allow lower resolution (higher level index) if needed to fit
|
|
830
|
-
newTargetLevel = selection.levelIndex
|
|
897
|
+
newTargetLevel = selection.levelIndex
|
|
831
898
|
}
|
|
832
899
|
// Otherwise: keep current level (no unnecessary resolution changes)
|
|
833
900
|
|
|
834
901
|
// Only refetch when resolution level changes
|
|
835
902
|
// Visual clipping is handled by NiiVue clip planes (already updated in setClipPlanes)
|
|
836
903
|
if (newTargetLevel !== this.targetLevelIndex) {
|
|
837
|
-
this.targetLevelIndex = newTargetLevel
|
|
838
|
-
this.populateVolume(true, "clipPlanesChanged")
|
|
904
|
+
this.targetLevelIndex = newTargetLevel
|
|
905
|
+
this.populateVolume(true, "clipPlanesChanged") // Skip preview for clip plane updates
|
|
839
906
|
}
|
|
840
907
|
|
|
841
908
|
// Emit clipPlanesChange event (after debounce)
|
|
842
909
|
this._emitEvent("clipPlanesChange", {
|
|
843
910
|
clipPlanes: this.copyClipPlanes(this._clipPlanes),
|
|
844
|
-
})
|
|
911
|
+
})
|
|
845
912
|
}
|
|
846
913
|
|
|
847
914
|
/**
|
|
@@ -852,7 +919,7 @@ export class OMEZarrNVImage extends NVImage {
|
|
|
852
919
|
(aligned.chunkAlignedEnd[0] - aligned.chunkAlignedStart[0]) *
|
|
853
920
|
(aligned.chunkAlignedEnd[1] - aligned.chunkAlignedStart[1]) *
|
|
854
921
|
(aligned.chunkAlignedEnd[2] - aligned.chunkAlignedStart[2])
|
|
855
|
-
)
|
|
922
|
+
)
|
|
856
923
|
}
|
|
857
924
|
|
|
858
925
|
/**
|
|
@@ -862,7 +929,7 @@ export class OMEZarrNVImage extends NVImage {
|
|
|
862
929
|
return planes.map((p) => ({
|
|
863
930
|
point: [...p.point] as [number, number, number],
|
|
864
931
|
normal: [...p.normal] as [number, number, number],
|
|
865
|
-
}))
|
|
932
|
+
}))
|
|
866
933
|
}
|
|
867
934
|
|
|
868
935
|
/**
|
|
@@ -874,7 +941,7 @@ export class OMEZarrNVImage extends NVImage {
|
|
|
874
941
|
return this._clipPlanes.map((p) => ({
|
|
875
942
|
point: [...p.point] as [number, number, number],
|
|
876
943
|
normal: [...p.normal] as [number, number, number],
|
|
877
|
-
}))
|
|
944
|
+
}))
|
|
878
945
|
}
|
|
879
946
|
|
|
880
947
|
/**
|
|
@@ -887,7 +954,7 @@ export class OMEZarrNVImage extends NVImage {
|
|
|
887
954
|
if (this._clipPlanes.length >= MAX_CLIP_PLANES) {
|
|
888
955
|
throw new Error(
|
|
889
956
|
`Cannot add clip plane: already at maximum of ${MAX_CLIP_PLANES} planes`,
|
|
890
|
-
)
|
|
957
|
+
)
|
|
891
958
|
}
|
|
892
959
|
|
|
893
960
|
const newPlanes = [
|
|
@@ -896,9 +963,9 @@ export class OMEZarrNVImage extends NVImage {
|
|
|
896
963
|
point: [...plane.point] as [number, number, number],
|
|
897
964
|
normal: [...plane.normal] as [number, number, number],
|
|
898
965
|
},
|
|
899
|
-
]
|
|
966
|
+
]
|
|
900
967
|
|
|
901
|
-
this.setClipPlanes(newPlanes)
|
|
968
|
+
this.setClipPlanes(newPlanes)
|
|
902
969
|
}
|
|
903
970
|
|
|
904
971
|
/**
|
|
@@ -911,39 +978,60 @@ export class OMEZarrNVImage extends NVImage {
|
|
|
911
978
|
if (index < 0 || index >= this._clipPlanes.length) {
|
|
912
979
|
throw new Error(
|
|
913
980
|
`Invalid clip plane index: ${index} (have ${this._clipPlanes.length} planes)`,
|
|
914
|
-
)
|
|
981
|
+
)
|
|
915
982
|
}
|
|
916
983
|
|
|
917
|
-
const newPlanes = this._clipPlanes.filter((_, i) => i !== index)
|
|
918
|
-
this.setClipPlanes(newPlanes)
|
|
984
|
+
const newPlanes = this._clipPlanes.filter((_, i) => i !== index)
|
|
985
|
+
this.setClipPlanes(newPlanes)
|
|
919
986
|
}
|
|
920
987
|
|
|
921
988
|
/**
|
|
922
989
|
* Clear all clip planes (show full volume).
|
|
923
990
|
*/
|
|
924
991
|
clearClipPlanes(): void {
|
|
925
|
-
this.setClipPlanes([])
|
|
992
|
+
this.setClipPlanes([])
|
|
926
993
|
}
|
|
927
994
|
|
|
928
995
|
/**
|
|
929
996
|
* Get the current resolution level index.
|
|
930
997
|
*/
|
|
931
998
|
getCurrentLevelIndex(): number {
|
|
932
|
-
return this.currentLevelIndex
|
|
999
|
+
return this.currentLevelIndex
|
|
933
1000
|
}
|
|
934
1001
|
|
|
935
1002
|
/**
|
|
936
1003
|
* Get the target resolution level index.
|
|
937
1004
|
*/
|
|
938
1005
|
getTargetLevelIndex(): number {
|
|
939
|
-
return this.targetLevelIndex
|
|
1006
|
+
return this.targetLevelIndex
|
|
940
1007
|
}
|
|
941
1008
|
|
|
942
1009
|
/**
|
|
943
1010
|
* Get the number of resolution levels.
|
|
944
1011
|
*/
|
|
945
1012
|
getNumLevels(): number {
|
|
946
|
-
return this.multiscales.images.length
|
|
1013
|
+
return this.multiscales.images.length
|
|
1014
|
+
}
|
|
1015
|
+
|
|
1016
|
+
/**
|
|
1017
|
+
* Load a specific resolution level.
|
|
1018
|
+
*
|
|
1019
|
+
* Overrides the automatic `maxPixels`-based level selection and loads the
|
|
1020
|
+
* requested level directly. The preview step is skipped because the caller
|
|
1021
|
+
* has explicitly chosen a level.
|
|
1022
|
+
*
|
|
1023
|
+
* @param levelIndex - Zero-based resolution level (0 = highest resolution)
|
|
1024
|
+
* @throws If `levelIndex` is out of range
|
|
1025
|
+
*/
|
|
1026
|
+
async loadLevel(levelIndex: number): Promise<void> {
|
|
1027
|
+
const numLevels = this.multiscales.images.length
|
|
1028
|
+
if (levelIndex < 0 || levelIndex >= numLevels) {
|
|
1029
|
+
throw new Error(
|
|
1030
|
+
`levelIndex ${levelIndex} out of range [0, ${numLevels - 1}]`,
|
|
1031
|
+
)
|
|
1032
|
+
}
|
|
1033
|
+
this.targetLevelIndex = levelIndex
|
|
1034
|
+
await this.populateVolume(true, "initial")
|
|
947
1035
|
}
|
|
948
1036
|
|
|
949
1037
|
/**
|
|
@@ -953,7 +1041,7 @@ export class OMEZarrNVImage extends NVImage {
|
|
|
953
1041
|
return {
|
|
954
1042
|
min: [...this._volumeBounds.min],
|
|
955
1043
|
max: [...this._volumeBounds.max],
|
|
956
|
-
}
|
|
1044
|
+
}
|
|
957
1045
|
}
|
|
958
1046
|
|
|
959
1047
|
// ============================================================
|
|
@@ -970,27 +1058,27 @@ export class OMEZarrNVImage extends NVImage {
|
|
|
970
1058
|
* @param enabled - Whether to enable viewport-aware resolution
|
|
971
1059
|
*/
|
|
972
1060
|
setViewportAware(enabled: boolean): void {
|
|
973
|
-
if (enabled === this._viewportAwareEnabled) return
|
|
974
|
-
this._viewportAwareEnabled = enabled
|
|
1061
|
+
if (enabled === this._viewportAwareEnabled) return
|
|
1062
|
+
this._viewportAwareEnabled = enabled
|
|
975
1063
|
|
|
976
1064
|
if (enabled) {
|
|
977
1065
|
// Hook viewport events on all attached NVs
|
|
978
1066
|
for (const [nv, state] of this._attachedNiivues) {
|
|
979
|
-
this._hookViewportEvents(nv, state)
|
|
1067
|
+
this._hookViewportEvents(nv, state)
|
|
980
1068
|
}
|
|
981
1069
|
// Compute initial viewport bounds and trigger refetch
|
|
982
|
-
this._recomputeViewportBounds()
|
|
1070
|
+
this._recomputeViewportBounds()
|
|
983
1071
|
} else {
|
|
984
1072
|
// Unhook viewport events on all attached NVs
|
|
985
1073
|
for (const [nv, state] of this._attachedNiivues) {
|
|
986
|
-
this._unhookViewportEvents(nv, state)
|
|
1074
|
+
this._unhookViewportEvents(nv, state)
|
|
987
1075
|
}
|
|
988
1076
|
// Clear viewport bounds and refetch at full volume
|
|
989
|
-
this._viewportBounds3D = null
|
|
990
|
-
this._viewportBoundsPerSlab.clear()
|
|
1077
|
+
this._viewportBounds3D = null
|
|
1078
|
+
this._viewportBoundsPerSlab.clear()
|
|
991
1079
|
if (this._viewportUpdateTimeout) {
|
|
992
|
-
clearTimeout(this._viewportUpdateTimeout)
|
|
993
|
-
this._viewportUpdateTimeout = null
|
|
1080
|
+
clearTimeout(this._viewportUpdateTimeout)
|
|
1081
|
+
this._viewportUpdateTimeout = null
|
|
994
1082
|
}
|
|
995
1083
|
// Recompute resolution without viewport constraint
|
|
996
1084
|
const selection = selectResolution(
|
|
@@ -998,13 +1086,13 @@ export class OMEZarrNVImage extends NVImage {
|
|
|
998
1086
|
this.maxPixels,
|
|
999
1087
|
this._clipPlanes,
|
|
1000
1088
|
this._volumeBounds,
|
|
1001
|
-
)
|
|
1089
|
+
)
|
|
1002
1090
|
if (selection.levelIndex !== this.targetLevelIndex) {
|
|
1003
|
-
this.targetLevelIndex = selection.levelIndex
|
|
1004
|
-
this.populateVolume(true, "viewportChanged")
|
|
1091
|
+
this.targetLevelIndex = selection.levelIndex
|
|
1092
|
+
this.populateVolume(true, "viewportChanged")
|
|
1005
1093
|
}
|
|
1006
1094
|
// Also reload slabs without viewport constraint
|
|
1007
|
-
this._reloadAllSlabs("viewportChanged")
|
|
1095
|
+
this._reloadAllSlabs("viewportChanged")
|
|
1008
1096
|
}
|
|
1009
1097
|
}
|
|
1010
1098
|
|
|
@@ -1012,7 +1100,7 @@ export class OMEZarrNVImage extends NVImage {
|
|
|
1012
1100
|
* Get whether viewport-aware resolution selection is enabled.
|
|
1013
1101
|
*/
|
|
1014
1102
|
get viewportAware(): boolean {
|
|
1015
|
-
return this._viewportAwareEnabled
|
|
1103
|
+
return this._viewportAwareEnabled
|
|
1016
1104
|
}
|
|
1017
1105
|
|
|
1018
1106
|
/**
|
|
@@ -1020,11 +1108,11 @@ export class OMEZarrNVImage extends NVImage {
|
|
|
1020
1108
|
* or no viewport constraint is active).
|
|
1021
1109
|
*/
|
|
1022
1110
|
getViewportBounds(): VolumeBounds | null {
|
|
1023
|
-
if (!this._viewportBounds3D) return null
|
|
1111
|
+
if (!this._viewportBounds3D) return null
|
|
1024
1112
|
return {
|
|
1025
1113
|
min: [...this._viewportBounds3D.min] as [number, number, number],
|
|
1026
1114
|
max: [...this._viewportBounds3D.max] as [number, number, number],
|
|
1027
|
-
}
|
|
1115
|
+
}
|
|
1028
1116
|
}
|
|
1029
1117
|
|
|
1030
1118
|
/**
|
|
@@ -1032,34 +1120,34 @@ export class OMEZarrNVImage extends NVImage {
|
|
|
1032
1120
|
*/
|
|
1033
1121
|
private _hookViewportEvents(nv: Niivue, state: AttachedNiivueState): void {
|
|
1034
1122
|
// Save and chain onMouseUp (fires at end of any mouse/touch interaction)
|
|
1035
|
-
state.previousOnMouseUp = nv.onMouseUp as (data: unknown) => void
|
|
1123
|
+
state.previousOnMouseUp = nv.onMouseUp as (data: unknown) => void
|
|
1036
1124
|
nv.onMouseUp = (data: unknown) => {
|
|
1037
1125
|
if (state.previousOnMouseUp) {
|
|
1038
|
-
state.previousOnMouseUp(data)
|
|
1126
|
+
state.previousOnMouseUp(data)
|
|
1039
1127
|
}
|
|
1040
|
-
this._handleViewportInteractionEnd(nv)
|
|
1041
|
-
}
|
|
1128
|
+
this._handleViewportInteractionEnd(nv)
|
|
1129
|
+
}
|
|
1042
1130
|
|
|
1043
1131
|
// Save and chain onZoom3DChange (fires when volScaleMultiplier changes)
|
|
1044
|
-
state.previousOnZoom3DChange = nv.onZoom3DChange
|
|
1132
|
+
state.previousOnZoom3DChange = nv.onZoom3DChange
|
|
1045
1133
|
nv.onZoom3DChange = (zoom: number) => {
|
|
1046
1134
|
if (state.previousOnZoom3DChange) {
|
|
1047
|
-
state.previousOnZoom3DChange(zoom)
|
|
1135
|
+
state.previousOnZoom3DChange(zoom)
|
|
1048
1136
|
}
|
|
1049
|
-
this._handleViewportInteractionEnd(nv)
|
|
1050
|
-
}
|
|
1137
|
+
this._handleViewportInteractionEnd(nv)
|
|
1138
|
+
}
|
|
1051
1139
|
|
|
1052
1140
|
// Add wheel event listener on the canvas for scroll-wheel zoom detection
|
|
1053
|
-
const controller = new AbortController()
|
|
1054
|
-
state.viewportAbortController = controller
|
|
1141
|
+
const controller = new AbortController()
|
|
1142
|
+
state.viewportAbortController = controller
|
|
1055
1143
|
if (nv.canvas) {
|
|
1056
1144
|
nv.canvas.addEventListener(
|
|
1057
1145
|
"wheel",
|
|
1058
1146
|
() => {
|
|
1059
|
-
this._handleViewportInteractionEnd(nv)
|
|
1147
|
+
this._handleViewportInteractionEnd(nv)
|
|
1060
1148
|
},
|
|
1061
1149
|
{ signal: controller.signal, passive: true },
|
|
1062
|
-
)
|
|
1150
|
+
)
|
|
1063
1151
|
}
|
|
1064
1152
|
}
|
|
1065
1153
|
|
|
@@ -1069,20 +1157,20 @@ export class OMEZarrNVImage extends NVImage {
|
|
|
1069
1157
|
private _unhookViewportEvents(nv: Niivue, state: AttachedNiivueState): void {
|
|
1070
1158
|
// Restore onMouseUp
|
|
1071
1159
|
if (state.previousOnMouseUp !== undefined) {
|
|
1072
|
-
nv.onMouseUp = state.previousOnMouseUp as typeof nv.onMouseUp
|
|
1073
|
-
state.previousOnMouseUp = undefined
|
|
1160
|
+
nv.onMouseUp = state.previousOnMouseUp as typeof nv.onMouseUp
|
|
1161
|
+
state.previousOnMouseUp = undefined
|
|
1074
1162
|
}
|
|
1075
1163
|
|
|
1076
1164
|
// Restore onZoom3DChange
|
|
1077
1165
|
if (state.previousOnZoom3DChange !== undefined) {
|
|
1078
|
-
nv.onZoom3DChange = state.previousOnZoom3DChange
|
|
1079
|
-
state.previousOnZoom3DChange = undefined
|
|
1166
|
+
nv.onZoom3DChange = state.previousOnZoom3DChange
|
|
1167
|
+
state.previousOnZoom3DChange = undefined
|
|
1080
1168
|
}
|
|
1081
1169
|
|
|
1082
1170
|
// Remove wheel event listener
|
|
1083
1171
|
if (state.viewportAbortController) {
|
|
1084
|
-
state.viewportAbortController.abort()
|
|
1085
|
-
state.viewportAbortController = undefined
|
|
1172
|
+
state.viewportAbortController.abort()
|
|
1173
|
+
state.viewportAbortController = undefined
|
|
1086
1174
|
}
|
|
1087
1175
|
}
|
|
1088
1176
|
|
|
@@ -1102,72 +1190,71 @@ export class OMEZarrNVImage extends NVImage {
|
|
|
1102
1190
|
* (depth < 1.8), the event passes through to NiiVue's native handler.
|
|
1103
1191
|
*/
|
|
1104
1192
|
private _hookZoomOverride(nv: Niivue, state: AttachedNiivueState): void {
|
|
1105
|
-
if (!nv.canvas) return
|
|
1193
|
+
if (!nv.canvas) return
|
|
1106
1194
|
|
|
1107
|
-
const controller = new AbortController()
|
|
1108
|
-
state.zoomOverrideAbortController = controller
|
|
1195
|
+
const controller = new AbortController()
|
|
1196
|
+
state.zoomOverrideAbortController = controller
|
|
1109
1197
|
|
|
1110
1198
|
nv.canvas.addEventListener(
|
|
1111
1199
|
"wheel",
|
|
1112
1200
|
(e: WheelEvent) => {
|
|
1113
1201
|
// Convert mouse position to DPR-scaled canvas coordinates
|
|
1114
|
-
const
|
|
1115
|
-
|
|
1116
|
-
const
|
|
1117
|
-
const
|
|
1202
|
+
const canvas = nv.canvas
|
|
1203
|
+
if (!canvas) return
|
|
1204
|
+
const rect = canvas.getBoundingClientRect()
|
|
1205
|
+
const dpr = nv.uiData.dpr ?? 1
|
|
1206
|
+
const x = (e.clientX - rect.left) * dpr
|
|
1207
|
+
const y = (e.clientY - rect.top) * dpr
|
|
1118
1208
|
|
|
1119
1209
|
// Only intercept if mouse is over a 3D render tile
|
|
1120
|
-
if (nv.inRenderTile(x, y) < 0) return
|
|
1210
|
+
if (nv.inRenderTile(x, y) < 0) return
|
|
1121
1211
|
|
|
1122
1212
|
// Preserve clip-plane scrolling: when a clip plane is active
|
|
1123
1213
|
// (depth < 1.8), let NiiVue handle the event normally.
|
|
1124
|
-
const clips = nv.scene.clipPlaneDepthAziElevs
|
|
1125
|
-
const activeIdx = nv.uiData.activeClipPlaneIndex
|
|
1214
|
+
const clips = nv.scene.clipPlaneDepthAziElevs
|
|
1215
|
+
const activeIdx = nv.uiData.activeClipPlaneIndex
|
|
1126
1216
|
if (
|
|
1127
1217
|
nv.volumes.length > 0 &&
|
|
1128
1218
|
clips?.[activeIdx]?.[0] !== undefined &&
|
|
1129
1219
|
clips[activeIdx][0] < 1.8
|
|
1130
1220
|
) {
|
|
1131
|
-
return
|
|
1221
|
+
return
|
|
1132
1222
|
}
|
|
1133
1223
|
|
|
1134
1224
|
// Prevent NiiVue's clamped handler from running.
|
|
1135
1225
|
// NiiVue registers its listener in the bubbling phase, so our
|
|
1136
1226
|
// capturing-phase listener fires first. stopImmediatePropagation
|
|
1137
1227
|
// ensures no other same-element listeners fire either.
|
|
1138
|
-
e.stopImmediatePropagation()
|
|
1139
|
-
e.preventDefault()
|
|
1228
|
+
e.stopImmediatePropagation()
|
|
1229
|
+
e.preventDefault()
|
|
1140
1230
|
|
|
1141
1231
|
// Compute new zoom (same ×1.1 / ×0.9 per step as NiiVue).
|
|
1142
1232
|
// Round to 2 decimal places (NiiVue rounds to 1, which causes the
|
|
1143
1233
|
// zoom to get stuck at small values like 0.5 where ×0.9 rounds back).
|
|
1144
|
-
const zoomDir = e.deltaY < 0 ? 1 : -1
|
|
1145
|
-
const current = nv.scene.volScaleMultiplier
|
|
1146
|
-
let newZoom = current * (zoomDir > 0 ? 1.1 : 0.9)
|
|
1147
|
-
newZoom = Math.round(newZoom * 100) / 100
|
|
1148
|
-
newZoom = Math.max(this._min3DZoom, Math.min(this._max3DZoom, newZoom))
|
|
1234
|
+
const zoomDir = e.deltaY < 0 ? 1 : -1
|
|
1235
|
+
const current = nv.scene.volScaleMultiplier
|
|
1236
|
+
let newZoom = current * (zoomDir > 0 ? 1.1 : 0.9)
|
|
1237
|
+
newZoom = Math.round(newZoom * 100) / 100
|
|
1238
|
+
newZoom = Math.max(this._min3DZoom, Math.min(this._max3DZoom, newZoom))
|
|
1149
1239
|
|
|
1150
|
-
nv.setScale(newZoom)
|
|
1240
|
+
nv.setScale(newZoom)
|
|
1151
1241
|
|
|
1152
1242
|
// Notify the viewport-aware system. Since we stopped propagation,
|
|
1153
1243
|
// the passive wheel listener from _hookViewportEvents won't fire,
|
|
1154
1244
|
// so we call this directly.
|
|
1155
|
-
this._handleViewportInteractionEnd(nv)
|
|
1245
|
+
this._handleViewportInteractionEnd(nv)
|
|
1156
1246
|
},
|
|
1157
1247
|
{ capture: true, signal: controller.signal },
|
|
1158
|
-
)
|
|
1248
|
+
)
|
|
1159
1249
|
}
|
|
1160
1250
|
|
|
1161
1251
|
/**
|
|
1162
1252
|
* Remove the 3D zoom override wheel listener from a NV instance.
|
|
1163
1253
|
*/
|
|
1164
|
-
private _unhookZoomOverride(
|
|
1165
|
-
_nv: Niivue,
|
|
1166
|
-
state: AttachedNiivueState,
|
|
1167
|
-
): void {
|
|
1254
|
+
private _unhookZoomOverride(_nv: Niivue, state: AttachedNiivueState): void {
|
|
1168
1255
|
if (state.zoomOverrideAbortController) {
|
|
1169
|
-
state.zoomOverrideAbortController.abort()
|
|
1170
|
-
state.zoomOverrideAbortController = undefined
|
|
1256
|
+
state.zoomOverrideAbortController.abort()
|
|
1257
|
+
state.zoomOverrideAbortController = undefined
|
|
1171
1258
|
}
|
|
1172
1259
|
}
|
|
1173
1260
|
|
|
@@ -1176,16 +1263,16 @@ export class OMEZarrNVImage extends NVImage {
|
|
|
1176
1263
|
* zoom change, scroll wheel). Debounces the viewport bounds recomputation.
|
|
1177
1264
|
*/
|
|
1178
1265
|
private _handleViewportInteractionEnd(_nv: Niivue): void {
|
|
1179
|
-
if (!this._viewportAwareEnabled) return
|
|
1266
|
+
if (!this._viewportAwareEnabled) return
|
|
1180
1267
|
|
|
1181
1268
|
// Debounce: clear any pending update and schedule a new one
|
|
1182
1269
|
if (this._viewportUpdateTimeout) {
|
|
1183
|
-
clearTimeout(this._viewportUpdateTimeout)
|
|
1270
|
+
clearTimeout(this._viewportUpdateTimeout)
|
|
1184
1271
|
}
|
|
1185
1272
|
this._viewportUpdateTimeout = setTimeout(() => {
|
|
1186
|
-
this._viewportUpdateTimeout = null
|
|
1187
|
-
this._recomputeViewportBounds()
|
|
1188
|
-
}, OMEZarrNVImage.VIEWPORT_DEBOUNCE_MS)
|
|
1273
|
+
this._viewportUpdateTimeout = null
|
|
1274
|
+
this._recomputeViewportBounds()
|
|
1275
|
+
}, OMEZarrNVImage.VIEWPORT_DEBOUNCE_MS)
|
|
1189
1276
|
}
|
|
1190
1277
|
|
|
1191
1278
|
/**
|
|
@@ -1193,13 +1280,13 @@ export class OMEZarrNVImage extends NVImage {
|
|
|
1193
1280
|
* resolution reselection if bounds changed significantly.
|
|
1194
1281
|
*/
|
|
1195
1282
|
private _recomputeViewportBounds(): void {
|
|
1196
|
-
if (!this._viewportAwareEnabled) return
|
|
1283
|
+
if (!this._viewportAwareEnabled) return
|
|
1197
1284
|
|
|
1198
1285
|
// Compute separate viewport bounds for:
|
|
1199
1286
|
// - 3D volume: union of all RENDER/MULTIPLANAR NV viewport bounds
|
|
1200
1287
|
// - Per-slab: each slab type gets its own NV's viewport bounds
|
|
1201
|
-
let new3DBounds: VolumeBounds | null = null
|
|
1202
|
-
const newSlabBounds = new Map<SlabSliceType, VolumeBounds | null>()
|
|
1288
|
+
let new3DBounds: VolumeBounds | null = null
|
|
1289
|
+
const newSlabBounds = new Map<SlabSliceType, VolumeBounds | null>()
|
|
1203
1290
|
|
|
1204
1291
|
for (const [nv, state] of this._attachedNiivues) {
|
|
1205
1292
|
if (
|
|
@@ -1207,9 +1294,9 @@ export class OMEZarrNVImage extends NVImage {
|
|
|
1207
1294
|
state.currentSliceType === SLICE_TYPE.MULTIPLANAR
|
|
1208
1295
|
) {
|
|
1209
1296
|
// 3D render mode: compute from orthographic frustum
|
|
1210
|
-
const nvBounds = computeViewportBounds3D(nv, this._volumeBounds)
|
|
1297
|
+
const nvBounds = computeViewportBounds3D(nv, this._volumeBounds)
|
|
1211
1298
|
if (!new3DBounds) {
|
|
1212
|
-
new3DBounds = nvBounds
|
|
1299
|
+
new3DBounds = nvBounds
|
|
1213
1300
|
} else {
|
|
1214
1301
|
// Union of multiple 3D views
|
|
1215
1302
|
new3DBounds = {
|
|
@@ -1223,48 +1310,49 @@ export class OMEZarrNVImage extends NVImage {
|
|
|
1223
1310
|
Math.max(new3DBounds.max[1], nvBounds.max[1]),
|
|
1224
1311
|
Math.max(new3DBounds.max[2], nvBounds.max[2]),
|
|
1225
1312
|
],
|
|
1226
|
-
}
|
|
1313
|
+
}
|
|
1227
1314
|
}
|
|
1228
1315
|
} else if (this._isSlabSliceType(state.currentSliceType)) {
|
|
1229
1316
|
// 2D slice mode: compute from pan/zoom
|
|
1230
|
-
const sliceType = state.currentSliceType as SlabSliceType
|
|
1231
|
-
const slabState = this._slabBuffers.get(sliceType)
|
|
1232
|
-
const normScale = slabState?.normalizationScale ?? 1.0
|
|
1317
|
+
const sliceType = state.currentSliceType as SlabSliceType
|
|
1318
|
+
const slabState = this._slabBuffers.get(sliceType)
|
|
1319
|
+
const normScale = slabState?.normalizationScale ?? 1.0
|
|
1233
1320
|
const nvBounds = computeViewportBounds2D(
|
|
1234
1321
|
nv,
|
|
1235
1322
|
state.currentSliceType,
|
|
1236
1323
|
this._volumeBounds,
|
|
1237
1324
|
normScale,
|
|
1238
|
-
)
|
|
1239
|
-
newSlabBounds.set(sliceType, nvBounds)
|
|
1325
|
+
)
|
|
1326
|
+
newSlabBounds.set(sliceType, nvBounds)
|
|
1240
1327
|
}
|
|
1241
1328
|
}
|
|
1242
1329
|
|
|
1243
1330
|
// Check if 3D bounds changed
|
|
1244
|
-
const bounds3DChanged =
|
|
1331
|
+
const bounds3DChanged =
|
|
1332
|
+
!new3DBounds !== !this._viewportBounds3D ||
|
|
1245
1333
|
(new3DBounds &&
|
|
1246
1334
|
this._viewportBounds3D &&
|
|
1247
|
-
!boundsApproxEqual(new3DBounds, this._viewportBounds3D))
|
|
1335
|
+
!boundsApproxEqual(new3DBounds, this._viewportBounds3D))
|
|
1248
1336
|
|
|
1249
1337
|
// Check if any slab bounds changed
|
|
1250
|
-
let slabBoundsChanged = false
|
|
1338
|
+
let slabBoundsChanged = false
|
|
1251
1339
|
for (const [sliceType, newBounds] of newSlabBounds) {
|
|
1252
|
-
const oldBounds = this._viewportBoundsPerSlab.get(sliceType) ?? null
|
|
1340
|
+
const oldBounds = this._viewportBoundsPerSlab.get(sliceType) ?? null
|
|
1253
1341
|
if (
|
|
1254
1342
|
!newBounds !== !oldBounds ||
|
|
1255
1343
|
(newBounds && oldBounds && !boundsApproxEqual(newBounds, oldBounds))
|
|
1256
1344
|
) {
|
|
1257
|
-
slabBoundsChanged = true
|
|
1258
|
-
break
|
|
1345
|
+
slabBoundsChanged = true
|
|
1346
|
+
break
|
|
1259
1347
|
}
|
|
1260
1348
|
}
|
|
1261
1349
|
|
|
1262
|
-
if (!bounds3DChanged && !slabBoundsChanged) return
|
|
1350
|
+
if (!bounds3DChanged && !slabBoundsChanged) return
|
|
1263
1351
|
|
|
1264
1352
|
// Update stored bounds
|
|
1265
|
-
this._viewportBounds3D = new3DBounds
|
|
1353
|
+
this._viewportBounds3D = new3DBounds
|
|
1266
1354
|
for (const [sliceType, bounds] of newSlabBounds) {
|
|
1267
|
-
this._viewportBoundsPerSlab.set(sliceType, bounds)
|
|
1355
|
+
this._viewportBoundsPerSlab.set(sliceType, bounds)
|
|
1268
1356
|
}
|
|
1269
1357
|
|
|
1270
1358
|
// Recompute 3D resolution selection with new 3D viewport bounds
|
|
@@ -1275,17 +1363,17 @@ export class OMEZarrNVImage extends NVImage {
|
|
|
1275
1363
|
this._clipPlanes,
|
|
1276
1364
|
this._volumeBounds,
|
|
1277
1365
|
this._viewportBounds3D ?? undefined,
|
|
1278
|
-
)
|
|
1366
|
+
)
|
|
1279
1367
|
|
|
1280
1368
|
if (selection.levelIndex !== this.targetLevelIndex) {
|
|
1281
|
-
this.targetLevelIndex = selection.levelIndex
|
|
1282
|
-
this.populateVolume(true, "viewportChanged")
|
|
1369
|
+
this.targetLevelIndex = selection.levelIndex
|
|
1370
|
+
this.populateVolume(true, "viewportChanged")
|
|
1283
1371
|
}
|
|
1284
1372
|
}
|
|
1285
1373
|
|
|
1286
1374
|
// Reload slabs with new per-slab viewport bounds
|
|
1287
1375
|
if (slabBoundsChanged) {
|
|
1288
|
-
this._reloadAllSlabs("viewportChanged")
|
|
1376
|
+
this._reloadAllSlabs("viewportChanged")
|
|
1289
1377
|
}
|
|
1290
1378
|
}
|
|
1291
1379
|
|
|
@@ -1300,28 +1388,28 @@ export class OMEZarrNVImage extends NVImage {
|
|
|
1300
1388
|
this._isSlabSliceType(attachedState.currentSliceType) &&
|
|
1301
1389
|
(attachedState.currentSliceType as SlabSliceType) === sliceType
|
|
1302
1390
|
) {
|
|
1303
|
-
const crosshairPos = nv.scene?.crosshairPos
|
|
1304
|
-
if (!crosshairPos || nv.volumes.length === 0) continue
|
|
1391
|
+
const crosshairPos = nv.scene?.crosshairPos
|
|
1392
|
+
if (!crosshairPos || nv.volumes.length === 0) continue
|
|
1305
1393
|
try {
|
|
1306
1394
|
const mm = nv.frac2mm([
|
|
1307
1395
|
crosshairPos[0],
|
|
1308
1396
|
crosshairPos[1],
|
|
1309
1397
|
crosshairPos[2],
|
|
1310
|
-
])
|
|
1398
|
+
])
|
|
1311
1399
|
// frac2mm returns values in the slab NVImage's mm space, which
|
|
1312
1400
|
// is normalized (world * normalizationScale). Convert back to
|
|
1313
1401
|
// physical world coordinates for worldToPixel and other callers.
|
|
1314
|
-
const ns = slabState.normalizationScale
|
|
1402
|
+
const ns = slabState.normalizationScale
|
|
1315
1403
|
const worldCoord: [number, number, number] = [
|
|
1316
1404
|
mm[0] / ns,
|
|
1317
1405
|
mm[1] / ns,
|
|
1318
1406
|
mm[2] / ns,
|
|
1319
|
-
]
|
|
1320
|
-
this._debouncedSlabReload(sliceType, worldCoord, trigger)
|
|
1407
|
+
]
|
|
1408
|
+
this._debouncedSlabReload(sliceType, worldCoord, trigger)
|
|
1321
1409
|
} catch {
|
|
1322
1410
|
// Can't convert coordinates yet
|
|
1323
1411
|
}
|
|
1324
|
-
break
|
|
1412
|
+
break
|
|
1325
1413
|
}
|
|
1326
1414
|
}
|
|
1327
1415
|
}
|
|
@@ -1331,14 +1419,14 @@ export class OMEZarrNVImage extends NVImage {
|
|
|
1331
1419
|
* Get whether the image is currently loading.
|
|
1332
1420
|
*/
|
|
1333
1421
|
getIsLoading(): boolean {
|
|
1334
|
-
return this.isLoading
|
|
1422
|
+
return this.isLoading
|
|
1335
1423
|
}
|
|
1336
1424
|
|
|
1337
1425
|
/**
|
|
1338
1426
|
* Wait for all pending fetches to complete.
|
|
1339
1427
|
*/
|
|
1340
1428
|
async waitForIdle(): Promise<void> {
|
|
1341
|
-
await this.coalescer.onIdle()
|
|
1429
|
+
await this.coalescer.onIdle()
|
|
1342
1430
|
}
|
|
1343
1431
|
|
|
1344
1432
|
// ============================================================
|
|
@@ -1360,7 +1448,7 @@ export class OMEZarrNVImage extends NVImage {
|
|
|
1360
1448
|
* @returns OMERO metadata or undefined if not yet loaded/computed
|
|
1361
1449
|
*/
|
|
1362
1450
|
getOmero(): Omero | undefined {
|
|
1363
|
-
return this._omero
|
|
1451
|
+
return this._omero
|
|
1364
1452
|
}
|
|
1365
1453
|
|
|
1366
1454
|
/**
|
|
@@ -1372,7 +1460,7 @@ export class OMEZarrNVImage extends NVImage {
|
|
|
1372
1460
|
* @returns Current active channel index (0-based)
|
|
1373
1461
|
*/
|
|
1374
1462
|
getActiveChannel(): number {
|
|
1375
|
-
return this._activeChannel
|
|
1463
|
+
return this._activeChannel
|
|
1376
1464
|
}
|
|
1377
1465
|
|
|
1378
1466
|
/**
|
|
@@ -1401,17 +1489,17 @@ export class OMEZarrNVImage extends NVImage {
|
|
|
1401
1489
|
*/
|
|
1402
1490
|
setActiveChannel(index: number): void {
|
|
1403
1491
|
if (!this._omero?.channels?.length) {
|
|
1404
|
-
throw new Error("No OMERO metadata available")
|
|
1492
|
+
throw new Error("No OMERO metadata available")
|
|
1405
1493
|
}
|
|
1406
1494
|
if (index < 0 || index >= this._omero.channels.length) {
|
|
1407
1495
|
throw new Error(
|
|
1408
1496
|
`Invalid channel index: ${index} (have ${this._omero.channels.length} channels)`,
|
|
1409
|
-
)
|
|
1497
|
+
)
|
|
1410
1498
|
}
|
|
1411
|
-
this._activeChannel = index
|
|
1412
|
-
this.applyOmeroToHeader()
|
|
1413
|
-
this.niivue.updateGLVolume()
|
|
1414
|
-
this._widenCalRangeIfNeeded(this)
|
|
1499
|
+
this._activeChannel = index
|
|
1500
|
+
this.applyOmeroToHeader()
|
|
1501
|
+
this.niivue.updateGLVolume()
|
|
1502
|
+
this._widenCalRangeIfNeeded(this)
|
|
1415
1503
|
}
|
|
1416
1504
|
|
|
1417
1505
|
// ============================================================
|
|
@@ -1432,15 +1520,15 @@ export class OMEZarrNVImage extends NVImage {
|
|
|
1432
1520
|
* @param nv - The Niivue instance to attach
|
|
1433
1521
|
*/
|
|
1434
1522
|
attachNiivue(nv: Niivue): void {
|
|
1435
|
-
if (this._attachedNiivues.has(nv)) return
|
|
1523
|
+
if (this._attachedNiivues.has(nv)) return // Already attached
|
|
1436
1524
|
|
|
1437
1525
|
const state: AttachedNiivueState = {
|
|
1438
1526
|
nv,
|
|
1439
1527
|
currentSliceType: this._detectSliceType(nv),
|
|
1440
1528
|
previousOnLocationChange: nv.onLocationChange,
|
|
1441
|
-
previousOnOptsChange:
|
|
1442
|
-
.onOptsChange as AttachedNiivueState["previousOnOptsChange"],
|
|
1443
|
-
}
|
|
1529
|
+
previousOnOptsChange:
|
|
1530
|
+
nv.onOptsChange as AttachedNiivueState["previousOnOptsChange"],
|
|
1531
|
+
}
|
|
1444
1532
|
|
|
1445
1533
|
// Hook onOptsChange to detect slice type changes
|
|
1446
1534
|
nv.onOptsChange = (
|
|
@@ -1450,36 +1538,36 @@ export class OMEZarrNVImage extends NVImage {
|
|
|
1450
1538
|
) => {
|
|
1451
1539
|
// Chain to previous handler
|
|
1452
1540
|
if (state.previousOnOptsChange) {
|
|
1453
|
-
state.previousOnOptsChange(propertyName, newValue, oldValue)
|
|
1541
|
+
state.previousOnOptsChange(propertyName, newValue, oldValue)
|
|
1454
1542
|
}
|
|
1455
1543
|
if (propertyName === "sliceType") {
|
|
1456
|
-
this._handleSliceTypeChange(nv, newValue as SLICE_TYPE)
|
|
1544
|
+
this._handleSliceTypeChange(nv, newValue as SLICE_TYPE)
|
|
1457
1545
|
}
|
|
1458
|
-
}
|
|
1546
|
+
}
|
|
1459
1547
|
|
|
1460
1548
|
// Hook onLocationChange to detect slice position changes
|
|
1461
1549
|
nv.onLocationChange = (location: unknown) => {
|
|
1462
1550
|
// Chain to previous handler
|
|
1463
1551
|
if (state.previousOnLocationChange) {
|
|
1464
|
-
state.previousOnLocationChange(location)
|
|
1552
|
+
state.previousOnLocationChange(location)
|
|
1465
1553
|
}
|
|
1466
|
-
this._handleLocationChange(nv, location)
|
|
1467
|
-
}
|
|
1554
|
+
this._handleLocationChange(nv, location)
|
|
1555
|
+
}
|
|
1468
1556
|
|
|
1469
|
-
this._attachedNiivues.set(nv, state)
|
|
1557
|
+
this._attachedNiivues.set(nv, state)
|
|
1470
1558
|
|
|
1471
1559
|
// Hook viewport events if viewport-aware mode is already enabled
|
|
1472
1560
|
if (this._viewportAwareEnabled) {
|
|
1473
|
-
this._hookViewportEvents(nv, state)
|
|
1561
|
+
this._hookViewportEvents(nv, state)
|
|
1474
1562
|
}
|
|
1475
1563
|
|
|
1476
1564
|
// Override NiiVue's hardcoded 3D zoom clamp (always-on)
|
|
1477
|
-
this._hookZoomOverride(nv, state)
|
|
1565
|
+
this._hookZoomOverride(nv, state)
|
|
1478
1566
|
|
|
1479
1567
|
// If the NV is already in a 2D slice mode, set up the slab buffer
|
|
1480
|
-
const sliceType = state.currentSliceType
|
|
1568
|
+
const sliceType = state.currentSliceType
|
|
1481
1569
|
if (this._isSlabSliceType(sliceType)) {
|
|
1482
|
-
this._ensureSlabForNiivue(nv, sliceType as SlabSliceType)
|
|
1570
|
+
this._ensureSlabForNiivue(nv, sliceType as SlabSliceType)
|
|
1483
1571
|
}
|
|
1484
1572
|
}
|
|
1485
1573
|
|
|
@@ -1489,21 +1577,21 @@ export class OMEZarrNVImage extends NVImage {
|
|
|
1489
1577
|
* @param nv - The Niivue instance to detach
|
|
1490
1578
|
*/
|
|
1491
1579
|
detachNiivue(nv: Niivue): void {
|
|
1492
|
-
const state = this._attachedNiivues.get(nv)
|
|
1493
|
-
if (!state) return
|
|
1580
|
+
const state = this._attachedNiivues.get(nv)
|
|
1581
|
+
if (!state) return
|
|
1494
1582
|
|
|
1495
1583
|
// Unhook viewport events if active
|
|
1496
|
-
this._unhookViewportEvents(nv, state)
|
|
1584
|
+
this._unhookViewportEvents(nv, state)
|
|
1497
1585
|
|
|
1498
1586
|
// Unhook 3D zoom override
|
|
1499
|
-
this._unhookZoomOverride(nv, state)
|
|
1587
|
+
this._unhookZoomOverride(nv, state)
|
|
1500
1588
|
|
|
1501
1589
|
// Restore original callbacks
|
|
1502
|
-
nv.onLocationChange = state.previousOnLocationChange ?? (() => {})
|
|
1503
|
-
nv.onOptsChange =
|
|
1504
|
-
(
|
|
1590
|
+
nv.onLocationChange = state.previousOnLocationChange ?? (() => {})
|
|
1591
|
+
nv.onOptsChange = (state.previousOnOptsChange ??
|
|
1592
|
+
(() => {})) as typeof nv.onOptsChange
|
|
1505
1593
|
|
|
1506
|
-
this._attachedNiivues.delete(nv)
|
|
1594
|
+
this._attachedNiivues.delete(nv)
|
|
1507
1595
|
}
|
|
1508
1596
|
|
|
1509
1597
|
/**
|
|
@@ -1514,14 +1602,14 @@ export class OMEZarrNVImage extends NVImage {
|
|
|
1514
1602
|
* @returns The slab buffer state, or undefined if not yet created
|
|
1515
1603
|
*/
|
|
1516
1604
|
getSlabBufferState(sliceType: SlabSliceType): SlabBufferState | undefined {
|
|
1517
|
-
return this._slabBuffers.get(sliceType)
|
|
1605
|
+
return this._slabBuffers.get(sliceType)
|
|
1518
1606
|
}
|
|
1519
1607
|
|
|
1520
1608
|
/**
|
|
1521
1609
|
* Get all attached Niivue instances.
|
|
1522
1610
|
*/
|
|
1523
1611
|
getAttachedNiivues(): Niivue[] {
|
|
1524
|
-
return Array.from(this._attachedNiivues.keys())
|
|
1612
|
+
return Array.from(this._attachedNiivues.keys())
|
|
1525
1613
|
}
|
|
1526
1614
|
|
|
1527
1615
|
// ---- Private slab helpers ----
|
|
@@ -1533,12 +1621,12 @@ export class OMEZarrNVImage extends NVImage {
|
|
|
1533
1621
|
// Access the opts.sliceType via the scene data or fall back to checking
|
|
1534
1622
|
// the convenience properties. Niivue stores the current sliceType in opts.
|
|
1535
1623
|
// We can read it from the NV instance's internal opts.
|
|
1536
|
-
const
|
|
1537
|
-
if (
|
|
1538
|
-
return
|
|
1624
|
+
const { sliceType } = nv.opts
|
|
1625
|
+
if (typeof sliceType === "number") {
|
|
1626
|
+
return sliceType
|
|
1539
1627
|
}
|
|
1540
1628
|
// Default to Render
|
|
1541
|
-
return SLICE_TYPE.RENDER
|
|
1629
|
+
return SLICE_TYPE.RENDER
|
|
1542
1630
|
}
|
|
1543
1631
|
|
|
1544
1632
|
/**
|
|
@@ -1549,7 +1637,7 @@ export class OMEZarrNVImage extends NVImage {
|
|
|
1549
1637
|
st === SLICE_TYPE.AXIAL ||
|
|
1550
1638
|
st === SLICE_TYPE.CORONAL ||
|
|
1551
1639
|
st === SLICE_TYPE.SAGITTAL
|
|
1552
|
-
)
|
|
1640
|
+
)
|
|
1553
1641
|
}
|
|
1554
1642
|
|
|
1555
1643
|
/**
|
|
@@ -1562,11 +1650,11 @@ export class OMEZarrNVImage extends NVImage {
|
|
|
1562
1650
|
private _getOrthogonalAxis(sliceType: SlabSliceType): OrthogonalAxis {
|
|
1563
1651
|
switch (sliceType) {
|
|
1564
1652
|
case SLICE_TYPE.AXIAL:
|
|
1565
|
-
return 0
|
|
1653
|
+
return 0 // Z
|
|
1566
1654
|
case SLICE_TYPE.CORONAL:
|
|
1567
|
-
return 1
|
|
1655
|
+
return 1 // Y
|
|
1568
1656
|
case SLICE_TYPE.SAGITTAL:
|
|
1569
|
-
return 2
|
|
1657
|
+
return 2 // X
|
|
1570
1658
|
}
|
|
1571
1659
|
}
|
|
1572
1660
|
|
|
@@ -1574,20 +1662,20 @@ export class OMEZarrNVImage extends NVImage {
|
|
|
1574
1662
|
* Handle a slice type change on an attached Niivue instance.
|
|
1575
1663
|
*/
|
|
1576
1664
|
private _handleSliceTypeChange(nv: Niivue, newSliceType: SLICE_TYPE): void {
|
|
1577
|
-
const state = this._attachedNiivues.get(nv)
|
|
1578
|
-
if (!state) return
|
|
1665
|
+
const state = this._attachedNiivues.get(nv)
|
|
1666
|
+
if (!state) return
|
|
1579
1667
|
|
|
1580
|
-
const oldSliceType = state.currentSliceType
|
|
1581
|
-
state.currentSliceType = newSliceType
|
|
1668
|
+
const oldSliceType = state.currentSliceType
|
|
1669
|
+
state.currentSliceType = newSliceType
|
|
1582
1670
|
|
|
1583
|
-
if (oldSliceType === newSliceType) return
|
|
1671
|
+
if (oldSliceType === newSliceType) return
|
|
1584
1672
|
|
|
1585
1673
|
if (this._isSlabSliceType(newSliceType)) {
|
|
1586
1674
|
// Switching TO a 2D slab mode: swap in the slab NVImage
|
|
1587
|
-
this._ensureSlabForNiivue(nv, newSliceType as SlabSliceType)
|
|
1675
|
+
this._ensureSlabForNiivue(nv, newSliceType as SlabSliceType)
|
|
1588
1676
|
} else {
|
|
1589
1677
|
// Switching TO Render or Multiplanar mode: swap back to the main (3D) NVImage
|
|
1590
|
-
this._swapVolumeInNiivue(nv, this as NVImage)
|
|
1678
|
+
this._swapVolumeInNiivue(nv, this as NVImage)
|
|
1591
1679
|
}
|
|
1592
1680
|
}
|
|
1593
1681
|
|
|
@@ -1596,44 +1684,40 @@ export class OMEZarrNVImage extends NVImage {
|
|
|
1596
1684
|
* Checks if the current slice position has moved outside the loaded slab.
|
|
1597
1685
|
*/
|
|
1598
1686
|
private _handleLocationChange(nv: Niivue, _location: unknown): void {
|
|
1599
|
-
const state = this._attachedNiivues.get(nv)
|
|
1600
|
-
if (!state || !this._isSlabSliceType(state.currentSliceType)) return
|
|
1687
|
+
const state = this._attachedNiivues.get(nv)
|
|
1688
|
+
if (!state || !this._isSlabSliceType(state.currentSliceType)) return
|
|
1601
1689
|
|
|
1602
|
-
const sliceType = state.currentSliceType as SlabSliceType
|
|
1603
|
-
const slabState = this._slabBuffers.get(sliceType)
|
|
1604
|
-
if (!slabState || slabState.slabStart < 0) return
|
|
1690
|
+
const sliceType = state.currentSliceType as SlabSliceType
|
|
1691
|
+
const slabState = this._slabBuffers.get(sliceType)
|
|
1692
|
+
if (!slabState || slabState.slabStart < 0) return // Slab not yet created or loaded
|
|
1605
1693
|
|
|
1606
1694
|
// Get the current crosshair position in fractional coordinates [0..1]
|
|
1607
|
-
const crosshairPos = nv.scene?.crosshairPos
|
|
1608
|
-
if (!crosshairPos || nv.volumes.length === 0) return
|
|
1695
|
+
const crosshairPos = nv.scene?.crosshairPos
|
|
1696
|
+
if (!crosshairPos || nv.volumes.length === 0) return
|
|
1609
1697
|
|
|
1610
|
-
let worldCoord: [number, number, number]
|
|
1698
|
+
let worldCoord: [number, number, number]
|
|
1611
1699
|
try {
|
|
1612
|
-
const mm = nv.frac2mm([
|
|
1613
|
-
crosshairPos[0],
|
|
1614
|
-
crosshairPos[1],
|
|
1615
|
-
crosshairPos[2],
|
|
1616
|
-
]);
|
|
1700
|
+
const mm = nv.frac2mm([crosshairPos[0], crosshairPos[1], crosshairPos[2]])
|
|
1617
1701
|
// frac2mm returns values in the slab NVImage's normalized mm space
|
|
1618
1702
|
// (world * normalizationScale). Convert back to physical world.
|
|
1619
|
-
const ns = slabState.normalizationScale
|
|
1620
|
-
worldCoord = [mm[0] / ns, mm[1] / ns, mm[2] / ns]
|
|
1703
|
+
const ns = slabState.normalizationScale
|
|
1704
|
+
worldCoord = [mm[0] / ns, mm[1] / ns, mm[2] / ns]
|
|
1621
1705
|
} catch {
|
|
1622
|
-
return
|
|
1706
|
+
return // Can't convert coordinates yet
|
|
1623
1707
|
}
|
|
1624
1708
|
|
|
1625
1709
|
// Convert world to pixel at the slab's current resolution level
|
|
1626
|
-
const ngffImage = this.multiscales.images[slabState.levelIndex]
|
|
1627
|
-
const pixelCoord = worldToPixel(worldCoord, ngffImage)
|
|
1710
|
+
const ngffImage = this.multiscales.images[slabState.levelIndex]
|
|
1711
|
+
const pixelCoord = worldToPixel(worldCoord, ngffImage)
|
|
1628
1712
|
|
|
1629
1713
|
// Check the orthogonal axis
|
|
1630
|
-
const orthAxis = this._getOrthogonalAxis(sliceType)
|
|
1631
|
-
const pixelPos = pixelCoord[orthAxis]
|
|
1714
|
+
const orthAxis = this._getOrthogonalAxis(sliceType)
|
|
1715
|
+
const pixelPos = pixelCoord[orthAxis]
|
|
1632
1716
|
|
|
1633
1717
|
// Is the pixel position outside the currently loaded slab?
|
|
1634
1718
|
if (pixelPos < slabState.slabStart || pixelPos >= slabState.slabEnd) {
|
|
1635
1719
|
// Need to reload the slab for the new position
|
|
1636
|
-
this._debouncedSlabReload(sliceType, worldCoord)
|
|
1720
|
+
this._debouncedSlabReload(sliceType, worldCoord)
|
|
1637
1721
|
}
|
|
1638
1722
|
}
|
|
1639
1723
|
|
|
@@ -1646,15 +1730,15 @@ export class OMEZarrNVImage extends NVImage {
|
|
|
1646
1730
|
trigger: PopulateTrigger = "sliceChanged",
|
|
1647
1731
|
): void {
|
|
1648
1732
|
// Clear any pending reload for this slice type
|
|
1649
|
-
const existing = this._slabReloadTimeouts.get(sliceType)
|
|
1650
|
-
if (existing) clearTimeout(existing)
|
|
1733
|
+
const existing = this._slabReloadTimeouts.get(sliceType)
|
|
1734
|
+
if (existing) clearTimeout(existing)
|
|
1651
1735
|
|
|
1652
1736
|
const timeout = setTimeout(() => {
|
|
1653
|
-
this._slabReloadTimeouts.delete(sliceType)
|
|
1654
|
-
void this._loadSlab(sliceType, worldCoord, trigger)
|
|
1655
|
-
}, 100)
|
|
1737
|
+
this._slabReloadTimeouts.delete(sliceType)
|
|
1738
|
+
void this._loadSlab(sliceType, worldCoord, trigger)
|
|
1739
|
+
}, 100) // Short debounce for slice scrolling (faster than clip plane debounce)
|
|
1656
1740
|
|
|
1657
|
-
this._slabReloadTimeouts.set(sliceType, timeout)
|
|
1741
|
+
this._slabReloadTimeouts.set(sliceType, timeout)
|
|
1658
1742
|
}
|
|
1659
1743
|
|
|
1660
1744
|
/**
|
|
@@ -1662,36 +1746,36 @@ export class OMEZarrNVImage extends NVImage {
|
|
|
1662
1746
|
* If needed, creates the slab buffer and triggers an initial load.
|
|
1663
1747
|
*/
|
|
1664
1748
|
private _ensureSlabForNiivue(nv: Niivue, sliceType: SlabSliceType): void {
|
|
1665
|
-
let slabState = this._slabBuffers.get(sliceType)
|
|
1749
|
+
let slabState = this._slabBuffers.get(sliceType)
|
|
1666
1750
|
|
|
1667
1751
|
if (!slabState) {
|
|
1668
1752
|
// Lazily create the slab buffer
|
|
1669
|
-
slabState = this._createSlabBuffer(sliceType)
|
|
1670
|
-
this._slabBuffers.set(sliceType, slabState)
|
|
1753
|
+
slabState = this._createSlabBuffer(sliceType)
|
|
1754
|
+
this._slabBuffers.set(sliceType, slabState)
|
|
1671
1755
|
}
|
|
1672
1756
|
|
|
1673
1757
|
// Swap the slab's NVImage into this NV instance
|
|
1674
|
-
this._swapVolumeInNiivue(nv, slabState.nvImage)
|
|
1758
|
+
this._swapVolumeInNiivue(nv, slabState.nvImage)
|
|
1675
1759
|
|
|
1676
1760
|
// Get the current crosshair position and load the slab.
|
|
1677
1761
|
// Use the volume bounds center as a fallback if crosshair isn't available yet.
|
|
1678
|
-
let worldCoord: [number, number, number]
|
|
1762
|
+
let worldCoord: [number, number, number]
|
|
1679
1763
|
try {
|
|
1680
|
-
const crosshairPos = nv.scene?.crosshairPos
|
|
1764
|
+
const crosshairPos = nv.scene?.crosshairPos
|
|
1681
1765
|
if (crosshairPos && nv.volumes.length > 0) {
|
|
1682
1766
|
const mm = nv.frac2mm([
|
|
1683
1767
|
crosshairPos[0],
|
|
1684
1768
|
crosshairPos[1],
|
|
1685
1769
|
crosshairPos[2],
|
|
1686
|
-
])
|
|
1687
|
-
worldCoord = [mm[0], mm[1], mm[2]]
|
|
1770
|
+
])
|
|
1771
|
+
worldCoord = [mm[0], mm[1], mm[2]]
|
|
1688
1772
|
} else {
|
|
1689
1773
|
// Fall back to volume center
|
|
1690
1774
|
worldCoord = [
|
|
1691
1775
|
(this._volumeBounds.min[0] + this._volumeBounds.max[0]) / 2,
|
|
1692
1776
|
(this._volumeBounds.min[1] + this._volumeBounds.max[1]) / 2,
|
|
1693
1777
|
(this._volumeBounds.min[2] + this._volumeBounds.max[2]) / 2,
|
|
1694
|
-
]
|
|
1778
|
+
]
|
|
1695
1779
|
}
|
|
1696
1780
|
} catch {
|
|
1697
1781
|
// Fall back to volume center if frac2mm fails
|
|
@@ -1699,52 +1783,54 @@ export class OMEZarrNVImage extends NVImage {
|
|
|
1699
1783
|
(this._volumeBounds.min[0] + this._volumeBounds.max[0]) / 2,
|
|
1700
1784
|
(this._volumeBounds.min[1] + this._volumeBounds.max[1]) / 2,
|
|
1701
1785
|
(this._volumeBounds.min[2] + this._volumeBounds.max[2]) / 2,
|
|
1702
|
-
]
|
|
1786
|
+
]
|
|
1703
1787
|
}
|
|
1704
1788
|
|
|
1705
1789
|
void this._loadSlab(sliceType, worldCoord, "initial").catch((err) => {
|
|
1706
1790
|
console.error(
|
|
1707
1791
|
`[fidnii] Error loading slab for ${SLICE_TYPE[sliceType]}:`,
|
|
1708
1792
|
err,
|
|
1709
|
-
)
|
|
1710
|
-
})
|
|
1793
|
+
)
|
|
1794
|
+
})
|
|
1711
1795
|
}
|
|
1712
1796
|
|
|
1713
1797
|
/**
|
|
1714
1798
|
* Create a new slab buffer state for a slice type.
|
|
1715
1799
|
*/
|
|
1716
1800
|
private _createSlabBuffer(sliceType: SlabSliceType): SlabBufferState {
|
|
1717
|
-
const bufferManager = new BufferManager(this.maxPixels, this.dtype)
|
|
1718
|
-
const nvImage = new NVImage()
|
|
1801
|
+
const bufferManager = new BufferManager(this.maxPixels, this.dtype)
|
|
1802
|
+
const nvImage = new NVImage()
|
|
1719
1803
|
|
|
1720
1804
|
// Initialize with placeholder NIfTI header (same as main image setup)
|
|
1721
|
-
const hdr = new NIFTI1()
|
|
1722
|
-
nvImage.hdr = hdr
|
|
1723
|
-
hdr.dims = [3, 1, 1, 1, 1, 1, 1, 1]
|
|
1724
|
-
hdr.datatypeCode = getNiftiDataType(this.dtype)
|
|
1725
|
-
hdr.numBitsPerVoxel = getBytesPerPixel(this.dtype) * 8
|
|
1726
|
-
hdr.pixDims = [1, 1, 1, 1, 0, 0, 0, 0]
|
|
1805
|
+
const hdr = new NIFTI1()
|
|
1806
|
+
nvImage.hdr = hdr
|
|
1807
|
+
hdr.dims = [3, 1, 1, 1, 1, 1, 1, 1]
|
|
1808
|
+
hdr.datatypeCode = getNiftiDataType(this.dtype)
|
|
1809
|
+
hdr.numBitsPerVoxel = getBytesPerPixel(this.dtype) * 8
|
|
1810
|
+
hdr.pixDims = [1, 1, 1, 1, 0, 0, 0, 0]
|
|
1727
1811
|
hdr.affine = [
|
|
1728
1812
|
[1, 0, 0, 0],
|
|
1729
1813
|
[0, 1, 0, 0],
|
|
1730
1814
|
[0, 0, 1, 0],
|
|
1731
1815
|
[0, 0, 0, 1],
|
|
1732
|
-
]
|
|
1733
|
-
hdr.sform_code = 1
|
|
1734
|
-
nvImage.name = `${this.name ?? "OME-Zarr"} [${SLICE_TYPE[sliceType]}]
|
|
1735
|
-
nvImage.img = bufferManager.resize([1, 1, 1]) as
|
|
1736
|
-
(
|
|
1737
|
-
|
|
1816
|
+
]
|
|
1817
|
+
hdr.sform_code = 1
|
|
1818
|
+
nvImage.name = `${this.name ?? "OME-Zarr"} [${SLICE_TYPE[sliceType]}]`
|
|
1819
|
+
nvImage.img = bufferManager.resize([1, 1, 1]) as NVImage["img"]
|
|
1820
|
+
if (!this.isLabelImage) {
|
|
1821
|
+
nvImage._colormap = "gray"
|
|
1822
|
+
}
|
|
1823
|
+
nvImage._opacity = 1.0
|
|
1738
1824
|
|
|
1739
1825
|
// Select initial resolution using 2D pixel budget
|
|
1740
|
-
const orthAxis = this._getOrthogonalAxis(sliceType)
|
|
1826
|
+
const orthAxis = this._getOrthogonalAxis(sliceType)
|
|
1741
1827
|
const selection = select2DResolution(
|
|
1742
1828
|
this.multiscales,
|
|
1743
1829
|
this.maxPixels,
|
|
1744
1830
|
this._clipPlanes,
|
|
1745
1831
|
this._volumeBounds,
|
|
1746
1832
|
orthAxis,
|
|
1747
|
-
)
|
|
1833
|
+
)
|
|
1748
1834
|
|
|
1749
1835
|
return {
|
|
1750
1836
|
nvImage,
|
|
@@ -1757,7 +1843,7 @@ export class OMEZarrNVImage extends NVImage {
|
|
|
1757
1843
|
dtype: this.dtype,
|
|
1758
1844
|
normalizationScale: 1.0, // Updated on first slab load
|
|
1759
1845
|
pendingReload: null,
|
|
1760
|
-
}
|
|
1846
|
+
}
|
|
1761
1847
|
}
|
|
1762
1848
|
|
|
1763
1849
|
/**
|
|
@@ -1766,16 +1852,16 @@ export class OMEZarrNVImage extends NVImage {
|
|
|
1766
1852
|
*/
|
|
1767
1853
|
private _swapVolumeInNiivue(nv: Niivue, targetVolume: NVImage): void {
|
|
1768
1854
|
// Find and remove any volumes we own (the main image or any slab NVImages)
|
|
1769
|
-
const ourVolumes = new Set<NVImage>([this as NVImage])
|
|
1855
|
+
const ourVolumes = new Set<NVImage>([this as NVImage])
|
|
1770
1856
|
for (const slab of this._slabBuffers.values()) {
|
|
1771
|
-
ourVolumes.add(slab.nvImage)
|
|
1857
|
+
ourVolumes.add(slab.nvImage)
|
|
1772
1858
|
}
|
|
1773
1859
|
|
|
1774
1860
|
// Remove our volumes from nv (in reverse to avoid index shifting issues)
|
|
1775
|
-
const toRemove = nv.volumes.filter((v) => ourVolumes.has(v))
|
|
1861
|
+
const toRemove = nv.volumes.filter((v) => ourVolumes.has(v))
|
|
1776
1862
|
for (const vol of toRemove) {
|
|
1777
1863
|
try {
|
|
1778
|
-
nv.removeVolume(vol)
|
|
1864
|
+
nv.removeVolume(vol)
|
|
1779
1865
|
} catch {
|
|
1780
1866
|
// Ignore errors during removal (volume may not be fully initialized)
|
|
1781
1867
|
}
|
|
@@ -1784,16 +1870,16 @@ export class OMEZarrNVImage extends NVImage {
|
|
|
1784
1870
|
// Add the target volume if not already present
|
|
1785
1871
|
if (!nv.volumes.includes(targetVolume)) {
|
|
1786
1872
|
try {
|
|
1787
|
-
nv.addVolume(targetVolume)
|
|
1873
|
+
nv.addVolume(targetVolume)
|
|
1788
1874
|
} catch (err) {
|
|
1789
|
-
console.warn("[fidnii] Failed to add volume to NV:", err)
|
|
1790
|
-
return
|
|
1875
|
+
console.warn("[fidnii] Failed to add volume to NV:", err)
|
|
1876
|
+
return
|
|
1791
1877
|
}
|
|
1792
1878
|
}
|
|
1793
1879
|
|
|
1794
1880
|
try {
|
|
1795
|
-
nv.updateGLVolume()
|
|
1796
|
-
this._widenCalRangeIfNeeded(targetVolume)
|
|
1881
|
+
nv.updateGLVolume()
|
|
1882
|
+
this._widenCalRangeIfNeeded(targetVolume)
|
|
1797
1883
|
} catch {
|
|
1798
1884
|
// May fail if GL context not ready
|
|
1799
1885
|
}
|
|
@@ -1818,38 +1904,38 @@ export class OMEZarrNVImage extends NVImage {
|
|
|
1818
1904
|
worldCoord: [number, number, number],
|
|
1819
1905
|
trigger: PopulateTrigger,
|
|
1820
1906
|
): Promise<void> {
|
|
1821
|
-
const slabState = this._slabBuffers.get(sliceType)
|
|
1822
|
-
if (!slabState) return
|
|
1907
|
+
const slabState = this._slabBuffers.get(sliceType)
|
|
1908
|
+
if (!slabState) return
|
|
1823
1909
|
|
|
1824
1910
|
if (slabState.isLoading) {
|
|
1825
1911
|
// Queue this request (latest wins) — auto-drained when current load finishes
|
|
1826
|
-
slabState.pendingReload = { worldCoord, trigger }
|
|
1912
|
+
slabState.pendingReload = { worldCoord, trigger }
|
|
1827
1913
|
// Abort the in-flight progressive load so it finishes faster
|
|
1828
|
-
const controller = this._slabAbortControllers.get(sliceType)
|
|
1829
|
-
if (controller) controller.abort()
|
|
1830
|
-
return
|
|
1914
|
+
const controller = this._slabAbortControllers.get(sliceType)
|
|
1915
|
+
if (controller) controller.abort()
|
|
1916
|
+
return
|
|
1831
1917
|
}
|
|
1832
1918
|
|
|
1833
|
-
slabState.isLoading = true
|
|
1834
|
-
slabState.pendingReload = null
|
|
1919
|
+
slabState.isLoading = true
|
|
1920
|
+
slabState.pendingReload = null
|
|
1835
1921
|
|
|
1836
1922
|
// Create an AbortController for this load so it can be cancelled if a
|
|
1837
1923
|
// newer request arrives while we're still fetching intermediate levels.
|
|
1838
|
-
const abortController = new AbortController()
|
|
1839
|
-
this._slabAbortControllers.set(sliceType, abortController)
|
|
1924
|
+
const abortController = new AbortController()
|
|
1925
|
+
this._slabAbortControllers.set(sliceType, abortController)
|
|
1840
1926
|
|
|
1841
1927
|
this._emitEvent("slabLoadingStart", {
|
|
1842
1928
|
sliceType,
|
|
1843
1929
|
levelIndex: slabState.targetLevelIndex,
|
|
1844
1930
|
trigger,
|
|
1845
|
-
})
|
|
1931
|
+
})
|
|
1846
1932
|
|
|
1847
1933
|
try {
|
|
1848
|
-
const orthAxis = this._getOrthogonalAxis(sliceType)
|
|
1934
|
+
const orthAxis = this._getOrthogonalAxis(sliceType)
|
|
1849
1935
|
|
|
1850
1936
|
// Recompute target resolution using 2D pixel budget with per-slab viewport bounds
|
|
1851
|
-
const slabViewportBounds =
|
|
1852
|
-
undefined
|
|
1937
|
+
const slabViewportBounds =
|
|
1938
|
+
this._viewportBoundsPerSlab.get(sliceType) ?? undefined
|
|
1853
1939
|
const selection = select2DResolution(
|
|
1854
1940
|
this.multiscales,
|
|
1855
1941
|
this.maxPixels,
|
|
@@ -1857,21 +1943,21 @@ export class OMEZarrNVImage extends NVImage {
|
|
|
1857
1943
|
this._volumeBounds,
|
|
1858
1944
|
orthAxis,
|
|
1859
1945
|
slabViewportBounds,
|
|
1860
|
-
)
|
|
1861
|
-
slabState.targetLevelIndex = selection.levelIndex
|
|
1946
|
+
)
|
|
1947
|
+
slabState.targetLevelIndex = selection.levelIndex
|
|
1862
1948
|
|
|
1863
|
-
const numLevels = this.multiscales.images.length
|
|
1864
|
-
const lowestLevel = numLevels - 1
|
|
1949
|
+
const numLevels = this.multiscales.images.length
|
|
1950
|
+
const lowestLevel = numLevels - 1
|
|
1865
1951
|
|
|
1866
1952
|
// For viewport-triggered reloads, skip progressive rendering — jump
|
|
1867
1953
|
// straight to the target level. The user already sees the previous
|
|
1868
1954
|
// resolution, so a single update is smoother than replaying the full
|
|
1869
1955
|
// progressive sequence which causes visual flicker during rapid
|
|
1870
1956
|
// zoom/pan interactions.
|
|
1871
|
-
const skipProgressive = trigger === "viewportChanged"
|
|
1957
|
+
const skipProgressive = trigger === "viewportChanged"
|
|
1872
1958
|
const startLevel = skipProgressive
|
|
1873
1959
|
? slabState.targetLevelIndex
|
|
1874
|
-
: lowestLevel
|
|
1960
|
+
: lowestLevel
|
|
1875
1961
|
|
|
1876
1962
|
for (
|
|
1877
1963
|
let level = startLevel;
|
|
@@ -1879,7 +1965,7 @@ export class OMEZarrNVImage extends NVImage {
|
|
|
1879
1965
|
level--
|
|
1880
1966
|
) {
|
|
1881
1967
|
// Check if this load has been superseded by a newer request
|
|
1882
|
-
if (abortController.signal.aborted) break
|
|
1968
|
+
if (abortController.signal.aborted) break
|
|
1883
1969
|
|
|
1884
1970
|
await this._loadSlabAtLevel(
|
|
1885
1971
|
slabState,
|
|
@@ -1888,23 +1974,23 @@ export class OMEZarrNVImage extends NVImage {
|
|
|
1888
1974
|
level,
|
|
1889
1975
|
orthAxis,
|
|
1890
1976
|
trigger,
|
|
1891
|
-
)
|
|
1977
|
+
)
|
|
1892
1978
|
|
|
1893
1979
|
// Check again after the async fetch completes
|
|
1894
|
-
if (abortController.signal.aborted) break
|
|
1980
|
+
if (abortController.signal.aborted) break
|
|
1895
1981
|
|
|
1896
|
-
slabState.levelIndex = level
|
|
1982
|
+
slabState.levelIndex = level
|
|
1897
1983
|
|
|
1898
1984
|
// Yield to the browser so the current level is actually painted before
|
|
1899
1985
|
// we start fetching the next (higher-resolution) level.
|
|
1900
1986
|
if (level > slabState.targetLevelIndex) {
|
|
1901
1987
|
await new Promise<void>((resolve) =>
|
|
1902
|
-
requestAnimationFrame(() => resolve())
|
|
1903
|
-
)
|
|
1988
|
+
requestAnimationFrame(() => resolve()),
|
|
1989
|
+
)
|
|
1904
1990
|
}
|
|
1905
1991
|
}
|
|
1906
1992
|
} finally {
|
|
1907
|
-
slabState.isLoading = false
|
|
1993
|
+
slabState.isLoading = false
|
|
1908
1994
|
|
|
1909
1995
|
this._emitEvent("slabLoadingComplete", {
|
|
1910
1996
|
sliceType,
|
|
@@ -1912,11 +1998,11 @@ export class OMEZarrNVImage extends NVImage {
|
|
|
1912
1998
|
slabStart: slabState.slabStart,
|
|
1913
1999
|
slabEnd: slabState.slabEnd,
|
|
1914
2000
|
trigger,
|
|
1915
|
-
})
|
|
2001
|
+
})
|
|
1916
2002
|
|
|
1917
2003
|
// Auto-drain: if a newer request was queued while we were loading,
|
|
1918
2004
|
// start it now (like populateVolume's handlePendingPopulateRequest).
|
|
1919
|
-
this._handlePendingSlabReload(sliceType)
|
|
2005
|
+
this._handlePendingSlabReload(sliceType)
|
|
1920
2006
|
}
|
|
1921
2007
|
}
|
|
1922
2008
|
|
|
@@ -1925,13 +2011,13 @@ export class OMEZarrNVImage extends NVImage {
|
|
|
1925
2011
|
* Mirrors populateVolume's handlePendingPopulateRequest pattern.
|
|
1926
2012
|
*/
|
|
1927
2013
|
private _handlePendingSlabReload(sliceType: SlabSliceType): void {
|
|
1928
|
-
const slabState = this._slabBuffers.get(sliceType)
|
|
1929
|
-
if (!slabState) return
|
|
2014
|
+
const slabState = this._slabBuffers.get(sliceType)
|
|
2015
|
+
if (!slabState) return
|
|
1930
2016
|
|
|
1931
|
-
const pending = slabState.pendingReload
|
|
2017
|
+
const pending = slabState.pendingReload
|
|
1932
2018
|
if (pending) {
|
|
1933
|
-
slabState.pendingReload = null
|
|
1934
|
-
void this._loadSlab(sliceType, pending.worldCoord, pending.trigger)
|
|
2019
|
+
slabState.pendingReload = null
|
|
2020
|
+
void this._loadSlab(sliceType, pending.worldCoord, pending.trigger)
|
|
1935
2021
|
}
|
|
1936
2022
|
}
|
|
1937
2023
|
|
|
@@ -1946,21 +2032,18 @@ export class OMEZarrNVImage extends NVImage {
|
|
|
1946
2032
|
orthAxis: OrthogonalAxis,
|
|
1947
2033
|
_trigger: PopulateTrigger,
|
|
1948
2034
|
): Promise<void> {
|
|
1949
|
-
const ngffImage = this.multiscales.images[levelIndex]
|
|
1950
|
-
const chunkShape = getChunkShape(ngffImage)
|
|
1951
|
-
const volumeShape = getVolumeShape(ngffImage)
|
|
2035
|
+
const ngffImage = this.multiscales.images[levelIndex]
|
|
2036
|
+
const chunkShape = getChunkShape(ngffImage)
|
|
2037
|
+
const volumeShape = getVolumeShape(ngffImage)
|
|
1952
2038
|
|
|
1953
2039
|
// Convert world position to pixel position at this level
|
|
1954
|
-
const pixelCoord = worldToPixel(worldCoord, ngffImage)
|
|
1955
|
-
const orthPixel = pixelCoord[orthAxis]
|
|
2040
|
+
const pixelCoord = worldToPixel(worldCoord, ngffImage)
|
|
2041
|
+
const orthPixel = pixelCoord[orthAxis]
|
|
1956
2042
|
|
|
1957
2043
|
// Find the chunk-aligned slab in the orthogonal axis
|
|
1958
|
-
const chunkSize = chunkShape[orthAxis]
|
|
1959
|
-
const slabStart = Math.max(
|
|
1960
|
-
|
|
1961
|
-
Math.floor(orthPixel / chunkSize) * chunkSize,
|
|
1962
|
-
);
|
|
1963
|
-
const slabEnd = Math.min(slabStart + chunkSize, volumeShape[orthAxis]);
|
|
2044
|
+
const chunkSize = chunkShape[orthAxis]
|
|
2045
|
+
const slabStart = Math.max(0, Math.floor(orthPixel / chunkSize) * chunkSize)
|
|
2046
|
+
const slabEnd = Math.min(slabStart + chunkSize, volumeShape[orthAxis])
|
|
1964
2047
|
|
|
1965
2048
|
// Get the full in-plane region (respecting clip planes only).
|
|
1966
2049
|
// Viewport bounds are intentionally NOT passed here — they are used only
|
|
@@ -1971,46 +2054,47 @@ export class OMEZarrNVImage extends NVImage {
|
|
|
1971
2054
|
this._clipPlanes,
|
|
1972
2055
|
this._volumeBounds,
|
|
1973
2056
|
ngffImage,
|
|
1974
|
-
)
|
|
1975
|
-
const alignedRegion = alignToChunks(pixelRegion, ngffImage)
|
|
2057
|
+
)
|
|
2058
|
+
const alignedRegion = alignToChunks(pixelRegion, ngffImage)
|
|
1976
2059
|
|
|
1977
2060
|
// Override the orthogonal axis with our slab extent
|
|
1978
2061
|
const fetchStart: [number, number, number] = [
|
|
1979
2062
|
alignedRegion.chunkAlignedStart[0],
|
|
1980
2063
|
alignedRegion.chunkAlignedStart[1],
|
|
1981
2064
|
alignedRegion.chunkAlignedStart[2],
|
|
1982
|
-
]
|
|
2065
|
+
]
|
|
1983
2066
|
const fetchEnd: [number, number, number] = [
|
|
1984
2067
|
alignedRegion.chunkAlignedEnd[0],
|
|
1985
2068
|
alignedRegion.chunkAlignedEnd[1],
|
|
1986
2069
|
alignedRegion.chunkAlignedEnd[2],
|
|
1987
|
-
]
|
|
1988
|
-
fetchStart[orthAxis] = slabStart
|
|
1989
|
-
fetchEnd[orthAxis] = slabEnd
|
|
2070
|
+
]
|
|
2071
|
+
fetchStart[orthAxis] = slabStart
|
|
2072
|
+
fetchEnd[orthAxis] = slabEnd
|
|
1990
2073
|
|
|
1991
2074
|
const fetchedShape: [number, number, number] = [
|
|
1992
2075
|
fetchEnd[0] - fetchStart[0],
|
|
1993
2076
|
fetchEnd[1] - fetchStart[1],
|
|
1994
2077
|
fetchEnd[2] - fetchStart[2],
|
|
1995
|
-
]
|
|
2078
|
+
]
|
|
1996
2079
|
|
|
1997
2080
|
// Fetch the data
|
|
1998
|
-
const fetchRegion: PixelRegion = { start: fetchStart, end: fetchEnd }
|
|
2081
|
+
const fetchRegion: PixelRegion = { start: fetchStart, end: fetchEnd }
|
|
1999
2082
|
const result = await this.coalescer.fetchRegion(
|
|
2000
2083
|
ngffImage,
|
|
2001
2084
|
levelIndex,
|
|
2002
2085
|
fetchRegion,
|
|
2003
2086
|
`slab-${SLICE_TYPE[sliceType]}-${levelIndex}`,
|
|
2004
|
-
)
|
|
2087
|
+
)
|
|
2005
2088
|
|
|
2006
2089
|
// Resize buffer and copy data
|
|
2007
|
-
const targetData = slabState.bufferManager.resize(fetchedShape)
|
|
2008
|
-
targetData.set(result.data)
|
|
2009
|
-
slabState.nvImage.img =
|
|
2090
|
+
const targetData = slabState.bufferManager.resize(fetchedShape)
|
|
2091
|
+
targetData.set(result.data)
|
|
2092
|
+
slabState.nvImage.img =
|
|
2093
|
+
slabState.bufferManager.getTypedArray() as NVImage["img"]
|
|
2010
2094
|
|
|
2011
2095
|
// Update slab position tracking
|
|
2012
|
-
slabState.slabStart = slabStart
|
|
2013
|
-
slabState.slabEnd = slabEnd
|
|
2096
|
+
slabState.slabStart = slabStart
|
|
2097
|
+
slabState.slabEnd = slabEnd
|
|
2014
2098
|
|
|
2015
2099
|
// Update the NVImage header for this slab region
|
|
2016
2100
|
this._updateSlabHeader(
|
|
@@ -2019,31 +2103,34 @@ export class OMEZarrNVImage extends NVImage {
|
|
|
2019
2103
|
fetchStart,
|
|
2020
2104
|
fetchEnd,
|
|
2021
2105
|
fetchedShape,
|
|
2022
|
-
)
|
|
2023
|
-
|
|
2024
|
-
|
|
2025
|
-
|
|
2026
|
-
this.
|
|
2106
|
+
)
|
|
2107
|
+
|
|
2108
|
+
if (this.isLabelImage) {
|
|
2109
|
+
// Label images: apply discrete colormap to the slab NVImage
|
|
2110
|
+
this._applyLabelColormap(slabState.nvImage, result.data)
|
|
2111
|
+
} else if (this._omero) {
|
|
2112
|
+
// Apply OMERO metadata if available
|
|
2113
|
+
this._applyOmeroToSlabHeader(slabState.nvImage)
|
|
2027
2114
|
}
|
|
2028
2115
|
|
|
2029
2116
|
// Reset global_min so NiiVue recalculates intensity ranges
|
|
2030
|
-
slabState.nvImage.global_min = undefined
|
|
2117
|
+
slabState.nvImage.global_min = undefined
|
|
2031
2118
|
|
|
2032
2119
|
// Compute the normalization scale used by _updateSlabHeader so we can
|
|
2033
2120
|
// convert the world coordinate into the slab's normalized mm space.
|
|
2034
|
-
const scale = ngffImage.scale
|
|
2121
|
+
const scale = ngffImage.scale
|
|
2035
2122
|
const maxVoxelSize = Math.max(
|
|
2036
2123
|
scale.x ?? scale.X ?? 1,
|
|
2037
2124
|
scale.y ?? scale.Y ?? 1,
|
|
2038
2125
|
scale.z ?? scale.Z ?? 1,
|
|
2039
|
-
)
|
|
2040
|
-
const normalizationScale = maxVoxelSize > 0 ? 1.0 / maxVoxelSize : 1.0
|
|
2041
|
-
slabState.normalizationScale = normalizationScale
|
|
2126
|
+
)
|
|
2127
|
+
const normalizationScale = maxVoxelSize > 0 ? 1.0 / maxVoxelSize : 1.0
|
|
2128
|
+
slabState.normalizationScale = normalizationScale
|
|
2042
2129
|
const normalizedMM: [number, number, number] = [
|
|
2043
2130
|
worldCoord[0] * normalizationScale,
|
|
2044
2131
|
worldCoord[1] * normalizationScale,
|
|
2045
2132
|
worldCoord[2] * normalizationScale,
|
|
2046
|
-
]
|
|
2133
|
+
]
|
|
2047
2134
|
|
|
2048
2135
|
// Refresh all NV instances using this slice type
|
|
2049
2136
|
for (const [attachedNv, attachedState] of this._attachedNiivues) {
|
|
@@ -2053,24 +2140,26 @@ export class OMEZarrNVImage extends NVImage {
|
|
|
2053
2140
|
) {
|
|
2054
2141
|
// Ensure this NV has the slab volume
|
|
2055
2142
|
if (attachedNv.volumes.includes(slabState.nvImage)) {
|
|
2056
|
-
attachedNv.updateGLVolume()
|
|
2143
|
+
attachedNv.updateGLVolume()
|
|
2057
2144
|
|
|
2058
|
-
|
|
2059
|
-
|
|
2060
|
-
|
|
2145
|
+
if (!this.isLabelImage) {
|
|
2146
|
+
// Widen the display window if actual data exceeds the OMERO range.
|
|
2147
|
+
// Must run after updateGLVolume() which computes global_min/global_max.
|
|
2148
|
+
this._widenCalRangeIfNeeded(slabState.nvImage)
|
|
2149
|
+
}
|
|
2061
2150
|
|
|
2062
2151
|
// Position the crosshair at the correct slice within this slab.
|
|
2063
2152
|
// Without this, NiiVue defaults to the center of the slab which
|
|
2064
2153
|
// corresponds to different physical positions at each resolution level.
|
|
2065
|
-
const frac = attachedNv.mm2frac(normalizedMM)
|
|
2154
|
+
const frac = attachedNv.mm2frac(normalizedMM)
|
|
2066
2155
|
// Clamp to [0,1] — when viewport-aware mode constrains the slab to
|
|
2067
2156
|
// a subregion, the crosshair world position may be outside the slab's
|
|
2068
2157
|
// spatial extent, causing mm2frac to return out-of-range values.
|
|
2069
|
-
frac[0] = Math.max(0, Math.min(1, frac[0]))
|
|
2070
|
-
frac[1] = Math.max(0, Math.min(1, frac[1]))
|
|
2071
|
-
frac[2] = Math.max(0, Math.min(1, frac[2]))
|
|
2072
|
-
attachedNv.scene.crosshairPos = frac
|
|
2073
|
-
attachedNv.drawScene()
|
|
2158
|
+
frac[0] = Math.max(0, Math.min(1, frac[0]))
|
|
2159
|
+
frac[1] = Math.max(0, Math.min(1, frac[1]))
|
|
2160
|
+
frac[2] = Math.max(0, Math.min(1, frac[2]))
|
|
2161
|
+
attachedNv.scene.crosshairPos = frac
|
|
2162
|
+
attachedNv.drawScene()
|
|
2074
2163
|
}
|
|
2075
2164
|
}
|
|
2076
2165
|
}
|
|
@@ -2086,25 +2175,25 @@ export class OMEZarrNVImage extends NVImage {
|
|
|
2086
2175
|
_fetchEnd: [number, number, number],
|
|
2087
2176
|
fetchedShape: [number, number, number],
|
|
2088
2177
|
): void {
|
|
2089
|
-
if (!nvImage.hdr) return
|
|
2178
|
+
if (!nvImage.hdr) return
|
|
2090
2179
|
|
|
2091
|
-
const scale = ngffImage.scale
|
|
2092
|
-
const sx = scale.x ?? scale.X ?? 1
|
|
2093
|
-
const sy = scale.y ?? scale.Y ?? 1
|
|
2094
|
-
const sz = scale.z ?? scale.Z ?? 1
|
|
2180
|
+
const scale = ngffImage.scale
|
|
2181
|
+
const sx = scale.x ?? scale.X ?? 1
|
|
2182
|
+
const sy = scale.y ?? scale.Y ?? 1
|
|
2183
|
+
const sz = scale.z ?? scale.Z ?? 1
|
|
2095
2184
|
|
|
2096
2185
|
// NiiVue's 2D slice renderer has precision issues when voxel sizes are
|
|
2097
2186
|
// very small (e.g. OME-Zarr datasets in meters where pixDims ~ 2e-5).
|
|
2098
2187
|
// Since the slab NVImage is rendered independently in its own Niivue
|
|
2099
2188
|
// instance, we can normalize coordinates to ~1mm voxels without affecting
|
|
2100
2189
|
// the 3D render. We scale uniformly to preserve aspect ratio.
|
|
2101
|
-
const maxVoxelSize = Math.max(sx, sy, sz)
|
|
2102
|
-
const normalizationScale = maxVoxelSize > 0 ? 1.0 / maxVoxelSize : 1.0
|
|
2103
|
-
const nsx = sx * normalizationScale
|
|
2104
|
-
const nsy = sy * normalizationScale
|
|
2105
|
-
const nsz = sz * normalizationScale
|
|
2190
|
+
const maxVoxelSize = Math.max(sx, sy, sz)
|
|
2191
|
+
const normalizationScale = maxVoxelSize > 0 ? 1.0 / maxVoxelSize : 1.0
|
|
2192
|
+
const nsx = sx * normalizationScale
|
|
2193
|
+
const nsy = sy * normalizationScale
|
|
2194
|
+
const nsz = sz * normalizationScale
|
|
2106
2195
|
|
|
2107
|
-
nvImage.hdr.pixDims = [1, nsx, nsy, nsz, 0, 0, 0, 0]
|
|
2196
|
+
nvImage.hdr.pixDims = [1, nsx, nsy, nsz, 0, 0, 0, 0]
|
|
2108
2197
|
// NIfTI dims: [ndim, x, y, z, t, ...]
|
|
2109
2198
|
nvImage.hdr.dims = [
|
|
2110
2199
|
3,
|
|
@@ -2115,51 +2204,51 @@ export class OMEZarrNVImage extends NVImage {
|
|
|
2115
2204
|
1,
|
|
2116
2205
|
1,
|
|
2117
2206
|
1,
|
|
2118
|
-
]
|
|
2207
|
+
]
|
|
2119
2208
|
|
|
2120
2209
|
// Build affine with offset for region start, then normalize
|
|
2121
|
-
const affine = createAffineFromNgffImage(ngffImage)
|
|
2210
|
+
const affine = createAffineFromNgffImage(ngffImage)
|
|
2122
2211
|
// Adjust translation for region offset (fetchStart is [z, y, x])
|
|
2123
|
-
affine[12] += fetchStart[2] * sx
|
|
2124
|
-
affine[13] += fetchStart[1] * sy
|
|
2125
|
-
affine[14] += fetchStart[0] * sz
|
|
2212
|
+
affine[12] += fetchStart[2] * sx // x offset
|
|
2213
|
+
affine[13] += fetchStart[1] * sy // y offset
|
|
2214
|
+
affine[14] += fetchStart[0] * sz // z offset
|
|
2126
2215
|
|
|
2127
2216
|
// Apply normalization to the entire affine (scale columns + translation)
|
|
2128
2217
|
for (let i = 0; i < 15; i++) {
|
|
2129
|
-
affine[i] *= normalizationScale
|
|
2218
|
+
affine[i] *= normalizationScale
|
|
2130
2219
|
}
|
|
2131
2220
|
// affine[15] stays 1
|
|
2132
2221
|
|
|
2133
|
-
const srows = affineToNiftiSrows(affine)
|
|
2222
|
+
const srows = affineToNiftiSrows(affine)
|
|
2134
2223
|
nvImage.hdr.affine = [
|
|
2135
2224
|
srows.srow_x,
|
|
2136
2225
|
srows.srow_y,
|
|
2137
2226
|
srows.srow_z,
|
|
2138
2227
|
[0, 0, 0, 1],
|
|
2139
|
-
]
|
|
2228
|
+
]
|
|
2140
2229
|
|
|
2141
|
-
nvImage.hdr.sform_code = 1
|
|
2142
|
-
nvImage.calculateRAS()
|
|
2230
|
+
nvImage.hdr.sform_code = 1
|
|
2231
|
+
nvImage.calculateRAS()
|
|
2143
2232
|
}
|
|
2144
2233
|
|
|
2145
2234
|
/**
|
|
2146
2235
|
* Apply OMERO metadata to a slab NVImage header.
|
|
2147
2236
|
*/
|
|
2148
2237
|
private _applyOmeroToSlabHeader(nvImage: NVImage): void {
|
|
2149
|
-
if (!nvImage.hdr || !this._omero?.channels?.length) return
|
|
2238
|
+
if (!nvImage.hdr || !this._omero?.channels?.length) return
|
|
2150
2239
|
|
|
2151
2240
|
const channelIndex = Math.min(
|
|
2152
2241
|
this._activeChannel,
|
|
2153
2242
|
this._omero.channels.length - 1,
|
|
2154
|
-
)
|
|
2155
|
-
const channel = this._omero.channels[channelIndex]
|
|
2156
|
-
const window = channel?.window
|
|
2243
|
+
)
|
|
2244
|
+
const channel = this._omero.channels[channelIndex]
|
|
2245
|
+
const window = channel?.window
|
|
2157
2246
|
|
|
2158
2247
|
if (window) {
|
|
2159
|
-
const calMin = window.start ?? window.min
|
|
2160
|
-
const calMax = window.end ?? window.max
|
|
2161
|
-
if (calMin !== undefined) nvImage.hdr.cal_min = calMin
|
|
2162
|
-
if (calMax !== undefined) nvImage.hdr.cal_max = calMax
|
|
2248
|
+
const calMin = window.start ?? window.min
|
|
2249
|
+
const calMax = window.end ?? window.max
|
|
2250
|
+
if (calMin !== undefined) nvImage.hdr.cal_min = calMin
|
|
2251
|
+
if (calMax !== undefined) nvImage.hdr.cal_max = calMax
|
|
2163
2252
|
}
|
|
2164
2253
|
}
|
|
2165
2254
|
|
|
@@ -2184,31 +2273,25 @@ export class OMEZarrNVImage extends NVImage {
|
|
|
2184
2273
|
*/
|
|
2185
2274
|
private _widenCalRangeIfNeeded(nvImage: NVImage): boolean {
|
|
2186
2275
|
if (nvImage.global_min === undefined || nvImage.global_max === undefined) {
|
|
2187
|
-
return false
|
|
2276
|
+
return false
|
|
2188
2277
|
}
|
|
2189
2278
|
|
|
2190
|
-
let widened = false
|
|
2279
|
+
let widened = false
|
|
2191
2280
|
|
|
2192
2281
|
// Widen the runtime display range (cal_min/cal_max) to encompass the
|
|
2193
2282
|
// actual data extremes (global_min/global_max) at this resolution level.
|
|
2194
2283
|
// The hdr values are NOT modified so the original OMERO window is
|
|
2195
2284
|
// preserved for next reload.
|
|
2196
|
-
if (
|
|
2197
|
-
nvImage.cal_max
|
|
2198
|
-
|
|
2199
|
-
) {
|
|
2200
|
-
nvImage.cal_max = nvImage.global_max;
|
|
2201
|
-
widened = true;
|
|
2285
|
+
if (nvImage.cal_max !== undefined && nvImage.global_max > nvImage.cal_max) {
|
|
2286
|
+
nvImage.cal_max = nvImage.global_max
|
|
2287
|
+
widened = true
|
|
2202
2288
|
}
|
|
2203
|
-
if (
|
|
2204
|
-
nvImage.cal_min
|
|
2205
|
-
|
|
2206
|
-
) {
|
|
2207
|
-
nvImage.cal_min = nvImage.global_min;
|
|
2208
|
-
widened = true;
|
|
2289
|
+
if (nvImage.cal_min !== undefined && nvImage.global_min < nvImage.cal_min) {
|
|
2290
|
+
nvImage.cal_min = nvImage.global_min
|
|
2291
|
+
widened = true
|
|
2209
2292
|
}
|
|
2210
2293
|
|
|
2211
|
-
return widened
|
|
2294
|
+
return widened
|
|
2212
2295
|
}
|
|
2213
2296
|
|
|
2214
2297
|
// ============================================================
|
|
@@ -2242,11 +2325,7 @@ export class OMEZarrNVImage extends NVImage {
|
|
|
2242
2325
|
listener: OMEZarrNVImageEventListener<K>,
|
|
2243
2326
|
options?: OMEZarrNVImageEventListenerOptions,
|
|
2244
2327
|
): void {
|
|
2245
|
-
this._eventTarget.addEventListener(
|
|
2246
|
-
type,
|
|
2247
|
-
listener as EventListener,
|
|
2248
|
-
options,
|
|
2249
|
-
);
|
|
2328
|
+
this._eventTarget.addEventListener(type, listener as EventListener, options)
|
|
2250
2329
|
}
|
|
2251
2330
|
|
|
2252
2331
|
/**
|
|
@@ -2265,7 +2344,7 @@ export class OMEZarrNVImage extends NVImage {
|
|
|
2265
2344
|
type,
|
|
2266
2345
|
listener as EventListener,
|
|
2267
2346
|
options,
|
|
2268
|
-
)
|
|
2347
|
+
)
|
|
2269
2348
|
}
|
|
2270
2349
|
|
|
2271
2350
|
/**
|
|
@@ -2277,10 +2356,10 @@ export class OMEZarrNVImage extends NVImage {
|
|
|
2277
2356
|
detail: OMEZarrNVImageEventMap[K],
|
|
2278
2357
|
): void {
|
|
2279
2358
|
try {
|
|
2280
|
-
const event = new OMEZarrNVImageEvent(eventName, detail)
|
|
2281
|
-
this._eventTarget.dispatchEvent(event)
|
|
2359
|
+
const event = new OMEZarrNVImageEvent(eventName, detail)
|
|
2360
|
+
this._eventTarget.dispatchEvent(event)
|
|
2282
2361
|
} catch (error) {
|
|
2283
|
-
console.error(`Error in ${eventName} event listener:`, error)
|
|
2362
|
+
console.error(`Error in ${eventName} event listener:`, error)
|
|
2284
2363
|
}
|
|
2285
2364
|
}
|
|
2286
2365
|
}
|