@niivue/niivue 0.67.1-dev.0 → 0.67.1-dev.2

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.
@@ -210,6 +210,8 @@ export type ImageFromUrlOptions = {
210
210
  zarrChannel?: number
211
211
  /** Convert OME spatial units to millimeters for NIfTI compatibility (default: true) */
212
212
  zarrConvertUnits?: boolean
213
+ /** World-space center [x, y, z] in mm where the zarr volume center should be positioned */
214
+ zarrCenterMM?: [number, number, number]
213
215
  }
214
216
 
215
217
  // TODO centralize shared options
@@ -95,9 +95,11 @@ function createTypedVoxelArray(datatypeCode: number, size: number): TypedVoxelAr
95
95
  case NiiDataType.DT_UINT16:
96
96
  return new Uint16Array(size)
97
97
  case NiiDataType.DT_INT16:
98
- case NiiDataType.DT_INT32:
99
98
  return new Int16Array(size)
99
+ case NiiDataType.DT_INT32:
100
+ return new Int32Array(size)
100
101
  case NiiDataType.DT_UINT32:
102
+ return new Uint32Array(size)
101
103
  case NiiDataType.DT_FLOAT32:
102
104
  return new Float32Array(size)
103
105
  case NiiDataType.DT_FLOAT64:
@@ -156,6 +158,7 @@ export class NVZarrHelper {
156
158
  private voxelTranslations: { width: number; height: number; depth: number }
157
159
  private hasTranslations: boolean
158
160
  private convertUnitsToMm: boolean
161
+ private worldOffsetMM: [number, number, number] = [0, 0, 0]
159
162
 
160
163
  private centerX: number
161
164
  private centerY: number
@@ -169,6 +172,7 @@ export class NVZarrHelper {
169
172
  private currentAbortController: AbortController | null = null
170
173
  private runningMin = Infinity
171
174
  private runningMax = -Infinity
175
+ private calibrationDone = false
172
176
 
173
177
  // Debounce state for batching chunk updates
174
178
  private updateDebounceTimer: ReturnType<typeof setTimeout> | null = null
@@ -248,7 +252,7 @@ export class NVZarrHelper {
248
252
  helper.pyramidLevel = level
249
253
  helper.updateLevelInfo()
250
254
 
251
- // Center on the level
255
+ // Center on the level (use levelDims so initial view shows the center of the zarr)
252
256
  helper.centerX = helper.levelDims.width / 2
253
257
  helper.centerY = helper.levelDims.height / 2
254
258
  helper.centerZ = helper.levelDims.depth / 2
@@ -256,12 +260,16 @@ export class NVZarrHelper {
256
260
  // Configure the host NVImage
257
261
  helper.configureHostImage()
258
262
 
259
- // Load initial chunks
260
- await helper.updateVolume()
263
+ // Don't load chunks here — loadInitialChunks() must be called after
264
+ // the onChunksUpdated callback is registered (see addVolume in Niivue).
261
265
 
262
266
  return helper
263
267
  }
264
268
 
269
+ async loadInitialChunks(): Promise<void> {
270
+ await this.updateVolume()
271
+ }
272
+
265
273
  private updateLevelInfo(): void {
266
274
  const levelInfo = this.pyramidInfo.levels[this.pyramidLevel]
267
275
  const shape = levelInfo.shape
@@ -352,12 +360,12 @@ export class NVZarrHelper {
352
360
  // Store original affine so resetVolumeAffine() works on zarr volumes
353
361
  img.originalAffine = copyAffine(hdr.affine)
354
362
 
355
- img.cal_min = NaN
356
- img.cal_max = NaN
357
- img.robust_min = NaN
358
- img.robust_max = NaN
359
- img.global_min = NaN
360
- img.global_max = NaN
363
+ img.cal_min = 0
364
+ img.cal_max = 0
365
+ img.robust_min = 0
366
+ img.robust_max = 0
367
+ img.global_min = 0
368
+ img.global_max = 0
361
369
  }
362
370
 
363
371
  /** Get unit-converted voxel scales */
@@ -451,6 +459,9 @@ export class NVZarrHelper {
451
459
  affine[row][col] = niftiCols[col].scale
452
460
  affine[row][3] = niftiCols[col].trans
453
461
  }
462
+ affine[0][3] += this.worldOffsetMM[0]
463
+ affine[1][3] += this.worldOffsetMM[1]
464
+ affine[2][3] += this.worldOffsetMM[2]
454
465
  hdr.affine = affine
455
466
  } else {
456
467
  // No OME translations — calculate offset based on pan position
@@ -481,13 +492,16 @@ export class NVZarrHelper {
481
492
  affine[row][col] = niftiCols[col].scale
482
493
  affine[row][3] = niftiCols[col].trans
483
494
  }
495
+ affine[0][3] += this.worldOffsetMM[0]
496
+ affine[1][3] += this.worldOffsetMM[1]
497
+ affine[2][3] += this.worldOffsetMM[2]
484
498
  hdr.affine = affine
485
499
  } else {
486
500
  // Fallback: simple diagonal affine with pan position
487
501
  hdr.affine = [
488
- [scaleW, 0, 0, volStartW * scaleW],
489
- [0, -scaleH, 0, volStartH * scaleH],
490
- [0, 0, -scaleD, volStartD * scaleD],
502
+ [scaleW, 0, 0, volStartW * scaleW + this.worldOffsetMM[0]],
503
+ [0, -scaleH, 0, volStartH * scaleH + this.worldOffsetMM[1]],
504
+ [0, 0, -scaleD, volStartD * scaleD + this.worldOffsetMM[2]],
491
505
  [0, 0, 0, 1]
492
506
  ]
493
507
  }
@@ -505,6 +519,10 @@ export class NVZarrHelper {
505
519
  this.centerAtDragStart = { x: this.centerX, y: this.centerY, z: this.centerZ }
506
520
  }
507
521
 
522
+ endDrag(): void {
523
+ this.centerAtDragStart = null
524
+ }
525
+
508
526
  async panBy(dx: number, dy: number, dz: number = 0): Promise<void> {
509
527
  this.centerX -= dx
510
528
  this.centerY -= dy
@@ -515,12 +533,22 @@ export class NVZarrHelper {
515
533
  }
516
534
 
517
535
  async panTo(newCenterX: number, newCenterY: number, newCenterZ?: number): Promise<void> {
536
+ const prevX = this.centerX
537
+ const prevY = this.centerY
538
+ const prevZ = this.centerZ
539
+
518
540
  this.centerX = newCenterX
519
541
  this.centerY = newCenterY
520
542
  if (newCenterZ !== undefined) {
521
543
  this.centerZ = newCenterZ
522
544
  }
523
545
  this.clampCenter()
546
+
547
+ // Skip if clamped position is same as before (prevents infinite loop at boundaries)
548
+ if (Math.abs(this.centerX - prevX) < 0.001 && Math.abs(this.centerY - prevY) < 0.001 && Math.abs(this.centerZ - prevZ) < 0.001) {
549
+ return
550
+ }
551
+
524
552
  this.updateAffine()
525
553
  await this.updateVolume()
526
554
  }
@@ -571,6 +599,124 @@ export class NVZarrHelper {
571
599
  return { ...this.volumeDims }
572
600
  }
573
601
 
602
+ getWorldOffset(): [number, number, number] {
603
+ return [...this.worldOffsetMM]
604
+ }
605
+
606
+ /**
607
+ * Set the world-space offset so the full level's center maps to targetMM in world space.
608
+ * Computes the native physical center of the zarr level, then sets worldOffsetMM
609
+ * so that center aligns with targetMM. Also centers the viewport on the level center.
610
+ */
611
+ setWorldCenter(targetMM: [number, number, number]): void {
612
+ const { scaleW, scaleH, scaleD } = this.getConvertedScales()
613
+ const { transW, transH, transD } = this.getConvertedTranslations()
614
+ const axisNames = this.pyramidInfo.axisMapping.spatialAxisNames
615
+
616
+ // Native physical center per OME dim (width=last, height=2nd-to-last, depth=3rd-to-last)
617
+ const physW = (this.levelDims.width / 2) * scaleW + transW
618
+ const physH = (this.levelDims.height / 2) * scaleH + transH
619
+ const physD = (this.levelDims.depth / 2) * scaleD + transD
620
+
621
+ // Map OME dims to x/y/z rows using axis names
622
+ const nativeCenter: [number, number, number] = [0, 0, 0]
623
+ const dims = [
624
+ { name: axisNames[axisNames.length - 1], phys: physW },
625
+ { name: axisNames[axisNames.length - 2], phys: physH },
626
+ { name: axisNames.length >= 3 ? axisNames[axisNames.length - 3] : 'z', phys: physD }
627
+ ]
628
+ for (const d of dims) {
629
+ const row = d.name === 'x' ? 0 : d.name === 'y' ? 1 : 2
630
+ nativeCenter[row] = d.phys
631
+ }
632
+
633
+ this.worldOffsetMM = [targetMM[0] - nativeCenter[0], targetMM[1] - nativeCenter[1], targetMM[2] - nativeCenter[2]]
634
+
635
+ // Center viewport on level center so visible region is at the target
636
+ this.centerX = this.levelDims.width / 2
637
+ this.centerY = this.levelDims.height / 2
638
+ this.centerZ = this.levelDims.depth / 2
639
+ this.clampCenter()
640
+
641
+ this.updateAffine()
642
+ }
643
+
644
+ /**
645
+ * Convert physical (mm) coordinates back to real zarr level pixel coordinates.
646
+ * Inverts the affine: levelPixel = (mm - OME_translation) / scale
647
+ */
648
+ mmToLevelCoords(
649
+ mmX: number,
650
+ mmY: number,
651
+ mmZ: number
652
+ ): {
653
+ width: number
654
+ height: number
655
+ depth: number
656
+ level: number
657
+ levelDims: { width: number; height: number; depth: number }
658
+ } {
659
+ // Subtract world offset to convert from offset world-space back to native zarr space
660
+ mmX -= this.worldOffsetMM[0]
661
+ mmY -= this.worldOffsetMM[1]
662
+ mmZ -= this.worldOffsetMM[2]
663
+
664
+ const { scaleW, scaleH, scaleD } = this.getConvertedScales()
665
+ const axisNames = this.pyramidInfo.axisMapping.spatialAxisNames
666
+
667
+ // Map physical mm (x/y/z) to OME spatial dimensions (width/height/depth)
668
+ // The affine maps NIfTI cols to physical rows by axis name:
669
+ // col 0 (width) → axis[-1], col 1 (height) → axis[-2], col 2 (depth) → axis[-3]
670
+ // We need the reverse: given physical x/y/z, find which OME dim it maps to
671
+ const mmByAxis: Record<string, number> = { x: mmX, y: mmY, z: mmZ }
672
+
673
+ let levelW = 0
674
+ let levelH = 0
675
+ let levelD = 0
676
+
677
+ if (axisNames.length >= 2) {
678
+ // Width maps to last spatial axis name
679
+ const wAxisName = axisNames[axisNames.length - 1]
680
+ const hAxisName = axisNames[axisNames.length - 2]
681
+ const dAxisName = axisNames.length >= 3 ? axisNames[axisNames.length - 3] : 'z'
682
+
683
+ const mmW = mmByAxis[wAxisName] ?? mmX
684
+ const mmH = mmByAxis[hAxisName] ?? mmY
685
+ const mmD = mmByAxis[dAxisName] ?? mmZ
686
+
687
+ if (this.hasTranslations) {
688
+ const { transW, transH, transD } = this.getConvertedTranslations()
689
+ levelW = scaleW !== 0 ? (mmW - transW) / scaleW : 0
690
+ levelH = scaleH !== 0 ? (mmH - transH) / scaleH : 0
691
+ levelD = scaleD !== 0 ? (mmD - transD) / scaleD : 0
692
+ } else {
693
+ levelW = scaleW !== 0 ? mmW / scaleW : 0
694
+ levelH = scaleH !== 0 ? mmH / scaleH : 0
695
+ levelD = scaleD !== 0 ? mmD / scaleD : 0
696
+ }
697
+ } else {
698
+ // Fallback: simple diagonal
699
+ if (this.hasTranslations) {
700
+ const { transW, transH, transD } = this.getConvertedTranslations()
701
+ levelW = scaleW !== 0 ? (mmX - transW) / scaleW : 0
702
+ levelH = scaleH !== 0 ? (mmY - transH) / scaleH : 0
703
+ levelD = scaleD !== 0 ? (mmZ - transD) / scaleD : 0
704
+ } else {
705
+ levelW = scaleW !== 0 ? mmX / scaleW : 0
706
+ levelH = scaleH !== 0 ? mmY / scaleH : 0
707
+ levelD = scaleD !== 0 ? mmZ / scaleD : 0
708
+ }
709
+ }
710
+
711
+ return {
712
+ width: Math.round(levelW),
713
+ height: Math.round(levelH),
714
+ depth: Math.round(levelD),
715
+ level: this.pyramidLevel,
716
+ levelDims: { ...this.levelDims }
717
+ }
718
+ }
719
+
574
720
  private clampCenter(): void {
575
721
  const halfW = this.volumeDims.width / 2
576
722
  const halfH = this.volumeDims.height / 2
@@ -651,9 +797,6 @@ export class NVZarrHelper {
651
797
  const abortController = new AbortController()
652
798
  this.currentAbortController = abortController
653
799
 
654
- this.runningMin = Infinity
655
- this.runningMax = -Infinity
656
-
657
800
  await new Promise<void>((resolve) => {
658
801
  requestAnimationFrame(() => resolve())
659
802
  })
@@ -800,15 +943,17 @@ export class NVZarrHelper {
800
943
  return
801
944
  }
802
945
 
803
- // Track min/max for calibration
804
- const step = Math.max(1, Math.floor(data.length / 2000))
805
- for (let i = 0; i < data.length; i += step) {
806
- const val = data[i]
807
- if (val < this.runningMin) {
808
- this.runningMin = val
809
- }
810
- if (val > this.runningMax) {
811
- this.runningMax = val
946
+ // Track min/max for initial calibration (skipped once calibration is done)
947
+ if (!this.calibrationDone) {
948
+ const step = Math.max(1, Math.floor(data.length / 2000))
949
+ for (let i = 0; i < data.length; i += step) {
950
+ const val = data[i]
951
+ if (val < this.runningMin) {
952
+ this.runningMin = val
953
+ }
954
+ if (val > this.runningMax) {
955
+ this.runningMax = val
956
+ }
812
957
  }
813
958
  }
814
959
 
@@ -849,6 +994,9 @@ export class NVZarrHelper {
849
994
  }
850
995
 
851
996
  private updateCalibration(): void {
997
+ if (this.calibrationDone) {
998
+ return
999
+ }
852
1000
  if (this.runningMin < Infinity && this.runningMax > -Infinity) {
853
1001
  this.hostImage.cal_min = this.runningMin
854
1002
  this.hostImage.cal_max = this.runningMax
@@ -856,6 +1004,7 @@ export class NVZarrHelper {
856
1004
  this.hostImage.robust_max = this.runningMax
857
1005
  this.hostImage.global_min = this.runningMin
858
1006
  this.hostImage.global_max = this.runningMax
1007
+ this.calibrationDone = true
859
1008
  }
860
1009
  }
861
1010
 
@@ -1,152 +0,0 @@
1
- <!doctype html>
2
- <html lang="en">
3
- <head>
4
- <meta charset="utf-8" />
5
- <meta http-equiv="X-UA-Compatible" content="IE=edge" />
6
- <meta name="viewport" content="width=device-width,initial-scale=1.0" />
7
- <title>NiiVue</title>
8
- <link rel="stylesheet" href="niivue.css" />
9
- <link rel="icon" href="data:;base64,iVBORw0KGgo=" />
10
- </head>
11
- <header>
12
- &nbsp;
13
- <label for="dragMode">Drag mode</label>
14
- <select id="dragMode">
15
- <option value="contrast">contrast</option>
16
- <option value="measurement">measurement</option>
17
- <option value="none">none</option>
18
- <option value="pan" selected>pan/zoom</option>
19
- </select>
20
- &nbsp;
21
- <label for="thickSelect">&nbsp; Thickness</label>
22
- <select id="thickSelect">
23
- <option>0.5</option>
24
- <option>1.0</option>
25
- <option>1.5</option>
26
- <option selected>2.0</option>
27
- <option>2.5</option>
28
- <option>3.0</option>
29
- </select>
30
- </header>
31
- <main id="canvas-container">
32
- <div style="display: flex; width: 100%; height: 100%">
33
- <canvas id="gl1"></canvas>
34
- </div>
35
- </main>
36
- <footer>
37
- <label id="statusBar">&nbsp; </label>
38
- </footer>
39
- </html>
40
- <script type="module" async>
41
- import { Niivue, NVImage, NVMesh, NVMeshLoaders, SHOW_RENDER, DRAG_MODE, SLICE_TYPE } from './niivue/index.ts'
42
- thickSelect.onchange = function() {
43
- nv1.setMeshThicknessOn2D(parseFloat(this.options[this.selectedIndex].text))
44
- }
45
- dragMode.onchange = function () {
46
- switch (this.value) {
47
- case "none":
48
- nv1.opts.dragMode = nv1.dragModes.none
49
- break
50
- case "contrast":
51
- nv1.opts.dragMode = nv1.dragModes.contrast
52
- break
53
- case "measurement":
54
- nv1.opts.dragMode = nv1.dragModes.measurement
55
- break
56
- case "pan":
57
- nv1.opts.dragMode = nv1.dragModes.pan
58
- break
59
- }
60
- }
61
- var volumeList1 = [
62
- { url: "../demos/images/fs/brainmask.mgz" },
63
- ]
64
- const onLocationChange = (data) => {
65
- statusBar.innerHTML = data.string
66
- }
67
- let defaults = {
68
- loglevel: 'debug',
69
- show3Dcrosshair: true,
70
- onLocationChange: onLocationChange,
71
- }
72
- var nv1 = new Niivue(defaults)
73
- await nv1.attachToCanvas(gl1)
74
- nv1.opts.multiplanarShowRender = SHOW_RENDER.ALWAYS
75
- nv1.opts.isOrientCube = true
76
- nv1.opts.yoke3Dto2DZoom = true
77
- nv1.opts.dragMode = nv1.dragModes.pan
78
- nv1.opts.crosshairGap = 6
79
- await nv1.loadVolumes(volumeList1)
80
-
81
- await nv1.loadMeshes([
82
- { url: "../demos/images/fs/rh.white", rgba255: [255, 0, 255, 255] },
83
- { url: "../demos/images/fs/rh.pial", rgba255: [0, 255, 64, 255] },
84
- ])
85
- nv1.setMeshShader(nv1.meshes[0].id, 'crosscut')
86
- nv1.setMeshShader(nv1.meshes[1].id, 'crosscut')
87
- nv1.setClipPlane([0.0, 180, 20])
88
-
89
- // Chunk-based random volume data animation
90
- function startChunkAnimation() {
91
- const vol = nv1.volumes[0]
92
- if (!vol || !vol.img) return
93
-
94
- const nx = vol.hdr.dims[1]
95
- const ny = vol.hdr.dims[2]
96
- const nz = vol.hdr.dims[3]
97
- const datatype = vol.hdr.datatypeCode
98
-
99
- const CHUNK_SIZE = 8
100
-
101
- // Pre-compute chunks (only include voxels with value > 0)
102
- const chunks = []
103
- for (let cz = 0; cz < nz; cz += CHUNK_SIZE) {
104
- for (let cy = 0; cy < ny; cy += CHUNK_SIZE) {
105
- for (let cx = 0; cx < nx; cx += CHUNK_SIZE) {
106
- const indices = []
107
- for (let z = cz; z < Math.min(cz + CHUNK_SIZE, nz); z++) {
108
- for (let y = cy; y < Math.min(cy + CHUNK_SIZE, ny); y++) {
109
- for (let x = cx; x < Math.min(cx + CHUNK_SIZE, nx); x++) {
110
- const idx = x + y * nx + z * nx * ny
111
- if (vol.img[idx] > 0) {
112
- indices.push(idx)
113
- }
114
- }
115
- }
116
- }
117
- // Only add chunk if it has non-zero voxels
118
- if (indices.length > 0) {
119
- chunks.push(indices)
120
- }
121
- }
122
- }
123
- }
124
-
125
- // Get random value generator based on data type
126
- function getRandomValue() {
127
- switch (datatype) {
128
- case 2: return Math.floor(Math.random() * 256) // UINT8
129
- case 4: return Math.floor(Math.random() * 65536) - 32768 // INT16
130
- case 512: return Math.floor(Math.random() * 65536) // UINT16
131
- case 16: return Math.random() // FLOAT32
132
- default: return Math.floor(Math.random() * 256)
133
- }
134
- }
135
-
136
- // Animation loop - run as fast as possible
137
- function animate() {
138
- for (const chunkIndices of chunks) {
139
- const value = getRandomValue()
140
- for (const idx of chunkIndices) {
141
- vol.img[idx] = value
142
- }
143
- }
144
- nv1.updateGLVolume()
145
- requestAnimationFrame(animate)
146
- }
147
- requestAnimationFrame(animate)
148
- }
149
-
150
- // Auto-execute on page load
151
- //startChunkAnimation()
152
- </script>