unveil-python 0.4.0__tar.gz → 0.5.1__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.
@@ -0,0 +1,74 @@
1
+ Metadata-Version: 2.4
2
+ Name: unveil-python
3
+ Version: 0.5.1
4
+ Summary: Implementation of UNVEIL
5
+ Author-email: Nicolas Delinte <nicolas.delinte@uclouvain.be>
6
+ License-Expression: GPL-3.0-only
7
+ Project-URL: GitHub, https://github.com/DelinteNicolas/UNVEIL
8
+ Keywords: tractography,viewer
9
+ Classifier: Intended Audience :: Science/Research
10
+ Classifier: Natural Language :: English
11
+ Classifier: Programming Language :: Python
12
+ Description-Content-Type: text/markdown
13
+ License-File: LICENSE
14
+ Requires-Dist: dipy
15
+ Requires-Dist: pyvista
16
+ Requires-Dist: pyvistaqt
17
+ Requires-Dist: PyQt6
18
+ Dynamic: license-file
19
+
20
+ # UNVEIL
21
+ [![PyPI](https://img.shields.io/pypi/v/unveil-python?label=pypi%20package)](https://pypi.org/project/unveil-python/)
22
+ [![PyPI - Downloads](https://img.shields.io/pypi/dm/unveil-python)](https://pypi.org/project/unveil-python/)
23
+ ![GitHub repo size](https://img.shields.io/github/repo-size/DelinteNicolas/unveil)
24
+
25
+
26
+ UNVEIL is a Python-based visualization tool for interactive exploration of diffusion MRI tractography, anatomical volumes, cortical surfaces, and ROI segmentations. It combines a 3D PyVista viewer with an orthogonal slice viewer.
27
+
28
+ <img width="1297" height="757" alt="image" src="https://github.com/user-attachments/assets/28807e18-17fb-485b-a8ce-2179cdef796b" />
29
+
30
+ ## Install
31
+
32
+ The GUI is launched with the following python command in a python console:
33
+
34
+ ```
35
+ import unveil.__main__
36
+ ```
37
+ or with
38
+ ```
39
+ python -c "import unveil.__main__"
40
+ ```
41
+ from a terminal with a python environment activated.
42
+
43
+ ## Features
44
+
45
+ - **3D Tractography Visualization**
46
+ - Load and display `.trk` streamline files.
47
+ - RGB orientation coloring or scalar-based coloring from NIfTI volumes.
48
+ - Adjustable opacity and point/line rendering.
49
+ - **Volume Rendering**
50
+ - Load `.nii.gz` images.
51
+ - Interactive volume rendering and orthogonal slice planes.
52
+ - Adjustable opacity.
53
+ - **ROI Visualization**
54
+ - Load ROI masks from `.nii` / `.nii.gz`.
55
+ - Automatic surface extraction and smoothing.
56
+ - Individual visibility and color control.
57
+ - **Surface Visualization**
58
+ - Load GIFTI (`.gii`) cortical surfaces.
59
+ - Adjustable opacity and smooth shading.
60
+ - **Orthogonal Viewer**
61
+ - Axial, coronal, and sagittal views.
62
+ - Mouse-wheel slice navigation.
63
+ - Click-to-navigate crosshair synchronization.
64
+ - ROI overlay display.
65
+ - Screenshot export.
66
+ - **Scene Management**
67
+ - Actor visibility control.
68
+ - ROI color editing.
69
+ - Background color toggle.
70
+ - Color-blind-friendly tractography coloring.
71
+ - **Export**
72
+ - 3D screenshots.
73
+ - Orthogonal-view screenshots.
74
+ - Animated 360° GIF generation.
@@ -0,0 +1,55 @@
1
+ # UNVEIL
2
+ [![PyPI](https://img.shields.io/pypi/v/unveil-python?label=pypi%20package)](https://pypi.org/project/unveil-python/)
3
+ [![PyPI - Downloads](https://img.shields.io/pypi/dm/unveil-python)](https://pypi.org/project/unveil-python/)
4
+ ![GitHub repo size](https://img.shields.io/github/repo-size/DelinteNicolas/unveil)
5
+
6
+
7
+ UNVEIL is a Python-based visualization tool for interactive exploration of diffusion MRI tractography, anatomical volumes, cortical surfaces, and ROI segmentations. It combines a 3D PyVista viewer with an orthogonal slice viewer.
8
+
9
+ <img width="1297" height="757" alt="image" src="https://github.com/user-attachments/assets/28807e18-17fb-485b-a8ce-2179cdef796b" />
10
+
11
+ ## Install
12
+
13
+ The GUI is launched with the following python command in a python console:
14
+
15
+ ```
16
+ import unveil.__main__
17
+ ```
18
+ or with
19
+ ```
20
+ python -c "import unveil.__main__"
21
+ ```
22
+ from a terminal with a python environment activated.
23
+
24
+ ## Features
25
+
26
+ - **3D Tractography Visualization**
27
+ - Load and display `.trk` streamline files.
28
+ - RGB orientation coloring or scalar-based coloring from NIfTI volumes.
29
+ - Adjustable opacity and point/line rendering.
30
+ - **Volume Rendering**
31
+ - Load `.nii.gz` images.
32
+ - Interactive volume rendering and orthogonal slice planes.
33
+ - Adjustable opacity.
34
+ - **ROI Visualization**
35
+ - Load ROI masks from `.nii` / `.nii.gz`.
36
+ - Automatic surface extraction and smoothing.
37
+ - Individual visibility and color control.
38
+ - **Surface Visualization**
39
+ - Load GIFTI (`.gii`) cortical surfaces.
40
+ - Adjustable opacity and smooth shading.
41
+ - **Orthogonal Viewer**
42
+ - Axial, coronal, and sagittal views.
43
+ - Mouse-wheel slice navigation.
44
+ - Click-to-navigate crosshair synchronization.
45
+ - ROI overlay display.
46
+ - Screenshot export.
47
+ - **Scene Management**
48
+ - Actor visibility control.
49
+ - ROI color editing.
50
+ - Background color toggle.
51
+ - Color-blind-friendly tractography coloring.
52
+ - **Export**
53
+ - 3D screenshots.
54
+ - Orthogonal-view screenshots.
55
+ - Animated 360° GIF generation.
@@ -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.4.0"
4
+ __version__ = "0.5.1"
5
5
  __author__ = 'Nicolas Delinte'
@@ -10,14 +10,17 @@ import nibabel as nib
10
10
  import pyvista as pv
11
11
  from pyvistaqt import QtInteractor
12
12
  from PyQt6.QtCore import Qt
13
- from PyQt6.QtGui import QAction
14
13
  from PyQt6.QtWidgets import (QApplication, QWidget, QHBoxLayout, QVBoxLayout,
15
14
  QPushButton, QFileDialog, QCheckBox, QSlider,
16
15
  QLabel, QComboBox, QMainWindow, QLineEdit,
17
- QDockWidget, QTreeWidget, QTreeWidgetItem)
16
+ QDockWidget, QTreeWidget, QTreeWidgetItem,
17
+ QColorDialog, QTabWidget)
18
+ from PyQt6.QtGui import QAction, QColor
18
19
  from dipy.io.streamline import load_tractogram
19
20
  from matplotlib import pyplot as plt
20
21
  from matplotlib.colors import LinearSegmentedColormap
22
+ from matplotlib.backends.backend_qtagg import FigureCanvasQTAgg
23
+ from matplotlib.figure import Figure
21
24
  # from unravel.utils import get_streamline_density
22
25
 
23
26
 
@@ -185,18 +188,34 @@ def plot_trk(trk_file, scalar=None, color_map='plasma', opacity: float = 1,
185
188
  class TrkViewer(QWidget):
186
189
  def __init__(self):
187
190
  super().__init__()
188
- self.trk_file = None
189
- self.nii_file = None
190
191
  self.nii_data = None
191
192
  self.grid = None
192
- self.names = []
193
+ self.actor_types = {}
193
194
  self.x = 0
194
195
  self.y = 0
195
196
  self.z = 0
197
+ self.colormap_list = ['gray',
198
+ 'viridis', 'plasma', 'inferno', 'magma', 'cividis',
199
+ 'Greys', 'Purples', 'Blues', 'Greens', 'Oranges',
200
+ 'Reds', 'bone', 'pink', 'spring', 'summer',
201
+ 'autumn', 'winter', 'cool', 'Wistia', 'hot',
202
+ 'afmhot', 'gist_heat', 'copper', 'RdBu', 'RdYlBu',
203
+ 'RdYlGn', 'Spectral', 'coolwarm', 'bwr', 'seismic',
204
+ 'berlin', 'managua', 'vanimo', 'twilight',
205
+ 'twilight_shifted', 'hsv', 'Pastel1', 'Pastel2',
206
+ 'Paired', 'Accent', 'okabe_ito', 'Dark2', 'Set1',
207
+ 'Set2', 'Set3', 'tab10', 'tab20', 'tab20b', 'tab20c',
208
+ 'flag', 'prism', 'ocean', 'gist_earth', 'terrain',
209
+ 'gist_stern', 'gnuplot', 'gnuplot2', 'CMRmap',
210
+ 'cubehelix', 'brg', 'gist_rainbow', 'rainbow',
211
+ 'jet', 'turbo', 'nipy_spectral', 'gist_ncar']
196
212
  self.initUI()
197
213
  self.background = 'white'
198
214
  self.color_blind = False
199
215
  self.flicker = False
216
+ self.roi_colors = {}
217
+ self.roi_volumes = {}
218
+ self.roi_visibility = {}
200
219
 
201
220
  def initUI(self):
202
221
  # Create the main layout
@@ -209,10 +228,10 @@ class TrkViewer(QWidget):
209
228
  btn_xy = QPushButton("Axe 1")
210
229
  btn_xz = QPushButton("Axe 2")
211
230
  btn_yz = QPushButton("Axe 3")
212
- btn_iso.clicked.connect(self.view_isometric)
213
- btn_xy.clicked.connect(self.view_xy)
214
- btn_xz.clicked.connect(self.view_xz)
215
- btn_yz.clicked.connect(self.view_yz)
231
+ btn_iso.clicked.connect(lambda: self.set_view("view_isometric"))
232
+ btn_xy.clicked.connect(lambda: self.set_view("view_xy"))
233
+ btn_xz.clicked.connect(lambda: self.set_view("view_xz"))
234
+ btn_yz.clicked.connect(lambda: self.set_view("view_yz"))
216
235
  cam_layout.addWidget(camera_label)
217
236
  cam_layout.addWidget(btn_iso)
218
237
  cam_layout.addWidget(btn_xy)
@@ -250,7 +269,7 @@ class TrkViewer(QWidget):
250
269
  self.showPointsCheckbox.stateChanged.connect(self.update_trk_viewer)
251
270
  control_layout.addWidget(self.showPointsCheckbox)
252
271
 
253
- self.colorMapLabel = QLabel('Color Map:')
272
+ self.colorMapLabel = QLabel('Tract Color Mode:')
254
273
  control_layout.addWidget(self.colorMapLabel)
255
274
 
256
275
  self.colorMapComboBox = QComboBox()
@@ -258,13 +277,17 @@ class TrkViewer(QWidget):
258
277
  self.colorMapComboBox.currentIndexChanged.connect(
259
278
  self.update_trk_viewer)
260
279
  control_layout.addWidget(self.colorMapComboBox)
261
- self.colorMapEdit = QLineEdit()
262
- self.colorMapEdit.textChanged.connect(self.update_trk_viewer)
263
- self.colorMapEdit.setToolTip(
264
- 'Insert color map name. Name must be in the matplotlib library. Scalar nii.gz must be loaded.')
265
- control_layout.addWidget(self.colorMapEdit)
266
280
 
267
- self.volume_label = QLabel('Volume')
281
+ self.tractColorMapLabel = QLabel('Tract Colormap (Scalar):')
282
+ control_layout.addWidget(self.tractColorMapLabel)
283
+
284
+ self.tractColorMapComboBox = QComboBox()
285
+ self.tractColorMapComboBox.addItems(self.colormap_list)
286
+ self.tractColorMapComboBox.currentIndexChanged.connect(
287
+ self.update_trk_viewer)
288
+ control_layout.addWidget(self.tractColorMapComboBox)
289
+
290
+ self.volume_label = QLabel('Volume Opacity')
268
291
  control_layout.addWidget(self.volume_label)
269
292
 
270
293
  self.nii_opacitySlider = QSlider(Qt.Orientation.Horizontal, self)
@@ -274,6 +297,15 @@ class TrkViewer(QWidget):
274
297
  self.nii_opacitySlider.sliderReleased.connect(self.update_nii_viewer)
275
298
  control_layout.addWidget(self.nii_opacitySlider)
276
299
 
300
+ self.niiColorMapLabel = QLabel('Global Volume Colormap:')
301
+ control_layout.addWidget(self.niiColorMapLabel)
302
+
303
+ self.niiColorMapComboBox = QComboBox()
304
+ self.niiColorMapComboBox.addItems(self.colormap_list)
305
+ self.niiColorMapComboBox.currentIndexChanged.connect(
306
+ self.on_nii_colormap_changed)
307
+ control_layout.addWidget(self.niiColorMapComboBox)
308
+
277
309
  self.showSlicesCheckbox = QCheckBox('Show Slices')
278
310
  self.showSlicesCheckbox.stateChanged.connect(self.update_nii_viewer)
279
311
  control_layout.addWidget(self.showSlicesCheckbox)
@@ -284,7 +316,8 @@ class TrkViewer(QWidget):
284
316
  self.XSlider.setMinimum(0)
285
317
  self.XSlider.setMaximum(100)
286
318
  self.XSlider.setValue(100)
287
- self.XSlider.sliderReleased.connect(self._update_nii_x)
319
+ self.XSlider.sliderReleased.connect(
320
+ lambda: self._update_slice('x', 'nii_x'))
288
321
  control_layout.addWidget(self.XSlider)
289
322
  self.y_label = QLabel('Y')
290
323
  control_layout.addWidget(self.y_label)
@@ -292,7 +325,8 @@ class TrkViewer(QWidget):
292
325
  self.YSlider.setMinimum(0)
293
326
  self.YSlider.setMaximum(100)
294
327
  self.YSlider.setValue(100)
295
- self.YSlider.sliderReleased.connect(self._update_nii_y)
328
+ self.YSlider.sliderReleased.connect(
329
+ lambda: self._update_slice('y', 'nii_y'))
296
330
  control_layout.addWidget(self.YSlider)
297
331
  self.z_label = QLabel('Z')
298
332
  control_layout.addWidget(self.z_label)
@@ -300,7 +334,8 @@ class TrkViewer(QWidget):
300
334
  self.ZSlider.setMinimum(0)
301
335
  self.ZSlider.setMaximum(100)
302
336
  self.ZSlider.setValue(100)
303
- self.ZSlider.sliderReleased.connect(self._update_nii_z)
337
+ self.ZSlider.sliderReleased.connect(
338
+ lambda: self._update_slice('z', 'nii_z'))
304
339
  control_layout.addWidget(self.ZSlider)
305
340
 
306
341
  # Gifti
@@ -333,23 +368,22 @@ class TrkViewer(QWidget):
333
368
  return
334
369
 
335
370
  for trk_path in filePaths:
336
- self.trk_file = trk_path
337
- print(f"Loaded TRK file: {self.trk_file}")
338
- self.names.append(self.trk_file)
371
+ print(f"Loaded TRK file: {trk_path}")
372
+ self.actor_types[trk_path] = 'trk'
339
373
 
340
374
  self.update_trk_viewer(reset_camera=True)
341
- self.parent().refreshActorList()
375
+ self.window().refreshActorList()
342
376
 
343
377
  def loadNiftiFile(self):
344
378
  options = QFileDialog.Options()
345
379
  filePath, _ = QFileDialog.getOpenFileName(
346
380
  self, "Open .nii.gz File", "", "Nifti Files (*.nii.gz)", options=options)
347
381
  if filePath:
348
- self.nii_file = filePath
349
- print(f"Loaded NIfTI file: {self.nii_file}")
382
+ print(f"Loaded NIfTI file: {filePath}")
350
383
  img = nib.load(filePath)
351
384
  self.nii_affine = img.affine
352
385
  self.nii_data = img.get_fdata()
386
+
353
387
  grid = pv.ImageData()
354
388
  grid.dimensions = np.array(self.nii_data.shape) + 1
355
389
  grid.cell_data['values'] = self.nii_data.flatten(order='F')
@@ -360,7 +394,12 @@ class TrkViewer(QWidget):
360
394
  self.ZSlider.setMaximum(self.nii_data.shape[2])
361
395
 
362
396
  self.update_nii_viewer(reset_camera=False)
363
- self.parent().refreshActorList()
397
+ self.window().refreshActorList()
398
+
399
+ # 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)
402
+ self.window().ortho_viewer.set_colormap(self.niiColorMapComboBox.currentText())
364
403
 
365
404
  def loadROIFile(self):
366
405
  """Load one or multiple ROI nifti volumes and render as surfaces."""
@@ -383,26 +422,34 @@ class TrkViewer(QWidget):
383
422
 
384
423
  smooth = mesh.smooth_taubin(n_iter=12)
385
424
 
386
- actor_name = f"roi_{roi_path.split('/')[-1]}"
425
+ actor_name = f"{roi_path.split('/')[-1]}"
426
+ self.actor_types[actor_name] = "roi"
427
+ self.roi_volumes[actor_name] = roi
428
+ self.roi_visibility[actor_name] = True
429
+
430
+ default_color = np.array([1.0, 1.0, 1.0])
431
+
432
+ self.roi_colors[actor_name] = default_color
387
433
 
388
434
  self.plotter.add_mesh(smooth, name=actor_name, opacity=1,
389
- color="white", user_matrix=img.affine,
435
+ color=default_color, user_matrix=img.affine,
390
436
  reset_camera=False, point_size=0,
391
437
  render_lines_as_tubes=True)
392
438
 
393
- self.parent().refreshActorList()
439
+ self.window().refreshActorList()
440
+
441
+ self.window().ortho_viewer.add_roi(actor_name, roi, default_color)
394
442
 
395
443
  def loadGiftiFile(self):
396
444
  options = QFileDialog.Options()
397
445
  filePath, _ = QFileDialog.getOpenFileName(
398
446
  self, "Open .gii File", "", "Gifti Files (*.gii)", options=options)
399
447
  if filePath:
400
- self.gii_file = filePath
401
- print(f"Loaded GIfTI file: {self.gii_file}")
448
+ print(f"Loaded GIfTI file: {filePath}")
402
449
  self.gii_mesh = gifti_to_pyvista(filePath)
403
450
 
404
451
  self.update_gii_viewer(reset_camera=False)
405
- self.parent().refreshActorList()
452
+ self.window().refreshActorList()
406
453
 
407
454
  def set_background_color(self):
408
455
 
@@ -413,29 +460,16 @@ class TrkViewer(QWidget):
413
460
 
414
461
  self.plotter.background_color = self.background
415
462
 
463
+ self.window().ortho_viewer.set_background(self.background)
464
+
416
465
  def toggleColorBlind(self):
417
466
 
418
467
  self.color_blind = not self.color_blind
419
468
  self.update_trk_viewer()
420
469
 
421
- def view_isometric(self):
422
- self.plotter.view_isometric()
423
- self.plotter.camera.azimuth += self.flicker*180
424
- self.flicker = not self.flicker
425
-
426
- def view_xy(self):
427
- self.plotter.view_xy()
428
- self.plotter.camera.azimuth += self.flicker*180
429
- self.flicker = not self.flicker
430
-
431
- def view_xz(self):
432
- self.plotter.view_xz()
433
- self.plotter.camera.azimuth += self.flicker*180
434
- self.flicker = not self.flicker
435
-
436
- def view_yz(self):
437
- self.plotter.view_yz()
438
- self.plotter.camera.azimuth += self.flicker*180
470
+ def set_view(self, method):
471
+ getattr(self.plotter, method)()
472
+ self.plotter.camera.azimuth += self.flicker * 180
439
473
  self.flicker = not self.flicker
440
474
 
441
475
  def take_screenshot(self):
@@ -487,104 +521,293 @@ class TrkViewer(QWidget):
487
521
  self.plotter.render()
488
522
  offscreen.close()
489
523
 
524
+ def on_nii_colormap_changed(self):
525
+ self.update_nii_viewer()
526
+ if hasattr(self.window(), 'ortho_viewer'):
527
+ self.window().ortho_viewer.set_colormap(self.niiColorMapComboBox.currentText())
528
+
490
529
  def update_trk_viewer(self, reset_camera: bool = False):
491
530
 
492
531
  opacity = self.opacitySlider.value() / 100.0
493
532
  show_points = self.showPointsCheckbox.isChecked()
494
533
 
495
- color_map = self.colorMapComboBox.currentText()
496
- if color_map == 'flesh':
534
+ color_mode = self.colorMapComboBox.currentText()
535
+ if color_mode == 'flesh':
497
536
  color_map = 'flesh'
498
537
  scalar = None
499
- elif color_map == 'rgb':
538
+ elif color_mode == 'rgb':
500
539
  color_map = 'plasma'
501
540
  scalar = None
502
541
  else:
503
- color_map = self.colorMapEdit.text()
542
+ color_map = self.tractColorMapComboBox.currentText()
504
543
  scalar = self.nii_data
505
544
 
506
- for file in self.names:
507
-
508
- plot_trk(file, opacity=opacity, plotter=self.plotter, scalar=scalar,
509
- show_points=show_points, color_map=color_map,
510
- name=file, background=self.background,
511
- reset_camera=reset_camera, color_blind=self.color_blind)
512
-
513
- def _update_nii_x(self):
545
+ for file in list(self.actor_types):
514
546
 
515
- if self.showSlicesCheckbox.isChecked():
547
+ if self.actor_types.get(file) == 'trk':
516
548
 
517
- center = (self.XSlider.value(),
518
- self.YSlider.value(), self.ZSlider.value())
549
+ plot_trk(file, opacity=opacity, plotter=self.plotter, scalar=scalar,
550
+ show_points=show_points, color_map=color_map,
551
+ name=file, background=self.background,
552
+ reset_camera=reset_camera, color_blind=self.color_blind)
519
553
 
520
- slice_x = self.grid.slice('x', center)
521
- self.plotter.add_mesh(slice_x, cmap='grey', name='nii_x',
522
- show_scalar_bar=False, point_size=0,
523
- render_lines_as_tubes=True,
524
- reset_camera=False,
525
- user_matrix=self.nii_affine)
526
- else:
527
- self.plotter.remove_actor('nii_x')
554
+ def _update_slice(self, axis, actor_name):
528
555
 
529
- def _update_nii_y(self):
556
+ if self.showSlicesCheckbox.isChecked() and self.grid is not None:
530
557
 
531
- if self.showSlicesCheckbox.isChecked():
558
+ center = (self.XSlider.value(), self.YSlider.value(),
559
+ self.ZSlider.value())
532
560
 
533
- center = (self.XSlider.value(),
534
- self.YSlider.value(), self.ZSlider.value())
561
+ slice_mesh = self.grid.slice(axis, origin=center)
562
+ cmap_name = self.niiColorMapComboBox.currentText()
535
563
 
536
- slice_y = self.grid.slice('y', center)
537
- self.plotter.add_mesh(slice_y, cmap='grey', name='nii_y',
564
+ self.plotter.add_mesh(slice_mesh, cmap=cmap_name, name=actor_name,
538
565
  show_scalar_bar=False, point_size=0,
539
566
  render_lines_as_tubes=True,
540
567
  reset_camera=False,
541
568
  user_matrix=self.nii_affine)
542
- else:
543
- self.plotter.remove_actor('nii_y')
544
-
545
- def _update_nii_z(self):
546
-
547
- if self.showSlicesCheckbox.isChecked():
548
569
 
549
- center = (self.XSlider.value(),
550
- self.YSlider.value(), self.ZSlider.value())
551
-
552
- slice_z = self.grid.slice('z', center)
553
- self.plotter.add_mesh(slice_z, cmap='grey', name='nii_z',
554
- show_scalar_bar=False, point_size=0,
555
- render_lines_as_tubes=True,
556
- reset_camera=False,
557
- user_matrix=self.nii_affine)
558
570
  else:
559
- self.plotter.remove_actor('nii_z')
571
+ self.plotter.remove_actor(actor_name)
560
572
 
561
573
  def update_nii_viewer(self, reset_camera: bool = False):
574
+ if self.grid is None:
575
+ return
562
576
 
563
- self._update_nii_x()
564
- self._update_nii_y()
565
- self._update_nii_z()
577
+ self._update_slice('x', 'nii_x')
578
+ self._update_slice('y', 'nii_y')
579
+ self._update_slice('z', 'nii_z')
566
580
 
567
581
  opacity = self.nii_opacitySlider.value()/1000
568
- self.plotter.add_volume(self.grid, cmap='gray', opacity=[0, opacity],
582
+ cmap_name = self.niiColorMapComboBox.currentText()
583
+
584
+ self.plotter.add_volume(self.grid, cmap=cmap_name, opacity=[0, opacity],
569
585
  show_scalar_bar=False, name='nii_volume',
570
586
  reset_camera=reset_camera,
571
587
  user_matrix=self.nii_affine)
572
588
 
573
- def update_gii_viewer(self, reset_camera: bool = False):
589
+ def update_gii_viewer(self, reset_camera=False):
590
+
591
+ if not hasattr(self, "gii_mesh"):
592
+ return
593
+
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,
606
+ )
607
+
608
+ # self.plotter.render()
609
+
610
+
611
+ class OrthogonalViewer(QWidget):
612
+
613
+ def __init__(self):
614
+ super().__init__()
615
+
616
+ self.volume = None
617
+ self.affine = None
618
+ self.cmap = "gray"
619
+
620
+ self.rois = {}
621
+ self.roi_visibility = {}
622
+
623
+ self.background = "white"
624
+
625
+ layout = QVBoxLayout(self)
626
+
627
+ toolbar = QHBoxLayout()
628
+
629
+ btn_screenshot = QPushButton("Screenshot")
630
+ btn_screenshot.clicked.connect(self.take_screenshot)
631
+
632
+ toolbar.addStretch()
633
+ toolbar.addWidget(btn_screenshot)
634
+
635
+ layout.addLayout(toolbar)
636
+
637
+ self.fig = Figure(figsize=(12, 4), layout='constrained')
638
+ self.canvas = FigureCanvasQTAgg(self.fig)
639
+
640
+ self.ax_axial = self.fig.add_subplot(131)
641
+ self.ax_coronal = self.fig.add_subplot(132)
642
+ self.ax_sagittal = self.fig.add_subplot(133)
643
+
644
+ layout.addWidget(self.canvas)
645
+
646
+ self.x = 0
647
+ self.y = 0
648
+ self.z = 0
649
+
650
+ self.canvas.mpl_connect("scroll_event", self.on_scroll)
651
+ self.canvas.mpl_connect("button_press_event", self.on_click)
652
+
653
+ def set_volume(self, volume, affine=None):
654
+
655
+ self.volume = volume
656
+ self.affine = affine
657
+
658
+ self.x = volume.shape[0] // 2
659
+ self.y = volume.shape[1] // 2
660
+ self.z = volume.shape[2] // 2
661
+
662
+ self.update_views()
663
+
664
+ def set_colormap(self, cmap_name):
665
+ self.cmap = cmap_name
666
+ self.update_views()
667
+
668
+ def add_roi(self, name, roi, color):
669
+
670
+ self.rois[name] = {"data": roi, "color": color}
671
+ self.roi_visibility[name] = True
672
+ self.update_views()
673
+
674
+ def draw_roi(self, ax, roi_slice, color):
675
+
676
+ if roi_slice.max() == 0:
677
+ return
678
+
679
+ ax.contour(roi_slice, levels=[0.5], colors=[color], linewidths=1.5,
680
+ antialiased=True)
681
+ ax.contourf(roi_slice, levels=[0.5, roi_slice.max()+1], colors=[color],
682
+ alpha=0.5)
683
+
684
+ def update_views(self):
685
+
686
+ if self.volume is None:
687
+ return
688
+
689
+ x = self.x
690
+ y = self.y
691
+ z = self.z
692
+
693
+ axial = self.volume[:, :, z]
694
+ coronal = self.volume[:, y, :]
695
+ sagittal = self.volume[x, :, :]
696
+
697
+ self.ax_axial.clear()
698
+ self.ax_coronal.clear()
699
+ self.ax_sagittal.clear()
574
700
 
575
- opacity = self.gii_opacitySlider.value()/100
576
- self.plotter.add_mesh(self.gii_mesh, color="ghostwhite",
577
- culling='back', smooth_shading=True,
578
- opacity=opacity, name='gii_surface',
579
- reset_camera=reset_camera, point_size=0,
580
- render_lines_as_tubes=True,)
701
+ for ax in (self.ax_axial, self.ax_coronal, self.ax_sagittal):
702
+ ax.axis("off")
703
+
704
+ self.ax_axial.imshow(np.rot90(axial), cmap=self.cmap, vmin=0)
705
+ self.ax_coronal.imshow(np.rot90(coronal), cmap=self.cmap, vmin=0)
706
+ self.ax_sagittal.imshow(np.rot90(sagittal), cmap=self.cmap, vmin=0)
707
+
708
+ for name, roi_info in self.rois.items():
709
+
710
+ if not self.roi_visibility[name]:
711
+ continue
712
+
713
+ roi = roi_info["data"]
714
+ color = roi_info["color"]
715
+
716
+ self.draw_roi(self.ax_axial, np.rot90(roi[:, :, z]), color)
717
+ self.draw_roi(self.ax_coronal, np.rot90(roi[:, y, :]), color)
718
+ self.draw_roi(self.ax_sagittal, np.rot90(roi[x, :, :]), color)
719
+
720
+ self.canvas.draw_idle()
721
+
722
+ def take_screenshot(self):
723
+
724
+ options = QFileDialog.Options()
725
+
726
+ file_path, _ = QFileDialog.getSaveFileName(self,
727
+ "Save Orthogonal View", "",
728
+ "PNG Image (*.png);;JPEG Image (*.jpg);;TIFF Image (*.tif);; SVG Image (*.svg)",
729
+ options=options
730
+ )
731
+
732
+ if file_path:
733
+ self.fig.savefig(file_path, dpi=300, transparent=True)
734
+
735
+ def set_background(self, color):
736
+
737
+ self.background = color
738
+
739
+ self.fig.patch.set_facecolor(color)
740
+
741
+ for ax in (self.ax_axial, self.ax_coronal, self.ax_sagittal):
742
+ ax.set_facecolor(color)
743
+
744
+ self.canvas.draw_idle()
745
+
746
+ def on_scroll(self, event):
747
+
748
+ if self.volume is None:
749
+ return
750
+
751
+ step = 1 if event.button == "up" else -1
752
+
753
+ if event.inaxes == self.ax_axial:
754
+ self.z = np.clip(self.z + step, 0, self.volume.shape[2]-1)
755
+
756
+ elif event.inaxes == self.ax_coronal:
757
+ self.y = np.clip(self.y + step, 0, self.volume.shape[1]-1)
758
+
759
+ elif event.inaxes == self.ax_sagittal:
760
+ self.x = np.clip(self.x + step, 0, self.volume.shape[0]-1)
761
+
762
+ self.update_views()
763
+
764
+ def on_click(self, event):
765
+
766
+ if self.volume is None:
767
+ return
768
+
769
+ if event.inaxes is None:
770
+ return
771
+
772
+ if event.xdata is None or event.ydata is None:
773
+ return
774
+
775
+ col = int(round(event.xdata))
776
+ row = int(round(event.ydata))
777
+
778
+ if event.inaxes == self.ax_axial:
779
+
780
+ self.x = np.clip(col, 0, self.volume.shape[0]-1)
781
+ self.y = np.clip(
782
+ self.volume.shape[1]-1-row, 0, self.volume.shape[1]-1)
783
+
784
+ elif event.inaxes == self.ax_coronal:
785
+
786
+ self.x = np.clip(col, 0, self.volume.shape[0]-1)
787
+ self.z = np.clip(
788
+ self.volume.shape[2]-1-row, 0, self.volume.shape[2]-1)
789
+
790
+ elif event.inaxes == self.ax_sagittal:
791
+
792
+ self.y = np.clip(col, 0, self.volume.shape[1]-1)
793
+ self.z = np.clip(
794
+ self.volume.shape[2]-1-row, 0, self.volume.shape[2]-1)
795
+
796
+ self.update_views()
581
797
 
582
798
 
583
799
  class MainWindow(QMainWindow):
584
800
  def __init__(self):
585
801
  super().__init__()
802
+ self.tabs = QTabWidget()
803
+
586
804
  self.viewer = TrkViewer()
587
- self.setCentralWidget(self.viewer)
805
+ self.ortho_viewer = OrthogonalViewer()
806
+
807
+ self.tabs.addTab(self.viewer, "3D Viewer")
808
+ self.tabs.addTab(self.ortho_viewer, "Orthogonal Viewer")
809
+
810
+ self.setCentralWidget(self.tabs)
588
811
  self.initActorDock()
589
812
  self.initMenuBar()
590
813
  self.setWindowTitle("UNVEIL - Tractography Viewer")
@@ -639,9 +862,14 @@ class MainWindow(QMainWindow):
639
862
  self.actorDock.setAllowedAreas(Qt.DockWidgetArea.RightDockWidgetArea)
640
863
 
641
864
  self.actorTree = QTreeWidget()
642
- self.actorTree.setHeaderLabels(["Actor", "Visible"])
643
- self.actorTree.setColumnCount(2)
644
- self.actorTree.setColumnWidth(0, 200)
865
+
866
+ self.actorTree.setHeaderLabels(["Visible", "Color", "Actor"])
867
+ self.actorTree.setColumnCount(3)
868
+ self.actorTree.setIndentation(5)
869
+
870
+ self.actorTree.setColumnWidth(0, 45)
871
+ self.actorTree.setColumnWidth(1, 40)
872
+ self.actorTree.setColumnWidth(2, 220)
645
873
 
646
874
  self.actorDock.setWidget(self.actorTree)
647
875
  self.addDockWidget(
@@ -653,15 +881,20 @@ class MainWindow(QMainWindow):
653
881
  self.viewer.plotter.mesh_added_callback = self.refreshActorList
654
882
  self.viewer.plotter.mesh_removed_callback = self.refreshActorList
655
883
 
884
+ self.actorTree.itemDoubleClicked.connect(self.changeActorColor)
885
+
886
+ # Connect visibility toggles
887
+ self.actorTree.itemChanged.connect(self.onActorVisibilityChanged)
888
+
656
889
  def toggleActorDock(self, checked):
657
890
  if checked:
658
-
659
891
  self.actorDock.show()
660
892
  else:
661
893
  self.actorDock.hide()
662
894
 
663
895
  def refreshActorList(self):
664
896
  """Rebuild the hierarchical actor list grouped by class."""
897
+ self.actorTree.blockSignals(True)
665
898
  self.actorTree.clear()
666
899
 
667
900
  # Groups
@@ -676,43 +909,91 @@ class MainWindow(QMainWindow):
676
909
  self.actorTree.addTopLevelItem(group_roi)
677
910
  # Loop through PyVista actors
678
911
  for name, actor in self.viewer.plotter.actors.items():
679
- item = QTreeWidgetItem([name, ""])
680
- item.setCheckState(
681
- 1, Qt.CheckState.Checked if actor.GetVisibility() else Qt.CheckState.Unchecked)
912
+
913
+ item = QTreeWidgetItem(["", "", name])
914
+
915
+ item.setCheckState(0, Qt.CheckState.Checked if actor.GetVisibility()
916
+ else Qt.CheckState.Unchecked)
682
917
 
683
918
  # Store name for callback
684
919
  item.actor_name = name
685
920
 
921
+ if self.viewer.actor_types.get(name) == 'roi':
922
+
923
+ rgb = self.viewer.roi_colors.get(name, (1, 1, 1))
924
+
925
+ qcolor = QColor(int(rgb[0] * 255), int(rgb[1] * 255),
926
+ int(rgb[2] * 255))
927
+ item.setBackground(1, qcolor)
928
+
686
929
  # Insert into the correct group
687
- if name.endswith(".trk"):
930
+ if self.viewer.actor_types.get(name) == 'trk':
688
931
  group_trk.addChild(item)
689
932
  elif name.startswith("nii_"):
690
933
  group_nii.addChild(item)
691
934
  elif name.startswith("gii_"):
692
935
  group_gii.addChild(item)
693
- elif name.startswith("roi_"):
936
+ elif self.viewer.actor_types.get(name) == 'roi':
694
937
  group_roi.addChild(item)
695
938
  else:
696
939
  group_trk.addChild(item) # default bucket
697
940
 
698
- # Connect visibility toggles
699
- self.actorTree.itemChanged.connect(self.onActorVisibilityChanged)
941
+ self.actorTree.blockSignals(False)
700
942
 
701
943
  def onActorVisibilityChanged(self, item, column):
702
944
  """Toggle visibility when user clicks checkbox."""
703
- if column != 1:
945
+ if column != 0:
704
946
  return
705
947
 
706
948
  actor_name = item.actor_name
707
- visible = item.checkState(1) == Qt.CheckState.Checked
949
+ visible = item.checkState(0) == Qt.CheckState.Checked
708
950
 
709
951
  try:
710
952
  self.viewer.plotter.actors[actor_name].SetVisibility(visible)
711
953
  except KeyError:
712
954
  pass
713
955
 
956
+ self.viewer.roi_visibility[actor_name] = visible
957
+ self.ortho_viewer.roi_visibility[actor_name] = visible
958
+ self.ortho_viewer.update_views()
959
+
714
960
  self.viewer.plotter.render()
715
961
 
962
+ def changeActorColor(self, item, column):
963
+
964
+ if column != 1:
965
+ return
966
+
967
+ actor_name = getattr(item, "actor_name", None)
968
+
969
+ if actor_name is None:
970
+ return
971
+
972
+ if self.viewer.actor_types.get(actor_name) != "roi":
973
+ return
974
+
975
+ color = QColorDialog.getColor()
976
+
977
+ if not color.isValid():
978
+ return
979
+
980
+ rgb = (color.red() / 255, color.green() / 255, color.blue() / 255)
981
+
982
+ self.viewer.roi_colors[actor_name] = rgb
983
+ if actor_name in self.ortho_viewer.rois:
984
+ self.ortho_viewer.rois[actor_name]["color"] = rgb
985
+ self.ortho_viewer.update_views()
986
+
987
+ item.setBackground(1, color)
988
+
989
+ try:
990
+ actor = self.viewer.plotter.actors[actor_name]
991
+ actor.GetProperty().SetColor(*rgb)
992
+ self.viewer.plotter.render()
993
+
994
+ except Exception:
995
+ pass
996
+
716
997
 
717
998
  if __name__ == '__main__':
718
999
  app = QApplication(sys.argv)
@@ -0,0 +1,74 @@
1
+ Metadata-Version: 2.4
2
+ Name: unveil-python
3
+ Version: 0.5.1
4
+ Summary: Implementation of UNVEIL
5
+ Author-email: Nicolas Delinte <nicolas.delinte@uclouvain.be>
6
+ License-Expression: GPL-3.0-only
7
+ Project-URL: GitHub, https://github.com/DelinteNicolas/UNVEIL
8
+ Keywords: tractography,viewer
9
+ Classifier: Intended Audience :: Science/Research
10
+ Classifier: Natural Language :: English
11
+ Classifier: Programming Language :: Python
12
+ Description-Content-Type: text/markdown
13
+ License-File: LICENSE
14
+ Requires-Dist: dipy
15
+ Requires-Dist: pyvista
16
+ Requires-Dist: pyvistaqt
17
+ Requires-Dist: PyQt6
18
+ Dynamic: license-file
19
+
20
+ # UNVEIL
21
+ [![PyPI](https://img.shields.io/pypi/v/unveil-python?label=pypi%20package)](https://pypi.org/project/unveil-python/)
22
+ [![PyPI - Downloads](https://img.shields.io/pypi/dm/unveil-python)](https://pypi.org/project/unveil-python/)
23
+ ![GitHub repo size](https://img.shields.io/github/repo-size/DelinteNicolas/unveil)
24
+
25
+
26
+ UNVEIL is a Python-based visualization tool for interactive exploration of diffusion MRI tractography, anatomical volumes, cortical surfaces, and ROI segmentations. It combines a 3D PyVista viewer with an orthogonal slice viewer.
27
+
28
+ <img width="1297" height="757" alt="image" src="https://github.com/user-attachments/assets/28807e18-17fb-485b-a8ce-2179cdef796b" />
29
+
30
+ ## Install
31
+
32
+ The GUI is launched with the following python command in a python console:
33
+
34
+ ```
35
+ import unveil.__main__
36
+ ```
37
+ or with
38
+ ```
39
+ python -c "import unveil.__main__"
40
+ ```
41
+ from a terminal with a python environment activated.
42
+
43
+ ## Features
44
+
45
+ - **3D Tractography Visualization**
46
+ - Load and display `.trk` streamline files.
47
+ - RGB orientation coloring or scalar-based coloring from NIfTI volumes.
48
+ - Adjustable opacity and point/line rendering.
49
+ - **Volume Rendering**
50
+ - Load `.nii.gz` images.
51
+ - Interactive volume rendering and orthogonal slice planes.
52
+ - Adjustable opacity.
53
+ - **ROI Visualization**
54
+ - Load ROI masks from `.nii` / `.nii.gz`.
55
+ - Automatic surface extraction and smoothing.
56
+ - Individual visibility and color control.
57
+ - **Surface Visualization**
58
+ - Load GIFTI (`.gii`) cortical surfaces.
59
+ - Adjustable opacity and smooth shading.
60
+ - **Orthogonal Viewer**
61
+ - Axial, coronal, and sagittal views.
62
+ - Mouse-wheel slice navigation.
63
+ - Click-to-navigate crosshair synchronization.
64
+ - ROI overlay display.
65
+ - Screenshot export.
66
+ - **Scene Management**
67
+ - Actor visibility control.
68
+ - ROI color editing.
69
+ - Background color toggle.
70
+ - Color-blind-friendly tractography coloring.
71
+ - **Export**
72
+ - 3D screenshots.
73
+ - Orthogonal-view screenshots.
74
+ - Animated 360° GIF generation.
@@ -1,40 +0,0 @@
1
- Metadata-Version: 2.4
2
- Name: unveil-python
3
- Version: 0.4.0
4
- Summary: Implementation of UNVEIL
5
- Author-email: Nicolas Delinte <nicolas.delinte@uclouvain.be>
6
- License-Expression: GPL-3.0-only
7
- Project-URL: GitHub, https://github.com/DelinteNicolas/UNVEIL
8
- Keywords: tractography,viewer
9
- Classifier: Intended Audience :: Science/Research
10
- Classifier: Natural Language :: English
11
- Classifier: Programming Language :: Python
12
- Description-Content-Type: text/markdown
13
- License-File: LICENSE
14
- Requires-Dist: dipy
15
- Requires-Dist: pyvista
16
- Requires-Dist: pyvistaqt
17
- Requires-Dist: PyQt6
18
- Dynamic: license-file
19
-
20
- # UNVEIL
21
- [![PyPI](https://img.shields.io/pypi/v/unveil-python?label=pypi%20package)](https://pypi.org/project/unveil-python/)
22
- [![PyPI - Downloads](https://img.shields.io/pypi/dm/unveil-python)](https://pypi.org/project/unveil-python/)
23
- ![GitHub repo size](https://img.shields.io/github/repo-size/DelinteNicolas/unveil)
24
-
25
-
26
- A simple tractography viewer implemented in python.
27
-
28
- <img width="1297" height="757" alt="image" src="https://github.com/user-attachments/assets/28807e18-17fb-485b-a8ce-2179cdef796b" />
29
-
30
-
31
- The GUI is launched with the following python command in a python console:
32
-
33
- ```
34
- import unveil.__main__
35
- ```
36
- or with
37
- ```
38
- python -c "import unveil.__main__"
39
- ```
40
- from a terminal with a python environment activated.
@@ -1,21 +0,0 @@
1
- # UNVEIL
2
- [![PyPI](https://img.shields.io/pypi/v/unveil-python?label=pypi%20package)](https://pypi.org/project/unveil-python/)
3
- [![PyPI - Downloads](https://img.shields.io/pypi/dm/unveil-python)](https://pypi.org/project/unveil-python/)
4
- ![GitHub repo size](https://img.shields.io/github/repo-size/DelinteNicolas/unveil)
5
-
6
-
7
- A simple tractography viewer implemented in python.
8
-
9
- <img width="1297" height="757" alt="image" src="https://github.com/user-attachments/assets/28807e18-17fb-485b-a8ce-2179cdef796b" />
10
-
11
-
12
- The GUI is launched with the following python command in a python console:
13
-
14
- ```
15
- import unveil.__main__
16
- ```
17
- or with
18
- ```
19
- python -c "import unveil.__main__"
20
- ```
21
- from a terminal with a python environment activated.
@@ -1,40 +0,0 @@
1
- Metadata-Version: 2.4
2
- Name: unveil-python
3
- Version: 0.4.0
4
- Summary: Implementation of UNVEIL
5
- Author-email: Nicolas Delinte <nicolas.delinte@uclouvain.be>
6
- License-Expression: GPL-3.0-only
7
- Project-URL: GitHub, https://github.com/DelinteNicolas/UNVEIL
8
- Keywords: tractography,viewer
9
- Classifier: Intended Audience :: Science/Research
10
- Classifier: Natural Language :: English
11
- Classifier: Programming Language :: Python
12
- Description-Content-Type: text/markdown
13
- License-File: LICENSE
14
- Requires-Dist: dipy
15
- Requires-Dist: pyvista
16
- Requires-Dist: pyvistaqt
17
- Requires-Dist: PyQt6
18
- Dynamic: license-file
19
-
20
- # UNVEIL
21
- [![PyPI](https://img.shields.io/pypi/v/unveil-python?label=pypi%20package)](https://pypi.org/project/unveil-python/)
22
- [![PyPI - Downloads](https://img.shields.io/pypi/dm/unveil-python)](https://pypi.org/project/unveil-python/)
23
- ![GitHub repo size](https://img.shields.io/github/repo-size/DelinteNicolas/unveil)
24
-
25
-
26
- A simple tractography viewer implemented in python.
27
-
28
- <img width="1297" height="757" alt="image" src="https://github.com/user-attachments/assets/28807e18-17fb-485b-a8ce-2179cdef796b" />
29
-
30
-
31
- The GUI is launched with the following python command in a python console:
32
-
33
- ```
34
- import unveil.__main__
35
- ```
36
- or with
37
- ```
38
- python -c "import unveil.__main__"
39
- ```
40
- from a terminal with a python environment activated.
File without changes
File without changes