unveil-python 0.3.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.3.0"
4
+ __version__ = "0.5.0"
5
5
  __author__ = 'Nicolas Delinte'
@@ -10,16 +10,18 @@ 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
- # from unravel.viz import plot_trk # We'll use our local version below
23
25
 
24
26
 
25
27
  def gifti_to_pyvista(gii_path):
@@ -51,9 +53,28 @@ def gifti_to_pyvista(gii_path):
51
53
  return mesh
52
54
 
53
55
 
56
+ def convert_rgb_array_to_new_basis(rgb_array):
57
+ """
58
+ Converts an array of RGB colors shape [n, 3] into a new basis.
59
+
60
+ rgb_array: numpy array of shape (n, 3) where each row is [R, G, B]
61
+ Returns: numpy array of shape (n, 3) where each row is [newR, newG, newB]
62
+ """
63
+
64
+ # Based on the Color-Blind Safe IBM Design Library Color Palette
65
+ C1 = np.array([220, 38, 127])
66
+ C2 = np.array([255, 176, 0])
67
+ C3 = np.array([120, 94, 240])
68
+
69
+ B = np.column_stack([C1, C2, C3])
70
+ new_rgb = rgb_array.dot(B.T)
71
+
72
+ return new_rgb
73
+
74
+
54
75
  def plot_trk(trk_file, scalar=None, color_map='plasma', opacity: float = 1,
55
76
  show_points: bool = False, background: str = 'black', plotter=None,
56
- name=None, reset_camera=False):
77
+ name=None, reset_camera=False, color_blind: bool = False):
57
78
  '''
58
79
  3D render for .trk files.
59
80
 
@@ -115,10 +136,12 @@ def plot_trk(trk_file, scalar=None, color_map='plasma', opacity: float = 1,
115
136
  point = streamlines._data
116
137
  next_point = np.roll(point, -1, axis=0)
117
138
  vs = next_point-point
139
+ ends = (streamlines._offsets+streamlines._lengths-1)
140
+ if color_blind:
141
+ vs = convert_rgb_array_to_new_basis(np.abs(vs))
118
142
  norm = np.linalg.norm(vs, axis=1)
119
143
  norm = np.stack((norm,)*3, axis=1, dtype=np.float32)
120
144
  norm = np.divide(vs, norm, dtype=np.float64)
121
- ends = (streamlines._offsets+streamlines._lengths-1)
122
145
  norm[ends, :] = norm[ends-1, :]
123
146
  scalars = np.abs(norm)
124
147
  rgb = True
@@ -144,41 +167,40 @@ def plot_trk(trk_file, scalar=None, color_map='plasma', opacity: float = 1,
144
167
  interpolate_before_map=False, render_lines_as_tubes=True,
145
168
  line_width=2, point_size=point_size, rgb=rgb,
146
169
  cmap=color_map, clim=color_lim, scalars=scalars, name=name,
147
- reset_camera=reset_camera,
148
- user_matrix=trk.affine)
170
+ reset_camera=reset_camera, user_matrix=trk.affine)
149
171
 
150
172
  elif color_map == 'flesh':
151
173
  p.add_mesh(mesh, opacity=opacity, diffuse=0.4, ambient=ambient,
152
174
  interpolate_before_map=False, render_lines_as_tubes=True,
153
175
  line_width=2, point_size=point_size, rgb=rgb,
154
176
  color=[250, 225, 210], name=name,
155
- reset_camera=reset_camera,
156
- user_matrix=trk.affine)
177
+ reset_camera=reset_camera, user_matrix=trk.affine)
157
178
  else:
158
179
  p.add_mesh(mesh, opacity=opacity, diffuse=diffuse, ambient=ambient,
159
180
  interpolate_before_map=False, render_lines_as_tubes=True,
160
181
  line_width=2, point_size=point_size, rgb=rgb,
161
182
  cmap=color_map, scalars=scalars, name=name,
162
- reset_camera=reset_camera,
163
- user_matrix=trk.affine)
183
+ reset_camera=reset_camera, user_matrix=trk.affine)
164
184
 
165
185
  p.background_color = background
166
- # Do not call p.show() here when using an embedded interactor
167
186
 
168
187
 
169
188
  class TrkViewer(QWidget):
170
189
  def __init__(self):
171
190
  super().__init__()
172
- self.trk_file = None
173
- self.nii_file = None
174
191
  self.nii_data = None
175
192
  self.grid = None
176
- self.names = []
193
+ self.actor_types = {}
177
194
  self.x = 0
178
195
  self.y = 0
179
196
  self.z = 0
180
197
  self.initUI()
181
198
  self.background = 'white'
199
+ self.color_blind = False
200
+ self.flicker = False
201
+ self.roi_colors = {}
202
+ self.roi_volumes = {}
203
+ self.roi_visibility = {}
182
204
 
183
205
  def initUI(self):
184
206
  # Create the main layout
@@ -191,10 +213,10 @@ class TrkViewer(QWidget):
191
213
  btn_xy = QPushButton("Axe 1")
192
214
  btn_xz = QPushButton("Axe 2")
193
215
  btn_yz = QPushButton("Axe 3")
194
- btn_iso.clicked.connect(self.view_isometric)
195
- btn_xy.clicked.connect(self.view_xy)
196
- btn_xz.clicked.connect(self.view_xz)
197
- 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"))
198
220
  cam_layout.addWidget(camera_label)
199
221
  cam_layout.addWidget(btn_iso)
200
222
  cam_layout.addWidget(btn_xy)
@@ -266,7 +288,8 @@ class TrkViewer(QWidget):
266
288
  self.XSlider.setMinimum(0)
267
289
  self.XSlider.setMaximum(100)
268
290
  self.XSlider.setValue(100)
269
- self.XSlider.sliderReleased.connect(self._update_nii_x)
291
+ self.XSlider.sliderReleased.connect(
292
+ lambda: self._update_slice('x', 'nii_x'))
270
293
  control_layout.addWidget(self.XSlider)
271
294
  self.y_label = QLabel('Y')
272
295
  control_layout.addWidget(self.y_label)
@@ -274,7 +297,8 @@ class TrkViewer(QWidget):
274
297
  self.YSlider.setMinimum(0)
275
298
  self.YSlider.setMaximum(100)
276
299
  self.YSlider.setValue(100)
277
- self.YSlider.sliderReleased.connect(self._update_nii_y)
300
+ self.YSlider.sliderReleased.connect(
301
+ lambda: self._update_slice('y', 'nii_y'))
278
302
  control_layout.addWidget(self.YSlider)
279
303
  self.z_label = QLabel('Z')
280
304
  control_layout.addWidget(self.z_label)
@@ -282,7 +306,8 @@ class TrkViewer(QWidget):
282
306
  self.ZSlider.setMinimum(0)
283
307
  self.ZSlider.setMaximum(100)
284
308
  self.ZSlider.setValue(100)
285
- self.ZSlider.sliderReleased.connect(self._update_nii_z)
309
+ self.ZSlider.sliderReleased.connect(
310
+ lambda: self._update_slice('z', 'nii_z'))
286
311
  control_layout.addWidget(self.ZSlider)
287
312
 
288
313
  # Gifti
@@ -315,23 +340,22 @@ class TrkViewer(QWidget):
315
340
  return
316
341
 
317
342
  for trk_path in filePaths:
318
- self.trk_file = trk_path
319
- print(f"Loaded TRK file: {self.trk_file}")
320
- self.names.append(self.trk_file)
343
+ print(f"Loaded TRK file: {trk_path}")
344
+ self.actor_types[trk_path] = 'trk'
321
345
 
322
346
  self.update_trk_viewer(reset_camera=True)
323
- self.parent().refreshActorList()
347
+ self.window().refreshActorList()
324
348
 
325
349
  def loadNiftiFile(self):
326
350
  options = QFileDialog.Options()
327
351
  filePath, _ = QFileDialog.getOpenFileName(
328
352
  self, "Open .nii.gz File", "", "Nifti Files (*.nii.gz)", options=options)
329
353
  if filePath:
330
- self.nii_file = filePath
331
- print(f"Loaded NIfTI file: {self.nii_file}")
354
+ print(f"Loaded NIfTI file: {filePath}")
332
355
  img = nib.load(filePath)
333
356
  self.nii_affine = img.affine
334
357
  self.nii_data = img.get_fdata()
358
+
335
359
  grid = pv.ImageData()
336
360
  grid.dimensions = np.array(self.nii_data.shape) + 1
337
361
  grid.cell_data['values'] = self.nii_data.flatten(order='F')
@@ -342,7 +366,10 @@ class TrkViewer(QWidget):
342
366
  self.ZSlider.setMaximum(self.nii_data.shape[2])
343
367
 
344
368
  self.update_nii_viewer(reset_camera=False)
345
- 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)
346
373
 
347
374
  def loadROIFile(self):
348
375
  """Load one or multiple ROI nifti volumes and render as surfaces."""
@@ -357,7 +384,6 @@ class TrkViewer(QWidget):
357
384
  for roi_path in filePaths:
358
385
  img = nib.load(roi_path)
359
386
  roi = img.get_fdata()
360
- affine = img.affine
361
387
 
362
388
  datapv = pv.wrap(roi)
363
389
  datapv.cell_data['labels'] = roi[:-1, :-1, :-1].flatten(order='F')
@@ -366,32 +392,34 @@ class TrkViewer(QWidget):
366
392
 
367
393
  smooth = mesh.smooth_taubin(n_iter=12)
368
394
 
369
- 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
403
+
404
+ self.plotter.add_mesh(smooth, name=actor_name, opacity=1,
405
+ color=default_color, user_matrix=img.affine,
406
+ reset_camera=False, point_size=0,
407
+ render_lines_as_tubes=True)
370
408
 
371
- self.plotter.add_mesh(
372
- smooth,
373
- name=actor_name,
374
- opacity=1,
375
- color="white",
376
- user_matrix=affine,
377
- reset_camera=False,
378
- point_size=0,
379
- render_lines_as_tubes=True,
380
- )
409
+ self.window().refreshActorList()
381
410
 
382
- self.parent().refreshActorList()
411
+ self.window().ortho_viewer.add_roi(actor_name, roi, default_color)
383
412
 
384
413
  def loadGiftiFile(self):
385
414
  options = QFileDialog.Options()
386
415
  filePath, _ = QFileDialog.getOpenFileName(
387
416
  self, "Open .gii File", "", "Gifti Files (*.gii)", options=options)
388
417
  if filePath:
389
- self.gii_file = filePath
390
- print(f"Loaded GIfTI file: {self.gii_file}")
418
+ print(f"Loaded GIfTI file: {filePath}")
391
419
  self.gii_mesh = gifti_to_pyvista(filePath)
392
420
 
393
421
  self.update_gii_viewer(reset_camera=False)
394
- self.parent().refreshActorList()
422
+ self.window().refreshActorList()
395
423
 
396
424
  def set_background_color(self):
397
425
 
@@ -402,25 +430,17 @@ class TrkViewer(QWidget):
402
430
 
403
431
  self.plotter.background_color = self.background
404
432
 
405
- def view_isometric(self):
406
- # equivalent to BackgroundPlotter 'iso' camera position
407
- self.plotter.view_isometric()
408
- self.plotter.render()
433
+ self.window().ortho_viewer.set_background(self.background)
409
434
 
410
- def view_xy(self):
411
- # view from +Z axis, XY plane (axial)
412
- self.plotter.view_xy() # pyvista method
413
- self.plotter.render()
435
+ def toggleColorBlind(self):
414
436
 
415
- def view_xz(self):
416
- # view from +Y axis, XZ plane (sagittal)
417
- self.plotter.view_xz()
418
- self.plotter.render()
437
+ self.color_blind = not self.color_blind
438
+ self.update_trk_viewer()
419
439
 
420
- def view_yz(self):
421
- # view from +X axis, YZ plane (coronal)
422
- self.plotter.view_yz()
423
- self.plotter.render()
440
+ def set_view(self, method):
441
+ getattr(self.plotter, method)()
442
+ self.plotter.camera.azimuth += self.flicker * 180
443
+ self.flicker = not self.flicker
424
444
 
425
445
  def take_screenshot(self):
426
446
  """Take a screenshot of the current 3D view."""
@@ -451,7 +471,7 @@ class TrkViewer(QWidget):
451
471
 
452
472
  # Copy all meshes and volumes from the main plotter to the offscreen one
453
473
  for actor_name, actor in self.plotter.actors.items():
454
- offscreen.add_actor(actor.copy())
474
+ offscreen.add_actor(actor)
455
475
 
456
476
  # Match background color and camera
457
477
  offscreen.background_color = self.plotter.background_color
@@ -487,66 +507,38 @@ class TrkViewer(QWidget):
487
507
  color_map = self.colorMapEdit.text()
488
508
  scalar = self.nii_data
489
509
 
490
- for file in self.names:
510
+ for file in list(self.actor_types):
491
511
 
492
- plot_trk(file, opacity=opacity, plotter=self.plotter, scalar=scalar,
493
- show_points=show_points, color_map=color_map,
494
- name=file, background=self.background,
495
- reset_camera=reset_camera)
512
+ if self.actor_types.get(file) == 'trk':
496
513
 
497
- def _update_nii_x(self):
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)
498
518
 
499
- if self.showSlicesCheckbox.isChecked():
500
-
501
- center = (self.XSlider.value(),
502
- self.YSlider.value(), self.ZSlider.value())
503
-
504
- slice_x = self.grid.slice('x', center)
505
- self.plotter.add_mesh(slice_x, cmap='grey', name='nii_x',
506
- show_scalar_bar=False, point_size=0,
507
- render_lines_as_tubes=True,
508
- reset_camera=False,
509
- user_matrix=self.nii_affine)
510
- else:
511
- self.plotter.remove_actor('nii_x')
512
-
513
- def _update_nii_y(self):
519
+ def _update_slice(self, axis, actor_name):
514
520
 
515
521
  if self.showSlicesCheckbox.isChecked():
516
522
 
517
- center = (self.XSlider.value(),
518
- self.YSlider.value(), self.ZSlider.value())
519
-
520
- slice_y = self.grid.slice('y', center)
521
- self.plotter.add_mesh(slice_y, cmap='grey', name='nii_y',
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_y')
523
+ center = (self.XSlider.value(), self.YSlider.value(),
524
+ self.ZSlider.value())
528
525
 
529
- def _update_nii_z(self):
526
+ slice_mesh = self.grid.slice(axis, origin=center)
530
527
 
531
- if self.showSlicesCheckbox.isChecked():
532
-
533
- center = (self.XSlider.value(),
534
- self.YSlider.value(), self.ZSlider.value())
535
-
536
- slice_z = self.grid.slice('z', center)
537
- self.plotter.add_mesh(slice_z, cmap='grey', name='nii_z',
528
+ self.plotter.add_mesh(slice_mesh, cmap='grey', name=actor_name,
538
529
  show_scalar_bar=False, point_size=0,
539
530
  render_lines_as_tubes=True,
540
531
  reset_camera=False,
541
532
  user_matrix=self.nii_affine)
533
+
542
534
  else:
543
- self.plotter.remove_actor('nii_z')
535
+ self.plotter.remove_actor(actor_name)
544
536
 
545
537
  def update_nii_viewer(self, reset_camera: bool = False):
546
538
 
547
- self._update_nii_x()
548
- self._update_nii_y()
549
- 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')
550
542
 
551
543
  opacity = self.nii_opacitySlider.value()/1000
552
544
  self.plotter.add_volume(self.grid, cmap='gray', opacity=[0, opacity],
@@ -564,11 +556,209 @@ class TrkViewer(QWidget):
564
556
  render_lines_as_tubes=True,)
565
557
 
566
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
+
567
750
  class MainWindow(QMainWindow):
568
751
  def __init__(self):
569
752
  super().__init__()
753
+ self.tabs = QTabWidget()
754
+
570
755
  self.viewer = TrkViewer()
571
- 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)
572
762
  self.initActorDock()
573
763
  self.initMenuBar()
574
764
  self.setWindowTitle("UNVEIL - Tractography Viewer")
@@ -609,19 +799,28 @@ class MainWindow(QMainWindow):
609
799
  setBackColorAction = QAction('Background Color', self)
610
800
  setBackColorAction.triggered.connect(self.viewer.set_background_color)
611
801
  viewMenu.addAction(setBackColorAction)
612
- toggleActorListAction = QAction(
613
- "Toggle Actor List", self, checkable=True)
802
+ toggleActorListAction = QAction("Toggle Actor List", self,
803
+ checkable=True)
614
804
  toggleActorListAction.triggered.connect(self.toggleActorDock)
615
805
  viewMenu.addAction(toggleActorListAction)
806
+ toggleColorBlindAction = QAction("Use color-blind colors", self,
807
+ checkable=True)
808
+ toggleColorBlindAction.triggered.connect(self.viewer.toggleColorBlind)
809
+ viewMenu.addAction(toggleColorBlindAction)
616
810
 
617
811
  def initActorDock(self):
618
812
  self.actorDock = QDockWidget("Scene Actors", self)
619
813
  self.actorDock.setAllowedAreas(Qt.DockWidgetArea.RightDockWidgetArea)
620
814
 
621
815
  self.actorTree = QTreeWidget()
622
- self.actorTree.setHeaderLabels(["Actor", "Visible"])
623
- self.actorTree.setColumnCount(2)
624
- 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)
625
824
 
626
825
  self.actorDock.setWidget(self.actorTree)
627
826
  self.addDockWidget(
@@ -633,15 +832,20 @@ class MainWindow(QMainWindow):
633
832
  self.viewer.plotter.mesh_added_callback = self.refreshActorList
634
833
  self.viewer.plotter.mesh_removed_callback = self.refreshActorList
635
834
 
835
+ self.actorTree.itemDoubleClicked.connect(self.changeActorColor)
836
+
837
+ # Connect visibility toggles
838
+ self.actorTree.itemChanged.connect(self.onActorVisibilityChanged)
839
+
636
840
  def toggleActorDock(self, checked):
637
841
  if checked:
638
-
639
842
  self.actorDock.show()
640
843
  else:
641
844
  self.actorDock.hide()
642
845
 
643
846
  def refreshActorList(self):
644
847
  """Rebuild the hierarchical actor list grouped by class."""
848
+ self.actorTree.blockSignals(True)
645
849
  self.actorTree.clear()
646
850
 
647
851
  # Groups
@@ -656,43 +860,91 @@ class MainWindow(QMainWindow):
656
860
  self.actorTree.addTopLevelItem(group_roi)
657
861
  # Loop through PyVista actors
658
862
  for name, actor in self.viewer.plotter.actors.items():
659
- item = QTreeWidgetItem([name, ""])
660
- item.setCheckState(
661
- 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)
662
868
 
663
869
  # Store name for callback
664
870
  item.actor_name = name
665
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
+
666
880
  # Insert into the correct group
667
- if name.endswith(".trk"):
881
+ if self.viewer.actor_types.get(name) == 'trk':
668
882
  group_trk.addChild(item)
669
883
  elif name.startswith("nii_"):
670
884
  group_nii.addChild(item)
671
885
  elif name.startswith("gii_"):
672
886
  group_gii.addChild(item)
673
- elif name.startswith("roi_"):
887
+ elif self.viewer.actor_types.get(name) == 'roi':
674
888
  group_roi.addChild(item)
675
889
  else:
676
890
  group_trk.addChild(item) # default bucket
677
891
 
678
- # Connect visibility toggles
679
- self.actorTree.itemChanged.connect(self.onActorVisibilityChanged)
892
+ self.actorTree.blockSignals(False)
680
893
 
681
894
  def onActorVisibilityChanged(self, item, column):
682
895
  """Toggle visibility when user clicks checkbox."""
683
- if column != 1:
896
+ if column != 0:
684
897
  return
685
898
 
686
899
  actor_name = item.actor_name
687
- visible = item.checkState(1) == Qt.CheckState.Checked
900
+ visible = item.checkState(0) == Qt.CheckState.Checked
688
901
 
689
902
  try:
690
903
  self.viewer.plotter.actors[actor_name].SetVisibility(visible)
691
904
  except KeyError:
692
905
  pass
693
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
+
694
911
  self.viewer.plotter.render()
695
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
+
696
948
 
697
949
  if __name__ == '__main__':
698
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.3.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
- ![image](https://github.com/user-attachments/assets/4fbefa7a-3fc2-446a-8835-2844266b14fa)
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
- ![image](https://github.com/user-attachments/assets/4fbefa7a-3fc2-446a-8835-2844266b14fa)
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.3.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
- ![image](https://github.com/user-attachments/assets/4fbefa7a-3fc2-446a-8835-2844266b14fa)
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