unveil-python 0.1.0__tar.gz → 0.3.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.1.0
3
+ Version: 0.3.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
@@ -27,8 +27,14 @@ A simple tractography viewer implemented in python.
27
27
 
28
28
  ![image](https://github.com/user-attachments/assets/4fbefa7a-3fc2-446a-8835-2844266b14fa)
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.
@@ -8,8 +8,14 @@ A simple tractography viewer implemented in python.
8
8
 
9
9
  ![image](https://github.com/user-attachments/assets/4fbefa7a-3fc2-446a-8835-2844266b14fa)
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.1.0"
4
+ __version__ = "0.3.0"
5
5
  __author__ = 'Nicolas Delinte'
@@ -2,18 +2,19 @@
2
2
  """
3
3
  Created on Tue Mar 4 13:03:28 2025
4
4
 
5
- @author: nicol
5
+ @author: DELINTE Nicolas
6
6
  """
7
7
  import sys
8
8
  import numpy as np
9
9
  import nibabel as nib
10
10
  import pyvista as pv
11
- from pyvistaqt import QtInteractor # For embedding PyVista in PyQt6
11
+ from pyvistaqt import QtInteractor
12
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
@@ -27,17 +28,6 @@ def gifti_to_pyvista(gii_path):
27
28
  using the inverse of the given NIfTI affine.
28
29
  """
29
30
 
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
31
  # Load GIFTI file
42
32
  img = nib.load(gii_path)
43
33
  coords_list = img.get_arrays_from_intent('NIFTI_INTENT_POINTSET')
@@ -51,15 +41,12 @@ def gifti_to_pyvista(gii_path):
51
41
  coords_world = coords_list[0].data.astype(np.float32)
52
42
  faces = face_list[0].data.astype(np.int32)
53
43
 
54
- # Transform to voxel space
55
- coords_voxel = nib.affines.apply_affine(inv_affine, coords_world)
56
-
57
44
  # Build PyVista face array (VTK expects [3, i1, i2, i3, 3, ...])
58
45
  faces_pv = np.hstack(
59
46
  (np.full((faces.shape[0], 1), 3, dtype=np.int32), faces)).astype(np.int32)
60
47
 
61
48
  # Create PolyData mesh in voxel space
62
- mesh = pv.PolyData(coords_voxel, faces_pv)
49
+ mesh = pv.PolyData(coords_world, faces_pv)
63
50
 
64
51
  return mesh
65
52
 
@@ -157,20 +144,23 @@ def plot_trk(trk_file, scalar=None, color_map='plasma', opacity: float = 1,
157
144
  interpolate_before_map=False, render_lines_as_tubes=True,
158
145
  line_width=2, point_size=point_size, rgb=rgb,
159
146
  cmap=color_map, clim=color_lim, scalars=scalars, name=name,
160
- reset_camera=reset_camera)
147
+ reset_camera=reset_camera,
148
+ user_matrix=trk.affine)
161
149
 
162
150
  elif color_map == 'flesh':
163
151
  p.add_mesh(mesh, opacity=opacity, diffuse=0.4, ambient=ambient,
164
152
  interpolate_before_map=False, render_lines_as_tubes=True,
165
153
  line_width=2, point_size=point_size, rgb=rgb,
166
154
  color=[250, 225, 210], name=name,
167
- reset_camera=reset_camera)
155
+ reset_camera=reset_camera,
156
+ user_matrix=trk.affine)
168
157
  else:
169
158
  p.add_mesh(mesh, opacity=opacity, diffuse=diffuse, ambient=ambient,
170
159
  interpolate_before_map=False, render_lines_as_tubes=True,
171
160
  line_width=2, point_size=point_size, rgb=rgb,
172
161
  cmap=color_map, scalars=scalars, name=name,
173
- reset_camera=reset_camera)
162
+ reset_camera=reset_camera,
163
+ user_matrix=trk.affine)
174
164
 
175
165
  p.background_color = background
176
166
  # Do not call p.show() here when using an embedded interactor
@@ -184,7 +174,6 @@ class TrkViewer(QWidget):
184
174
  self.nii_data = None
185
175
  self.grid = None
186
176
  self.names = []
187
- self.selected_name = None
188
177
  self.x = 0
189
178
  self.y = 0
190
179
  self.z = 0
@@ -214,6 +203,10 @@ class TrkViewer(QWidget):
214
203
  btn_screenshot = QPushButton("Screenshot")
215
204
  btn_screenshot.clicked.connect(self.take_screenshot)
216
205
  cam_layout.addWidget(btn_screenshot)
206
+ btn_gif = QPushButton("Create GIF")
207
+ btn_gif.setToolTip("This takes ~2min to save after animation end.")
208
+ btn_gif.clicked.connect(self.create_gif)
209
+ cam_layout.addWidget(btn_gif)
217
210
 
218
211
  global_layout.addLayout(cam_layout)
219
212
 
@@ -253,9 +246,8 @@ class TrkViewer(QWidget):
253
246
  'Insert color map name. Name must be in the matplotlib library. Scalar nii.gz must be loaded.')
254
247
  control_layout.addWidget(self.colorMapEdit)
255
248
 
256
- self.showVolumeCheckbox = QCheckBox('Show Volume')
257
- self.showVolumeCheckbox.stateChanged.connect(self.update_nii_viewer)
258
- control_layout.addWidget(self.showVolumeCheckbox)
249
+ self.volume_label = QLabel('Volume')
250
+ control_layout.addWidget(self.volume_label)
259
251
 
260
252
  self.nii_opacitySlider = QSlider(Qt.Orientation.Horizontal, self)
261
253
  self.nii_opacitySlider.setMinimum(0)
@@ -268,27 +260,24 @@ class TrkViewer(QWidget):
268
260
  self.showSlicesCheckbox.stateChanged.connect(self.update_nii_viewer)
269
261
  control_layout.addWidget(self.showSlicesCheckbox)
270
262
 
271
- self.showXCheckbox = QCheckBox('X')
272
- self.showXCheckbox.stateChanged.connect(self._update_nii_x)
273
- control_layout.addWidget(self.showXCheckbox)
263
+ self.x_label = QLabel('X')
264
+ control_layout.addWidget(self.x_label)
274
265
  self.XSlider = QSlider(Qt.Orientation.Horizontal, self)
275
266
  self.XSlider.setMinimum(0)
276
267
  self.XSlider.setMaximum(100)
277
268
  self.XSlider.setValue(100)
278
269
  self.XSlider.sliderReleased.connect(self._update_nii_x)
279
270
  control_layout.addWidget(self.XSlider)
280
- self.showYCheckbox = QCheckBox('Y')
281
- self.showYCheckbox.stateChanged.connect(self._update_nii_y)
282
- control_layout.addWidget(self.showYCheckbox)
271
+ self.y_label = QLabel('Y')
272
+ control_layout.addWidget(self.y_label)
283
273
  self.YSlider = QSlider(Qt.Orientation.Horizontal, self)
284
274
  self.YSlider.setMinimum(0)
285
275
  self.YSlider.setMaximum(100)
286
276
  self.YSlider.setValue(100)
287
277
  self.YSlider.sliderReleased.connect(self._update_nii_y)
288
278
  control_layout.addWidget(self.YSlider)
289
- self.showZCheckbox = QCheckBox('Z')
290
- self.showZCheckbox.stateChanged.connect(self._update_nii_z)
291
- control_layout.addWidget(self.showZCheckbox)
279
+ self.z_label = QLabel('Z')
280
+ control_layout.addWidget(self.z_label)
292
281
  self.ZSlider = QSlider(Qt.Orientation.Horizontal, self)
293
282
  self.ZSlider.setMinimum(0)
294
283
  self.ZSlider.setMaximum(100)
@@ -297,9 +286,8 @@ class TrkViewer(QWidget):
297
286
  control_layout.addWidget(self.ZSlider)
298
287
 
299
288
  # Gifti
300
- self.showSurfaceCheckbox = QCheckBox('Show Surface')
301
- self.showSurfaceCheckbox.stateChanged.connect(self.update_gii_viewer)
302
- control_layout.addWidget(self.showSurfaceCheckbox)
289
+ self.surface_label = QLabel('Surface')
290
+ control_layout.addWidget(self.surface_label)
303
291
 
304
292
  self.gii_opacitySlider = QSlider(Qt.Orientation.Horizontal, self)
305
293
  self.gii_opacitySlider.setMinimum(0)
@@ -320,15 +308,19 @@ class TrkViewer(QWidget):
320
308
 
321
309
  def loadTrkFile(self):
322
310
  options = QFileDialog.Options()
323
- filePath, _ = QFileDialog.getOpenFileName(
324
- self, "Open TRK File", "", "Tractography Files (*.trk)", options=options)
325
- if filePath:
326
- self.trk_file = filePath
311
+ filePaths, _ = QFileDialog.getOpenFileNames(self, "Open TRK Files", "",
312
+ "Tractography Files (*.trk)",
313
+ options=options)
314
+ if not filePaths:
315
+ return
316
+
317
+ for trk_path in filePaths:
318
+ self.trk_file = trk_path
327
319
  print(f"Loaded TRK file: {self.trk_file}")
328
320
  self.names.append(self.trk_file)
329
- self.selected_name = self.trk_file
330
321
 
331
322
  self.update_trk_viewer(reset_camera=True)
323
+ self.parent().refreshActorList()
332
324
 
333
325
  def loadNiftiFile(self):
334
326
  options = QFileDialog.Options()
@@ -338,12 +330,10 @@ class TrkViewer(QWidget):
338
330
  self.nii_file = filePath
339
331
  print(f"Loaded NIfTI file: {self.nii_file}")
340
332
  img = nib.load(filePath)
333
+ self.nii_affine = img.affine
341
334
  self.nii_data = img.get_fdata()
342
335
  grid = pv.ImageData()
343
336
  grid.dimensions = np.array(self.nii_data.shape) + 1
344
- # World coordinates
345
- # grid.origin = img.affine[:3, 3]
346
- # grid.spacing = np.diag(img.affine)[:3]
347
337
  grid.cell_data['values'] = self.nii_data.flatten(order='F')
348
338
  self.grid = grid
349
339
 
@@ -351,7 +341,45 @@ class TrkViewer(QWidget):
351
341
  self.YSlider.setMaximum(self.nii_data.shape[1])
352
342
  self.ZSlider.setMaximum(self.nii_data.shape[2])
353
343
 
354
- self.update_nii_viewer(reset_camera=True)
344
+ self.update_nii_viewer(reset_camera=False)
345
+ self.parent().refreshActorList()
346
+
347
+ def loadROIFile(self):
348
+ """Load one or multiple ROI nifti volumes and render as surfaces."""
349
+ options = QFileDialog.Options()
350
+ filePaths, _ = QFileDialog.getOpenFileNames(self, "Open ROI Files", "",
351
+ "Nifti Files (*.nii *.nii.gz)",
352
+ options=options)
353
+
354
+ if not filePaths:
355
+ return
356
+
357
+ for roi_path in filePaths:
358
+ img = nib.load(roi_path)
359
+ roi = img.get_fdata()
360
+ affine = img.affine
361
+
362
+ datapv = pv.wrap(roi)
363
+ datapv.cell_data['labels'] = roi[:-1, :-1, :-1].flatten(order='F')
364
+ vol = datapv.threshold(value=1, scalars='labels')
365
+ mesh = vol.extract_surface()
366
+
367
+ smooth = mesh.smooth_taubin(n_iter=12)
368
+
369
+ actor_name = f"roi_{roi_path.split('/')[-1]}"
370
+
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
+ )
381
+
382
+ self.parent().refreshActorList()
355
383
 
356
384
  def loadGiftiFile(self):
357
385
  options = QFileDialog.Options()
@@ -362,7 +390,8 @@ class TrkViewer(QWidget):
362
390
  print(f"Loaded GIfTI file: {self.gii_file}")
363
391
  self.gii_mesh = gifti_to_pyvista(filePath)
364
392
 
365
- self.update_gii_viewer(reset_camera=True)
393
+ self.update_gii_viewer(reset_camera=False)
394
+ self.parent().refreshActorList()
366
395
 
367
396
  def set_background_color(self):
368
397
 
@@ -396,17 +425,53 @@ class TrkViewer(QWidget):
396
425
  def take_screenshot(self):
397
426
  """Take a screenshot of the current 3D view."""
398
427
  options = QFileDialog.Options()
399
- file_path, _ = QFileDialog.getSaveFileName(
400
- self,
401
- "Save Screenshot",
402
- "",
403
- "PNG Image (*.png);;JPEG Image (*.jpg);;TIFF Image (*.tif)",
404
- options=options
405
- )
428
+ file_path, _ = QFileDialog.getSaveFileName(self, "Save Screenshot", "",
429
+ "PNG Image (*.png);;JPEG Image (*.jpg);;TIFF Image (*.tif)",
430
+ options=options)
406
431
  if file_path:
407
432
  self.plotter.screenshot(file_path, transparent_background=True)
408
433
 
409
- def update_trk_viewer(self, reset_camera=False):
434
+ def create_gif(self):
435
+ """Create a 360° rotation GIF of the current 3D view."""
436
+ options = QFileDialog.Options()
437
+ file_path, _ = QFileDialog.getSaveFileName(self, "Save GIF", "",
438
+ "GIF Files (*.gif)",
439
+ options=options)
440
+
441
+ if not file_path:
442
+ return
443
+
444
+ # Ensure the file has a .gif extension
445
+ if not file_path.lower().endswith(".gif"):
446
+ file_path += ".gif"
447
+
448
+ # Use an offscreen plotter so the main one remains visible
449
+ offscreen = pv.Plotter(
450
+ off_screen=True, window_size=self.plotter.window_size)
451
+
452
+ # Copy all meshes and volumes from the main plotter to the offscreen one
453
+ for actor_name, actor in self.plotter.actors.items():
454
+ offscreen.add_actor(actor.copy())
455
+
456
+ # Match background color and camera
457
+ offscreen.background_color = self.plotter.background_color
458
+ offscreen.camera_position = self.plotter.camera_position
459
+ offscreen.camera.SetPosition(self.plotter.camera.GetPosition())
460
+ offscreen.camera.SetFocalPoint(self.plotter.camera.GetFocalPoint())
461
+ offscreen.camera.SetViewUp(self.plotter.camera.GetViewUp())
462
+
463
+ # Create the 360° rotation GIF
464
+ offscreen.open_gif(file_path, fps=20)
465
+ n_frames = 360
466
+ for i in range(n_frames):
467
+ offscreen.camera.Azimuth(360 / n_frames)
468
+ offscreen.render()
469
+ offscreen.write_frame()
470
+ self.plotter.camera.azimuth += 360 / n_frames
471
+ self.plotter.render()
472
+ offscreen.close()
473
+
474
+ def update_trk_viewer(self, reset_camera: bool = False):
410
475
 
411
476
  opacity = self.opacitySlider.value() / 100.0
412
477
  show_points = self.showPointsCheckbox.isChecked()
@@ -436,14 +501,14 @@ class TrkViewer(QWidget):
436
501
  center = (self.XSlider.value(),
437
502
  self.YSlider.value(), self.ZSlider.value())
438
503
 
439
- if self.showXCheckbox.isChecked():
440
- slice_x = self.grid.slice('x', center)
441
- self.plotter.add_mesh(slice_x, cmap='grey', name='nii_x',
442
- show_scalar_bar=False, point_size=0,
443
- render_lines_as_tubes=True,
444
- reset_camera=False)
445
- else:
446
- self.plotter.remove_actor('nii_x')
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')
447
512
 
448
513
  def _update_nii_y(self):
449
514
 
@@ -452,14 +517,14 @@ class TrkViewer(QWidget):
452
517
  center = (self.XSlider.value(),
453
518
  self.YSlider.value(), self.ZSlider.value())
454
519
 
455
- if self.showYCheckbox.isChecked():
456
- slice_y = self.grid.slice('y', center)
457
- self.plotter.add_mesh(slice_y, cmap='grey', name='nii_y',
458
- show_scalar_bar=False, point_size=0,
459
- render_lines_as_tubes=True,
460
- reset_camera=False)
461
- else:
462
- self.plotter.remove_actor('nii_y')
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')
463
528
 
464
529
  def _update_nii_z(self):
465
530
 
@@ -468,48 +533,35 @@ class TrkViewer(QWidget):
468
533
  center = (self.XSlider.value(),
469
534
  self.YSlider.value(), self.ZSlider.value())
470
535
 
471
- if self.showZCheckbox.isChecked():
472
- slice_z = self.grid.slice('z', center)
473
- self.plotter.add_mesh(slice_z, cmap='grey', name='nii_z',
474
- show_scalar_bar=False, point_size=0,
475
- render_lines_as_tubes=True,
476
- reset_camera=False)
477
- else:
478
- self.plotter.remove_actor('nii_z')
479
-
480
- def update_nii_viewer(self, reset_camera=False):
481
-
482
- if self.showSlicesCheckbox.isChecked():
483
-
484
- self._update_nii_x()
485
- self._update_nii_y()
486
- self._update_nii_z()
487
-
536
+ slice_z = self.grid.slice('z', center)
537
+ self.plotter.add_mesh(slice_z, cmap='grey', name='nii_z',
538
+ show_scalar_bar=False, point_size=0,
539
+ render_lines_as_tubes=True,
540
+ reset_camera=False,
541
+ user_matrix=self.nii_affine)
488
542
  else:
489
- self.plotter.remove_actor('nii_x')
490
- self.plotter.remove_actor('nii_y')
491
543
  self.plotter.remove_actor('nii_z')
492
544
 
493
- if self.showVolumeCheckbox.isChecked():
494
- opacity = self.nii_opacitySlider.value()/1000
495
- self.plotter.add_volume(self.grid, cmap='gray', opacity=[0, opacity],
496
- show_scalar_bar=False, name='nii_volume',
497
- reset_camera=reset_camera)
498
- else:
499
- self.plotter.remove_actor('nii_volume', reset_camera=reset_camera)
545
+ def update_nii_viewer(self, reset_camera: bool = False):
500
546
 
501
- def update_gii_viewer(self, reset_camera=False):
547
+ self._update_nii_x()
548
+ self._update_nii_y()
549
+ self._update_nii_z()
502
550
 
503
- if self.showSurfaceCheckbox.isChecked():
551
+ opacity = self.nii_opacitySlider.value()/1000
552
+ self.plotter.add_volume(self.grid, cmap='gray', opacity=[0, opacity],
553
+ show_scalar_bar=False, name='nii_volume',
554
+ reset_camera=reset_camera,
555
+ user_matrix=self.nii_affine)
504
556
 
505
- opacity = self.gii_opacitySlider.value()/100
506
- self.plotter.add_mesh(self.gii_mesh, color="ghostwhite",
507
- culling='front', smooth_shading=True,
508
- opacity=opacity, name='gii_surface',
509
- reset_camera=reset_camera)
510
- else:
511
- # self.plotter.remove_actor('gii_surface', reset_camera=reset_camera)
512
- self.plotter.actors['gii_surface'].visibility = False
557
+ def update_gii_viewer(self, reset_camera: bool = False):
558
+
559
+ opacity = self.gii_opacitySlider.value()/100
560
+ self.plotter.add_mesh(self.gii_mesh, color="ghostwhite",
561
+ culling='back', smooth_shading=True,
562
+ opacity=opacity, name='gii_surface',
563
+ reset_camera=reset_camera, point_size=0,
564
+ render_lines_as_tubes=True,)
513
565
 
514
566
 
515
567
  class MainWindow(QMainWindow):
@@ -517,8 +569,9 @@ class MainWindow(QMainWindow):
517
569
  super().__init__()
518
570
  self.viewer = TrkViewer()
519
571
  self.setCentralWidget(self.viewer)
572
+ self.initActorDock()
520
573
  self.initMenuBar()
521
- self.setWindowTitle("Tractography Viewer with Menubar")
574
+ self.setWindowTitle("UNVEIL - Tractography Viewer")
522
575
 
523
576
  def initMenuBar(self):
524
577
  menubar = self.menuBar()
@@ -541,16 +594,104 @@ class MainWindow(QMainWindow):
541
594
  loadGiftiAction.triggered.connect(self.viewer.loadGiftiFile)
542
595
  fileMenu.addAction(loadGiftiAction)
543
596
 
597
+ # Load ROIs
598
+ loadROIAction = QAction('Load ROIs (.nii/.nii.gz)', self)
599
+ loadROIAction.triggered.connect(self.viewer.loadROIFile)
600
+ fileMenu.addAction(loadROIAction)
601
+
544
602
  # Optional: Exit action
545
603
  exitAction = QAction('Exit', self)
546
604
  exitAction.triggered.connect(self.close)
547
605
  fileMenu.addAction(exitAction)
548
606
 
549
607
  # View menu
550
- fileMenu = menubar.addMenu('View')
608
+ viewMenu = menubar.addMenu('View')
551
609
  setBackColorAction = QAction('Background Color', self)
552
610
  setBackColorAction.triggered.connect(self.viewer.set_background_color)
553
- fileMenu.addAction(setBackColorAction)
611
+ viewMenu.addAction(setBackColorAction)
612
+ toggleActorListAction = QAction(
613
+ "Toggle Actor List", self, checkable=True)
614
+ toggleActorListAction.triggered.connect(self.toggleActorDock)
615
+ viewMenu.addAction(toggleActorListAction)
616
+
617
+ def initActorDock(self):
618
+ self.actorDock = QDockWidget("Scene Actors", self)
619
+ self.actorDock.setAllowedAreas(Qt.DockWidgetArea.RightDockWidgetArea)
620
+
621
+ self.actorTree = QTreeWidget()
622
+ self.actorTree.setHeaderLabels(["Actor", "Visible"])
623
+ self.actorTree.setColumnCount(2)
624
+ self.actorTree.setColumnWidth(0, 200)
625
+
626
+ self.actorDock.setWidget(self.actorTree)
627
+ self.addDockWidget(
628
+ Qt.DockWidgetArea.RightDockWidgetArea, self.actorDock)
629
+
630
+ self.actorDock.hide() # start hidden
631
+
632
+ # Refresh the list whenever actors change
633
+ self.viewer.plotter.mesh_added_callback = self.refreshActorList
634
+ self.viewer.plotter.mesh_removed_callback = self.refreshActorList
635
+
636
+ def toggleActorDock(self, checked):
637
+ if checked:
638
+
639
+ self.actorDock.show()
640
+ else:
641
+ self.actorDock.hide()
642
+
643
+ def refreshActorList(self):
644
+ """Rebuild the hierarchical actor list grouped by class."""
645
+ self.actorTree.clear()
646
+
647
+ # Groups
648
+ group_trk = QTreeWidgetItem(["TRK", ""])
649
+ group_nii = QTreeWidgetItem(["NIfTI Volume", ""])
650
+ group_roi = QTreeWidgetItem(["ROI Surfaces", ""])
651
+ group_gii = QTreeWidgetItem(["GIFTI", ""])
652
+
653
+ self.actorTree.addTopLevelItem(group_trk)
654
+ self.actorTree.addTopLevelItem(group_nii)
655
+ self.actorTree.addTopLevelItem(group_gii)
656
+ self.actorTree.addTopLevelItem(group_roi)
657
+ # Loop through PyVista actors
658
+ 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)
662
+
663
+ # Store name for callback
664
+ item.actor_name = name
665
+
666
+ # Insert into the correct group
667
+ if name.endswith(".trk"):
668
+ group_trk.addChild(item)
669
+ elif name.startswith("nii_"):
670
+ group_nii.addChild(item)
671
+ elif name.startswith("gii_"):
672
+ group_gii.addChild(item)
673
+ elif name.startswith("roi_"):
674
+ group_roi.addChild(item)
675
+ else:
676
+ group_trk.addChild(item) # default bucket
677
+
678
+ # Connect visibility toggles
679
+ self.actorTree.itemChanged.connect(self.onActorVisibilityChanged)
680
+
681
+ def onActorVisibilityChanged(self, item, column):
682
+ """Toggle visibility when user clicks checkbox."""
683
+ if column != 1:
684
+ return
685
+
686
+ actor_name = item.actor_name
687
+ visible = item.checkState(1) == Qt.CheckState.Checked
688
+
689
+ try:
690
+ self.viewer.plotter.actors[actor_name].SetVisibility(visible)
691
+ except KeyError:
692
+ pass
693
+
694
+ self.viewer.plotter.render()
554
695
 
555
696
 
556
697
  if __name__ == '__main__':
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: unveil-python
3
- Version: 0.1.0
3
+ Version: 0.3.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
@@ -27,8 +27,14 @@ A simple tractography viewer implemented in python.
27
27
 
28
28
  ![image](https://github.com/user-attachments/assets/4fbefa7a-3fc2-446a-8835-2844266b14fa)
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