unveil-python 0.5.1__tar.gz → 0.7.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.7.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
@@ -15,6 +15,7 @@ Requires-Dist: dipy
15
15
  Requires-Dist: pyvista
16
16
  Requires-Dist: pyvistaqt
17
17
  Requires-Dist: PyQt6
18
+ Requires-Dist: binama
18
19
  Dynamic: license-file
19
20
 
20
21
  # UNVEIL
@@ -18,7 +18,8 @@ classifiers = ['Intended Audience :: Science/Research',
18
18
  dependencies = ['dipy',
19
19
  'pyvista',
20
20
  'pyvistaqt',
21
- 'PyQt6'
21
+ 'PyQt6',
22
+ 'binama'
22
23
  ]
23
24
  dynamic = ["version"]
24
25
 
@@ -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.7.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):
@@ -184,6 +186,24 @@ def plot_trk(trk_file, scalar=None, color_map='plasma', opacity: float = 1,
184
186
 
185
187
  p.background_color = background
186
188
 
189
+ def load_gii_data(gii_path):
190
+ """
191
+ Load all data contained in the GifTi file except vertex coordinates and triangles.
192
+ Returns : dictionnary {"Gii table name" : data}
193
+ """
194
+ gii_img = nib.load(gii_path)
195
+ vertex_coords = gii_img.get_arrays_from_intent("NIFTI_INTENT_POINTSET")[0].data
196
+ gii_data = {}
197
+
198
+ # Iterate over all tables of the GIfTI
199
+ for darray in gii_img.darrays:
200
+ # Vertex coords and triangles are stored with None as 'Name'
201
+ if darray.meta.get("Name") is not None:
202
+ # Check that the table contains 1 value per vertex
203
+ if darray.data.shape == vertex_coords[:,0].shape:
204
+ gii_data[darray.meta.get("Name")] = darray.data
205
+
206
+ return gii_data
187
207
 
188
208
  class TrkViewer(QWidget):
189
209
  def __init__(self):
@@ -199,7 +219,8 @@ class TrkViewer(QWidget):
199
219
  'Greys', 'Purples', 'Blues', 'Greens', 'Oranges',
200
220
  'Reds', 'bone', 'pink', 'spring', 'summer',
201
221
  'autumn', 'winter', 'cool', 'Wistia', 'hot',
202
- 'afmhot', 'gist_heat', 'copper', 'RdBu', 'RdYlBu',
222
+ 'afmhot', 'gist_heat', 'copper', 'YlOrRd', 'YlOrBr',
223
+ 'RdBu', 'RdYlBu',
203
224
  'RdYlGn', 'Spectral', 'coolwarm', 'bwr', 'seismic',
204
225
  'berlin', 'managua', 'vanimo', 'twilight',
205
226
  'twilight_shifted', 'hsv', 'Pastel1', 'Pastel2',
@@ -253,101 +274,220 @@ class TrkViewer(QWidget):
253
274
  # Left side: Control panel
254
275
  control_widget = QWidget()
255
276
  control_layout = QVBoxLayout(control_widget)
256
- control_widget.setMaximumWidth(250)
277
+ control_widget.setMaximumWidth(260)
257
278
 
258
- self.opacityLabel = QLabel('Opacity:')
259
- control_layout.addWidget(self.opacityLabel)
279
+ # Tractography
280
+ trk_group = QGroupBox("TRK / Tractography")
281
+ trk_layout = QVBoxLayout(trk_group)
260
282
 
283
+ opacity_trk_row = QHBoxLayout()
284
+ opacity_trk_row.setSpacing(5)
285
+ self.opacityLabel = QLabel('Opacity:')
286
+ opacity_trk_row.addWidget(self.opacityLabel)
261
287
  self.opacitySlider = QSlider(Qt.Orientation.Horizontal, self)
262
288
  self.opacitySlider.setMinimum(1)
263
289
  self.opacitySlider.setMaximum(100)
264
290
  self.opacitySlider.setValue(100)
265
291
  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)
292
+ opacity_trk_row.addWidget(self.opacitySlider)
293
+ trk_layout.addLayout(opacity_trk_row)
274
294
 
295
+ color_mode_trk_row = QHBoxLayout()
296
+ color_mode_trk_row.setSpacing(5)
297
+ self.colorMapLabel = QLabel('Color Mode:')
298
+ color_mode_trk_row.addWidget(self.colorMapLabel)
275
299
  self.colorMapComboBox = QComboBox()
276
300
  self.colorMapComboBox.addItems(['rgb', 'flesh', 'scalar'])
277
301
  self.colorMapComboBox.currentIndexChanged.connect(
278
302
  self.update_trk_viewer)
279
- control_layout.addWidget(self.colorMapComboBox)
280
-
281
- self.tractColorMapLabel = QLabel('Tract Colormap (Scalar):')
282
- control_layout.addWidget(self.tractColorMapLabel)
303
+ color_mode_trk_row.addWidget(self.colorMapComboBox)
304
+ trk_layout.addLayout(color_mode_trk_row)
283
305
 
306
+ colormap_trk_row = QHBoxLayout()
307
+ colormap_trk_row.setSpacing(5)
308
+ self.tractColorMapLabel = QLabel('Colormap (Scalar):')
309
+ colormap_trk_row.addWidget(self.tractColorMapLabel)
284
310
  self.tractColorMapComboBox = QComboBox()
285
311
  self.tractColorMapComboBox.addItems(self.colormap_list)
286
312
  self.tractColorMapComboBox.currentIndexChanged.connect(
287
313
  self.update_trk_viewer)
288
- control_layout.addWidget(self.tractColorMapComboBox)
314
+ colormap_trk_row.addWidget(self.tractColorMapComboBox)
315
+ trk_layout.addLayout(colormap_trk_row)
316
+
317
+ self.showPointsCheckbox = QCheckBox('Show Points')
318
+ self.showPointsCheckbox.stateChanged.connect(self.update_trk_viewer)
319
+ trk_layout.addWidget(self.showPointsCheckbox)
289
320
 
290
- self.volume_label = QLabel('Volume Opacity')
291
- control_layout.addWidget(self.volume_label)
321
+ control_layout.addWidget(trk_group)
292
322
 
323
+ # Volume
324
+ nii_group = QGroupBox("NIfTI / Volume")
325
+ nii_layout = QVBoxLayout(nii_group)
326
+
327
+ opacity_nii_row = QHBoxLayout()
328
+ opacity_nii_row.setSpacing(5)
329
+ self.volume_label = QLabel('Opacity:')
330
+ opacity_nii_row.addWidget(self.volume_label)
293
331
  self.nii_opacitySlider = QSlider(Qt.Orientation.Horizontal, self)
294
332
  self.nii_opacitySlider.setMinimum(0)
295
333
  self.nii_opacitySlider.setMaximum(1000)
296
334
  self.nii_opacitySlider.setValue(45)
297
335
  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)
336
+ opacity_nii_row.addWidget(self.nii_opacitySlider)
337
+ nii_layout.addLayout(opacity_nii_row)
302
338
 
339
+ colormap_nii_row = QHBoxLayout()
340
+ colormap_nii_row.setSpacing(5)
341
+ self.niiColorMapLabel = QLabel('Colormap:')
342
+ colormap_nii_row.addWidget(self.niiColorMapLabel)
303
343
  self.niiColorMapComboBox = QComboBox()
304
344
  self.niiColorMapComboBox.addItems(self.colormap_list)
305
345
  self.niiColorMapComboBox.currentIndexChanged.connect(
306
346
  self.on_nii_colormap_changed)
307
- control_layout.addWidget(self.niiColorMapComboBox)
347
+ colormap_nii_row.addWidget(self.niiColorMapComboBox)
348
+ nii_layout.addLayout(colormap_nii_row)
349
+
350
+ nii_range_layout = QHBoxLayout()
351
+
352
+ nii_min_layout = QVBoxLayout()
353
+ self.niiMinLabel = QLabel('Min:')
354
+ self.niiMinSpinBox = QDoubleSpinBox()
355
+ self.niiMinSpinBox.setDecimals(2)
356
+ self.niiMinSpinBox.setRange(-1e6, 1e6)
357
+ self.niiMinSpinBox.setSingleStep(0.1)
358
+ self.niiMinSpinBox.setValue(0.0)
359
+ self.niiMinSpinBox.editingFinished.connect(self.update_nii_viewer)
360
+ nii_min_layout.addWidget(self.niiMinLabel)
361
+ nii_min_layout.addWidget(self.niiMinSpinBox)
362
+
363
+ nii_max_layout = QVBoxLayout()
364
+ self.niiMaxLabel = QLabel('Max:')
365
+ self.niiMaxSpinBox = QDoubleSpinBox()
366
+ self.niiMaxSpinBox.setDecimals(2)
367
+ self.niiMaxSpinBox.setRange(-1e6, 1e6)
368
+ self.niiMaxSpinBox.setSingleStep(0.1)
369
+ self.niiMaxSpinBox.setValue(1.0)
370
+ self.niiMaxSpinBox.editingFinished.connect(self.update_nii_viewer)
371
+ nii_max_layout.addWidget(self.niiMaxLabel)
372
+ nii_max_layout.addWidget(self.niiMaxSpinBox)
373
+
374
+ nii_range_layout.addLayout(nii_min_layout)
375
+ nii_range_layout.addLayout(nii_max_layout)
376
+ nii_layout.addLayout(nii_range_layout)
308
377
 
309
378
  self.showSlicesCheckbox = QCheckBox('Show Slices')
310
379
  self.showSlicesCheckbox.stateChanged.connect(self.update_nii_viewer)
311
- control_layout.addWidget(self.showSlicesCheckbox)
380
+ nii_layout.addWidget(self.showSlicesCheckbox)
312
381
 
313
- self.x_label = QLabel('X')
314
- control_layout.addWidget(self.x_label)
382
+ x_nii_row = QHBoxLayout()
383
+ x_nii_row.setSpacing(5)
384
+ self.x_label = QLabel('X:')
385
+ x_nii_row.addWidget(self.x_label)
315
386
  self.XSlider = QSlider(Qt.Orientation.Horizontal, self)
316
387
  self.XSlider.setMinimum(0)
317
388
  self.XSlider.setMaximum(100)
318
389
  self.XSlider.setValue(100)
319
390
  self.XSlider.sliderReleased.connect(
320
391
  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)
392
+ x_nii_row.addWidget(self.XSlider)
393
+ nii_layout.addLayout(x_nii_row)
394
+ y_nii_row = QHBoxLayout()
395
+ y_nii_row.setSpacing(5)
396
+ self.y_label = QLabel('Y:')
397
+ y_nii_row.addWidget(self.y_label)
324
398
  self.YSlider = QSlider(Qt.Orientation.Horizontal, self)
325
399
  self.YSlider.setMinimum(0)
326
400
  self.YSlider.setMaximum(100)
327
401
  self.YSlider.setValue(100)
328
402
  self.YSlider.sliderReleased.connect(
329
403
  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)
404
+ y_nii_row.addWidget(self.YSlider)
405
+ nii_layout.addLayout(y_nii_row)
406
+ z_nii_row = QHBoxLayout()
407
+ z_nii_row.setSpacing(5)
408
+ self.z_label = QLabel('Z:')
409
+ z_nii_row.addWidget(self.z_label)
333
410
  self.ZSlider = QSlider(Qt.Orientation.Horizontal, self)
334
411
  self.ZSlider.setMinimum(0)
335
412
  self.ZSlider.setMaximum(100)
336
413
  self.ZSlider.setValue(100)
337
414
  self.ZSlider.sliderReleased.connect(
338
415
  lambda: self._update_slice('z', 'nii_z'))
339
- control_layout.addWidget(self.ZSlider)
416
+ z_nii_row.addWidget(self.ZSlider)
417
+ nii_layout.addLayout(z_nii_row)
418
+
419
+ control_layout.addWidget(nii_group)
340
420
 
341
421
  # Gifti
342
- self.surface_label = QLabel('Surface')
343
- control_layout.addWidget(self.surface_label)
422
+ gii_group = QGroupBox("GIFTI / Surface")
423
+ gii_layout = QVBoxLayout(gii_group)
344
424
 
425
+ opacity_gii_row = QHBoxLayout()
426
+ opacity_gii_row.setSpacing(5)
427
+ self.gii_opacityLabel = QLabel('Opacity:')
428
+ opacity_gii_row.addWidget(self.gii_opacityLabel)
345
429
  self.gii_opacitySlider = QSlider(Qt.Orientation.Horizontal, self)
346
430
  self.gii_opacitySlider.setMinimum(0)
347
431
  self.gii_opacitySlider.setMaximum(100)
348
432
  self.gii_opacitySlider.setValue(15)
349
433
  self.gii_opacitySlider.sliderReleased.connect(self.update_gii_viewer)
350
- control_layout.addWidget(self.gii_opacitySlider)
434
+ opacity_gii_row.addWidget(self.gii_opacitySlider)
435
+ gii_layout.addLayout(opacity_gii_row)
436
+
437
+ color_mode_gii_row = QHBoxLayout()
438
+ color_mode_gii_row.setSpacing(5)
439
+ self.giiColorModeLabel = QLabel('Color Mode:')
440
+ color_mode_gii_row.addWidget(self.giiColorModeLabel)
441
+ self.giiColorModeComboBox = QComboBox()
442
+ self.giiColorModeComboBox.addItems(['Solid', 'NIfTI values'])
443
+ self.giiColorModeComboBox.currentIndexChanged.connect(
444
+ lambda index: self.update_gii_viewer(update_color_range=True))
445
+ color_mode_gii_row.addWidget(self.giiColorModeComboBox)
446
+ gii_layout.addLayout(color_mode_gii_row)
447
+
448
+ colormap_gii_row = QHBoxLayout()
449
+ colormap_gii_row.setSpacing(5)
450
+ self.giiColorMapLabel = QLabel('Colormap:')
451
+ colormap_gii_row.addWidget(self.giiColorMapLabel)
452
+ self.giiColorMapComboBox = QComboBox()
453
+ self.giiColorMapComboBox.addItems(self.colormap_list)
454
+ self.giiColorMapComboBox.setCurrentText('viridis')
455
+ self.giiColorMapComboBox.currentIndexChanged.connect(
456
+ self.update_gii_viewer)
457
+ colormap_gii_row.addWidget(self.giiColorMapComboBox)
458
+ gii_layout.addLayout(colormap_gii_row)
459
+
460
+ gii_range_layout = QHBoxLayout()
461
+
462
+ gii_min_layout = QVBoxLayout()
463
+ self.giiMinLabel = QLabel('Min:')
464
+ self.giiMinSpinBox = QDoubleSpinBox()
465
+ self.giiMinSpinBox.setDecimals(2)
466
+ self.giiMinSpinBox.setRange(-1e6, 1e6)
467
+ self.giiMinSpinBox.setSingleStep(0.1)
468
+ self.giiMinSpinBox.setValue(0.0)
469
+ self.giiMinSpinBox.editingFinished.connect(self.update_gii_viewer)
470
+ gii_min_layout.addWidget(self.giiMinLabel)
471
+ gii_min_layout.addWidget(self.giiMinSpinBox)
472
+
473
+ gii_max_layout = QVBoxLayout()
474
+ self.giiMaxLabel = QLabel('Max:')
475
+ self.giiMaxSpinBox = QDoubleSpinBox()
476
+ self.giiMaxSpinBox.setDecimals(2)
477
+ self.giiMaxSpinBox.setRange(-1e6, 1e6)
478
+ self.giiMaxSpinBox.setSingleStep(0.1)
479
+ self.giiMaxSpinBox.setValue(1.0)
480
+ self.giiMaxSpinBox.editingFinished.connect(self.update_gii_viewer)
481
+ gii_max_layout.addWidget(self.giiMaxLabel)
482
+ gii_max_layout.addWidget(self.giiMaxSpinBox)
483
+
484
+ gii_range_layout.addLayout(gii_min_layout)
485
+ gii_range_layout.addLayout(gii_max_layout)
486
+ gii_layout.addLayout(gii_range_layout)
487
+
488
+ control_layout.addWidget(gii_group)
489
+
490
+ self.update_surface_color_controls()
351
491
 
352
492
  # Add the control panel layout to the main layout
353
493
  main_layout.addWidget(control_widget)
@@ -378,27 +518,49 @@ class TrkViewer(QWidget):
378
518
  options = QFileDialog.Options()
379
519
  filePath, _ = QFileDialog.getOpenFileName(
380
520
  self, "Open .nii.gz File", "", "Nifti Files (*.nii.gz)", options=options)
381
- if filePath:
382
- print(f"Loaded NIfTI file: {filePath}")
383
- img = nib.load(filePath)
384
- self.nii_affine = img.affine
385
- self.nii_data = img.get_fdata()
521
+
522
+ if not filePath:
523
+ return
524
+
525
+ print(f"Loaded NIfTI file: {filePath}")
526
+ img = nib.load(filePath)
527
+ self.nii_affine = img.affine
528
+ self.nii_data = img.get_fdata()
386
529
 
387
- grid = pv.ImageData()
388
- grid.dimensions = np.array(self.nii_data.shape) + 1
389
- grid.cell_data['values'] = self.nii_data.flatten(order='F')
390
- self.grid = grid
530
+ self.update_volume_color_range_from_nifti()
531
+
532
+ grid = pv.ImageData()
533
+ grid.dimensions = np.array(self.nii_data.shape) + 1
534
+ grid.cell_data['values'] = self.nii_data.flatten(order='F')
535
+ self.grid = grid
391
536
 
392
537
  self.XSlider.setMaximum(self.nii_data.shape[0])
393
538
  self.YSlider.setMaximum(self.nii_data.shape[1])
394
539
  self.ZSlider.setMaximum(self.nii_data.shape[2])
395
540
 
396
541
  self.update_nii_viewer(reset_camera=False)
542
+
543
+ # Dilation for surface
544
+ self.nii_data_dilated = dilate_atlas_labels(
545
+ self.nii_data, dilation_width=4)
546
+ self.nii_data_dilated = np.where(self.nii_data_dilated == 0, np.nan,
547
+ self.nii_data_dilated)
548
+
549
+ if hasattr(self, "gii_mesh"):
550
+ # Sample NIfTI values at each surface vertex
551
+ self.nii_surface_values = self.sample_nifti_on_surface(self.gii_mesh)
552
+ self.update_gii_viewer(reset_camera=False)
553
+ else:
554
+ self.nii_surface_values = None
397
555
  self.window().refreshActorList()
398
556
 
399
557
  # 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)
558
+ ortho_data = self.nii_data.copy()
559
+ ortho_data[ortho_data == 0] = None
560
+
561
+
562
+
563
+ self.window().ortho_viewer.set_volume(ortho_data, self.nii_affine)
402
564
  self.window().ortho_viewer.set_colormap(self.niiColorMapComboBox.currentText())
403
565
 
404
566
  def loadROIFile(self):
@@ -444,9 +606,31 @@ class TrkViewer(QWidget):
444
606
  options = QFileDialog.Options()
445
607
  filePath, _ = QFileDialog.getOpenFileName(
446
608
  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)
609
+
610
+ if not filePath:
611
+ return
612
+
613
+ print(f"Loaded GIFTI file: {filePath}")
614
+
615
+ self.gii_mesh = gifti_to_pyvista(filePath)
616
+
617
+ self.gii_data = load_gii_data(filePath)
618
+
619
+ self.giiColorModeComboBox.setCurrentIndex(0)
620
+
621
+ for _ in range(self.giiColorModeComboBox.count()-2):
622
+ self.giiColorModeComboBox.removeItem(2)
623
+
624
+ self.giiColorModeComboBox.addItems(list(self.gii_data.keys()))
625
+
626
+ self.actor_types["gii_surface"] = "gii"
627
+
628
+ if hasattr(self, "nii_data"):
629
+ # Sample NIfTI values at each surface vertex
630
+ self.nii_surface_values = self.sample_nifti_on_surface(self.gii_mesh)
631
+ self.update_gii_viewer(reset_camera=False)
632
+ else:
633
+ self.nii_surface_values = None
450
634
 
451
635
  self.update_gii_viewer(reset_camera=False)
452
636
  self.window().refreshActorList()
@@ -581,31 +765,286 @@ class TrkViewer(QWidget):
581
765
  opacity = self.nii_opacitySlider.value()/1000
582
766
  cmap_name = self.niiColorMapComboBox.currentText()
583
767
 
768
+ vmin = self.niiMinSpinBox.value()
769
+ vmax = self.niiMaxSpinBox.value()
770
+
771
+ # Protect against an invalid range
772
+ if vmax <= vmin:
773
+ vmax = vmin + 1e-6
774
+
584
775
  self.plotter.add_volume(self.grid, cmap=cmap_name, opacity=[0, opacity],
585
776
  show_scalar_bar=False, name='nii_volume',
586
- reset_camera=reset_camera,
777
+ reset_camera=reset_camera, clim=[vmin, vmax],
587
778
  user_matrix=self.nii_affine)
588
779
 
589
- def update_gii_viewer(self, reset_camera=False):
780
+ def update_gii_viewer(self, reset_camera=False, update_color_range = False):
781
+ """Update the GIFTI surface rendering."""
590
782
 
591
783
  if not hasattr(self, "gii_mesh"):
592
784
  return
593
785
 
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,
786
+ self.update_surface_color_controls()
787
+
788
+ opacity = self.gii_opacitySlider.value() / 100.0
789
+
790
+ color_mode = self.giiColorModeComboBox.currentText()
791
+
792
+ if color_mode == "Solid":
793
+
794
+ self.plotter.add_mesh(self.gii_mesh, color="ghostwhite",
795
+ culling="back", smooth_shading=True,
796
+ opacity=opacity, name="gii_surface",
797
+ reset_camera=reset_camera,
798
+ point_size=0, render_lines_as_tubes=True)
799
+
800
+ else:
801
+ if color_mode == "NIfTI values":
802
+ scalars = self.nii_surface_values
803
+ else:
804
+ scalars = self.gii_data[color_mode]
805
+
806
+ if scalars is None:
807
+ self.plotter.add_mesh(self.gii_mesh, color="ghostwhite",
808
+ culling="back", smooth_shading=True,
809
+ opacity=opacity, name="gii_surface",
810
+ reset_camera=reset_camera,
811
+ point_size=0, render_lines_as_tubes=True)
812
+ return
813
+
814
+ # Copy the mesh so that the original GIFTI mesh is not modified
815
+ surface_mesh = self.gii_mesh.copy(deep=True)
816
+ surface_mesh.point_data[color_mode] = scalars
817
+
818
+ cmap_name = self.giiColorMapComboBox.currentText()
819
+
820
+ if update_color_range:
821
+ self.update_surface_color_range(scalars)
822
+
823
+ vmin = self.giiMinSpinBox.value()
824
+ vmax = self.giiMaxSpinBox.value()
825
+
826
+ # Protect against an invalid range
827
+ if vmax <= vmin:
828
+ vmax = vmin + 1e-6
829
+
830
+ self.plotter.add_mesh(surface_mesh, scalars=color_mode,
831
+ cmap=cmap_name, clim=[vmin, vmax],
832
+ culling="back", smooth_shading=True,
833
+ opacity=opacity, name="gii_surface",
834
+ reset_camera=reset_camera, point_size=0,
835
+ render_lines_as_tubes=True,
836
+ show_scalar_bar=False)
837
+
838
+ self.plotter.render()
839
+
840
+ def update_volume_color_range_from_nifti(self):
841
+
842
+ if self.nii_data is None:
843
+ return
844
+
845
+ data = np.asarray(self.nii_data, dtype=float)
846
+ finite = np.isfinite(data)
847
+
848
+ if not np.any(finite):
849
+ return
850
+
851
+ values = data[finite]
852
+
853
+ vmin = float(np.min(values))
854
+ vmax = float(np.max(values))
855
+
856
+ # Avoid identical limits because PyVista/VTK does not like
857
+ # clim=[x, x].
858
+ if np.isclose(vmin, vmax):
859
+ vmax = vmin + 1.0
860
+
861
+ self.niiMinSpinBox.blockSignals(True)
862
+ self.niiMaxSpinBox.blockSignals(True)
863
+
864
+ self.niiMinSpinBox.setValue(vmin)
865
+ self.niiMaxSpinBox.setValue(vmax)
866
+
867
+ self.niiMinSpinBox.blockSignals(False)
868
+ self.niiMaxSpinBox.blockSignals(False)
869
+
870
+ def update_surface_color_range(self,data):
871
+
872
+ if data is None:
873
+ return
874
+
875
+ data = np.asarray(data, dtype=float)
876
+ finite = np.isfinite(data)
877
+
878
+ if not np.any(finite):
879
+ return
880
+
881
+ values = data[finite]
882
+
883
+ vmin = float(np.min(values))
884
+ vmax = float(np.max(values))
885
+
886
+ # Avoid identical limits because PyVista/VTK does not like
887
+ # clim=[x, x].
888
+ if np.isclose(vmin, vmax):
889
+ vmax = vmin + 1.0
890
+
891
+ self.giiMinSpinBox.blockSignals(True)
892
+ self.giiMaxSpinBox.blockSignals(True)
893
+
894
+ self.giiMinSpinBox.setValue(vmin)
895
+ self.giiMaxSpinBox.setValue(vmax)
896
+
897
+ self.giiMinSpinBox.blockSignals(False)
898
+ self.giiMaxSpinBox.blockSignals(False)
899
+
900
+ def sample_nifti_on_surface(self, mesh):
901
+ """
902
+ Trilinearly sample the NIfTI volume onto the surface.
903
+
904
+ Vertices outside the NIfTI are assigned NaN.
905
+ """
906
+
907
+ if self.nii_data is None or not hasattr(self, "nii_affine"):
908
+ return None
909
+
910
+ volume = np.asarray(self.nii_data_dilated, dtype=np.float64)
911
+
912
+ points_world = np.asarray(mesh.points, dtype=np.float64)
913
+
914
+ inv_affine = np.linalg.inv(self.nii_affine)
915
+
916
+ homogeneous = np.column_stack([
917
+ points_world,
918
+ np.ones(len(points_world))
919
+ ])
920
+
921
+ points_voxel = (homogeneous @ inv_affine.T)[:, :3]
922
+
923
+ nx, ny, nz = volume.shape[:3]
924
+
925
+ # Identify points that are actually inside the NIfTI
926
+ inside = (
927
+ (points_voxel[:, 0] >= 0) &
928
+ (points_voxel[:, 0] <= nx - 1) &
929
+ (points_voxel[:, 1] >= 0) &
930
+ (points_voxel[:, 1] <= ny - 1) &
931
+ (points_voxel[:, 2] >= 0) &
932
+ (points_voxel[:, 2] <= nz - 1)
606
933
  )
607
934
 
608
- # self.plotter.render()
935
+ # Clip only for interpolation
936
+ x = np.clip(points_voxel[:, 0], 0, nx - 1)
937
+ y = np.clip(points_voxel[:, 1], 0, ny - 1)
938
+ z = np.clip(points_voxel[:, 2], 0, nz - 1)
939
+
940
+ x0 = np.floor(x).astype(int)
941
+ y0 = np.floor(y).astype(int)
942
+ z0 = np.floor(z).astype(int)
943
+
944
+ x1 = np.minimum(x0 + 1, nx - 1)
945
+ y1 = np.minimum(y0 + 1, ny - 1)
946
+ z1 = np.minimum(z0 + 1, nz - 1)
947
+
948
+ xd = x - x0
949
+ yd = y - y0
950
+ zd = z - z0
951
+
952
+ c000 = volume[x0, y0, z0]
953
+ c001 = volume[x0, y0, z1]
954
+ c010 = volume[x0, y1, z0]
955
+ c011 = volume[x0, y1, z1]
956
+
957
+ c100 = volume[x1, y0, z0]
958
+ c101 = volume[x1, y0, z1]
959
+ c110 = volume[x1, y1, z0]
960
+ c111 = volume[x1, y1, z1]
961
+
962
+ c00 = c000 * (1 - xd) + c100 * xd
963
+ c01 = c001 * (1 - xd) + c101 * xd
964
+ c10 = c010 * (1 - xd) + c110 * xd
965
+ c11 = c011 * (1 - xd) + c111 * xd
966
+
967
+ c0 = c00 * (1 - yd) + c10 * yd
968
+ c1 = c01 * (1 - yd) + c11 * yd
969
+
970
+ values = c0 * (1 - zd) + c1 * zd
971
+
972
+ # Outside the NIfTI = NaN
973
+ values[~inside] = np.nan
974
+
975
+ return values.astype(np.float32)
976
+
977
+ def sample_nifti_on_surface2(self, mesh):
978
+ """
979
+ !!! Currently unused
980
+ Sample the currently loaded NIfTI volume at every GIFTI surface vertex.
981
+
982
+ GIFTI vertices are assumed to be in world/mm coordinates.
983
+ The NIfTI affine is used to convert them into voxel coordinates.
984
+
985
+ Nearest-neighbor sampling is used.
986
+ """
987
+
988
+ if self.nii_data is None:
989
+ return None
990
+
991
+ if not hasattr(self, 'nii_affine'):
992
+ return None
993
+
994
+ vertices_world = np.asarray(mesh.points)
995
+
996
+ # Convert world coordinates -> NIfTI voxel coordinates
997
+ inv_affine = np.linalg.inv(self.nii_affine)
998
+
999
+ homogeneous = np.column_stack([
1000
+ vertices_world,
1001
+ np.ones(len(vertices_world))
1002
+ ])
1003
+
1004
+ vertices_voxel = (homogeneous @ inv_affine.T)[:, :3]
1005
+
1006
+ # Nearest-neighbor voxel coordinates
1007
+ vertices_voxel = np.rint(vertices_voxel).astype(int)
1008
+
1009
+ shape = self.nii_data.shape
1010
+
1011
+ # Determine which vertices are inside the NIfTI
1012
+ valid = (
1013
+ (vertices_voxel[:, 0] >= 0) &
1014
+ (vertices_voxel[:, 0] < shape[0]) &
1015
+ (vertices_voxel[:, 1] >= 0) &
1016
+ (vertices_voxel[:, 1] < shape[1]) &
1017
+ (vertices_voxel[:, 2] >= 0) &
1018
+ (vertices_voxel[:, 2] < shape[2])
1019
+ )
1020
+
1021
+ # Initialize outside-of-volume vertices with NaN
1022
+ values = np.full(len(vertices_world), np.nan, dtype=np.float32)
1023
+
1024
+ valid_voxels = vertices_voxel[valid]
1025
+
1026
+ values[valid] = self.nii_data[
1027
+ valid_voxels[:, 0],
1028
+ valid_voxels[:, 1],
1029
+ valid_voxels[:, 2]
1030
+ ]
1031
+
1032
+ return values
1033
+
1034
+ def update_surface_color_controls(self):
1035
+ """Enable surface scalar controls only when using NIfTI values."""
1036
+
1037
+ use_nifti = (self.giiColorModeComboBox.currentText()
1038
+ != "Solid")
1039
+
1040
+ self.giiColorMapLabel.setEnabled(use_nifti)
1041
+ self.giiColorMapComboBox.setEnabled(use_nifti)
1042
+
1043
+ self.giiMinLabel.setEnabled(use_nifti)
1044
+ self.giiMinSpinBox.setEnabled(use_nifti)
1045
+
1046
+ self.giiMaxLabel.setEnabled(use_nifti)
1047
+ self.giiMaxSpinBox.setEnabled(use_nifti)
609
1048
 
610
1049
 
611
1050
  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.7.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
@@ -15,6 +15,7 @@ Requires-Dist: dipy
15
15
  Requires-Dist: pyvista
16
16
  Requires-Dist: pyvistaqt
17
17
  Requires-Dist: PyQt6
18
+ Requires-Dist: binama
18
19
  Dynamic: license-file
19
20
 
20
21
  # UNVEIL
@@ -2,3 +2,4 @@ dipy
2
2
  pyvista
3
3
  pyvistaqt
4
4
  PyQt6
5
+ binama
File without changes
File without changes
File without changes