unveil-python 0.4.0__tar.gz → 0.5.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.
@@ -0,0 +1,74 @@
1
+ Metadata-Version: 2.4
2
+ Name: unveil-python
3
+ Version: 0.5.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
+ 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.0"
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,11 +188,9 @@ 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,6 +198,9 @@ class TrkViewer(QWidget):
197
198
  self.background = 'white'
198
199
  self.color_blind = False
199
200
  self.flicker = False
201
+ self.roi_colors = {}
202
+ self.roi_volumes = {}
203
+ self.roi_visibility = {}
200
204
 
201
205
  def initUI(self):
202
206
  # Create the main layout
@@ -209,10 +213,10 @@ class TrkViewer(QWidget):
209
213
  btn_xy = QPushButton("Axe 1")
210
214
  btn_xz = QPushButton("Axe 2")
211
215
  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)
216
+ btn_iso.clicked.connect(lambda: self.set_view("view_isometric"))
217
+ btn_xy.clicked.connect(lambda: self.set_view("view_xy"))
218
+ btn_xz.clicked.connect(lambda: self.set_view("view_xz"))
219
+ btn_yz.clicked.connect(lambda: self.set_view("view_yz"))
216
220
  cam_layout.addWidget(camera_label)
217
221
  cam_layout.addWidget(btn_iso)
218
222
  cam_layout.addWidget(btn_xy)
@@ -284,7 +288,8 @@ class TrkViewer(QWidget):
284
288
  self.XSlider.setMinimum(0)
285
289
  self.XSlider.setMaximum(100)
286
290
  self.XSlider.setValue(100)
287
- self.XSlider.sliderReleased.connect(self._update_nii_x)
291
+ self.XSlider.sliderReleased.connect(
292
+ lambda: self._update_slice('x', 'nii_x'))
288
293
  control_layout.addWidget(self.XSlider)
289
294
  self.y_label = QLabel('Y')
290
295
  control_layout.addWidget(self.y_label)
@@ -292,7 +297,8 @@ class TrkViewer(QWidget):
292
297
  self.YSlider.setMinimum(0)
293
298
  self.YSlider.setMaximum(100)
294
299
  self.YSlider.setValue(100)
295
- self.YSlider.sliderReleased.connect(self._update_nii_y)
300
+ self.YSlider.sliderReleased.connect(
301
+ lambda: self._update_slice('y', 'nii_y'))
296
302
  control_layout.addWidget(self.YSlider)
297
303
  self.z_label = QLabel('Z')
298
304
  control_layout.addWidget(self.z_label)
@@ -300,7 +306,8 @@ class TrkViewer(QWidget):
300
306
  self.ZSlider.setMinimum(0)
301
307
  self.ZSlider.setMaximum(100)
302
308
  self.ZSlider.setValue(100)
303
- self.ZSlider.sliderReleased.connect(self._update_nii_z)
309
+ self.ZSlider.sliderReleased.connect(
310
+ lambda: self._update_slice('z', 'nii_z'))
304
311
  control_layout.addWidget(self.ZSlider)
305
312
 
306
313
  # Gifti
@@ -333,23 +340,22 @@ class TrkViewer(QWidget):
333
340
  return
334
341
 
335
342
  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)
343
+ print(f"Loaded TRK file: {trk_path}")
344
+ self.actor_types[trk_path] = 'trk'
339
345
 
340
346
  self.update_trk_viewer(reset_camera=True)
341
- self.parent().refreshActorList()
347
+ self.window().refreshActorList()
342
348
 
343
349
  def loadNiftiFile(self):
344
350
  options = QFileDialog.Options()
345
351
  filePath, _ = QFileDialog.getOpenFileName(
346
352
  self, "Open .nii.gz File", "", "Nifti Files (*.nii.gz)", options=options)
347
353
  if filePath:
348
- self.nii_file = filePath
349
- print(f"Loaded NIfTI file: {self.nii_file}")
354
+ print(f"Loaded NIfTI file: {filePath}")
350
355
  img = nib.load(filePath)
351
356
  self.nii_affine = img.affine
352
357
  self.nii_data = img.get_fdata()
358
+
353
359
  grid = pv.ImageData()
354
360
  grid.dimensions = np.array(self.nii_data.shape) + 1
355
361
  grid.cell_data['values'] = self.nii_data.flatten(order='F')
@@ -360,7 +366,10 @@ class TrkViewer(QWidget):
360
366
  self.ZSlider.setMaximum(self.nii_data.shape[2])
361
367
 
362
368
  self.update_nii_viewer(reset_camera=False)
363
- self.parent().refreshActorList()
369
+ self.window().refreshActorList()
370
+
371
+ self.nii_data[self.nii_data == 0] = None
372
+ self.window().ortho_viewer.set_volume(self.nii_data, self.nii_affine)
364
373
 
365
374
  def loadROIFile(self):
366
375
  """Load one or multiple ROI nifti volumes and render as surfaces."""
@@ -383,26 +392,34 @@ class TrkViewer(QWidget):
383
392
 
384
393
  smooth = mesh.smooth_taubin(n_iter=12)
385
394
 
386
- actor_name = f"roi_{roi_path.split('/')[-1]}"
395
+ actor_name = f"{roi_path.split('/')[-1]}"
396
+ self.actor_types[actor_name] = "roi"
397
+ self.roi_volumes[actor_name] = roi
398
+ self.roi_visibility[actor_name] = True
399
+
400
+ default_color = np.array([1.0, 1.0, 1.0])
401
+
402
+ self.roi_colors[actor_name] = default_color
387
403
 
388
404
  self.plotter.add_mesh(smooth, name=actor_name, opacity=1,
389
- color="white", user_matrix=img.affine,
405
+ color=default_color, user_matrix=img.affine,
390
406
  reset_camera=False, point_size=0,
391
407
  render_lines_as_tubes=True)
392
408
 
393
- self.parent().refreshActorList()
409
+ self.window().refreshActorList()
410
+
411
+ self.window().ortho_viewer.add_roi(actor_name, roi, default_color)
394
412
 
395
413
  def loadGiftiFile(self):
396
414
  options = QFileDialog.Options()
397
415
  filePath, _ = QFileDialog.getOpenFileName(
398
416
  self, "Open .gii File", "", "Gifti Files (*.gii)", options=options)
399
417
  if filePath:
400
- self.gii_file = filePath
401
- print(f"Loaded GIfTI file: {self.gii_file}")
418
+ print(f"Loaded GIfTI file: {filePath}")
402
419
  self.gii_mesh = gifti_to_pyvista(filePath)
403
420
 
404
421
  self.update_gii_viewer(reset_camera=False)
405
- self.parent().refreshActorList()
422
+ self.window().refreshActorList()
406
423
 
407
424
  def set_background_color(self):
408
425
 
@@ -413,29 +430,16 @@ class TrkViewer(QWidget):
413
430
 
414
431
  self.plotter.background_color = self.background
415
432
 
433
+ self.window().ortho_viewer.set_background(self.background)
434
+
416
435
  def toggleColorBlind(self):
417
436
 
418
437
  self.color_blind = not self.color_blind
419
438
  self.update_trk_viewer()
420
439
 
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
440
+ def set_view(self, method):
441
+ getattr(self.plotter, method)()
442
+ self.plotter.camera.azimuth += self.flicker * 180
439
443
  self.flicker = not self.flicker
440
444
 
441
445
  def take_screenshot(self):
@@ -503,66 +507,38 @@ class TrkViewer(QWidget):
503
507
  color_map = self.colorMapEdit.text()
504
508
  scalar = self.nii_data
505
509
 
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)
510
+ for file in list(self.actor_types):
512
511
 
513
- def _update_nii_x(self):
514
-
515
- if self.showSlicesCheckbox.isChecked():
512
+ if self.actor_types.get(file) == 'trk':
516
513
 
517
- center = (self.XSlider.value(),
518
- self.YSlider.value(), self.ZSlider.value())
519
-
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')
514
+ plot_trk(file, opacity=opacity, plotter=self.plotter, scalar=scalar,
515
+ show_points=show_points, color_map=color_map,
516
+ name=file, background=self.background,
517
+ reset_camera=reset_camera, color_blind=self.color_blind)
528
518
 
529
- def _update_nii_y(self):
519
+ def _update_slice(self, axis, actor_name):
530
520
 
531
521
  if self.showSlicesCheckbox.isChecked():
532
522
 
533
- center = (self.XSlider.value(),
534
- self.YSlider.value(), self.ZSlider.value())
535
-
536
- slice_y = self.grid.slice('y', center)
537
- self.plotter.add_mesh(slice_y, cmap='grey', name='nii_y',
538
- show_scalar_bar=False, point_size=0,
539
- render_lines_as_tubes=True,
540
- reset_camera=False,
541
- user_matrix=self.nii_affine)
542
- else:
543
- self.plotter.remove_actor('nii_y')
544
-
545
- def _update_nii_z(self):
523
+ center = (self.XSlider.value(), self.YSlider.value(),
524
+ self.ZSlider.value())
546
525
 
547
- if self.showSlicesCheckbox.isChecked():
526
+ slice_mesh = self.grid.slice(axis, origin=center)
548
527
 
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',
528
+ self.plotter.add_mesh(slice_mesh, cmap='grey', name=actor_name,
554
529
  show_scalar_bar=False, point_size=0,
555
530
  render_lines_as_tubes=True,
556
531
  reset_camera=False,
557
532
  user_matrix=self.nii_affine)
533
+
558
534
  else:
559
- self.plotter.remove_actor('nii_z')
535
+ self.plotter.remove_actor(actor_name)
560
536
 
561
537
  def update_nii_viewer(self, reset_camera: bool = False):
562
538
 
563
- self._update_nii_x()
564
- self._update_nii_y()
565
- self._update_nii_z()
539
+ self._update_slice('x', 'nii_x')
540
+ self._update_slice('y', 'nii_y')
541
+ self._update_slice('z', 'nii_z')
566
542
 
567
543
  opacity = self.nii_opacitySlider.value()/1000
568
544
  self.plotter.add_volume(self.grid, cmap='gray', opacity=[0, opacity],
@@ -580,11 +556,209 @@ class TrkViewer(QWidget):
580
556
  render_lines_as_tubes=True,)
581
557
 
582
558
 
559
+ class OrthogonalViewer(QWidget):
560
+
561
+ def __init__(self):
562
+ super().__init__()
563
+
564
+ self.volume = None
565
+ self.affine = None
566
+
567
+ self.rois = {}
568
+ self.roi_visibility = {}
569
+
570
+ self.background = "white"
571
+
572
+ layout = QVBoxLayout(self)
573
+
574
+ toolbar = QHBoxLayout()
575
+
576
+ btn_screenshot = QPushButton("Screenshot")
577
+ btn_screenshot.clicked.connect(self.take_screenshot)
578
+
579
+ toolbar.addStretch()
580
+ toolbar.addWidget(btn_screenshot)
581
+
582
+ layout.addLayout(toolbar)
583
+
584
+ self.fig = Figure(figsize=(12, 4), layout='constrained')
585
+ self.canvas = FigureCanvasQTAgg(self.fig)
586
+
587
+ self.ax_axial = self.fig.add_subplot(131)
588
+ self.ax_coronal = self.fig.add_subplot(132)
589
+ self.ax_sagittal = self.fig.add_subplot(133)
590
+
591
+ layout.addWidget(self.canvas)
592
+
593
+ self.x = 0
594
+ self.y = 0
595
+ self.z = 0
596
+
597
+ self.canvas.mpl_connect("scroll_event", self.on_scroll)
598
+ self.canvas.mpl_connect("button_press_event", self.on_click)
599
+
600
+ def set_volume(self, volume, affine=None):
601
+
602
+ self.volume = volume
603
+ self.affine = affine
604
+
605
+ self.x = volume.shape[0] // 2
606
+ self.y = volume.shape[1] // 2
607
+ self.z = volume.shape[2] // 2
608
+
609
+ self.update_views()
610
+
611
+ def add_roi(self, name, roi, color):
612
+
613
+ self.rois[name] = {"data": roi, "color": color}
614
+ self.roi_visibility[name] = True
615
+ self.update_views()
616
+
617
+ def draw_roi(self, ax, roi_slice, color):
618
+
619
+ if roi_slice.max() == 0:
620
+ return
621
+
622
+ ax.contour(roi_slice, levels=[0.5], colors=[color], linewidths=1.5,
623
+ antialiased=True)
624
+ ax.contourf(roi_slice, levels=[0.5, roi_slice.max()+1], colors=[color],
625
+ alpha=0.5)
626
+
627
+ def update_views(self):
628
+
629
+ if self.volume is None:
630
+ return
631
+
632
+ x = self.x
633
+ y = self.y
634
+ z = self.z
635
+
636
+ axial = self.volume[:, :, z]
637
+ coronal = self.volume[:, y, :]
638
+ sagittal = self.volume[x, :, :]
639
+
640
+ self.ax_axial.clear()
641
+ self.ax_coronal.clear()
642
+ self.ax_sagittal.clear()
643
+
644
+ for ax in (self.ax_axial, self.ax_coronal, self.ax_sagittal):
645
+ ax.axis("off")
646
+
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)
650
+
651
+ for name, roi_info in self.rois.items():
652
+
653
+ if not self.roi_visibility[name]:
654
+ continue
655
+
656
+ roi = roi_info["data"]
657
+ color = roi_info["color"]
658
+
659
+ self.draw_roi(self.ax_axial, np.rot90(roi[:, :, z]), color)
660
+ self.draw_roi(self.ax_coronal, np.rot90(roi[:, y, :]), color)
661
+ self.draw_roi(self.ax_sagittal, np.rot90(roi[x, :, :]), color)
662
+
663
+ self.canvas.draw_idle()
664
+
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
+ def take_screenshot(self):
674
+
675
+ options = QFileDialog.Options()
676
+
677
+ file_path, _ = QFileDialog.getSaveFileName(self,
678
+ "Save Orthogonal View", "",
679
+ "PNG Image (*.png);;JPEG Image (*.jpg);;TIFF Image (*.tif);; SVG Image (*.svg)",
680
+ options=options
681
+ )
682
+
683
+ if file_path:
684
+ self.fig.savefig(file_path, dpi=300, transparent=True)
685
+
686
+ def set_background(self, color):
687
+
688
+ self.background = color
689
+
690
+ self.fig.patch.set_facecolor(color)
691
+
692
+ for ax in (self.ax_axial, self.ax_coronal, self.ax_sagittal):
693
+ ax.set_facecolor(color)
694
+
695
+ self.canvas.draw_idle()
696
+
697
+ def on_scroll(self, event):
698
+
699
+ if self.volume is None:
700
+ return
701
+
702
+ step = 1 if event.button == "up" else -1
703
+
704
+ if event.inaxes == self.ax_axial:
705
+ self.z = np.clip(self.z + step, 0, self.volume.shape[2]-1)
706
+
707
+ elif event.inaxes == self.ax_coronal:
708
+ self.y = np.clip(self.y + step, 0, self.volume.shape[1]-1)
709
+
710
+ elif event.inaxes == self.ax_sagittal:
711
+ self.x = np.clip(self.x + step, 0, self.volume.shape[0]-1)
712
+
713
+ self.update_views()
714
+
715
+ def on_click(self, event):
716
+
717
+ if self.volume is None:
718
+ return
719
+
720
+ if event.inaxes is None:
721
+ return
722
+
723
+ if event.xdata is None or event.ydata is None:
724
+ return
725
+
726
+ col = int(round(event.xdata))
727
+ row = int(round(event.ydata))
728
+
729
+ if event.inaxes == self.ax_axial:
730
+
731
+ self.x = np.clip(col, 0, self.volume.shape[0]-1)
732
+ self.y = np.clip(
733
+ self.volume.shape[1]-1-row, 0, self.volume.shape[1]-1)
734
+
735
+ elif event.inaxes == self.ax_coronal:
736
+
737
+ self.x = np.clip(col, 0, self.volume.shape[0]-1)
738
+ self.z = np.clip(
739
+ self.volume.shape[2]-1-row, 0, self.volume.shape[2]-1)
740
+
741
+ elif event.inaxes == self.ax_sagittal:
742
+
743
+ self.y = np.clip(col, 0, self.volume.shape[1]-1)
744
+ self.z = np.clip(
745
+ self.volume.shape[2]-1-row, 0, self.volume.shape[2]-1)
746
+
747
+ self.update_views()
748
+
749
+
583
750
  class MainWindow(QMainWindow):
584
751
  def __init__(self):
585
752
  super().__init__()
753
+ self.tabs = QTabWidget()
754
+
586
755
  self.viewer = TrkViewer()
587
- self.setCentralWidget(self.viewer)
756
+ self.ortho_viewer = OrthogonalViewer()
757
+
758
+ self.tabs.addTab(self.viewer, "3D Viewer")
759
+ self.tabs.addTab(self.ortho_viewer, "Orthogonal Viewer")
760
+
761
+ self.setCentralWidget(self.tabs)
588
762
  self.initActorDock()
589
763
  self.initMenuBar()
590
764
  self.setWindowTitle("UNVEIL - Tractography Viewer")
@@ -639,9 +813,14 @@ class MainWindow(QMainWindow):
639
813
  self.actorDock.setAllowedAreas(Qt.DockWidgetArea.RightDockWidgetArea)
640
814
 
641
815
  self.actorTree = QTreeWidget()
642
- self.actorTree.setHeaderLabels(["Actor", "Visible"])
643
- self.actorTree.setColumnCount(2)
644
- self.actorTree.setColumnWidth(0, 200)
816
+
817
+ self.actorTree.setHeaderLabels(["Visible", "Color", "Actor"])
818
+ self.actorTree.setColumnCount(3)
819
+ self.actorTree.setIndentation(5)
820
+
821
+ self.actorTree.setColumnWidth(0, 45)
822
+ self.actorTree.setColumnWidth(1, 40)
823
+ self.actorTree.setColumnWidth(2, 220)
645
824
 
646
825
  self.actorDock.setWidget(self.actorTree)
647
826
  self.addDockWidget(
@@ -653,15 +832,20 @@ class MainWindow(QMainWindow):
653
832
  self.viewer.plotter.mesh_added_callback = self.refreshActorList
654
833
  self.viewer.plotter.mesh_removed_callback = self.refreshActorList
655
834
 
835
+ self.actorTree.itemDoubleClicked.connect(self.changeActorColor)
836
+
837
+ # Connect visibility toggles
838
+ self.actorTree.itemChanged.connect(self.onActorVisibilityChanged)
839
+
656
840
  def toggleActorDock(self, checked):
657
841
  if checked:
658
-
659
842
  self.actorDock.show()
660
843
  else:
661
844
  self.actorDock.hide()
662
845
 
663
846
  def refreshActorList(self):
664
847
  """Rebuild the hierarchical actor list grouped by class."""
848
+ self.actorTree.blockSignals(True)
665
849
  self.actorTree.clear()
666
850
 
667
851
  # Groups
@@ -676,43 +860,91 @@ class MainWindow(QMainWindow):
676
860
  self.actorTree.addTopLevelItem(group_roi)
677
861
  # Loop through PyVista actors
678
862
  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)
863
+
864
+ item = QTreeWidgetItem(["", "", name])
865
+
866
+ item.setCheckState(0, Qt.CheckState.Checked if actor.GetVisibility()
867
+ else Qt.CheckState.Unchecked)
682
868
 
683
869
  # Store name for callback
684
870
  item.actor_name = name
685
871
 
872
+ if self.viewer.actor_types.get(name) == 'roi':
873
+
874
+ rgb = self.viewer.roi_colors.get(name, (1, 1, 1))
875
+
876
+ qcolor = QColor(int(rgb[0] * 255), int(rgb[1] * 255),
877
+ int(rgb[2] * 255))
878
+ item.setBackground(1, qcolor)
879
+
686
880
  # Insert into the correct group
687
- if name.endswith(".trk"):
881
+ if self.viewer.actor_types.get(name) == 'trk':
688
882
  group_trk.addChild(item)
689
883
  elif name.startswith("nii_"):
690
884
  group_nii.addChild(item)
691
885
  elif name.startswith("gii_"):
692
886
  group_gii.addChild(item)
693
- elif name.startswith("roi_"):
887
+ elif self.viewer.actor_types.get(name) == 'roi':
694
888
  group_roi.addChild(item)
695
889
  else:
696
890
  group_trk.addChild(item) # default bucket
697
891
 
698
- # Connect visibility toggles
699
- self.actorTree.itemChanged.connect(self.onActorVisibilityChanged)
892
+ self.actorTree.blockSignals(False)
700
893
 
701
894
  def onActorVisibilityChanged(self, item, column):
702
895
  """Toggle visibility when user clicks checkbox."""
703
- if column != 1:
896
+ if column != 0:
704
897
  return
705
898
 
706
899
  actor_name = item.actor_name
707
- visible = item.checkState(1) == Qt.CheckState.Checked
900
+ visible = item.checkState(0) == Qt.CheckState.Checked
708
901
 
709
902
  try:
710
903
  self.viewer.plotter.actors[actor_name].SetVisibility(visible)
711
904
  except KeyError:
712
905
  pass
713
906
 
907
+ self.viewer.roi_visibility[actor_name] = visible
908
+ self.ortho_viewer.roi_visibility[actor_name] = visible
909
+ self.ortho_viewer.update_views()
910
+
714
911
  self.viewer.plotter.render()
715
912
 
913
+ def changeActorColor(self, item, column):
914
+
915
+ if column != 1:
916
+ return
917
+
918
+ actor_name = getattr(item, "actor_name", None)
919
+
920
+ if actor_name is None:
921
+ return
922
+
923
+ if self.viewer.actor_types.get(actor_name) != "roi":
924
+ return
925
+
926
+ color = QColorDialog.getColor()
927
+
928
+ if not color.isValid():
929
+ return
930
+
931
+ rgb = (color.red() / 255, color.green() / 255, color.blue() / 255)
932
+
933
+ self.viewer.roi_colors[actor_name] = rgb
934
+ if actor_name in self.ortho_viewer.rois:
935
+ self.ortho_viewer.rois[actor_name]["color"] = rgb
936
+ self.ortho_viewer.update_views()
937
+
938
+ item.setBackground(1, color)
939
+
940
+ try:
941
+ actor = self.viewer.plotter.actors[actor_name]
942
+ actor.GetProperty().SetColor(*rgb)
943
+ self.viewer.plotter.render()
944
+
945
+ except Exception:
946
+ pass
947
+
716
948
 
717
949
  if __name__ == '__main__':
718
950
  app = QApplication(sys.argv)
@@ -0,0 +1,74 @@
1
+ Metadata-Version: 2.4
2
+ Name: unveil-python
3
+ Version: 0.5.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
+ 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