unveil-python 0.2.0__tar.gz → 0.4.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: unveil-python
3
- Version: 0.2.0
3
+ Version: 0.4.0
4
4
  Summary: Implementation of UNVEIL
5
5
  Author-email: Nicolas Delinte <nicolas.delinte@uclouvain.be>
6
6
  License-Expression: GPL-3.0-only
@@ -25,10 +25,16 @@ Dynamic: license-file
25
25
 
26
26
  A simple tractography viewer implemented in python.
27
27
 
28
- ![image](https://github.com/user-attachments/assets/4fbefa7a-3fc2-446a-8835-2844266b14fa)
28
+ <img width="1297" height="757" alt="image" src="https://github.com/user-attachments/assets/28807e18-17fb-485b-a8ce-2179cdef796b" />
29
29
 
30
- The GUI is launched with the following python command:
30
+
31
+ The GUI is launched with the following python command in a python console:
31
32
 
32
33
  ```
33
34
  import unveil.__main__
34
35
  ```
36
+ or with
37
+ ```
38
+ python -c "import unveil.__main__"
39
+ ```
40
+ from a terminal with a python environment activated.
@@ -6,10 +6,16 @@
6
6
 
7
7
  A simple tractography viewer implemented in python.
8
8
 
9
- ![image](https://github.com/user-attachments/assets/4fbefa7a-3fc2-446a-8835-2844266b14fa)
9
+ <img width="1297" height="757" alt="image" src="https://github.com/user-attachments/assets/28807e18-17fb-485b-a8ce-2179cdef796b" />
10
10
 
11
- The GUI is launched with the following python command:
11
+
12
+ The GUI is launched with the following python command in a python console:
12
13
 
13
14
  ```
14
15
  import unveil.__main__
15
16
  ```
17
+ or with
18
+ ```
19
+ python -c "import unveil.__main__"
20
+ ```
21
+ from a terminal with a python environment activated.
@@ -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.2.0"
4
+ __version__ = "0.4.0"
5
5
  __author__ = 'Nicolas Delinte'
@@ -13,12 +13,12 @@ from PyQt6.QtCore import Qt
13
13
  from PyQt6.QtGui import QAction
14
14
  from PyQt6.QtWidgets import (QApplication, QWidget, QHBoxLayout, QVBoxLayout,
15
15
  QPushButton, QFileDialog, QCheckBox, QSlider,
16
- QLabel, QComboBox, QMainWindow, QLineEdit)
16
+ QLabel, QComboBox, QMainWindow, QLineEdit,
17
+ QDockWidget, QTreeWidget, QTreeWidgetItem)
17
18
  from dipy.io.streamline import load_tractogram
18
19
  from matplotlib import pyplot as plt
19
20
  from matplotlib.colors import LinearSegmentedColormap
20
21
  # from unravel.utils import get_streamline_density
21
- # from unravel.viz import plot_trk # We'll use our local version below
22
22
 
23
23
 
24
24
  def gifti_to_pyvista(gii_path):
@@ -27,17 +27,6 @@ def gifti_to_pyvista(gii_path):
27
27
  using the inverse of the given NIfTI affine.
28
28
  """
29
29
 
30
- # Affine from MNI152 1mm template (voxel→world)
31
- affine = np.array([
32
- [-1.0, 0.0, 0.0, 90.0],
33
- [0.0, 1.0, 0.0, -126.0],
34
- [0.0, 0.0, 1.0, -72.0],
35
- [0.0, 0.0, 0.0, 1.0]
36
- ])
37
-
38
- # Compute inverse to go from world (mm) → voxel coordinates
39
- inv_affine = np.linalg.inv(affine)
40
-
41
30
  # Load GIFTI file
42
31
  img = nib.load(gii_path)
43
32
  coords_list = img.get_arrays_from_intent('NIFTI_INTENT_POINTSET')
@@ -51,22 +40,38 @@ def gifti_to_pyvista(gii_path):
51
40
  coords_world = coords_list[0].data.astype(np.float32)
52
41
  faces = face_list[0].data.astype(np.int32)
53
42
 
54
- # Transform to voxel space
55
- coords_voxel = nib.affines.apply_affine(inv_affine, coords_world)
56
-
57
43
  # Build PyVista face array (VTK expects [3, i1, i2, i3, 3, ...])
58
44
  faces_pv = np.hstack(
59
45
  (np.full((faces.shape[0], 1), 3, dtype=np.int32), faces)).astype(np.int32)
60
46
 
61
47
  # Create PolyData mesh in voxel space
62
- mesh = pv.PolyData(coords_voxel, faces_pv)
48
+ mesh = pv.PolyData(coords_world, faces_pv)
63
49
 
64
50
  return mesh
65
51
 
66
52
 
53
+ def convert_rgb_array_to_new_basis(rgb_array):
54
+ """
55
+ Converts an array of RGB colors shape [n, 3] into a new basis.
56
+
57
+ rgb_array: numpy array of shape (n, 3) where each row is [R, G, B]
58
+ Returns: numpy array of shape (n, 3) where each row is [newR, newG, newB]
59
+ """
60
+
61
+ # Based on the Color-Blind Safe IBM Design Library Color Palette
62
+ C1 = np.array([220, 38, 127])
63
+ C2 = np.array([255, 176, 0])
64
+ C3 = np.array([120, 94, 240])
65
+
66
+ B = np.column_stack([C1, C2, C3])
67
+ new_rgb = rgb_array.dot(B.T)
68
+
69
+ return new_rgb
70
+
71
+
67
72
  def plot_trk(trk_file, scalar=None, color_map='plasma', opacity: float = 1,
68
73
  show_points: bool = False, background: str = 'black', plotter=None,
69
- name=None, reset_camera=False):
74
+ name=None, reset_camera=False, color_blind: bool = False):
70
75
  '''
71
76
  3D render for .trk files.
72
77
 
@@ -128,10 +133,12 @@ def plot_trk(trk_file, scalar=None, color_map='plasma', opacity: float = 1,
128
133
  point = streamlines._data
129
134
  next_point = np.roll(point, -1, axis=0)
130
135
  vs = next_point-point
136
+ ends = (streamlines._offsets+streamlines._lengths-1)
137
+ if color_blind:
138
+ vs = convert_rgb_array_to_new_basis(np.abs(vs))
131
139
  norm = np.linalg.norm(vs, axis=1)
132
140
  norm = np.stack((norm,)*3, axis=1, dtype=np.float32)
133
141
  norm = np.divide(vs, norm, dtype=np.float64)
134
- ends = (streamlines._offsets+streamlines._lengths-1)
135
142
  norm[ends, :] = norm[ends-1, :]
136
143
  scalars = np.abs(norm)
137
144
  rgb = True
@@ -157,23 +164,22 @@ def plot_trk(trk_file, scalar=None, color_map='plasma', opacity: float = 1,
157
164
  interpolate_before_map=False, render_lines_as_tubes=True,
158
165
  line_width=2, point_size=point_size, rgb=rgb,
159
166
  cmap=color_map, clim=color_lim, scalars=scalars, name=name,
160
- reset_camera=reset_camera)
167
+ reset_camera=reset_camera, user_matrix=trk.affine)
161
168
 
162
169
  elif color_map == 'flesh':
163
170
  p.add_mesh(mesh, opacity=opacity, diffuse=0.4, ambient=ambient,
164
171
  interpolate_before_map=False, render_lines_as_tubes=True,
165
172
  line_width=2, point_size=point_size, rgb=rgb,
166
173
  color=[250, 225, 210], name=name,
167
- reset_camera=reset_camera)
174
+ reset_camera=reset_camera, user_matrix=trk.affine)
168
175
  else:
169
176
  p.add_mesh(mesh, opacity=opacity, diffuse=diffuse, ambient=ambient,
170
177
  interpolate_before_map=False, render_lines_as_tubes=True,
171
178
  line_width=2, point_size=point_size, rgb=rgb,
172
179
  cmap=color_map, scalars=scalars, name=name,
173
- reset_camera=reset_camera)
180
+ reset_camera=reset_camera, user_matrix=trk.affine)
174
181
 
175
182
  p.background_color = background
176
- # Do not call p.show() here when using an embedded interactor
177
183
 
178
184
 
179
185
  class TrkViewer(QWidget):
@@ -184,12 +190,13 @@ class TrkViewer(QWidget):
184
190
  self.nii_data = None
185
191
  self.grid = None
186
192
  self.names = []
187
- self.selected_name = None
188
193
  self.x = 0
189
194
  self.y = 0
190
195
  self.z = 0
191
196
  self.initUI()
192
197
  self.background = 'white'
198
+ self.color_blind = False
199
+ self.flicker = False
193
200
 
194
201
  def initUI(self):
195
202
  # Create the main layout
@@ -257,9 +264,8 @@ class TrkViewer(QWidget):
257
264
  'Insert color map name. Name must be in the matplotlib library. Scalar nii.gz must be loaded.')
258
265
  control_layout.addWidget(self.colorMapEdit)
259
266
 
260
- self.showVolumeCheckbox = QCheckBox('Show Volume')
261
- self.showVolumeCheckbox.stateChanged.connect(self.update_nii_viewer)
262
- control_layout.addWidget(self.showVolumeCheckbox)
267
+ self.volume_label = QLabel('Volume')
268
+ control_layout.addWidget(self.volume_label)
263
269
 
264
270
  self.nii_opacitySlider = QSlider(Qt.Orientation.Horizontal, self)
265
271
  self.nii_opacitySlider.setMinimum(0)
@@ -272,27 +278,24 @@ class TrkViewer(QWidget):
272
278
  self.showSlicesCheckbox.stateChanged.connect(self.update_nii_viewer)
273
279
  control_layout.addWidget(self.showSlicesCheckbox)
274
280
 
275
- self.showXCheckbox = QCheckBox('X')
276
- self.showXCheckbox.stateChanged.connect(self._update_nii_x)
277
- control_layout.addWidget(self.showXCheckbox)
281
+ self.x_label = QLabel('X')
282
+ control_layout.addWidget(self.x_label)
278
283
  self.XSlider = QSlider(Qt.Orientation.Horizontal, self)
279
284
  self.XSlider.setMinimum(0)
280
285
  self.XSlider.setMaximum(100)
281
286
  self.XSlider.setValue(100)
282
287
  self.XSlider.sliderReleased.connect(self._update_nii_x)
283
288
  control_layout.addWidget(self.XSlider)
284
- self.showYCheckbox = QCheckBox('Y')
285
- self.showYCheckbox.stateChanged.connect(self._update_nii_y)
286
- control_layout.addWidget(self.showYCheckbox)
289
+ self.y_label = QLabel('Y')
290
+ control_layout.addWidget(self.y_label)
287
291
  self.YSlider = QSlider(Qt.Orientation.Horizontal, self)
288
292
  self.YSlider.setMinimum(0)
289
293
  self.YSlider.setMaximum(100)
290
294
  self.YSlider.setValue(100)
291
295
  self.YSlider.sliderReleased.connect(self._update_nii_y)
292
296
  control_layout.addWidget(self.YSlider)
293
- self.showZCheckbox = QCheckBox('Z')
294
- self.showZCheckbox.stateChanged.connect(self._update_nii_z)
295
- control_layout.addWidget(self.showZCheckbox)
297
+ self.z_label = QLabel('Z')
298
+ control_layout.addWidget(self.z_label)
296
299
  self.ZSlider = QSlider(Qt.Orientation.Horizontal, self)
297
300
  self.ZSlider.setMinimum(0)
298
301
  self.ZSlider.setMaximum(100)
@@ -301,9 +304,8 @@ class TrkViewer(QWidget):
301
304
  control_layout.addWidget(self.ZSlider)
302
305
 
303
306
  # Gifti
304
- self.showSurfaceCheckbox = QCheckBox('Show Surface')
305
- self.showSurfaceCheckbox.stateChanged.connect(self.update_gii_viewer)
306
- control_layout.addWidget(self.showSurfaceCheckbox)
307
+ self.surface_label = QLabel('Surface')
308
+ control_layout.addWidget(self.surface_label)
307
309
 
308
310
  self.gii_opacitySlider = QSlider(Qt.Orientation.Horizontal, self)
309
311
  self.gii_opacitySlider.setMinimum(0)
@@ -324,15 +326,19 @@ class TrkViewer(QWidget):
324
326
 
325
327
  def loadTrkFile(self):
326
328
  options = QFileDialog.Options()
327
- filePath, _ = QFileDialog.getOpenFileName(
328
- self, "Open TRK File", "", "Tractography Files (*.trk)", options=options)
329
- if filePath:
330
- self.trk_file = filePath
329
+ filePaths, _ = QFileDialog.getOpenFileNames(self, "Open TRK Files", "",
330
+ "Tractography Files (*.trk)",
331
+ options=options)
332
+ if not filePaths:
333
+ return
334
+
335
+ for trk_path in filePaths:
336
+ self.trk_file = trk_path
331
337
  print(f"Loaded TRK file: {self.trk_file}")
332
338
  self.names.append(self.trk_file)
333
- self.selected_name = self.trk_file
334
339
 
335
340
  self.update_trk_viewer(reset_camera=True)
341
+ self.parent().refreshActorList()
336
342
 
337
343
  def loadNiftiFile(self):
338
344
  options = QFileDialog.Options()
@@ -342,12 +348,10 @@ class TrkViewer(QWidget):
342
348
  self.nii_file = filePath
343
349
  print(f"Loaded NIfTI file: {self.nii_file}")
344
350
  img = nib.load(filePath)
351
+ self.nii_affine = img.affine
345
352
  self.nii_data = img.get_fdata()
346
353
  grid = pv.ImageData()
347
354
  grid.dimensions = np.array(self.nii_data.shape) + 1
348
- # World coordinates
349
- # grid.origin = img.affine[:3, 3]
350
- # grid.spacing = np.diag(img.affine)[:3]
351
355
  grid.cell_data['values'] = self.nii_data.flatten(order='F')
352
356
  self.grid = grid
353
357
 
@@ -355,7 +359,38 @@ class TrkViewer(QWidget):
355
359
  self.YSlider.setMaximum(self.nii_data.shape[1])
356
360
  self.ZSlider.setMaximum(self.nii_data.shape[2])
357
361
 
358
- self.update_nii_viewer(reset_camera=True)
362
+ self.update_nii_viewer(reset_camera=False)
363
+ self.parent().refreshActorList()
364
+
365
+ def loadROIFile(self):
366
+ """Load one or multiple ROI nifti volumes and render as surfaces."""
367
+ options = QFileDialog.Options()
368
+ filePaths, _ = QFileDialog.getOpenFileNames(self, "Open ROI Files", "",
369
+ "Nifti Files (*.nii *.nii.gz)",
370
+ options=options)
371
+
372
+ if not filePaths:
373
+ return
374
+
375
+ for roi_path in filePaths:
376
+ img = nib.load(roi_path)
377
+ roi = img.get_fdata()
378
+
379
+ datapv = pv.wrap(roi)
380
+ datapv.cell_data['labels'] = roi[:-1, :-1, :-1].flatten(order='F')
381
+ vol = datapv.threshold(value=1, scalars='labels')
382
+ mesh = vol.extract_surface()
383
+
384
+ smooth = mesh.smooth_taubin(n_iter=12)
385
+
386
+ actor_name = f"roi_{roi_path.split('/')[-1]}"
387
+
388
+ self.plotter.add_mesh(smooth, name=actor_name, opacity=1,
389
+ color="white", user_matrix=img.affine,
390
+ reset_camera=False, point_size=0,
391
+ render_lines_as_tubes=True)
392
+
393
+ self.parent().refreshActorList()
359
394
 
360
395
  def loadGiftiFile(self):
361
396
  options = QFileDialog.Options()
@@ -366,7 +401,8 @@ class TrkViewer(QWidget):
366
401
  print(f"Loaded GIfTI file: {self.gii_file}")
367
402
  self.gii_mesh = gifti_to_pyvista(filePath)
368
403
 
369
- self.update_gii_viewer(reset_camera=True)
404
+ self.update_gii_viewer(reset_camera=False)
405
+ self.parent().refreshActorList()
370
406
 
371
407
  def set_background_color(self):
372
408
 
@@ -377,25 +413,30 @@ class TrkViewer(QWidget):
377
413
 
378
414
  self.plotter.background_color = self.background
379
415
 
416
+ def toggleColorBlind(self):
417
+
418
+ self.color_blind = not self.color_blind
419
+ self.update_trk_viewer()
420
+
380
421
  def view_isometric(self):
381
- # equivalent to BackgroundPlotter 'iso' camera position
382
422
  self.plotter.view_isometric()
383
- self.plotter.render()
423
+ self.plotter.camera.azimuth += self.flicker*180
424
+ self.flicker = not self.flicker
384
425
 
385
426
  def view_xy(self):
386
- # view from +Z axis, XY plane (axial)
387
- self.plotter.view_xy() # pyvista method
388
- self.plotter.render()
427
+ self.plotter.view_xy()
428
+ self.plotter.camera.azimuth += self.flicker*180
429
+ self.flicker = not self.flicker
389
430
 
390
431
  def view_xz(self):
391
- # view from +Y axis, XZ plane (sagittal)
392
432
  self.plotter.view_xz()
393
- self.plotter.render()
433
+ self.plotter.camera.azimuth += self.flicker*180
434
+ self.flicker = not self.flicker
394
435
 
395
436
  def view_yz(self):
396
- # view from +X axis, YZ plane (coronal)
397
437
  self.plotter.view_yz()
398
- self.plotter.render()
438
+ self.plotter.camera.azimuth += self.flicker*180
439
+ self.flicker = not self.flicker
399
440
 
400
441
  def take_screenshot(self):
401
442
  """Take a screenshot of the current 3D view."""
@@ -426,7 +467,7 @@ class TrkViewer(QWidget):
426
467
 
427
468
  # Copy all meshes and volumes from the main plotter to the offscreen one
428
469
  for actor_name, actor in self.plotter.actors.items():
429
- offscreen.add_actor(actor.copy())
470
+ offscreen.add_actor(actor)
430
471
 
431
472
  # Match background color and camera
432
473
  offscreen.background_color = self.plotter.background_color
@@ -446,7 +487,7 @@ class TrkViewer(QWidget):
446
487
  self.plotter.render()
447
488
  offscreen.close()
448
489
 
449
- def update_trk_viewer(self, reset_camera=False):
490
+ def update_trk_viewer(self, reset_camera: bool = False):
450
491
 
451
492
  opacity = self.opacitySlider.value() / 100.0
452
493
  show_points = self.showPointsCheckbox.isChecked()
@@ -467,7 +508,7 @@ class TrkViewer(QWidget):
467
508
  plot_trk(file, opacity=opacity, plotter=self.plotter, scalar=scalar,
468
509
  show_points=show_points, color_map=color_map,
469
510
  name=file, background=self.background,
470
- reset_camera=reset_camera)
511
+ reset_camera=reset_camera, color_blind=self.color_blind)
471
512
 
472
513
  def _update_nii_x(self):
473
514
 
@@ -476,14 +517,14 @@ class TrkViewer(QWidget):
476
517
  center = (self.XSlider.value(),
477
518
  self.YSlider.value(), self.ZSlider.value())
478
519
 
479
- if self.showXCheckbox.isChecked():
480
- slice_x = self.grid.slice('x', center)
481
- self.plotter.add_mesh(slice_x, cmap='grey', name='nii_x',
482
- show_scalar_bar=False, point_size=0,
483
- render_lines_as_tubes=True,
484
- reset_camera=False)
485
- else:
486
- self.plotter.remove_actor('nii_x')
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')
487
528
 
488
529
  def _update_nii_y(self):
489
530
 
@@ -492,14 +533,14 @@ class TrkViewer(QWidget):
492
533
  center = (self.XSlider.value(),
493
534
  self.YSlider.value(), self.ZSlider.value())
494
535
 
495
- if self.showYCheckbox.isChecked():
496
- slice_y = self.grid.slice('y', center)
497
- self.plotter.add_mesh(slice_y, cmap='grey', name='nii_y',
498
- show_scalar_bar=False, point_size=0,
499
- render_lines_as_tubes=True,
500
- reset_camera=False)
501
- else:
502
- self.plotter.remove_actor('nii_y')
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')
503
544
 
504
545
  def _update_nii_z(self):
505
546
 
@@ -508,48 +549,35 @@ class TrkViewer(QWidget):
508
549
  center = (self.XSlider.value(),
509
550
  self.YSlider.value(), self.ZSlider.value())
510
551
 
511
- if self.showZCheckbox.isChecked():
512
- slice_z = self.grid.slice('z', center)
513
- self.plotter.add_mesh(slice_z, cmap='grey', name='nii_z',
514
- show_scalar_bar=False, point_size=0,
515
- render_lines_as_tubes=True,
516
- reset_camera=False)
517
- else:
518
- self.plotter.remove_actor('nii_z')
519
-
520
- def update_nii_viewer(self, reset_camera=False):
521
-
522
- if self.showSlicesCheckbox.isChecked():
523
-
524
- self._update_nii_x()
525
- self._update_nii_y()
526
- self._update_nii_z()
527
-
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)
528
558
  else:
529
- self.plotter.remove_actor('nii_x')
530
- self.plotter.remove_actor('nii_y')
531
559
  self.plotter.remove_actor('nii_z')
532
560
 
533
- if self.showVolumeCheckbox.isChecked():
534
- opacity = self.nii_opacitySlider.value()/1000
535
- self.plotter.add_volume(self.grid, cmap='gray', opacity=[0, opacity],
536
- show_scalar_bar=False, name='nii_volume',
537
- reset_camera=reset_camera)
538
- else:
539
- self.plotter.remove_actor('nii_volume', reset_camera=reset_camera)
561
+ def update_nii_viewer(self, reset_camera: bool = False):
540
562
 
541
- def update_gii_viewer(self, reset_camera=False):
563
+ self._update_nii_x()
564
+ self._update_nii_y()
565
+ self._update_nii_z()
542
566
 
543
- if self.showSurfaceCheckbox.isChecked():
567
+ opacity = self.nii_opacitySlider.value()/1000
568
+ self.plotter.add_volume(self.grid, cmap='gray', opacity=[0, opacity],
569
+ show_scalar_bar=False, name='nii_volume',
570
+ reset_camera=reset_camera,
571
+ user_matrix=self.nii_affine)
544
572
 
545
- opacity = self.gii_opacitySlider.value()/100
546
- self.plotter.add_mesh(self.gii_mesh, color="ghostwhite",
547
- culling='front', smooth_shading=True,
548
- opacity=opacity, name='gii_surface',
549
- reset_camera=reset_camera)
550
- else:
551
- self.plotter.remove_actor('gii_surface', reset_camera=reset_camera)
552
- # self.plotter.actors['gii_surface'].visibility = False
573
+ def update_gii_viewer(self, reset_camera: bool = False):
574
+
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,)
553
581
 
554
582
 
555
583
  class MainWindow(QMainWindow):
@@ -557,6 +585,7 @@ class MainWindow(QMainWindow):
557
585
  super().__init__()
558
586
  self.viewer = TrkViewer()
559
587
  self.setCentralWidget(self.viewer)
588
+ self.initActorDock()
560
589
  self.initMenuBar()
561
590
  self.setWindowTitle("UNVEIL - Tractography Viewer")
562
591
 
@@ -581,16 +610,108 @@ class MainWindow(QMainWindow):
581
610
  loadGiftiAction.triggered.connect(self.viewer.loadGiftiFile)
582
611
  fileMenu.addAction(loadGiftiAction)
583
612
 
613
+ # Load ROIs
614
+ loadROIAction = QAction('Load ROIs (.nii/.nii.gz)', self)
615
+ loadROIAction.triggered.connect(self.viewer.loadROIFile)
616
+ fileMenu.addAction(loadROIAction)
617
+
584
618
  # Optional: Exit action
585
619
  exitAction = QAction('Exit', self)
586
620
  exitAction.triggered.connect(self.close)
587
621
  fileMenu.addAction(exitAction)
588
622
 
589
623
  # View menu
590
- fileMenu = menubar.addMenu('View')
624
+ viewMenu = menubar.addMenu('View')
591
625
  setBackColorAction = QAction('Background Color', self)
592
626
  setBackColorAction.triggered.connect(self.viewer.set_background_color)
593
- fileMenu.addAction(setBackColorAction)
627
+ viewMenu.addAction(setBackColorAction)
628
+ toggleActorListAction = QAction("Toggle Actor List", self,
629
+ checkable=True)
630
+ toggleActorListAction.triggered.connect(self.toggleActorDock)
631
+ viewMenu.addAction(toggleActorListAction)
632
+ toggleColorBlindAction = QAction("Use color-blind colors", self,
633
+ checkable=True)
634
+ toggleColorBlindAction.triggered.connect(self.viewer.toggleColorBlind)
635
+ viewMenu.addAction(toggleColorBlindAction)
636
+
637
+ def initActorDock(self):
638
+ self.actorDock = QDockWidget("Scene Actors", self)
639
+ self.actorDock.setAllowedAreas(Qt.DockWidgetArea.RightDockWidgetArea)
640
+
641
+ self.actorTree = QTreeWidget()
642
+ self.actorTree.setHeaderLabels(["Actor", "Visible"])
643
+ self.actorTree.setColumnCount(2)
644
+ self.actorTree.setColumnWidth(0, 200)
645
+
646
+ self.actorDock.setWidget(self.actorTree)
647
+ self.addDockWidget(
648
+ Qt.DockWidgetArea.RightDockWidgetArea, self.actorDock)
649
+
650
+ self.actorDock.hide() # start hidden
651
+
652
+ # Refresh the list whenever actors change
653
+ self.viewer.plotter.mesh_added_callback = self.refreshActorList
654
+ self.viewer.plotter.mesh_removed_callback = self.refreshActorList
655
+
656
+ def toggleActorDock(self, checked):
657
+ if checked:
658
+
659
+ self.actorDock.show()
660
+ else:
661
+ self.actorDock.hide()
662
+
663
+ def refreshActorList(self):
664
+ """Rebuild the hierarchical actor list grouped by class."""
665
+ self.actorTree.clear()
666
+
667
+ # Groups
668
+ group_trk = QTreeWidgetItem(["TRK", ""])
669
+ group_nii = QTreeWidgetItem(["NIfTI Volume", ""])
670
+ group_roi = QTreeWidgetItem(["ROI Surfaces", ""])
671
+ group_gii = QTreeWidgetItem(["GIFTI", ""])
672
+
673
+ self.actorTree.addTopLevelItem(group_trk)
674
+ self.actorTree.addTopLevelItem(group_nii)
675
+ self.actorTree.addTopLevelItem(group_gii)
676
+ self.actorTree.addTopLevelItem(group_roi)
677
+ # Loop through PyVista actors
678
+ 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)
682
+
683
+ # Store name for callback
684
+ item.actor_name = name
685
+
686
+ # Insert into the correct group
687
+ if name.endswith(".trk"):
688
+ group_trk.addChild(item)
689
+ elif name.startswith("nii_"):
690
+ group_nii.addChild(item)
691
+ elif name.startswith("gii_"):
692
+ group_gii.addChild(item)
693
+ elif name.startswith("roi_"):
694
+ group_roi.addChild(item)
695
+ else:
696
+ group_trk.addChild(item) # default bucket
697
+
698
+ # Connect visibility toggles
699
+ self.actorTree.itemChanged.connect(self.onActorVisibilityChanged)
700
+
701
+ def onActorVisibilityChanged(self, item, column):
702
+ """Toggle visibility when user clicks checkbox."""
703
+ if column != 1:
704
+ return
705
+
706
+ actor_name = item.actor_name
707
+ visible = item.checkState(1) == Qt.CheckState.Checked
708
+
709
+ try:
710
+ self.viewer.plotter.actors[actor_name].SetVisibility(visible)
711
+ except KeyError:
712
+ pass
713
+
714
+ self.viewer.plotter.render()
594
715
 
595
716
 
596
717
  if __name__ == '__main__':
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: unveil-python
3
- Version: 0.2.0
3
+ Version: 0.4.0
4
4
  Summary: Implementation of UNVEIL
5
5
  Author-email: Nicolas Delinte <nicolas.delinte@uclouvain.be>
6
6
  License-Expression: GPL-3.0-only
@@ -25,10 +25,16 @@ Dynamic: license-file
25
25
 
26
26
  A simple tractography viewer implemented in python.
27
27
 
28
- ![image](https://github.com/user-attachments/assets/4fbefa7a-3fc2-446a-8835-2844266b14fa)
28
+ <img width="1297" height="757" alt="image" src="https://github.com/user-attachments/assets/28807e18-17fb-485b-a8ce-2179cdef796b" />
29
29
 
30
- The GUI is launched with the following python command:
30
+
31
+ The GUI is launched with the following python command in a python console:
31
32
 
32
33
  ```
33
34
  import unveil.__main__
34
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