unveil-python 0.5.1__tar.gz → 0.6.0__tar.gz

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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: unveil-python
3
- Version: 0.5.1
3
+ Version: 0.6.0
4
4
  Summary: Implementation of UNVEIL
5
5
  Author-email: Nicolas Delinte <nicolas.delinte@uclouvain.be>
6
6
  License-Expression: GPL-3.0-only
@@ -1,5 +1,5 @@
1
1
  """
2
2
  This package contains an UNVEIL, a lightweight tractography viewer implemented in python."""
3
3
 
4
- __version__ = "0.5.1"
4
+ __version__ = "0.6.0"
5
5
  __author__ = 'Nicolas Delinte'
@@ -14,7 +14,8 @@ from PyQt6.QtWidgets import (QApplication, QWidget, QHBoxLayout, QVBoxLayout,
14
14
  QPushButton, QFileDialog, QCheckBox, QSlider,
15
15
  QLabel, QComboBox, QMainWindow, QLineEdit,
16
16
  QDockWidget, QTreeWidget, QTreeWidgetItem,
17
- QColorDialog, QTabWidget)
17
+ QColorDialog, QTabWidget, QDoubleSpinBox,
18
+ QGroupBox)
18
19
  from PyQt6.QtGui import QAction, QColor
19
20
  from dipy.io.streamline import load_tractogram
20
21
  from matplotlib import pyplot as plt
@@ -22,6 +23,7 @@ from matplotlib.colors import LinearSegmentedColormap
22
23
  from matplotlib.backends.backend_qtagg import FigureCanvasQTAgg
23
24
  from matplotlib.figure import Figure
24
25
  # from unravel.utils import get_streamline_density
26
+ from binama.utils import dilate_atlas_labels
25
27
 
26
28
 
27
29
  def gifti_to_pyvista(gii_path):
@@ -199,7 +201,8 @@ class TrkViewer(QWidget):
199
201
  'Greys', 'Purples', 'Blues', 'Greens', 'Oranges',
200
202
  'Reds', 'bone', 'pink', 'spring', 'summer',
201
203
  'autumn', 'winter', 'cool', 'Wistia', 'hot',
202
- 'afmhot', 'gist_heat', 'copper', 'RdBu', 'RdYlBu',
204
+ 'afmhot', 'gist_heat', 'copper', 'YlOrRd', 'YlOrBr',
205
+ 'RdBu', 'RdYlBu',
203
206
  'RdYlGn', 'Spectral', 'coolwarm', 'bwr', 'seismic',
204
207
  'berlin', 'managua', 'vanimo', 'twilight',
205
208
  'twilight_shifted', 'hsv', 'Pastel1', 'Pastel2',
@@ -253,101 +256,220 @@ class TrkViewer(QWidget):
253
256
  # Left side: Control panel
254
257
  control_widget = QWidget()
255
258
  control_layout = QVBoxLayout(control_widget)
256
- control_widget.setMaximumWidth(250)
259
+ control_widget.setMaximumWidth(260)
257
260
 
258
- self.opacityLabel = QLabel('Opacity:')
259
- control_layout.addWidget(self.opacityLabel)
261
+ # Tractography
262
+ trk_group = QGroupBox("TRK / Tractography")
263
+ trk_layout = QVBoxLayout(trk_group)
260
264
 
265
+ opacity_trk_row = QHBoxLayout()
266
+ opacity_trk_row.setSpacing(5)
267
+ self.opacityLabel = QLabel('Opacity:')
268
+ opacity_trk_row.addWidget(self.opacityLabel)
261
269
  self.opacitySlider = QSlider(Qt.Orientation.Horizontal, self)
262
270
  self.opacitySlider.setMinimum(1)
263
271
  self.opacitySlider.setMaximum(100)
264
272
  self.opacitySlider.setValue(100)
265
273
  self.opacitySlider.sliderReleased.connect(self.update_trk_viewer)
266
- control_layout.addWidget(self.opacitySlider)
267
-
268
- self.showPointsCheckbox = QCheckBox('Show Points')
269
- self.showPointsCheckbox.stateChanged.connect(self.update_trk_viewer)
270
- control_layout.addWidget(self.showPointsCheckbox)
271
-
272
- self.colorMapLabel = QLabel('Tract Color Mode:')
273
- control_layout.addWidget(self.colorMapLabel)
274
+ opacity_trk_row.addWidget(self.opacitySlider)
275
+ trk_layout.addLayout(opacity_trk_row)
274
276
 
277
+ color_mode_trk_row = QHBoxLayout()
278
+ color_mode_trk_row.setSpacing(5)
279
+ self.colorMapLabel = QLabel('Color Mode:')
280
+ color_mode_trk_row.addWidget(self.colorMapLabel)
275
281
  self.colorMapComboBox = QComboBox()
276
282
  self.colorMapComboBox.addItems(['rgb', 'flesh', 'scalar'])
277
283
  self.colorMapComboBox.currentIndexChanged.connect(
278
284
  self.update_trk_viewer)
279
- control_layout.addWidget(self.colorMapComboBox)
280
-
281
- self.tractColorMapLabel = QLabel('Tract Colormap (Scalar):')
282
- control_layout.addWidget(self.tractColorMapLabel)
285
+ color_mode_trk_row.addWidget(self.colorMapComboBox)
286
+ trk_layout.addLayout(color_mode_trk_row)
283
287
 
288
+ colormap_trk_row = QHBoxLayout()
289
+ colormap_trk_row.setSpacing(5)
290
+ self.tractColorMapLabel = QLabel('Colormap (Scalar):')
291
+ colormap_trk_row.addWidget(self.tractColorMapLabel)
284
292
  self.tractColorMapComboBox = QComboBox()
285
293
  self.tractColorMapComboBox.addItems(self.colormap_list)
286
294
  self.tractColorMapComboBox.currentIndexChanged.connect(
287
295
  self.update_trk_viewer)
288
- control_layout.addWidget(self.tractColorMapComboBox)
296
+ colormap_trk_row.addWidget(self.tractColorMapComboBox)
297
+ trk_layout.addLayout(colormap_trk_row)
289
298
 
290
- self.volume_label = QLabel('Volume Opacity')
291
- control_layout.addWidget(self.volume_label)
299
+ self.showPointsCheckbox = QCheckBox('Show Points')
300
+ self.showPointsCheckbox.stateChanged.connect(self.update_trk_viewer)
301
+ trk_layout.addWidget(self.showPointsCheckbox)
302
+
303
+ control_layout.addWidget(trk_group)
304
+
305
+ # Volume
306
+ nii_group = QGroupBox("NIfTI / Volume")
307
+ nii_layout = QVBoxLayout(nii_group)
292
308
 
309
+ opacity_nii_row = QHBoxLayout()
310
+ opacity_nii_row.setSpacing(5)
311
+ self.volume_label = QLabel('Opacity:')
312
+ opacity_nii_row.addWidget(self.volume_label)
293
313
  self.nii_opacitySlider = QSlider(Qt.Orientation.Horizontal, self)
294
314
  self.nii_opacitySlider.setMinimum(0)
295
315
  self.nii_opacitySlider.setMaximum(1000)
296
316
  self.nii_opacitySlider.setValue(45)
297
317
  self.nii_opacitySlider.sliderReleased.connect(self.update_nii_viewer)
298
- control_layout.addWidget(self.nii_opacitySlider)
299
-
300
- self.niiColorMapLabel = QLabel('Global Volume Colormap:')
301
- control_layout.addWidget(self.niiColorMapLabel)
318
+ opacity_nii_row.addWidget(self.nii_opacitySlider)
319
+ nii_layout.addLayout(opacity_nii_row)
302
320
 
321
+ colormap_nii_row = QHBoxLayout()
322
+ colormap_nii_row.setSpacing(5)
323
+ self.niiColorMapLabel = QLabel('Colormap:')
324
+ colormap_nii_row.addWidget(self.niiColorMapLabel)
303
325
  self.niiColorMapComboBox = QComboBox()
304
326
  self.niiColorMapComboBox.addItems(self.colormap_list)
305
327
  self.niiColorMapComboBox.currentIndexChanged.connect(
306
328
  self.on_nii_colormap_changed)
307
- control_layout.addWidget(self.niiColorMapComboBox)
329
+ colormap_nii_row.addWidget(self.niiColorMapComboBox)
330
+ nii_layout.addLayout(colormap_nii_row)
331
+
332
+ nii_range_layout = QHBoxLayout()
333
+
334
+ nii_min_layout = QVBoxLayout()
335
+ self.niiMinLabel = QLabel('Min:')
336
+ self.niiMinSpinBox = QDoubleSpinBox()
337
+ self.niiMinSpinBox.setDecimals(2)
338
+ self.niiMinSpinBox.setRange(-1e6, 1e6)
339
+ self.niiMinSpinBox.setSingleStep(0.1)
340
+ self.niiMinSpinBox.setValue(0.0)
341
+ self.niiMinSpinBox.editingFinished.connect(self.update_nii_viewer)
342
+ nii_min_layout.addWidget(self.niiMinLabel)
343
+ nii_min_layout.addWidget(self.niiMinSpinBox)
344
+
345
+ nii_max_layout = QVBoxLayout()
346
+ self.niiMaxLabel = QLabel('Max:')
347
+ self.niiMaxSpinBox = QDoubleSpinBox()
348
+ self.niiMaxSpinBox.setDecimals(2)
349
+ self.niiMaxSpinBox.setRange(-1e6, 1e6)
350
+ self.niiMaxSpinBox.setSingleStep(0.1)
351
+ self.niiMaxSpinBox.setValue(1.0)
352
+ self.niiMaxSpinBox.editingFinished.connect(self.update_nii_viewer)
353
+ nii_max_layout.addWidget(self.niiMaxLabel)
354
+ nii_max_layout.addWidget(self.niiMaxSpinBox)
355
+
356
+ nii_range_layout.addLayout(nii_min_layout)
357
+ nii_range_layout.addLayout(nii_max_layout)
358
+ nii_layout.addLayout(nii_range_layout)
308
359
 
309
360
  self.showSlicesCheckbox = QCheckBox('Show Slices')
310
361
  self.showSlicesCheckbox.stateChanged.connect(self.update_nii_viewer)
311
- control_layout.addWidget(self.showSlicesCheckbox)
362
+ nii_layout.addWidget(self.showSlicesCheckbox)
312
363
 
313
- self.x_label = QLabel('X')
314
- control_layout.addWidget(self.x_label)
364
+ x_nii_row = QHBoxLayout()
365
+ x_nii_row.setSpacing(5)
366
+ self.x_label = QLabel('X:')
367
+ x_nii_row.addWidget(self.x_label)
315
368
  self.XSlider = QSlider(Qt.Orientation.Horizontal, self)
316
369
  self.XSlider.setMinimum(0)
317
370
  self.XSlider.setMaximum(100)
318
371
  self.XSlider.setValue(100)
319
372
  self.XSlider.sliderReleased.connect(
320
373
  lambda: self._update_slice('x', 'nii_x'))
321
- control_layout.addWidget(self.XSlider)
322
- self.y_label = QLabel('Y')
323
- control_layout.addWidget(self.y_label)
374
+ x_nii_row.addWidget(self.XSlider)
375
+ nii_layout.addLayout(x_nii_row)
376
+ y_nii_row = QHBoxLayout()
377
+ y_nii_row.setSpacing(5)
378
+ self.y_label = QLabel('Y:')
379
+ y_nii_row.addWidget(self.y_label)
324
380
  self.YSlider = QSlider(Qt.Orientation.Horizontal, self)
325
381
  self.YSlider.setMinimum(0)
326
382
  self.YSlider.setMaximum(100)
327
383
  self.YSlider.setValue(100)
328
384
  self.YSlider.sliderReleased.connect(
329
385
  lambda: self._update_slice('y', 'nii_y'))
330
- control_layout.addWidget(self.YSlider)
331
- self.z_label = QLabel('Z')
332
- control_layout.addWidget(self.z_label)
386
+ y_nii_row.addWidget(self.YSlider)
387
+ nii_layout.addLayout(y_nii_row)
388
+ z_nii_row = QHBoxLayout()
389
+ z_nii_row.setSpacing(5)
390
+ self.z_label = QLabel('Z:')
391
+ z_nii_row.addWidget(self.z_label)
333
392
  self.ZSlider = QSlider(Qt.Orientation.Horizontal, self)
334
393
  self.ZSlider.setMinimum(0)
335
394
  self.ZSlider.setMaximum(100)
336
395
  self.ZSlider.setValue(100)
337
396
  self.ZSlider.sliderReleased.connect(
338
397
  lambda: self._update_slice('z', 'nii_z'))
339
- control_layout.addWidget(self.ZSlider)
398
+ z_nii_row.addWidget(self.ZSlider)
399
+ nii_layout.addLayout(z_nii_row)
400
+
401
+ control_layout.addWidget(nii_group)
340
402
 
341
403
  # Gifti
342
- self.surface_label = QLabel('Surface')
343
- control_layout.addWidget(self.surface_label)
404
+ gii_group = QGroupBox("GIFTI / Surface")
405
+ gii_layout = QVBoxLayout(gii_group)
344
406
 
407
+ opacity_gii_row = QHBoxLayout()
408
+ opacity_gii_row.setSpacing(5)
409
+ self.gii_opacityLabel = QLabel('Opacity:')
410
+ opacity_gii_row.addWidget(self.gii_opacityLabel)
345
411
  self.gii_opacitySlider = QSlider(Qt.Orientation.Horizontal, self)
346
412
  self.gii_opacitySlider.setMinimum(0)
347
413
  self.gii_opacitySlider.setMaximum(100)
348
414
  self.gii_opacitySlider.setValue(15)
349
415
  self.gii_opacitySlider.sliderReleased.connect(self.update_gii_viewer)
350
- control_layout.addWidget(self.gii_opacitySlider)
416
+ opacity_gii_row.addWidget(self.gii_opacitySlider)
417
+ gii_layout.addLayout(opacity_gii_row)
418
+
419
+ color_mode_gii_row = QHBoxLayout()
420
+ color_mode_gii_row.setSpacing(5)
421
+ self.giiColorModeLabel = QLabel('Color Mode:')
422
+ color_mode_gii_row.addWidget(self.giiColorModeLabel)
423
+ self.giiColorModeComboBox = QComboBox()
424
+ self.giiColorModeComboBox.addItems(['Solid', 'NIfTI values'])
425
+ self.giiColorModeComboBox.currentIndexChanged.connect(
426
+ self.update_gii_viewer)
427
+ color_mode_gii_row.addWidget(self.giiColorModeComboBox)
428
+ gii_layout.addLayout(color_mode_gii_row)
429
+
430
+ colormap_gii_row = QHBoxLayout()
431
+ colormap_gii_row.setSpacing(5)
432
+ self.giiColorMapLabel = QLabel('Colormap:')
433
+ colormap_gii_row.addWidget(self.giiColorMapLabel)
434
+ self.giiColorMapComboBox = QComboBox()
435
+ self.giiColorMapComboBox.addItems(self.colormap_list)
436
+ self.giiColorMapComboBox.setCurrentText('viridis')
437
+ self.giiColorMapComboBox.currentIndexChanged.connect(
438
+ self.update_gii_viewer)
439
+ colormap_gii_row.addWidget(self.giiColorMapComboBox)
440
+ gii_layout.addLayout(colormap_gii_row)
441
+
442
+ gii_range_layout = QHBoxLayout()
443
+
444
+ gii_min_layout = QVBoxLayout()
445
+ self.giiMinLabel = QLabel('Min:')
446
+ self.giiMinSpinBox = QDoubleSpinBox()
447
+ self.giiMinSpinBox.setDecimals(2)
448
+ self.giiMinSpinBox.setRange(-1e6, 1e6)
449
+ self.giiMinSpinBox.setSingleStep(0.1)
450
+ self.giiMinSpinBox.setValue(0.0)
451
+ self.giiMinSpinBox.editingFinished.connect(self.update_gii_viewer)
452
+ gii_min_layout.addWidget(self.giiMinLabel)
453
+ gii_min_layout.addWidget(self.giiMinSpinBox)
454
+
455
+ gii_max_layout = QVBoxLayout()
456
+ self.giiMaxLabel = QLabel('Max:')
457
+ self.giiMaxSpinBox = QDoubleSpinBox()
458
+ self.giiMaxSpinBox.setDecimals(2)
459
+ self.giiMaxSpinBox.setRange(-1e6, 1e6)
460
+ self.giiMaxSpinBox.setSingleStep(0.1)
461
+ self.giiMaxSpinBox.setValue(1.0)
462
+ self.giiMaxSpinBox.editingFinished.connect(self.update_gii_viewer)
463
+ gii_max_layout.addWidget(self.giiMaxLabel)
464
+ gii_max_layout.addWidget(self.giiMaxSpinBox)
465
+
466
+ gii_range_layout.addLayout(gii_min_layout)
467
+ gii_range_layout.addLayout(gii_max_layout)
468
+ gii_layout.addLayout(gii_range_layout)
469
+
470
+ control_layout.addWidget(gii_group)
471
+
472
+ self.update_surface_color_controls()
351
473
 
352
474
  # Add the control panel layout to the main layout
353
475
  main_layout.addWidget(control_widget)
@@ -384,6 +506,9 @@ class TrkViewer(QWidget):
384
506
  self.nii_affine = img.affine
385
507
  self.nii_data = img.get_fdata()
386
508
 
509
+ self.update_volume_color_range_from_nifti()
510
+ self.update_surface_color_range_from_nifti()
511
+
387
512
  grid = pv.ImageData()
388
513
  grid.dimensions = np.array(self.nii_data.shape) + 1
389
514
  grid.cell_data['values'] = self.nii_data.flatten(order='F')
@@ -394,11 +519,21 @@ class TrkViewer(QWidget):
394
519
  self.ZSlider.setMaximum(self.nii_data.shape[2])
395
520
 
396
521
  self.update_nii_viewer(reset_camera=False)
522
+ if hasattr(self, "gii_mesh"):
523
+ self.update_gii_viewer(reset_camera=False)
397
524
  self.window().refreshActorList()
398
525
 
399
526
  # Update 2D view with current colormap
400
- self.nii_data[self.nii_data == 0] = None
401
- self.window().ortho_viewer.set_volume(self.nii_data, self.nii_affine)
527
+ ortho_data = self.nii_data.copy()
528
+ ortho_data[ortho_data == 0] = None
529
+
530
+ # Dilation for surface
531
+ self.nii_data_dilated = dilate_atlas_labels(
532
+ self.nii_data, dilation_width=4)
533
+ self.nii_data_dilated = np.where(self.nii_data_dilated == 0, np.nan,
534
+ self.nii_data_dilated)
535
+
536
+ self.window().ortho_viewer.set_volume(ortho_data, self.nii_affine)
402
537
  self.window().ortho_viewer.set_colormap(self.niiColorMapComboBox.currentText())
403
538
 
404
539
  def loadROIFile(self):
@@ -442,11 +577,23 @@ class TrkViewer(QWidget):
442
577
 
443
578
  def loadGiftiFile(self):
444
579
  options = QFileDialog.Options()
580
+
445
581
  filePath, _ = QFileDialog.getOpenFileName(
446
- self, "Open .gii File", "", "Gifti Files (*.gii)", options=options)
447
- if filePath:
448
- print(f"Loaded GIfTI file: {filePath}")
449
- self.gii_mesh = gifti_to_pyvista(filePath)
582
+ self,
583
+ "Open .gii File",
584
+ "",
585
+ "Gifti Files (*.gii)",
586
+ options=options
587
+ )
588
+
589
+ if not filePath:
590
+ return
591
+
592
+ print(f"Loaded GIFTI file: {filePath}")
593
+
594
+ self.gii_mesh = gifti_to_pyvista(filePath)
595
+
596
+ self.actor_types["gii_surface"] = "gii"
450
597
 
451
598
  self.update_gii_viewer(reset_camera=False)
452
599
  self.window().refreshActorList()
@@ -581,31 +728,285 @@ class TrkViewer(QWidget):
581
728
  opacity = self.nii_opacitySlider.value()/1000
582
729
  cmap_name = self.niiColorMapComboBox.currentText()
583
730
 
731
+ vmin = self.niiMinSpinBox.value()
732
+ vmax = self.niiMaxSpinBox.value()
733
+
734
+ # Protect against an invalid range
735
+ if vmax <= vmin:
736
+ vmax = vmin + 1e-6
737
+
584
738
  self.plotter.add_volume(self.grid, cmap=cmap_name, opacity=[0, opacity],
585
739
  show_scalar_bar=False, name='nii_volume',
586
- reset_camera=reset_camera,
740
+ reset_camera=reset_camera, clim=[vmin, vmax],
587
741
  user_matrix=self.nii_affine)
588
742
 
589
743
  def update_gii_viewer(self, reset_camera=False):
744
+ """Update the GIFTI surface rendering."""
590
745
 
591
746
  if not hasattr(self, "gii_mesh"):
592
747
  return
593
748
 
594
- opacity = self.gii_opacitySlider.value() / 100
595
-
596
- self.plotter.add_mesh(
597
- self.gii_mesh,
598
- color="ghostwhite",
599
- culling="back",
600
- smooth_shading=True,
601
- opacity=opacity,
602
- name="gii_surface",
603
- reset_camera=reset_camera,
604
- point_size=0,
605
- render_lines_as_tubes=True,
749
+ self.update_surface_color_controls()
750
+
751
+ opacity = self.gii_opacitySlider.value() / 100.0
752
+
753
+ color_mode = self.giiColorModeComboBox.currentText()
754
+
755
+ if color_mode == "Solid":
756
+
757
+ self.plotter.add_mesh(self.gii_mesh, color="ghostwhite",
758
+ culling="back", smooth_shading=True,
759
+ opacity=opacity, name="gii_surface",
760
+ reset_camera=reset_camera,
761
+ point_size=0, render_lines_as_tubes=True)
762
+
763
+ elif color_mode == "NIfTI values":
764
+
765
+ if self.nii_data is None:
766
+ self.plotter.add_mesh(self.gii_mesh, color="ghostwhite",
767
+ culling="back", smooth_shading=True,
768
+ opacity=opacity, name="gii_surface",
769
+ reset_camera=reset_camera,
770
+ point_size=0, render_lines_as_tubes=True)
771
+ return
772
+
773
+ # Sample NIfTI values at each surface vertex
774
+ scalars = self.sample_nifti_on_surface(self.gii_mesh)
775
+
776
+ if scalars is None:
777
+ return
778
+
779
+ # Copy the mesh so that the original GIFTI mesh is not modified
780
+ surface_mesh = self.gii_mesh.copy(deep=True)
781
+ surface_mesh.point_data["NIfTI values"] = scalars
782
+
783
+ cmap_name = self.giiColorMapComboBox.currentText()
784
+
785
+ vmin = self.giiMinSpinBox.value()
786
+ vmax = self.giiMaxSpinBox.value()
787
+
788
+ # Protect against an invalid range
789
+ if vmax <= vmin:
790
+ vmax = vmin + 1e-6
791
+
792
+ self.plotter.add_mesh(surface_mesh, scalars="NIfTI values",
793
+ cmap=cmap_name, clim=[vmin, vmax],
794
+ culling="back", smooth_shading=True,
795
+ opacity=opacity, name="gii_surface",
796
+ reset_camera=reset_camera, point_size=0,
797
+ render_lines_as_tubes=True,
798
+ show_scalar_bar=False)
799
+
800
+ self.plotter.render()
801
+
802
+ def update_volume_color_range_from_nifti(self):
803
+
804
+ if self.nii_data is None:
805
+ return
806
+
807
+ data = np.asarray(self.nii_data, dtype=float)
808
+ finite = np.isfinite(data)
809
+
810
+ if not np.any(finite):
811
+ return
812
+
813
+ values = data[finite]
814
+
815
+ vmin = float(np.min(values))
816
+ vmax = float(np.max(values))
817
+
818
+ # Avoid identical limits because PyVista/VTK does not like
819
+ # clim=[x, x].
820
+ if np.isclose(vmin, vmax):
821
+ vmax = vmin + 1.0
822
+
823
+ self.niiMinSpinBox.blockSignals(True)
824
+ self.niiMaxSpinBox.blockSignals(True)
825
+
826
+ self.niiMinSpinBox.setValue(vmin)
827
+ self.niiMaxSpinBox.setValue(vmax)
828
+
829
+ self.niiMinSpinBox.blockSignals(False)
830
+ self.niiMaxSpinBox.blockSignals(False)
831
+
832
+ def update_surface_color_range_from_nifti(self):
833
+
834
+ if self.nii_data is None:
835
+ return
836
+
837
+ data = np.asarray(self.nii_data, dtype=float)
838
+ finite = np.isfinite(data)
839
+
840
+ if not np.any(finite):
841
+ return
842
+
843
+ values = data[finite]
844
+
845
+ vmin = float(np.min(values))
846
+ vmax = float(np.max(values))
847
+
848
+ # Avoid identical limits because PyVista/VTK does not like
849
+ # clim=[x, x].
850
+ if np.isclose(vmin, vmax):
851
+ vmax = vmin + 1.0
852
+
853
+ self.giiMinSpinBox.blockSignals(True)
854
+ self.giiMaxSpinBox.blockSignals(True)
855
+
856
+ self.giiMinSpinBox.setValue(vmin)
857
+ self.giiMaxSpinBox.setValue(vmax)
858
+
859
+ self.giiMinSpinBox.blockSignals(False)
860
+ self.giiMaxSpinBox.blockSignals(False)
861
+
862
+ def sample_nifti_on_surface(self, mesh):
863
+ """
864
+ Trilinearly sample the NIfTI volume onto the surface.
865
+
866
+ Vertices outside the NIfTI are assigned NaN.
867
+ """
868
+
869
+ if self.nii_data is None or not hasattr(self, "nii_affine"):
870
+ return None
871
+
872
+ volume = np.asarray(self.nii_data_dilated, dtype=np.float64)
873
+
874
+ points_world = np.asarray(mesh.points, dtype=np.float64)
875
+
876
+ inv_affine = np.linalg.inv(self.nii_affine)
877
+
878
+ homogeneous = np.column_stack([
879
+ points_world,
880
+ np.ones(len(points_world))
881
+ ])
882
+
883
+ points_voxel = (homogeneous @ inv_affine.T)[:, :3]
884
+
885
+ nx, ny, nz = volume.shape[:3]
886
+
887
+ # Identify points that are actually inside the NIfTI
888
+ inside = (
889
+ (points_voxel[:, 0] >= 0) &
890
+ (points_voxel[:, 0] <= nx - 1) &
891
+ (points_voxel[:, 1] >= 0) &
892
+ (points_voxel[:, 1] <= ny - 1) &
893
+ (points_voxel[:, 2] >= 0) &
894
+ (points_voxel[:, 2] <= nz - 1)
606
895
  )
607
896
 
608
- # self.plotter.render()
897
+ # Clip only for interpolation
898
+ x = np.clip(points_voxel[:, 0], 0, nx - 1)
899
+ y = np.clip(points_voxel[:, 1], 0, ny - 1)
900
+ z = np.clip(points_voxel[:, 2], 0, nz - 1)
901
+
902
+ x0 = np.floor(x).astype(int)
903
+ y0 = np.floor(y).astype(int)
904
+ z0 = np.floor(z).astype(int)
905
+
906
+ x1 = np.minimum(x0 + 1, nx - 1)
907
+ y1 = np.minimum(y0 + 1, ny - 1)
908
+ z1 = np.minimum(z0 + 1, nz - 1)
909
+
910
+ xd = x - x0
911
+ yd = y - y0
912
+ zd = z - z0
913
+
914
+ c000 = volume[x0, y0, z0]
915
+ c001 = volume[x0, y0, z1]
916
+ c010 = volume[x0, y1, z0]
917
+ c011 = volume[x0, y1, z1]
918
+
919
+ c100 = volume[x1, y0, z0]
920
+ c101 = volume[x1, y0, z1]
921
+ c110 = volume[x1, y1, z0]
922
+ c111 = volume[x1, y1, z1]
923
+
924
+ c00 = c000 * (1 - xd) + c100 * xd
925
+ c01 = c001 * (1 - xd) + c101 * xd
926
+ c10 = c010 * (1 - xd) + c110 * xd
927
+ c11 = c011 * (1 - xd) + c111 * xd
928
+
929
+ c0 = c00 * (1 - yd) + c10 * yd
930
+ c1 = c01 * (1 - yd) + c11 * yd
931
+
932
+ values = c0 * (1 - zd) + c1 * zd
933
+
934
+ # Outside the NIfTI = NaN
935
+ values[~inside] = np.nan
936
+
937
+ return values.astype(np.float32)
938
+
939
+ def sample_nifti_on_surface2(self, mesh):
940
+ """
941
+ !!! Currently unused
942
+ Sample the currently loaded NIfTI volume at every GIFTI surface vertex.
943
+
944
+ GIFTI vertices are assumed to be in world/mm coordinates.
945
+ The NIfTI affine is used to convert them into voxel coordinates.
946
+
947
+ Nearest-neighbor sampling is used.
948
+ """
949
+
950
+ if self.nii_data is None:
951
+ return None
952
+
953
+ if not hasattr(self, 'nii_affine'):
954
+ return None
955
+
956
+ vertices_world = np.asarray(mesh.points)
957
+
958
+ # Convert world coordinates -> NIfTI voxel coordinates
959
+ inv_affine = np.linalg.inv(self.nii_affine)
960
+
961
+ homogeneous = np.column_stack([
962
+ vertices_world,
963
+ np.ones(len(vertices_world))
964
+ ])
965
+
966
+ vertices_voxel = (homogeneous @ inv_affine.T)[:, :3]
967
+
968
+ # Nearest-neighbor voxel coordinates
969
+ vertices_voxel = np.rint(vertices_voxel).astype(int)
970
+
971
+ shape = self.nii_data.shape
972
+
973
+ # Determine which vertices are inside the NIfTI
974
+ valid = (
975
+ (vertices_voxel[:, 0] >= 0) &
976
+ (vertices_voxel[:, 0] < shape[0]) &
977
+ (vertices_voxel[:, 1] >= 0) &
978
+ (vertices_voxel[:, 1] < shape[1]) &
979
+ (vertices_voxel[:, 2] >= 0) &
980
+ (vertices_voxel[:, 2] < shape[2])
981
+ )
982
+
983
+ # Initialize outside-of-volume vertices with NaN
984
+ values = np.full(len(vertices_world), np.nan, dtype=np.float32)
985
+
986
+ valid_voxels = vertices_voxel[valid]
987
+
988
+ values[valid] = self.nii_data[
989
+ valid_voxels[:, 0],
990
+ valid_voxels[:, 1],
991
+ valid_voxels[:, 2]
992
+ ]
993
+
994
+ return values
995
+
996
+ def update_surface_color_controls(self):
997
+ """Enable surface scalar controls only when using NIfTI values."""
998
+
999
+ use_nifti = (self.giiColorModeComboBox.currentText()
1000
+ == "NIfTI values")
1001
+
1002
+ self.giiColorMapLabel.setEnabled(use_nifti)
1003
+ self.giiColorMapComboBox.setEnabled(use_nifti)
1004
+
1005
+ self.giiMinLabel.setEnabled(use_nifti)
1006
+ self.giiMinSpinBox.setEnabled(use_nifti)
1007
+
1008
+ self.giiMaxLabel.setEnabled(use_nifti)
1009
+ self.giiMaxSpinBox.setEnabled(use_nifti)
609
1010
 
610
1011
 
611
1012
  class OrthogonalViewer(QWidget):
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: unveil-python
3
- Version: 0.5.1
3
+ Version: 0.6.0
4
4
  Summary: Implementation of UNVEIL
5
5
  Author-email: Nicolas Delinte <nicolas.delinte@uclouvain.be>
6
6
  License-Expression: GPL-3.0-only
File without changes
File without changes
File without changes