unveil-python 0.5.0__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.0
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.0"
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):
@@ -194,6 +196,22 @@ class TrkViewer(QWidget):
194
196
  self.x = 0
195
197
  self.y = 0
196
198
  self.z = 0
199
+ self.colormap_list = ['gray',
200
+ 'viridis', 'plasma', 'inferno', 'magma', 'cividis',
201
+ 'Greys', 'Purples', 'Blues', 'Greens', 'Oranges',
202
+ 'Reds', 'bone', 'pink', 'spring', 'summer',
203
+ 'autumn', 'winter', 'cool', 'Wistia', 'hot',
204
+ 'afmhot', 'gist_heat', 'copper', 'YlOrRd', 'YlOrBr',
205
+ 'RdBu', 'RdYlBu',
206
+ 'RdYlGn', 'Spectral', 'coolwarm', 'bwr', 'seismic',
207
+ 'berlin', 'managua', 'vanimo', 'twilight',
208
+ 'twilight_shifted', 'hsv', 'Pastel1', 'Pastel2',
209
+ 'Paired', 'Accent', 'okabe_ito', 'Dark2', 'Set1',
210
+ 'Set2', 'Set3', 'tab10', 'tab20', 'tab20b', 'tab20c',
211
+ 'flag', 'prism', 'ocean', 'gist_earth', 'terrain',
212
+ 'gist_stern', 'gnuplot', 'gnuplot2', 'CMRmap',
213
+ 'cubehelix', 'brg', 'gist_rainbow', 'rainbow',
214
+ 'jet', 'turbo', 'nipy_spectral', 'gist_ncar']
197
215
  self.initUI()
198
216
  self.background = 'white'
199
217
  self.color_blind = False
@@ -238,88 +256,220 @@ class TrkViewer(QWidget):
238
256
  # Left side: Control panel
239
257
  control_widget = QWidget()
240
258
  control_layout = QVBoxLayout(control_widget)
241
- control_widget.setMaximumWidth(250)
259
+ control_widget.setMaximumWidth(260)
242
260
 
243
- self.opacityLabel = QLabel('Opacity:')
244
- control_layout.addWidget(self.opacityLabel)
261
+ # Tractography
262
+ trk_group = QGroupBox("TRK / Tractography")
263
+ trk_layout = QVBoxLayout(trk_group)
245
264
 
265
+ opacity_trk_row = QHBoxLayout()
266
+ opacity_trk_row.setSpacing(5)
267
+ self.opacityLabel = QLabel('Opacity:')
268
+ opacity_trk_row.addWidget(self.opacityLabel)
246
269
  self.opacitySlider = QSlider(Qt.Orientation.Horizontal, self)
247
270
  self.opacitySlider.setMinimum(1)
248
271
  self.opacitySlider.setMaximum(100)
249
272
  self.opacitySlider.setValue(100)
250
273
  self.opacitySlider.sliderReleased.connect(self.update_trk_viewer)
251
- control_layout.addWidget(self.opacitySlider)
252
-
253
- self.showPointsCheckbox = QCheckBox('Show Points')
254
- self.showPointsCheckbox.stateChanged.connect(self.update_trk_viewer)
255
- control_layout.addWidget(self.showPointsCheckbox)
256
-
257
- self.colorMapLabel = QLabel('Color Map:')
258
- control_layout.addWidget(self.colorMapLabel)
274
+ opacity_trk_row.addWidget(self.opacitySlider)
275
+ trk_layout.addLayout(opacity_trk_row)
259
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)
260
281
  self.colorMapComboBox = QComboBox()
261
282
  self.colorMapComboBox.addItems(['rgb', 'flesh', 'scalar'])
262
283
  self.colorMapComboBox.currentIndexChanged.connect(
263
284
  self.update_trk_viewer)
264
- control_layout.addWidget(self.colorMapComboBox)
265
- self.colorMapEdit = QLineEdit()
266
- self.colorMapEdit.textChanged.connect(self.update_trk_viewer)
267
- self.colorMapEdit.setToolTip(
268
- 'Insert color map name. Name must be in the matplotlib library. Scalar nii.gz must be loaded.')
269
- control_layout.addWidget(self.colorMapEdit)
285
+ color_mode_trk_row.addWidget(self.colorMapComboBox)
286
+ trk_layout.addLayout(color_mode_trk_row)
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)
292
+ self.tractColorMapComboBox = QComboBox()
293
+ self.tractColorMapComboBox.addItems(self.colormap_list)
294
+ self.tractColorMapComboBox.currentIndexChanged.connect(
295
+ self.update_trk_viewer)
296
+ colormap_trk_row.addWidget(self.tractColorMapComboBox)
297
+ trk_layout.addLayout(colormap_trk_row)
270
298
 
271
- self.volume_label = QLabel('Volume')
272
- 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)
273
304
 
305
+ # Volume
306
+ nii_group = QGroupBox("NIfTI / Volume")
307
+ nii_layout = QVBoxLayout(nii_group)
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)
274
313
  self.nii_opacitySlider = QSlider(Qt.Orientation.Horizontal, self)
275
314
  self.nii_opacitySlider.setMinimum(0)
276
315
  self.nii_opacitySlider.setMaximum(1000)
277
316
  self.nii_opacitySlider.setValue(45)
278
317
  self.nii_opacitySlider.sliderReleased.connect(self.update_nii_viewer)
279
- control_layout.addWidget(self.nii_opacitySlider)
318
+ opacity_nii_row.addWidget(self.nii_opacitySlider)
319
+ nii_layout.addLayout(opacity_nii_row)
320
+
321
+ colormap_nii_row = QHBoxLayout()
322
+ colormap_nii_row.setSpacing(5)
323
+ self.niiColorMapLabel = QLabel('Colormap:')
324
+ colormap_nii_row.addWidget(self.niiColorMapLabel)
325
+ self.niiColorMapComboBox = QComboBox()
326
+ self.niiColorMapComboBox.addItems(self.colormap_list)
327
+ self.niiColorMapComboBox.currentIndexChanged.connect(
328
+ self.on_nii_colormap_changed)
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)
280
359
 
281
360
  self.showSlicesCheckbox = QCheckBox('Show Slices')
282
361
  self.showSlicesCheckbox.stateChanged.connect(self.update_nii_viewer)
283
- control_layout.addWidget(self.showSlicesCheckbox)
362
+ nii_layout.addWidget(self.showSlicesCheckbox)
284
363
 
285
- self.x_label = QLabel('X')
286
- 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)
287
368
  self.XSlider = QSlider(Qt.Orientation.Horizontal, self)
288
369
  self.XSlider.setMinimum(0)
289
370
  self.XSlider.setMaximum(100)
290
371
  self.XSlider.setValue(100)
291
372
  self.XSlider.sliderReleased.connect(
292
373
  lambda: self._update_slice('x', 'nii_x'))
293
- control_layout.addWidget(self.XSlider)
294
- self.y_label = QLabel('Y')
295
- 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)
296
380
  self.YSlider = QSlider(Qt.Orientation.Horizontal, self)
297
381
  self.YSlider.setMinimum(0)
298
382
  self.YSlider.setMaximum(100)
299
383
  self.YSlider.setValue(100)
300
384
  self.YSlider.sliderReleased.connect(
301
385
  lambda: self._update_slice('y', 'nii_y'))
302
- control_layout.addWidget(self.YSlider)
303
- self.z_label = QLabel('Z')
304
- 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)
305
392
  self.ZSlider = QSlider(Qt.Orientation.Horizontal, self)
306
393
  self.ZSlider.setMinimum(0)
307
394
  self.ZSlider.setMaximum(100)
308
395
  self.ZSlider.setValue(100)
309
396
  self.ZSlider.sliderReleased.connect(
310
397
  lambda: self._update_slice('z', 'nii_z'))
311
- 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)
312
402
 
313
403
  # Gifti
314
- self.surface_label = QLabel('Surface')
315
- control_layout.addWidget(self.surface_label)
404
+ gii_group = QGroupBox("GIFTI / Surface")
405
+ gii_layout = QVBoxLayout(gii_group)
316
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)
317
411
  self.gii_opacitySlider = QSlider(Qt.Orientation.Horizontal, self)
318
412
  self.gii_opacitySlider.setMinimum(0)
319
413
  self.gii_opacitySlider.setMaximum(100)
320
414
  self.gii_opacitySlider.setValue(15)
321
415
  self.gii_opacitySlider.sliderReleased.connect(self.update_gii_viewer)
322
- 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()
323
473
 
324
474
  # Add the control panel layout to the main layout
325
475
  main_layout.addWidget(control_widget)
@@ -356,6 +506,9 @@ class TrkViewer(QWidget):
356
506
  self.nii_affine = img.affine
357
507
  self.nii_data = img.get_fdata()
358
508
 
509
+ self.update_volume_color_range_from_nifti()
510
+ self.update_surface_color_range_from_nifti()
511
+
359
512
  grid = pv.ImageData()
360
513
  grid.dimensions = np.array(self.nii_data.shape) + 1
361
514
  grid.cell_data['values'] = self.nii_data.flatten(order='F')
@@ -366,10 +519,22 @@ class TrkViewer(QWidget):
366
519
  self.ZSlider.setMaximum(self.nii_data.shape[2])
367
520
 
368
521
  self.update_nii_viewer(reset_camera=False)
522
+ if hasattr(self, "gii_mesh"):
523
+ self.update_gii_viewer(reset_camera=False)
369
524
  self.window().refreshActorList()
370
525
 
371
- self.nii_data[self.nii_data == 0] = None
372
- self.window().ortho_viewer.set_volume(self.nii_data, self.nii_affine)
526
+ # Update 2D view with current colormap
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)
537
+ self.window().ortho_viewer.set_colormap(self.niiColorMapComboBox.currentText())
373
538
 
374
539
  def loadROIFile(self):
375
540
  """Load one or multiple ROI nifti volumes and render as surfaces."""
@@ -412,11 +577,23 @@ class TrkViewer(QWidget):
412
577
 
413
578
  def loadGiftiFile(self):
414
579
  options = QFileDialog.Options()
580
+
415
581
  filePath, _ = QFileDialog.getOpenFileName(
416
- self, "Open .gii File", "", "Gifti Files (*.gii)", options=options)
417
- if filePath:
418
- print(f"Loaded GIfTI file: {filePath}")
419
- 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"
420
597
 
421
598
  self.update_gii_viewer(reset_camera=False)
422
599
  self.window().refreshActorList()
@@ -491,20 +668,25 @@ class TrkViewer(QWidget):
491
668
  self.plotter.render()
492
669
  offscreen.close()
493
670
 
671
+ def on_nii_colormap_changed(self):
672
+ self.update_nii_viewer()
673
+ if hasattr(self.window(), 'ortho_viewer'):
674
+ self.window().ortho_viewer.set_colormap(self.niiColorMapComboBox.currentText())
675
+
494
676
  def update_trk_viewer(self, reset_camera: bool = False):
495
677
 
496
678
  opacity = self.opacitySlider.value() / 100.0
497
679
  show_points = self.showPointsCheckbox.isChecked()
498
680
 
499
- color_map = self.colorMapComboBox.currentText()
500
- if color_map == 'flesh':
681
+ color_mode = self.colorMapComboBox.currentText()
682
+ if color_mode == 'flesh':
501
683
  color_map = 'flesh'
502
684
  scalar = None
503
- elif color_map == 'rgb':
685
+ elif color_mode == 'rgb':
504
686
  color_map = 'plasma'
505
687
  scalar = None
506
688
  else:
507
- color_map = self.colorMapEdit.text()
689
+ color_map = self.tractColorMapComboBox.currentText()
508
690
  scalar = self.nii_data
509
691
 
510
692
  for file in list(self.actor_types):
@@ -518,14 +700,15 @@ class TrkViewer(QWidget):
518
700
 
519
701
  def _update_slice(self, axis, actor_name):
520
702
 
521
- if self.showSlicesCheckbox.isChecked():
703
+ if self.showSlicesCheckbox.isChecked() and self.grid is not None:
522
704
 
523
705
  center = (self.XSlider.value(), self.YSlider.value(),
524
706
  self.ZSlider.value())
525
707
 
526
708
  slice_mesh = self.grid.slice(axis, origin=center)
709
+ cmap_name = self.niiColorMapComboBox.currentText()
527
710
 
528
- self.plotter.add_mesh(slice_mesh, cmap='grey', name=actor_name,
711
+ self.plotter.add_mesh(slice_mesh, cmap=cmap_name, name=actor_name,
529
712
  show_scalar_bar=False, point_size=0,
530
713
  render_lines_as_tubes=True,
531
714
  reset_camera=False,
@@ -535,25 +718,295 @@ class TrkViewer(QWidget):
535
718
  self.plotter.remove_actor(actor_name)
536
719
 
537
720
  def update_nii_viewer(self, reset_camera: bool = False):
721
+ if self.grid is None:
722
+ return
538
723
 
539
724
  self._update_slice('x', 'nii_x')
540
725
  self._update_slice('y', 'nii_y')
541
726
  self._update_slice('z', 'nii_z')
542
727
 
543
728
  opacity = self.nii_opacitySlider.value()/1000
544
- self.plotter.add_volume(self.grid, cmap='gray', opacity=[0, opacity],
729
+ cmap_name = self.niiColorMapComboBox.currentText()
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
+
738
+ self.plotter.add_volume(self.grid, cmap=cmap_name, opacity=[0, opacity],
545
739
  show_scalar_bar=False, name='nii_volume',
546
- reset_camera=reset_camera,
740
+ reset_camera=reset_camera, clim=[vmin, vmax],
547
741
  user_matrix=self.nii_affine)
548
742
 
549
- def update_gii_viewer(self, reset_camera: bool = False):
743
+ def update_gii_viewer(self, reset_camera=False):
744
+ """Update the GIFTI surface rendering."""
745
+
746
+ if not hasattr(self, "gii_mesh"):
747
+ return
748
+
749
+ self.update_surface_color_controls()
550
750
 
551
- opacity = self.gii_opacitySlider.value()/100
552
- self.plotter.add_mesh(self.gii_mesh, color="ghostwhite",
553
- culling='back', smooth_shading=True,
554
- opacity=opacity, name='gii_surface',
555
- reset_camera=reset_camera, point_size=0,
556
- render_lines_as_tubes=True,)
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)
895
+ )
896
+
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)
557
1010
 
558
1011
 
559
1012
  class OrthogonalViewer(QWidget):
@@ -563,6 +1016,7 @@ class OrthogonalViewer(QWidget):
563
1016
 
564
1017
  self.volume = None
565
1018
  self.affine = None
1019
+ self.cmap = "gray"
566
1020
 
567
1021
  self.rois = {}
568
1022
  self.roi_visibility = {}
@@ -608,6 +1062,10 @@ class OrthogonalViewer(QWidget):
608
1062
 
609
1063
  self.update_views()
610
1064
 
1065
+ def set_colormap(self, cmap_name):
1066
+ self.cmap = cmap_name
1067
+ self.update_views()
1068
+
611
1069
  def add_roi(self, name, roi, color):
612
1070
 
613
1071
  self.rois[name] = {"data": roi, "color": color}
@@ -644,9 +1102,9 @@ class OrthogonalViewer(QWidget):
644
1102
  for ax in (self.ax_axial, self.ax_coronal, self.ax_sagittal):
645
1103
  ax.axis("off")
646
1104
 
647
- self.ax_axial.imshow(np.rot90(axial), cmap="gray", vmin=0)
648
- self.ax_coronal.imshow(np.rot90(coronal), cmap="gray", vmin=0)
649
- self.ax_sagittal.imshow(np.rot90(sagittal), cmap="gray", vmin=0)
1105
+ self.ax_axial.imshow(np.rot90(axial), cmap=self.cmap, vmin=0)
1106
+ self.ax_coronal.imshow(np.rot90(coronal), cmap=self.cmap, vmin=0)
1107
+ self.ax_sagittal.imshow(np.rot90(sagittal), cmap=self.cmap, vmin=0)
650
1108
 
651
1109
  for name, roi_info in self.rois.items():
652
1110
 
@@ -662,14 +1120,6 @@ class OrthogonalViewer(QWidget):
662
1120
 
663
1121
  self.canvas.draw_idle()
664
1122
 
665
- # self.window().viewer.XSlider.setValue(self.x)
666
- # self.window().viewer.YSlider.setValue(self.y)
667
- # self.window().viewer.ZSlider.setValue(self.z)
668
-
669
- # self.window().viewer._update_slice('x', 'nii_x')
670
- # self.window().viewer._update_slice('y', 'nii_y')
671
- # self.window().viewer._update_slice('z', 'nii_z')
672
-
673
1123
  def take_screenshot(self):
674
1124
 
675
1125
  options = QFileDialog.Options()
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: unveil-python
3
- Version: 0.5.0
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