Coverage for main/ui/nifti_viewer.py: 86%
1279 statements
« prev ^ index » next coverage.py v7.15.0, created at 2026-07-03 15:51 +0000
« prev ^ index » next coverage.py v7.15.0, created at 2026-07-03 15:51 +0000
1import re
2import sys
3import os
4import gc
5import json
7import numpy as np
8import nibabel as nib
10from components.crosshair_graphic_view import CrosshairGraphicsView
11from components.nifti_file_dialog import NiftiFileDialog
12from logger import get_logger
13from threads.nifti_utils_threads import ImageLoadThread, SaveNiftiThread
15log = get_logger()
17from PyQt6 import QtCore
19# Attempt to import all required PyQt6 modules and fallback gracefully if not available
20try:
21 from PyQt6.QtWidgets import (QMainWindow, QWidget, QVBoxLayout, QHBoxLayout,
22 QLabel, QSlider, QPushButton, QFileDialog, QSpinBox,
23 QGraphicsView, QGraphicsScene, QGraphicsPixmapItem,
24 QStatusBar, QMessageBox, QProgressDialog, QGridLayout,
25 QSplitter, QFrame, QSizePolicy, QCheckBox, QComboBox, QScrollArea, QDialog, QLineEdit,
26 QListWidget, QDialogButtonBox, QListWidgetItem, QGroupBox)
27 from PyQt6.QtCore import Qt, QPointF, QTimer, QThread, pyqtSignal, QSize, QCoreApplication, QRectF
28 from PyQt6.QtGui import (QPixmap, QImage, QPainter, QColor, QPen, QPalette,
29 QBrush, QResizeEvent, QMouseEvent, QTransform, QFont)
30 from matplotlib.figure import Figure
32 from matplotlib.backends.backend_qtagg import FigureCanvasQTAgg as FigureCanvas
33except ImportError:
34 log.error("PyQt6 not available. Install with: pip install PyQt6")
35 sys.exit(1)
37# Configure matplotlib to use a non-interactive backend (for thread-safe rendering)
38import matplotlib
39matplotlib.use('Agg')
40import matplotlib.cm as cm
42# JIT optimization for numerical computations
43from numba import njit, prange
46@njit(parallel=True)
47def compute_mask_numba_mm(img, x0, y0, z0, radius_mm, voxel_sizes,
48 seed_intensity, diff,
49 x_min, x_max, y_min, y_max, z_min, z_max):
50 """
51 Compute a binary spherical mask around a seed point in millimeter space.
53 This function is compiled with Numba for high-performance execution and
54 performs voxel-wise checks to include all voxels within a given radius
55 (in mm) and within a specified intensity difference from a seed value.
57 Args:
58 img (np.ndarray): Input 3D image array.
59 x0, y0, z0 (int): Coordinates of the seed voxel.
60 radius_mm (float): Radius of the spherical mask in millimeters.
61 voxel_sizes (tuple[float, float, float]): Physical voxel sizes along each axis.
62 seed_intensity (float): Intensity value at the seed voxel.
63 diff (float): Maximum allowed intensity difference from the seed.
64 x_min, x_max, y_min, y_max, z_min, z_max (int): Bounding box limits in voxel space.
66 Returns:
67 np.ndarray: Binary mask (uint8) with 1 where the voxel meets the criteria.
68 """
69 mask = np.zeros(img.shape, dtype=np.uint8)
70 r2 = radius_mm * radius_mm
71 vx, vy, vz = voxel_sizes # voxel dimensions in mm
73 for x in prange(x_min, x_max):
74 for y in range(y_min, y_max):
75 for z in range(z_min, z_max):
76 dx_mm = (x - x0) * vx
77 dy_mm = (y - y0) * vy
78 dz_mm = (z - z0) * vz
79 if dx_mm * dx_mm + dy_mm * dy_mm + dz_mm * dz_mm <= r2:
80 if abs(img[x, y, z] - seed_intensity) <= diff:
81 mask[x, y, z] = 1
82 return mask
85@njit(parallel=True)
86def apply_overlay_numba(rgba_image, overlay_mask, overlay_intensity, overlay_color):
87 """
88 Apply a semi-transparent overlay to an RGBA image.
90 This function adds colorized overlay regions based on the provided mask and
91 intensity map, applying blending only on RGB channels.
93 Args:
94 rgba_image (np.ndarray): Base image (H, W, 3) in float format (0–1 range).
95 overlay_mask (np.ndarray): Binary mask (H, W) specifying overlay pixels.
96 overlay_intensity (np.ndarray): Intensity weight map (H, W) for overlay blending.
97 overlay_color (tuple[float, float, float]): RGB overlay color (0–1 range).
99 Returns:
100 np.ndarray: Modified RGBA image with overlay applied.
101 """
102 h, w, c = rgba_image.shape
103 for y in prange(h):
104 for x in range(w):
105 if overlay_mask[y, x]:
106 for ch in range(3):
107 # Apply color to RGB channels only
108 if overlay_color[ch] != 0:
109 rgba_image[y, x, ch] = min(1.0, rgba_image[y, x, ch] + overlay_intensity[y, x] * overlay_color[ch])
110 else:
111 rgba_image[y, x, ch] *= (1.0 - overlay_intensity[y, x])
112 return rgba_image
115def _slice(data, plane_idx, slice_idx):
116 slice = None
117 if plane_idx == 0:
118 slice = data[:, :, slice_idx].T
119 slice = np.flipud(slice)
120 elif plane_idx == 1:
121 slice = data[:, slice_idx, :].T
122 slice = np.flipud(slice)
123 elif plane_idx == 2:
124 slice = data[slice_idx, :, :].T
125 slice = np.flipud(slice)
126 else:
127 log.warning(f"Invalid plane_idx {plane_idx}")
128 return slice
130class NiftiViewer(QMainWindow):
131 """
132 Main application window for viewing and interacting with NIfTI images.
134 This class provides a complete NIfTI image viewer with:
135 - Triplanar slice visualization (axial, coronal, sagittal)
136 - Support for 4D volumes (time series)
137 - Overlay support for mask visualization
138 - Interactive ROI drawing and threshold-based segmentation
139 - Threaded image loading/saving
141 Attributes:
142 context (dict): Optional shared context for multi-component communication.
143 img_data (np.ndarray): Loaded image data.
144 overlay_data (np.ndarray): Optional overlay volume.
145 affine (np.ndarray): Image affine transformation matrix.
146 voxel_sizes (tuple[float]): Voxel dimensions (mm).
147 current_slices (list[int]): Current indices for each viewing plane.
148 current_time (int): Current time frame (for 4D data).
149 overlay_alpha (float): Overlay transparency level.
150 overlay_threshold (float): Intensity threshold for overlay visibility.
151 colormap (str): Current colormap name used for visualization.
152 """
154 def __init__(self, context=None):
155 """
156 Initialize the NIfTI Viewer window and prepare all internal components.
158 Args:
159 context (dict, optional): Shared context for language translation and inter-component signaling.
160 """
161 super().__init__()
164 self.threads = []
165 self.context = context
167 self.progress_dialog = None
168 self.setWindowTitle(QtCore.QCoreApplication.translate("NIfTIViewer", "NIfTI Image Viewer"))
169 self.setMinimumSize(1000, 700)
170 self.resize(1400, 1000)
172 # === Image data variables ===
173 self.img_data = None
174 self.affine = None
175 self.dims = None
176 self.is_4d = False
177 self.current_slices = [0, 0, 0] # axial, coronal, sagittal slice indices
178 self.current_time = 0
179 self.current_coordinates = [0, 0, 0] # x, y, z voxel coordinates
180 self.file_path = None
181 self.stretch_factors = {}
182 self.voxel_sizes = None
184 # === Overlay-related attributes ===
185 self.overlay_data = None
186 self.overlay_dims = None
187 self.overlay_alpha = 0.7
188 self.overlay_threshold = 0.1
189 self.overlay_enabled = False
190 self.overlay_file_path = None
191 self.overlay_max = 0
192 self.overlay_thresholded_data = None
194 # === UI element placeholders ===
195 self.info_text = None
196 self.plane_labels = None
197 self.status_bar = None
198 self.slice_info_label = None
199 self.value_label = None
200 self.coord_label = None
202 # === Visualization color configuration ===
203 self.colormap = 'gray'
204 self.overlay_colors = {
205 "gray": np.array([1.0, 0.0, 0.0]),
206 "viridis": np.array([1.0, 0.0, 0.0]),
207 "plasma": np.array([0.0, 1.0, 0.0]),
208 "inferno": np.array([0.0, 1.0, 1.0]),
209 "magma": np.array([0.0, 1.0, 1.0]),
210 "hot": np.array([0.0, 1.0, 1.0]),
211 "cool": np.array([1.0, 1.0, 0.0]),
212 "bone": np.array([1.0, 0.0, 0.0])
213 }
215 # === Viewer components ===
216 self.views = []
217 self.scenes = []
218 self.pixmap_items = []
219 self.slice_sliders = []
220 self.slice_spins = []
221 self.slice_labels = []
222 self.coord_displays = []
224 # === Time-series visualization (for 4D data) ===
225 self.time_slider = None
226 self.time_spin = None
227 self.time_checkbox = None
228 self.time_plot_figure = None
229 self.time_plot_canvas = None
231 # === Additional UI components ===
232 self.file_info_label = None
233 self.slice_navigation_label = None
234 self.time_point_label = None
235 self.colormap_combo = None
236 self.overlay_threshold_slider = None
237 self.display_options_label = None
238 self.overlay_alpha_slider = None
239 self.overlay_info_label = None
241 # === Automatic ROI drawing ===
242 self.automaticROI_data = None
243 self.automatic_ROI_label = None
244 self.automaticROIbtn = None
245 self.automaticROI_overlay = False
246 self.automaticROI_radius_slider = None
247 self.automaticROI_radius_label = None
248 self.automaticROI_diff_label = None
249 self.AutomaticROI_diff_slider = None
250 self.automaticROI_sliders_group = None
251 self.automaticROI_seed_coordinates = None
252 self.ROI_save_btn = None
253 self.automaticROI_overlay = None
255 self.incrementalROI_data = None
256 self.incrementalROI_checkbox = None
257 self.incrementalROI_enabled = False
258 self.addOrigin_btn = None
259 self.cancelROI_btn = None
260 self.incrementalROI_origins = []
262 # === Initialize and connect the UI ===
263 self.init_ui()
264 self.setup_connections()
266 # === Translation setup ===
267 self._translate_ui()
268 if context and "language_changed" in context:
269 context["language_changed"].connect(self._translate_ui)
271 def init_ui(self):
272 """
273 Initialize the main user interface of the NIfTI Viewer.
275 This method builds the primary application layout, including:
276 - A central widget that contains a horizontal splitter.
277 - A left control panel for user interactions (file operations, display options, etc.).
278 - A right area dedicated to image visualization.
279 - A status bar for displaying real-time information (coordinates, intensity values, slice index).
281 The layout uses a responsive design with adjustable proportions between
282 the control panel and the image display area.
284 Raises:
285 Exception: Propagates exceptions from widget creation or layout setup.
286 """
287 # Create the central widget that will contain the main splitter
288 central_widget = QWidget()
289 self.setCentralWidget(central_widget)
291 # Create a horizontal splitter for side-by-side layout (control panel + image display)
292 main_splitter = QSplitter(Qt.Orientation.Horizontal)
293 central_widget.layout = QHBoxLayout(central_widget)
294 central_widget.layout.setContentsMargins(5, 5, 5, 5)
295 central_widget.layout.addWidget(main_splitter)
297 # Initialize and attach the left control panel to the splitter
298 self.create_control_panel(main_splitter)
300 # Initialize and attach the right image display area to the splitter
301 self.create_image_display(main_splitter)
303 # Define relative sizes and resizing behavior of the splitter sections
304 main_splitter.setSizes([300, 1100]) # Default size proportions
305 main_splitter.setStretchFactor(0, 0) # Fix the control panel width
306 main_splitter.setStretchFactor(1, 1) # Allow image display to stretch
308 # Create and set up the status bar at the bottom of the window
309 self.status_bar = QStatusBar()
310 self.setStatusBar(self.status_bar)
312 # Initialize status bar labels for dynamic coordinate and voxel value display
313 self.coord_label = QLabel(QtCore.QCoreApplication.translate("NIfTIViewer", "Coordinates: (-, -, -)"))
314 self.value_label = QLabel(QtCore.QCoreApplication.translate("NIfTIViewer", "Value: -"))
315 self.slice_info_label = QLabel(QtCore.QCoreApplication.translate("NIfTIViewer", "Slice: -/-"))
317 # Display initial status message when the viewer is ready
318 self.status_bar.showMessage(
319 QtCore.QCoreApplication.translate("NIfTIViewer", "Ready - Open a NIfTI file to begin")
320 )
322 def create_control_panel(self, parent):
323 """
324 Create the left-side control panel for the NIfTI Viewer interface.
326 This method builds an interactive, scrollable control panel that allows
327 the user to:
328 - Open and display NIfTI files.
329 - Navigate through 2D slices in axial, coronal, and sagittal planes.
330 - Control time navigation for 4D datasets.
331 - Adjust visualization parameters such as colormap and display options.
332 - Perform automatic ROI (Region of Interest) generation.
333 - Manage overlay images (load, transparency, threshold, enable/disable).
335 Args:
336 parent (QSplitter): The parent splitter widget where the control panel is added.
338 Raises:
339 Exception: Propagates exceptions from any UI creation or layout step.
340 """
342 # Scroll area wrapper (enables vertical scrolling for the control panel)
343 scroll_area = QScrollArea()
344 scroll_area.setWidgetResizable(True)
345 scroll_area.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
346 scroll_area.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded)
348 # Control panel main content widget and layout
349 control_content = QWidget()
350 control_content.setMaximumWidth(340) # Prevent horizontal scrolling
351 layout = QVBoxLayout(control_content)
352 layout.setSpacing(8)
353 layout.setContentsMargins(8, 8, 8, 8)
355 # ========================
356 # File Operations Group
357 # ========================
358 file_group = QFrame()
359 file_layout = QVBoxLayout(file_group)
360 file_layout.setContentsMargins(5, 5, 5, 5)
362 # Button to open NIfTI files
363 self.open_btn = QPushButton(QtCore.QCoreApplication.translate("NIfTIViewer", "📁 Open NIfTI"))
364 self.open_btn.setMinimumHeight(35)
365 self.open_btn.setMaximumHeight(40)
366 self.open_btn.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
367 self.open_btn.setToolTip(QtCore.QCoreApplication.translate("NIfTIViewer", "Open NIfTI File"))
368 file_layout.addWidget(self.open_btn)
370 # Label displaying currently loaded file info
371 self.file_info_label = QLabel(QtCore.QCoreApplication.translate("NIfTIViewer", "No file loaded"))
372 self.file_info_label.setWordWrap(True)
373 self.file_info_label.setStyleSheet("font-size: 10px;")
374 self.file_info_label.setSizePolicy(QSizePolicy.Policy.Ignored, QSizePolicy.Policy.Minimum)
375 self.file_info_label.setMaximumWidth(320)
376 self.file_info_label.setMinimumHeight(40)
377 file_layout.addWidget(self.file_info_label)
378 layout.addWidget(file_group)
380 # =====================
381 # Slice Navigation
382 # =====================
383 slice_group = QFrame()
384 slice_layout = QVBoxLayout(slice_group)
385 slice_layout.setContentsMargins(5, 5, 5, 5)
387 # Section label
388 self.slice_navigation_label = QLabel(QtCore.QCoreApplication.translate("NIfTIViewer", "Slice Navigation:"))
389 self.slice_navigation_label.setSizePolicy(QSizePolicy.Policy.Ignored, QSizePolicy.Policy.Fixed)
390 self.slice_navigation_label.setMaximumWidth(320)
391 self.slice_navigation_label.setStyleSheet("font-weight: bold; font-size: 11px;")
392 slice_layout.addWidget(self.slice_navigation_label)
394 plane_names = [
395 QtCore.QCoreApplication.translate("NIfTIViewer", "Axial (Z)"),
396 QtCore.QCoreApplication.translate("NIfTIViewer", "Coronal (Y)"),
397 QtCore.QCoreApplication.translate("NIfTIViewer", "Sagittal (X)")
398 ]
399 self.plane_labels = []
401 # Create controls for each anatomical plane
402 for i, plane_name in enumerate(plane_names):
403 # Label for plane name
404 label = QLabel(plane_name)
405 self.plane_labels.append(label)
406 label.setStyleSheet("font-weight: bold; margin-top: 10px;")
407 label.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
408 slice_layout.addWidget(label)
409 self.slice_labels.append(label)
411 # Container for slider + spinbox + coordinates
412 controls_widget = QWidget()
413 controls_layout = QHBoxLayout(controls_widget)
414 controls_layout.setContentsMargins(0, 0, 0, 0)
415 controls_layout.setSpacing(5)
417 # Slice slider
418 slider = QSlider(Qt.Orientation.Horizontal)
419 slider.setMinimum(0)
420 slider.setMaximum(100)
421 slider.setValue(50)
422 slider.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
423 controls_layout.addWidget(slider, stretch=3)
425 # Spinbox to match slider
426 spinbox = QSpinBox()
427 spinbox.setMinimum(0)
428 spinbox.setMaximum(100)
429 spinbox.setValue(50)
430 spinbox.setMaximumWidth(60)
431 spinbox.setMinimumWidth(50)
432 spinbox.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed)
433 controls_layout.addWidget(spinbox, stretch=0)
435 # Coordinate label display
436 coord_label = QLabel("(-, -)")
437 coord_label.setStyleSheet("color: #4CAF50; font-weight: bold; font-size: 10px;")
438 coord_label.setMinimumWidth(45)
439 coord_label.setMaximumWidth(60)
440 coord_label.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed)
441 controls_layout.addWidget(coord_label, stretch=0)
443 controls_widget.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
444 slice_layout.addWidget(controls_widget)
446 # Store references
447 self.slice_sliders.append(slider)
448 self.slice_spins.append(spinbox)
449 self.coord_displays.append(coord_label)
451 layout.addWidget(slice_group)
453 # ===================
454 # ️ 4D Time Controls
455 # ===================
456 self.time_group = QFrame()
457 time_layout = QVBoxLayout(self.time_group)
458 time_layout.setContentsMargins(5, 5, 5, 5)
460 # Enable time navigation
461 self.time_checkbox = QCheckBox(QtCore.QCoreApplication.translate("NIfTIViewer", "Enable 4D Time Navigation"))
462 self.time_checkbox.setChecked(False)
463 self.time_checkbox.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
464 time_layout.addWidget(self.time_checkbox)
466 # Slider + spinbox controls for time navigation
467 time_controls_widget = QWidget()
468 time_controls_layout = QHBoxLayout(time_controls_widget)
469 time_controls_layout.setContentsMargins(0, 0, 0, 0)
470 time_controls_layout.setSpacing(5)
472 self.time_slider = QSlider(Qt.Orientation.Horizontal)
473 self.time_slider.setMinimum(0)
474 self.time_slider.setMaximum(0)
475 self.time_slider.setValue(0)
476 self.time_slider.setEnabled(False)
477 self.time_slider.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
478 time_controls_layout.addWidget(self.time_slider, stretch=3)
480 self.time_spin = QSpinBox()
481 self.time_spin.setMinimum(0)
482 self.time_spin.setMaximum(0)
483 self.time_spin.setValue(0)
484 self.time_spin.setEnabled(False)
485 self.time_spin.setMaximumWidth(80)
486 self.time_spin.setMinimumWidth(60)
487 self.time_spin.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed)
488 time_controls_layout.addWidget(self.time_spin, stretch=0)
490 self.time_point_label = QLabel(QtCore.QCoreApplication.translate("NIfTIViewer", "Time Point:"))
491 self.time_point_label.setSizePolicy(QSizePolicy.Policy.Ignored, QSizePolicy.Policy.Fixed)
492 self.time_point_label.setMaximumWidth(320)
493 self.time_point_label.setStyleSheet("font-size: 11px;")
494 time_layout.addWidget(self.time_point_label)
495 time_controls_widget.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
496 time_layout.addWidget(time_controls_widget)
498 self.time_group.setVisible(False)
499 layout.addWidget(self.time_group)
501 # ======================
502 # Display Options
503 # ======================
504 display_group = QFrame()
505 display_layout = QVBoxLayout(display_group)
506 display_layout.setContentsMargins(5, 5, 5, 5)
508 self.display_options_label = QLabel(QtCore.QCoreApplication.translate("NIfTIViewer", "Display Options:"))
509 self.display_options_label.setSizePolicy(QSizePolicy.Policy.Ignored, QSizePolicy.Policy.Fixed)
510 self.display_options_label.setMaximumWidth(320)
511 self.display_options_label.setStyleSheet("font-weight: bold; font-size: 11px;")
512 display_layout.addWidget(self.display_options_label)
514 # Colormap selection dropdown
515 colormap_widget = QWidget()
516 colormap_layout = QVBoxLayout(colormap_widget)
517 colormap_layout.setContentsMargins(0, 0, 0, 0)
518 colormap_layout.setSpacing(3)
520 self.colormap_label = QLabel(QtCore.QCoreApplication.translate("NIfTIViewer", "Colormap:"))
521 self.colormap_label.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
522 self.colormap_label.setStyleSheet("font-size: 10px; font-weight: bold;")
523 colormap_layout.addWidget(self.colormap_label)
525 self.colormap_combo = QComboBox()
526 self.colormap_combo.addItems(
527 ['gray', 'viridis', 'plasma',
528 'inferno', 'magma', 'hot',
529 'cool', 'bone'])
531 self.colormap_combo.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
532 self.colormap_combo.setMaximumHeight(25)
533 colormap_layout.addWidget(self.colormap_combo)
534 colormap_widget.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
535 display_layout.addWidget(colormap_widget)
536 layout.addWidget(display_group)
538 # ==========================
539 # Automatic ROI Controls
540 # ==========================
541 # Automatic ROI
542 self.automaticROI_group = QFrame()
543 automaticROI_layout = QVBoxLayout(self.automaticROI_group)
544 automaticROI_layout.setContentsMargins(5, 5, 5, 5)
546 self.automatic_ROI_label = QLabel(QtCore.QCoreApplication.translate("NIfTIViewer", "Automatic ROI:"))
547 self.automatic_ROI_label.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
548 self.automatic_ROI_label.setStyleSheet("font-size: 10px; font-weight: bold;")
549 automaticROI_layout.addWidget(self.automatic_ROI_label)
551 automaticROIbtns_group = QFrame()
552 automaticROIbtns_layout = QVBoxLayout(automaticROIbtns_group) # Cambiato a verticale
553 automaticROIbtns_layout.setContentsMargins(0, 0, 0, 0)
554 automaticROIbtns_layout.setSpacing(3)
556 self.automaticROIbtn = QPushButton(QtCore.QCoreApplication.translate("NIfTIViewer", "Automatic ROI"))
557 self.automaticROIbtn.setEnabled(False)
558 self.automaticROIbtn.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
559 self.automaticROIbtn.setMaximumHeight(30)
560 self.automaticROIbtn.setToolTip(QtCore.QCoreApplication.translate("NIfTIViewer", "Automatic ROI Drawing"))
561 automaticROIbtns_layout.addWidget(self.automaticROIbtn)
563 self.addOrigin_btn = QPushButton(QtCore.QCoreApplication.translate("NIfTIViewer", "Fix ROI and add new Origin"))
564 self.addOrigin_btn.setEnabled(False)
565 self.addOrigin_btn.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
566 self.addOrigin_btn.setMaximumHeight(30)
567 self.addOrigin_btn.setToolTip(QtCore.QCoreApplication.translate("NIfTIViewer", "Increment the current incremental ROI with the current ROI drawing"))
568 automaticROIbtns_layout.addWidget(self.addOrigin_btn)
570 automaticROIbtns_group.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
571 automaticROI_layout.addWidget(automaticROIbtns_group)
573 # Incremental ROI checkbox
574 self.incrementalROI_checkbox = QCheckBox(
575 QtCore.QCoreApplication.translate("NIfTIViewer", "Show incremental ROI"))
576 self.incrementalROI_checkbox.setEnabled(False)
577 self.incrementalROI_checkbox.setVisible(False)
578 self.incrementalROI_checkbox.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
579 automaticROI_layout.addWidget(self.incrementalROI_checkbox)
581 # Overlay enable/disable checkbox
582 self.automaticROI_checkbox = QCheckBox(QtCore.QCoreApplication.translate("NIfTIViewer", "Show automatic ROI"))
583 self.automaticROI_checkbox.setEnabled(False)
584 self.automaticROI_checkbox.setVisible(False)
585 self.automaticROI_checkbox.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
586 automaticROI_layout.addWidget(self.automaticROI_checkbox)
588 self.automaticROI_sliders_group = QFrame()
589 automaticROI_sliders_layout = QVBoxLayout(self.automaticROI_sliders_group)
590 automaticROI_sliders_layout.setContentsMargins(0, 0, 0, 0)
592 self.automaticROI_radius_label = QLabel(QtCore.QCoreApplication.translate("NIfTIViewer", "Radius:"))
593 self.automaticROI_radius_label.setStyleSheet("font-size: 10px;")
594 self.automaticROI_radius_label.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
595 automaticROI_sliders_layout.addWidget(self.automaticROI_radius_label)
597 # Radius slider and spinbox container
598 radius_controls_widget = QWidget()
599 radius_controls_layout = QHBoxLayout(radius_controls_widget)
600 radius_controls_layout.setContentsMargins(0, 0, 0, 0)
601 radius_controls_layout.setSpacing(5)
603 self.automaticROI_radius_slider = QSlider(Qt.Orientation.Horizontal)
604 self.automaticROI_radius_slider.setMinimum(0)
605 self.automaticROI_radius_slider.setMaximum(9999) # Valore sufficientemente alto per gestire tutti i casi
606 self.automaticROI_radius_slider.setValue(32)
607 self.automaticROI_radius_slider.setEnabled(True)
608 self.automaticROI_radius_slider.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
609 radius_controls_layout.addWidget(self.automaticROI_radius_slider, stretch=3)
611 self.automaticROI_radius_spin = QSpinBox()
612 self.automaticROI_radius_spin.setMinimum(0)
613 self.automaticROI_radius_spin.setMaximum(9999) # Valore sufficientemente alto per gestire tutti i casi
614 self.automaticROI_radius_spin.setValue(32)
615 self.automaticROI_radius_spin.setMaximumWidth(60)
616 self.automaticROI_radius_spin.setMinimumWidth(50)
617 self.automaticROI_radius_spin.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed)
618 radius_controls_layout.addWidget(self.automaticROI_radius_spin, stretch=0)
620 radius_controls_widget.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
621 automaticROI_sliders_layout.addWidget(radius_controls_widget)
623 self.automaticROI_diff_label = QLabel(QtCore.QCoreApplication.translate("NIfTIViewer", "Difference:"))
624 self.automaticROI_diff_label.setStyleSheet("font-size: 10px;")
625 self.automaticROI_diff_label.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
626 automaticROI_sliders_layout.addWidget(self.automaticROI_diff_label)
628 # Difference slider and spinbox container
629 diff_controls_widget = QWidget()
630 diff_controls_layout = QHBoxLayout(diff_controls_widget)
631 diff_controls_layout.setContentsMargins(0, 0, 0, 0)
632 diff_controls_layout.setSpacing(5)
634 self.automaticROI_diff_slider = QSlider(Qt.Orientation.Horizontal)
635 self.automaticROI_diff_slider.setMinimum(0)
636 self.automaticROI_diff_slider.setMaximum(99999) # Valore molto alto per gestire qualsiasi range di intensità
637 self.automaticROI_diff_slider.setValue(16)
638 self.automaticROI_diff_slider.setEnabled(True)
639 self.automaticROI_diff_slider.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
640 diff_controls_layout.addWidget(self.automaticROI_diff_slider, stretch=3)
642 self.automaticROI_diff_spin = QSpinBox()
643 self.automaticROI_diff_spin.setMinimum(0)
644 self.automaticROI_diff_spin.setMaximum(99999) # Valore molto alto per gestire qualsiasi range di intensità
645 self.automaticROI_diff_spin.setValue(16)
646 self.automaticROI_diff_spin.setMaximumWidth(60)
647 self.automaticROI_diff_spin.setMinimumWidth(50)
648 self.automaticROI_diff_spin.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed)
649 diff_controls_layout.addWidget(self.automaticROI_diff_spin, stretch=0)
651 diff_controls_widget.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
652 automaticROI_sliders_layout.addWidget(diff_controls_widget)
654 self.automaticROI_sliders_group.setVisible(False)
655 self.automaticROI_sliders_group.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Minimum)
656 automaticROI_layout.addWidget(self.automaticROI_sliders_group)
658 automaticROI_bottom_btns_group = QFrame()
659 automaticROIb_bottom_btns_layout = QVBoxLayout(automaticROI_bottom_btns_group) # Cambiato a verticale
660 automaticROIb_bottom_btns_layout.setContentsMargins(0, 0, 0, 0)
661 automaticROIb_bottom_btns_layout.setSpacing(3)
664 self.ROI_save_btn = QPushButton(QtCore.QCoreApplication.translate("NIfTIViewer", "Save ROI"))
665 self.ROI_save_btn.setStyleSheet("""
666 QPushButton {
667 font-weight: bold;
668 color:#27ae60 }
669 QPushButton:disabled {
670 color:none;
671 font-weight: normal
672 }""")
673 self.ROI_save_btn.setEnabled(False)
674 self.ROI_save_btn.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
675 self.ROI_save_btn.setMaximumHeight(30)
676 self.ROI_save_btn.setToolTip(QtCore.QCoreApplication.translate("NIfTIViewer", "Save ROI Drawing"))
677 automaticROIb_bottom_btns_layout.addWidget(self.ROI_save_btn)
680 self.cancelROI_btn = QPushButton(QtCore.QCoreApplication.translate("NIfTIViewer", "Cancel ROI"))
681 self.cancelROI_btn.setEnabled(False)
682 self.cancelROI_btn.setStyleSheet("""
683 QPushButton {
684 font-weight: bold;
685 color:#c0392b }
686 QPushButton:disabled {
687 color:none;
688 font-weight: normal
689 }""")
690 self.cancelROI_btn.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
691 self.cancelROI_btn.setMaximumHeight(30)
692 self.cancelROI_btn.setToolTip(QtCore.QCoreApplication.translate("NIfTIViewer", "Cancel ROI Drawing"))
693 automaticROIb_bottom_btns_layout.addWidget(self.cancelROI_btn)
695 automaticROI_layout.addWidget(automaticROI_bottom_btns_group)
697 layout.addWidget(self.automaticROI_group)
699 # Overlay controls
700 overlay_group = QFrame()
701 overlay_layout = QVBoxLayout(overlay_group)
702 overlay_layout.setContentsMargins(5, 5, 5, 5)
704 self.overlay_control_label = QLabel(QtCore.QCoreApplication.translate("NIfTIViewer", "Overlay Controls:"))
705 self.overlay_control_label.setSizePolicy(QSizePolicy.Policy.Ignored, QSizePolicy.Policy.Fixed)
706 self.overlay_control_label.setMaximumWidth(320)
707 self.overlay_control_label.setStyleSheet("font-weight: bold; font-size: 11px;")
708 overlay_layout.addWidget(self.overlay_control_label)
710 # Overlay file button
711 self.overlay_btn = QPushButton(QtCore.QCoreApplication.translate("NIfTIViewer", "Load Overlay"))
712 self.overlay_btn.setMinimumHeight(30)
713 self.overlay_btn.setMaximumHeight(35)
714 self.overlay_btn.setEnabled(False) # Enable only when base image is loaded
715 self.overlay_btn.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
716 self.overlay_btn.setToolTip(QtCore.QCoreApplication.translate("NIfTIViewer", "Load NIfTI Overlay"))
717 overlay_layout.addWidget(self.overlay_btn)
719 # Overlay enable/disable checkbox
720 self.overlay_checkbox = QCheckBox(QtCore.QCoreApplication.translate("NIfTIViewer", "Show Overlay"))
721 self.overlay_checkbox.setEnabled(False)
722 self.overlay_checkbox.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
723 overlay_layout.addWidget(self.overlay_checkbox)
725 # Overlay alpha slider
726 self.alpha_overlay_label = QLabel(QtCore.QCoreApplication.translate("NIfTIViewer", "Overlay Transparency:"))
727 self.alpha_overlay_label.setStyleSheet("font-size: 10px;")
728 self.alpha_overlay_label.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
729 overlay_layout.addWidget(self.alpha_overlay_label)
731 # Alpha slider and spinbox container
732 alpha_controls_widget = QWidget()
733 alpha_controls_layout = QHBoxLayout(alpha_controls_widget)
734 alpha_controls_layout.setContentsMargins(0, 0, 0, 0)
735 alpha_controls_layout.setSpacing(5)
737 self.overlay_alpha_slider = QSlider(Qt.Orientation.Horizontal)
738 self.overlay_alpha_slider.setMinimum(10)
739 self.overlay_alpha_slider.setMaximum(100)
740 self.overlay_alpha_slider.setValue(70)
741 self.overlay_alpha_slider.setEnabled(False)
742 self.overlay_alpha_slider.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
743 alpha_controls_layout.addWidget(self.overlay_alpha_slider, stretch=3)
745 self.overlay_alpha_spin = QSpinBox()
746 self.overlay_alpha_spin.setMinimum(10)
747 self.overlay_alpha_spin.setMaximum(100)
748 self.overlay_alpha_spin.setValue(70)
749 self.overlay_alpha_spin.setEnabled(False)
750 self.overlay_alpha_spin.setMaximumWidth(60)
751 self.overlay_alpha_spin.setMinimumWidth(50)
752 self.overlay_alpha_spin.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed)
753 alpha_controls_layout.addWidget(self.overlay_alpha_spin, stretch=0)
755 alpha_controls_widget.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
756 overlay_layout.addWidget(alpha_controls_widget)
758 # Overlay threshold slider
759 self.overlay_threshold_label = QLabel(QtCore.QCoreApplication.translate("NIfTIViewer", "Overlay Threshold:"))
760 self.overlay_threshold_label.setStyleSheet("font-size: 10px;")
761 self.overlay_threshold_label.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
762 overlay_layout.addWidget(self.overlay_threshold_label)
764 # Threshold slider and spinbox container
765 threshold_controls_widget = QWidget()
766 threshold_controls_layout = QHBoxLayout(threshold_controls_widget)
767 threshold_controls_layout.setContentsMargins(0, 0, 0, 0)
768 threshold_controls_layout.setSpacing(5)
770 self.overlay_threshold_slider = QSlider(Qt.Orientation.Horizontal)
771 self.overlay_threshold_slider.setMinimum(0)
772 self.overlay_threshold_slider.setMaximum(100)
773 self.overlay_threshold_slider.setValue(10)
774 self.overlay_threshold_slider.setEnabled(False)
775 self.overlay_threshold_slider.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
776 threshold_controls_layout.addWidget(self.overlay_threshold_slider, stretch=3)
778 self.overlay_threshold_spin = QSpinBox()
779 self.overlay_threshold_spin.setMinimum(0)
780 self.overlay_threshold_spin.setMaximum(100)
781 self.overlay_threshold_spin.setValue(10)
782 self.overlay_threshold_spin.setEnabled(False)
783 self.overlay_threshold_spin.setMaximumWidth(60)
784 self.overlay_threshold_spin.setMinimumWidth(50)
785 self.overlay_threshold_spin.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed)
786 threshold_controls_layout.addWidget(self.overlay_threshold_spin, stretch=0)
788 threshold_controls_widget.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
789 overlay_layout.addWidget(threshold_controls_widget)
791 # Overlay info
792 self.overlay_info_label = QLabel(QtCore.QCoreApplication.translate("NIfTIViewer", "No overlay loaded"))
793 self.overlay_info_label.setWordWrap(True)
794 self.overlay_info_label.setStyleSheet("font-size: 10px;")
795 self.overlay_info_label.setSizePolicy(QSizePolicy.Policy.Ignored, QSizePolicy.Policy.Minimum)
796 # Imposta larghezza massima per prevenire espansione
797 self.overlay_info_label.setMaximumWidth(320)
798 self.overlay_info_label.setMinimumHeight(20)
799 overlay_layout.addWidget(self.overlay_info_label)
801 layout.addWidget(overlay_group)
803 # Add stretch to push everything to top
804 layout.addStretch()
806 # Set the content inside the QScrollArea
807 scroll_area.setWidget(control_content)
809 # Set dimensions to eliminate horizontal scrolling
810 scroll_area.setMinimumWidth(240)
811 scroll_area.setMaximumWidth(340)
813 # Signal/slot connections to synchronize slider and spinbox
815 # Automatic ROI - Radius
816 self.automaticROI_radius_slider.valueChanged.connect(self.automaticROI_radius_spin.setValue)
817 self.automaticROI_radius_spin.valueChanged.connect(self.automaticROI_radius_slider.setValue)
819 # Automatic ROI - Difference
820 self.automaticROI_diff_slider.valueChanged.connect(self.automaticROI_diff_spin.setValue)
821 self.automaticROI_diff_spin.valueChanged.connect(self.automaticROI_diff_slider.setValue)
823 # Overlay - Alpha/Transparency
824 self.overlay_alpha_slider.valueChanged.connect(self.overlay_alpha_spin.setValue)
825 self.overlay_alpha_spin.valueChanged.connect(self.overlay_alpha_slider.setValue)
827 # Overlay - Threshold
828 self.overlay_threshold_slider.valueChanged.connect(self.overlay_threshold_spin.setValue)
829 self.overlay_threshold_spin.valueChanged.connect(self.overlay_threshold_slider.setValue)
831 # Add scroll area to parent
832 parent.addWidget(scroll_area)
834 def format_info_text(self, text, max_line_length=35):
835 """
836 Format text to prevent horizontal scrolling in the control panel.
838 This helper function ensures that long lines of information (such as
839 NIfTI file metadata) are wrapped neatly to fit within the UI, avoiding
840 horizontal overflow. It also attempts to break lines at logical points
841 (e.g., after colons or spaces) for improved readability.
843 Args:
844 text (str): The text to be formatted.
845 max_line_length (int, optional): Maximum number of characters per line
846 before wrapping. Defaults to 35.
848 Returns:
849 str: The formatted text with inserted line breaks for better layout.
850 """
851 import textwrap
853 # Split text into lines to process them individually
854 lines = text.split('\n')
855 formatted_lines = []
857 for line in lines:
858 # If the line fits within limit, keep it unchanged
859 if len(line) <= max_line_length:
860 formatted_lines.append(line)
861 else:
862 # Try to split logically around ':' if possible
863 if ':' in line:
864 parts = line.split(':', 1)
865 if len(parts[0]) <= max_line_length:
866 # Add the first part and indent the continuation
867 formatted_lines.append(parts[0] + ':')
868 wrapped = textwrap.fill(parts[1].strip(), width=max_line_length - 2)
869 formatted_lines.append(' ' + wrapped.replace('\n', '\n '))
870 else:
871 # If even the key part is long, wrap entire line
872 wrapped = textwrap.fill(line, width=max_line_length)
873 formatted_lines.append(wrapped)
874 else:
875 # Wrap normally if no logical split point found
876 wrapped = textwrap.fill(line, width=max_line_length)
877 formatted_lines.append(wrapped)
879 # Join all formatted lines back into a single string
880 return '\n'.join(formatted_lines)
882 def create_image_display(self, parent):
883 """
884 Create the main image display panel with three anatomical views.
886 This function builds the right-hand visualization area of the NIfTI viewer,
887 which displays axial, coronal, and sagittal image slices. It also prepares
888 a fourth panel for displaying image information or time-series plots
889 (depending on whether the dataset is 3D or 4D).
891 Args:
892 parent (QSplitter): The parent splitter widget where the display
893 area is added.
895 Components:
896 - Three synchronized image views with crosshairs.
897 - A fourth panel for displaying image metadata or 4D time plots.
898 - Dynamically initialized graphics scenes and pixmap items.
900 Raises:
901 Exception: Propagates initialization errors in UI creation.
902 """
903 # Create base container and grid layout for the 2x2 view grid
904 display_widget = QWidget()
905 display_layout = QGridLayout(display_widget)
906 display_layout.setSpacing(5)
908 # Define positions for the three main anatomical views
909 view_positions = [(0, 0), (0, 1), (1, 0)]
910 view_titles = [
911 QtCore.QCoreApplication.translate("NIfTIViewer", "Axial"),
912 QtCore.QCoreApplication.translate("NIfTIViewer", "Coronal"),
913 QtCore.QCoreApplication.translate("NIfTIViewer", "Sagittal")
914 ]
916 self.view_titles_labels = []
918 # Iterate through each anatomical plane and create a visualization frame
919 for i, (row, col) in enumerate(view_positions):
920 # Create container with border style
921 view_container = QFrame()
922 view_container.setFrameStyle(QFrame.Shape.StyledPanel)
923 container_layout = QVBoxLayout(view_container)
924 container_layout.setContentsMargins(2, 2, 2, 2)
926 # Title label (Axial, Coronal, Sagittal)
927 title_label = QLabel(view_titles[i])
928 self.view_titles_labels.append(title_label)
929 title_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
930 title_label.setStyleSheet("font-weight: bold; padding: 4px;")
931 container_layout.addWidget(title_label)
933 # Initialize graphics view for image rendering
934 view = CrosshairGraphicsView(i, self)
935 view.setMinimumSize(200, 200)
936 view.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
938 # Create graphics scene for the view
939 scene = QGraphicsScene()
940 view.setScene(scene)
942 # Create pixmap item (the actual displayed image)
943 pixmap_item = QGraphicsPixmapItem()
944 scene.addItem(pixmap_item)
946 # Add view to its container layout
947 container_layout.addWidget(view)
949 # Place container in grid layout
950 display_layout.addWidget(view_container, row, col)
952 # Keep references for later updates/redraws
953 self.views.append(view)
954 self.scenes.append(scene)
955 self.pixmap_items.append(pixmap_item)
957 # ==============================
958 # Fourth Panel (Info / Plot)
959 # ==============================
960 # Bottom-right panel for metadata or time-series visualization
961 self.fourth_widget = QFrame()
962 self.fourth_widget.setFrameStyle(QFrame.Shape.StyledPanel)
963 fourth_layout = QVBoxLayout(self.fourth_widget)
965 # Section title
966 self.fourth_title = QLabel(QtCore.QCoreApplication.translate("NIfTIViewer", "Image Information"))
967 self.fourth_title.setAlignment(Qt.AlignmentFlag.AlignCenter)
968 self.fourth_title.setStyleSheet("font-weight: bold; padding: 4px;")
969 fourth_layout.addWidget(self.fourth_title)
971 # Content container allows swapping between info text and plots
972 self.fourth_content = QWidget()
973 self.fourth_content_layout = QVBoxLayout(self.fourth_content)
975 # Default info label (shown when no image is loaded)
976 self.info_text = QLabel(QtCore.QCoreApplication.translate("NIfTIViewer", "No image loaded"))
977 self.info_text.setAlignment(Qt.AlignmentFlag.AlignTop)
978 self.info_text.setStyleSheet("color: #cccccc; font-size: 11px; padding: 10px;")
979 self.info_text.setWordWrap(True)
980 self.fourth_content_layout.addWidget(self.info_text)
982 # Placeholders for 4D time-series plotting (initialized later)
983 self.time_plot_widget = None
984 self.time_plot_canvas = None
985 self.time_indicator_line = None
987 # Add dynamic content area to layout
988 fourth_layout.addWidget(self.fourth_content)
990 # Add the fourth widget to the main grid (bottom-right cell)
991 display_layout.addWidget(self.fourth_widget, 1, 1)
993 # Attach the full display grid to the parent splitter
994 parent.addWidget(display_widget)
996 # Delay setup of interactive crosshairs until UI is ready
997 QTimer.singleShot(100, self.setup_crosshairs)
999 def setup_crosshairs(self):
1000 """
1001 Initialize and configure crosshair overlays for all active image views.
1003 This method ensures that each anatomical view (axial, coronal, sagittal)
1004 has its own interactive crosshair layer properly initialized.
1006 Purpose:
1007 - Enables visual reference lines for voxel coordinates.
1008 - Synchronizes navigation across views.
1010 Returns:
1011 None
1012 """
1013 # Iterate over all graphics views and initialize their crosshairs
1014 for view in self.views:
1015 view.setup_crosshairs()
1017 def setup_connections(self):
1018 """
1019 Establish all signal-slot connections for the viewer interface.
1021 This method binds user interface widgets (buttons, sliders, spin boxes, checkboxes)
1022 to their respective event handlers to enable interactive control over the
1023 visualization and processing of NIfTI images.
1025 Connections include:
1026 - File operations (open base and overlay images)
1027 - Slice navigation (sliders and spinboxes)
1028 - ROI (Region of Interest) automation controls
1029 - Time series navigation (for 4D datasets)
1030 - Colormap and overlay adjustments
1031 - Coordinate updates between synchronized views
1033 Returns:
1034 None
1035 """
1036 # ----------------------------
1037 # File-related connections
1038 # ----------------------------
1039 self.open_btn.clicked.connect(lambda: self.open_file())
1040 self.overlay_btn.clicked.connect(lambda: self.open_file(is_overlay=True))
1041 self.overlay_checkbox.toggled.connect(self.toggle_overlay)
1042 self.overlay_alpha_slider.valueChanged.connect(self.update_overlay_alpha)
1043 self.overlay_threshold_slider.valueChanged.connect(self.update_overlay_threshold)
1045 # ----------------------------
1046 # Slice navigation connections
1047 # ----------------------------
1048 # Connect slice sliders and spinboxes for all anatomical planes
1049 for i, (slider, spinbox) in enumerate(zip(self.slice_sliders, self.slice_spins)):
1050 slider.valueChanged.connect(lambda value, idx=i: self.slice_changed(idx, value))
1051 spinbox.valueChanged.connect(lambda value, idx=i: self.slice_changed(idx, value))
1053 # ----------------------------
1054 # Automatic ROI Drawing controls
1055 # ----------------------------
1056 self.automaticROIbtn.clicked.connect(self.automaticROI_clicked)
1057 self.automaticROI_diff_slider.valueChanged.connect(self.update_automaticROI)
1058 self.automaticROI_radius_slider.valueChanged.connect(self.update_automaticROI)
1059 self.ROI_save_btn.clicked.connect(self.ROI_save)
1060 self.automaticROI_checkbox.toggled.connect(self.toggle_automaticROI)
1061 self.addOrigin_btn.clicked.connect(self.addOrigin_clicked)
1062 self.incrementalROI_checkbox.toggled.connect(self.toggle_incrementalROI)
1063 self.cancelROI_btn.clicked.connect(self.resetROI)
1064 # ----------------------------
1065 # Time-series control connections (for 4D images)
1066 # ----------------------------
1067 self.time_checkbox.toggled.connect(self.toggle_time_controls)
1068 self.time_slider.valueChanged.connect(self.time_changed)
1069 self.time_spin.valueChanged.connect(self.time_changed)
1071 # ----------------------------
1072 # Colormap control
1073 # ----------------------------
1074 self.colormap_combo.currentTextChanged.connect(self.colormap_changed)
1076 # ----------------------------
1077 # Coordinate synchronization across views
1078 # ----------------------------
1079 for view in self.views:
1080 view.coordinate_changed.connect(self.update_coordinates)
1082 def show_workspace_nii_dialog(self, is_overlay=False):
1083 """
1084 Open a workspace-aware file selection dialog for NIfTI files.
1086 Depending on the mode (`is_overlay`), this dialog either selects
1087 a base anatomical image or a secondary overlay image from the user’s workspace.
1089 Args:
1090 is_overlay (bool, optional): Whether the dialog is for selecting an overlay file.
1091 Defaults to False.
1093 Returns:
1094 None: If a file is selected, it triggers `open_file()` automatically.
1095 """
1096 # ----------------------------
1097 # Select file using NiftiFileDialog, filtering appropriately
1098 # ----------------------------
1099 if is_overlay:
1100 result = NiftiFileDialog.get_files(
1101 self.context,
1102 allow_multiple=False,
1103 has_existing_func=False,
1104 label=None,
1105 forced_filters=None
1106 )
1107 else:
1108 result = NiftiFileDialog.get_files(
1109 self.context,
1110 allow_multiple=False,
1111 has_existing_func=False,
1112 label=None
1113 )
1115 # ----------------------------
1116 # Open file if a valid selection was made
1117 # ----------------------------
1118 if result:
1119 self.open_file(result[0], is_overlay=is_overlay)
1121 def open_file(self, file_path=None, is_overlay=False):
1122 """
1123 Load a NIfTI file (base or overlay) using a background thread.
1125 This method either opens a file dialog for selecting a NIfTI file
1126 or loads a specified path directly. The loading process runs in a
1127 separate thread to keep the UI responsive, with progress reported
1128 via a modal progress dialog.
1130 Args:
1131 file_path (str, optional): Path to the NIfTI file to load. If None,
1132 a file dialog will be displayed.
1133 is_overlay (bool, optional): Whether the file being opened is an overlay
1134 (requires a base image to be already loaded). Defaults to False.
1136 Raises:
1137 RuntimeError: If the overlay is loaded without a base image.
1138 """
1139 # ----------------------------
1140 # Prevent overlay loading without a base image
1141 # ----------------------------
1142 if is_overlay and self.img_data is None:
1143 QMessageBox.warning(
1144 self,
1145 QtCore.QCoreApplication.translate("NIfTIViewer", "Warning"),
1146 QtCore.QCoreApplication.translate("NIfTIViewer", "Please load a base image first!")
1147 )
1148 log.warning("No base image")
1149 return
1151 # ----------------------------
1152 # Show file dialog if no path is provided
1153 # ----------------------------
1154 if file_path is None:
1155 file_path = self.show_workspace_nii_dialog(is_overlay=is_overlay)
1156 if not file_path: # User canceled the dialog
1157 return
1159 # ----------------------------
1160 # Start file loading process
1161 # ----------------------------
1162 if file_path:
1163 # Create and configure progress dialog
1164 self.progress_dialog = QProgressDialog(
1165 QtCore.QCoreApplication.translate("NIfTIViewer", "Loading NIfTI file..."),
1166 QtCore.QCoreApplication.translate("NIfTIViewer", "Cancel"), 0, 100, self
1167 )
1168 self.progress_dialog.setWindowModality(Qt.WindowModality.WindowModal)
1169 self.progress_dialog.setMinimumDuration(0)
1171 # Launch threaded image loading
1172 self.threads.append(ImageLoadThread(file_path, is_overlay))
1173 self.threads[-1].finished.connect(self.on_file_loaded)
1174 self.threads[-1].error.connect(self.on_load_error)
1175 self.threads[-1].progress.connect(self.progress_dialog.setValue)
1176 self.threads[-1].start()
1178 # Allow user to cancel the loading process
1179 self.progress_dialog.canceled.connect(self.on_load_canceled)
1181 # Save file path to appropriate variable
1182 if is_overlay:
1183 self.overlay_file_path = file_path
1184 else:
1185 self.file_path = file_path
1187 def on_file_loaded(self, img_data, dims, affine, is_4d, is_overlay):
1188 """
1189 Handle successful completion of a NIfTI file loading operation.
1191 This method is called when the background loading thread finishes without errors.
1192 It manages both base image and overlay image loading results.
1194 Args:
1195 img_data (numpy.ndarray): Loaded image data array.
1196 dims (tuple): Dimensions of the loaded image (e.g., (X, Y, Z) or (X, Y, Z, T)).
1197 affine (numpy.ndarray): Affine transformation matrix defining voxel-to-world mapping.
1198 is_4d (bool): Whether the loaded image is a 4D time series.
1199 is_overlay (bool): Whether the loaded file is an overlay rather than a base image.
1201 Returns:
1202 None
1203 """
1204 log.debug("Loading NIfTI image...")
1205 # Disconnect progress dialog cancel signal and close it
1206 self.progress_dialog.canceled.disconnect()
1207 self.progress_dialog.close()
1208 log.debug("Remove the finished thread")
1209 # Remove the finished thread from active threads list
1210 thread_to_cancel = self.sender()
1211 self.threads.remove(thread_to_cancel)
1213 # ---------------------------------------------------
1214 # Handle overlay image loading
1215 # ---------------------------------------------------
1216 if is_overlay:
1217 # Store overlay data and its dimensions
1218 self.overlay_data = img_data
1219 self.overlay_dims = dims
1221 # Check for dimension mismatch and apply padding if necessary
1222 if hasattr(self, "dims") and self.overlay_data.shape[:3] != self.dims[:3]:
1223 QMessageBox.warning(
1224 self,
1225 "Dimensions mismatch!",
1226 f"The main image has dimensions {self.dims[:3]} and the overlay has dimensions {self.overlay_data.shape[:3]}."
1227 )
1228 self.overlay_data = self.pad_volume_to_shape(self.overlay_data, self.dims[:3])
1230 self.overlay_max = np.max(self.overlay_data) if np.max(self.overlay_data) > 0 else 1
1232 # Update overlay information label
1233 filename = os.path.basename(self.overlay_file_path)
1234 self.overlay_info_label.setText(
1235 f"Overlay: {filename}\n" +
1236 QtCore.QCoreApplication.translate("NIfTIViewer", "Dimensions") +
1237 f":{self.overlay_dims}"
1238 )
1239 log.debug("Activate the UI")
1240 # Enable and activate overlay in UI
1241 self.toggle_overlay(True,update_all=False)
1242 log.debug("Update overlay threshold")
1243 self.update_overlay_threshold(self.overlay_threshold_slider.value(),update_all=False)
1244 log.debug("Update display settings")
1245 # Refresh display with updated overlay settings
1246 self.update_overlay_settings(update_all=False)
1247 log.debug("Update all displays")
1248 self.update_all_displays()
1249 log.debug("Finished loading NIfTI image, updating checkbox")
1251 self.overlay_checkbox.setChecked(True)
1252 self.overlay_checkbox.setEnabled(True)
1253 log.debug("Updating status bar")
1254 # Update status bar message
1255 self.status_bar.showMessage(
1256 QtCore.QCoreApplication.translate("NIfTIViewer", "Overlay loaded") + f":{filename}"
1257 )
1259 # ---------------------------------------------------
1260 # Handle base image loading
1261 # ---------------------------------------------------
1262 else:
1263 # Reset any existing overlay and ROI tools
1264 self.reset_overlay()
1266 # Store loaded base image attributes
1267 self.img_data = img_data
1268 self.dims = dims
1269 self.affine = affine
1270 self.is_4d = is_4d
1271 self.voxel_sizes = np.sqrt((self.affine[:3, :3] ** 2).sum(axis=0)) # Compute voxel size in mm
1273 # Compose file information text
1274 filename = os.path.basename(self.file_path)
1275 if is_4d:
1276 # 4D image information
1277 info_text = QtCore.QCoreApplication.translate("NIfTIViewer", "File") + f":{filename}\n" + \
1278 QtCore.QCoreApplication.translate("NIfTIViewer", "Dimensions") + \
1279 f":{dims[0]}×{dims[1]}×{dims[2]}×{dims[3]}\n" + \
1280 QtCore.QCoreApplication.translate("NIfTIViewer", "4D Time Series")
1282 # Enable time-series group and plot setup
1283 self.time_group.setVisible(True)
1284 self.time_checkbox.setChecked(True)
1285 self.time_checkbox.setEnabled(True)
1286 self.setup_time_series_plot()
1287 else:
1288 # 3D volume information
1289 info_text = QtCore.QCoreApplication.translate("NIfTIViewer", "File") + f":{filename}\n" + \
1290 QtCore.QCoreApplication.translate("NIfTIViewer", "Dimensions") + \
1291 f":{dims[0]}×{dims[1]}×{dims[2]}\n" + \
1292 QtCore.QCoreApplication.translate("NIfTIViewer", "3D Volume")
1294 # Disable time controls for 3D data
1295 self.time_group.setVisible(False)
1296 self.time_checkbox.setChecked(False)
1297 self.time_checkbox.setEnabled(False)
1298 self.hide_time_series_plot()
1300 # Update status bar layout and messages
1301 self.status_bar.clearMessage()
1302 self.status_bar.addWidget(self.coord_label)
1303 self.status_bar.addPermanentWidget(self.slice_info_label)
1304 self.status_bar.addPermanentWidget(self.value_label)
1306 # Enable ROI controls
1307 self.automaticROIbtn.setEnabled(True)
1309 self.automaticROIbtn.setText(QtCore.QCoreApplication.translate("NIfTIViewer", "Automatic ROI"))
1311 # Update information panel
1312 self.file_info_label.setText(info_text)
1313 self.info_text.setText(info_text)
1315 # Initialize visual display of loaded data
1316 self.initialize_display()
1318 self.resetROI()
1319 self.reset_overlay()
1321 def on_load_error(self, error_message):
1322 """
1323 Handle errors during NIfTI file loading.
1325 Displays an error message, logs the issue, and cleans up any pending thread.
1327 Args:
1328 error_message (str): Error message returned by the worker thread.
1330 Returns:
1331 None
1332 """
1333 # Disconnect cancel signal and close progress dialog
1334 self.progress_dialog.canceled.disconnect()
1335 self.progress_dialog.close()
1337 # Display critical error dialog
1338 QMessageBox.critical(
1339 self,
1340 QtCore.QCoreApplication.translate("NIfTIViewer", "Error Loading File"),
1341 QtCore.QCoreApplication.translate("NIfTIViewer", "Failed to load NIfTI file") + f":\n{error_message}"
1342 )
1344 # Log the critical error message
1345 log.critical(f"Error loading NIfTI file: {error_message}")
1347 # Remove failed thread from thread list
1348 thread_to_cancel = self.sender()
1349 if thread_to_cancel in self.threads:
1350 self.threads.remove(thread_to_cancel)
1352 def on_load_canceled(self):
1353 """
1354 Handle manual cancellation of the NIfTI file loading process.
1356 Terminates the most recent loading thread and removes it from
1357 the active thread list to prevent resource leakage.
1359 Returns:
1360 None
1361 """
1362 self.threads[-1].terminate()
1363 self.threads.pop()
1365 def initialize_display(self):
1366 """
1367 Initialize and configure the viewer display after a NIfTI file is loaded.
1369 Sets up slice navigation controls, initializes time-series sliders for 4D data,
1370 updates current coordinates, and enables overlay controls.
1372 Returns:
1373 None
1374 """
1375 if self.img_data is None:
1376 return
1378 # Determine spatial dimension order (reverse for display consistency)
1379 spatial_dims = self.dims[:3][::-1] if self.is_4d else self.dims[::-1]
1381 # Configure slice sliders and spin boxes
1382 for i in range(3):
1383 max_slice = spatial_dims[i] - 1
1384 self.slice_sliders[i].setMaximum(max_slice)
1385 self.slice_spins[i].setMaximum(max_slice)
1386 self.current_slices[i] = max_slice // 2 # Start in the middle slice
1387 self.slice_sliders[i].setValue(self.current_slices[i])
1388 self.slice_spins[i].setValue(self.current_slices[i])
1390 # Configure time slider and spinbox for 4D datasets
1391 if self.is_4d:
1392 max_time = self.dims[3] - 1
1393 self.time_slider.setMaximum(max_time)
1394 self.time_spin.setMaximum(max_time)
1395 self.current_time = 0
1396 self.time_slider.setValue(0)
1397 self.time_spin.setValue(0)
1398 self.toggle_time_controls(self.time_checkbox.isChecked())
1400 # Initialize coordinate tracking
1401 self.current_coordinates = [
1402 self.current_slices[2], # X coordinate
1403 self.current_slices[1], # Y coordinate
1404 self.current_slices[0] # Z coordinate
1405 ]
1407 # Update all displays and coordinate readouts
1408 self.update_all_displays()
1409 self.update_coordinate_displays()
1411 # Enable overlay loading button after successful image load
1412 self.overlay_btn.setEnabled(True)
1414 def toggle_overlay(self, enabled,update_all=True):
1415 """
1416 Enable or disable the overlay display on top of the base image.
1418 Args:
1419 enabled (bool): Whether to enable (True) or disable (False) the overlay display.
1421 Behavior:
1422 - Toggles transparency and threshold controls.
1423 - Refreshes all active views if overlay data exists.
1425 Returns:
1426 None
1427 """
1428 # Update internal overlay state
1429 self.overlay_enabled = enabled
1431 # Enable or disable overlay-related UI controls
1432 self.overlay_alpha_slider.setEnabled(enabled or self.automaticROI_overlay or self.incrementalROI_enabled)
1433 self.overlay_alpha_spin.setEnabled(enabled or self.automaticROI_overlay or self.incrementalROI_enabled)
1434 self.overlay_threshold_slider.setEnabled(enabled or self.automaticROI_overlay or self.incrementalROI_enabled)
1435 self.overlay_threshold_spin.setEnabled(enabled or self.automaticROI_overlay or self.incrementalROI_enabled)
1436 self.ROI_save_btn.setEnabled(enabled or self.automaticROI_overlay or self.incrementalROI_enabled)
1439 if update_all:
1440 # Redraw display if overlay data is available
1441 log.debug("Update all display.")
1442 self.update_all_displays()
1443 log.debug("Update time series plot")
1444 # Update time series plot if available
1445 self.update_time_series_plot()
1447 def update_overlay_alpha(self, value,update_all=True):
1448 """
1449 Update overlay transparency (alpha blending).
1451 Args:
1452 value (int): Slider value representing overlay opacity (0–100).
1454 Notes:
1455 Converts the integer slider value to a float ratio (0.0–1.0) and
1456 refreshes the display only if the overlay is active and data is loaded.
1457 """
1458 self.overlay_alpha = value / 100.0
1459 if (self.overlay_enabled or self.automaticROI_overlay or self.incrementalROI_enabled) and update_all:
1460 self.update_all_displays()
1462 def update_overlay_threshold(self, value,update_all=True):
1463 """
1464 Update overlay intensity threshold.
1466 Args:
1467 value (int): Slider value representing overlay threshold (0–100).
1469 Notes:
1470 Adjusts visibility cutoff for overlay voxels and refreshes display
1471 when overlay is active and data is present.
1472 """
1473 self.overlay_threshold = value / 100.0
1474 if self.overlay_enabled and self.overlay_data is not None and self.overlay_max is not None:
1475 # Determine overlay threshold
1476 threshold_value = self.overlay_threshold * self.overlay_max
1477 # Create boolean mask of overlay pixels above threshold
1478 self.overlay_thresholded_data = self.overlay_data > threshold_value
1479 if update_all:
1480 self.update_all_displays()
1482 def update_overlay_settings(self,update_all=True):
1483 """
1484 Synchronize overlay alpha and threshold values from the UI controls.
1486 Notes:
1487 Reads the slider positions for alpha and threshold,
1488 updates their internal numeric equivalents, and refreshes
1489 the image view if the overlay is active.
1490 """
1491 if hasattr(self, 'overlay_alpha_slider') and hasattr(self, 'overlay_threshold_slider'):
1492 self.overlay_alpha = self.overlay_alpha_slider.value() / 100.0
1493 self.overlay_threshold = self.overlay_threshold_slider.value() / 100.0
1495 # Update visualization only if overlay is active and available
1496 if self.overlay_enabled and hasattr(self, 'overlay_data') and self.overlay_data is not None and update_all:
1497 self.update_all_displays()
1499 def slice_changed(self, plane_idx, value):
1500 """
1501 Handle slice navigation events from sliders or spinboxes.
1503 Args:
1504 plane_idx (int): Index of the anatomical plane (0=axial, 1=coronal, 2=sagittal).
1505 value (int): New slice index within the volume.
1507 Notes:
1508 Updates the current slice, synchronizes the corresponding slider and spinbox,
1509 recalculates coordinates, and refreshes the associated 2D view.
1510 """
1511 self.current_slices[plane_idx] = value
1513 # Keep slider and spinbox synchronized
1514 self.slice_sliders[plane_idx].setValue(value)
1515 self.slice_spins[plane_idx].setValue(value)
1517 # Update coordinate system based on selected plane
1518 if plane_idx == 0: # Axial
1519 self.current_coordinates[2] = value
1520 elif plane_idx == 1: # Coronal
1521 self.current_coordinates[1] = value
1522 elif plane_idx == 2: # Sagittal
1523 self.current_coordinates[0] = value
1525 # Refresh the corresponding view and UI indicators
1526 self.update_display(plane_idx)
1527 self.update_coordinate_displays()
1528 self.update_cross_view_lines()
1530 def time_changed(self, value,update_all=True):
1531 """
1532 Handle time slider or spinbox change for 4D data.
1534 Args:
1535 value (int): New time index.
1537 Notes:
1538 Updates the current time frame and refreshes all views.
1539 """
1540 self.current_time = value
1541 self.time_slider.setValue(value)
1542 self.time_spin.setValue(value)
1543 if update_all:
1544 self.update_all_displays()
1546 def toggle_time_controls(self, enabled):
1547 """
1548 Enable or disable time navigation controls based on data dimensionality.
1550 Args:
1551 enabled (bool): Whether to show and enable time controls.
1553 Notes:
1554 Time controls are visible only when both 4D data is loaded and
1555 the toggle checkbox is active.
1556 """
1557 value = enabled and self.is_4d
1558 self.time_slider.setVisible(value)
1559 self.time_slider.setEnabled(value)
1560 self.time_spin.setVisible(value)
1561 self.time_spin.setEnabled(value)
1562 self.time_point_label.setVisible(value)
1564 def colormap_changed(self, colormap_name,update_all=True):
1565 """
1566 Handle colormap selection change from the dropdown.
1568 Args:
1569 colormap_name (str): Name of the selected colormap.
1571 Notes:
1572 Updates the active colormap for display and refreshes all views.
1573 """
1574 self.colormap = colormap_name
1575 if update_all:
1576 self.update_all_displays()
1578 def handle_click_coordinates(self, view_idx, x, y):
1579 """
1580 Handle user mouse clicks within a 2D slice view.
1582 Args:
1583 view_idx (int): Index of the view where the click occurred (0=axial, 1=coronal, 2=sagittal).
1584 x (float): X coordinate in screen space.
1585 y (float): Y coordinate in screen space.
1587 Notes:
1588 Converts screen coordinates to image voxel coordinates,
1589 updates slice positions accordingly, and synchronizes all views.
1590 """
1591 if self.img_data is None:
1592 return
1594 # Convert click from display to image coordinate space
1595 img_coords = self.screen_to_image_coords(view_idx, x, y)
1596 if img_coords is None:
1597 return
1599 # Update global coordinates
1600 self.current_coordinates = img_coords
1602 # Update slice positions based on clicked voxel
1603 self.current_slices[0] = img_coords[2] # Axial (Z)
1604 self.current_slices[1] = img_coords[1] # Coronal (Y)
1605 self.current_slices[2] = img_coords[0] # Sagittal (X)
1607 # Synchronize controls for all planes
1608 for i in range(3):
1609 self.slice_sliders[i].setValue(self.current_slices[i])
1610 self.slice_spins[i].setValue(self.current_slices[i])
1612 # Refresh display and coordinate info
1613 self.update_all_displays()
1614 self.update_coordinate_displays()
1615 self.update_cross_view_lines()
1617 def screen_to_image_coords(self, view_idx, x, y):
1618 """
1619 Convert 2D screen coordinates from a slice view into 3D voxel coordinates.
1621 Args:
1622 view_idx (int): View index (0=axial, 1=coronal, 2=sagittal).
1623 x (float): X coordinate in view space.
1624 y (float): Y coordinate in view space.
1626 Returns:
1627 list[int] | None: Image-space voxel indices [x, y, z] or None if invalid.
1629 Notes:
1630 Applies stretch factor correction, flips orientation for proper display,
1631 and clamps coordinates to valid volume bounds.
1632 """
1633 if self.img_data is None:
1634 return None
1636 # Compensate for view stretching
1637 stretch_x, stretch_y = self.stretch_factors.get(view_idx, (1.0, 1.0))
1638 x = x / stretch_x
1639 y = y / stretch_y
1641 # Determine image dimensions (ignore time axis if 4D)
1642 shape = self.img_data.shape[:3] if self.is_4d else self.img_data.shape
1644 # Map view-specific coordinates to image indices
1645 if view_idx == 0: # Axial (XY plane)
1646 img_x = min(max(x, 0), shape[0] - 1)
1647 img_y = min(max(shape[1] - 1 - y, 0), shape[1] - 1) # Flip Y-axis
1648 img_z = self.current_slices[0]
1649 elif view_idx == 1: # Coronal (XZ plane)
1650 img_x = min(max(x, 0), shape[0] - 1)
1651 img_y = self.current_slices[1]
1652 img_z = min(max(shape[2] - 1 - y, 0), shape[2] - 1) # Flip Z-axis
1653 elif view_idx == 2: # Sagittal (YZ plane)
1654 img_x = self.current_slices[2]
1655 img_y = min(max(x, 0), shape[1] - 1)
1656 img_z = min(max(shape[2] - 1 - y, 0), shape[2] - 1) # Flip Z-axis
1657 else:
1658 return None
1660 return [int(img_x), int(img_y), int(img_z)]
1662 def update_coordinates(self, view_idx, x, y):
1663 """
1664 Update displayed voxel coordinates and value from mouse movement.
1666 Args:
1667 view_idx (int): Index of the active view.
1668 x (float): X mouse position.
1669 y (float): Y mouse position.
1671 Notes:
1672 Displays current voxel indices and intensity in the status bar
1673 as the user moves the mouse across an image slice.
1674 """
1675 if self.img_data is None:
1676 return
1678 img_coords = self.screen_to_image_coords(view_idx, x, y)
1679 if img_coords is None:
1680 return
1682 # Retrieve voxel value safely
1683 try:
1684 if self.is_4d:
1685 value = self.img_data[img_coords[0], img_coords[1], img_coords[2], self.current_time]
1686 else:
1687 value = self.img_data[img_coords[0], img_coords[1], img_coords[2]]
1689 # Update coordinate and voxel value display
1690 self.coord_label.setText(QtCore.QCoreApplication.translate(
1691 "NIfTIViewer", "Coordinates") + f": ({img_coords[0]}, {img_coords[1]}, {img_coords[2]})")
1692 self.value_label.setText(QtCore.QCoreApplication.translate(
1693 "NIfTIViewer", "Value") + f": {value:.2f}")
1694 except (IndexError, ValueError):
1695 log.exception("Failed to update coordinates")
1697 def update_coordinate_displays(self):
1698 """
1699 Update coordinate display labels beside each view and in the status bar.
1701 Notes:
1702 Shows current voxel positions (X, Y, Z) and value, ensuring real-time
1703 synchronization with slice sliders and mouse navigation.
1704 """
1705 if self.img_data is None:
1706 return
1708 coords = self.current_coordinates
1710 # Update per-view coordinate readouts
1711 self.coord_displays[0].setText(f"({coords[0]}, {coords[1]})") # Axial
1712 self.coord_displays[1].setText(f"({coords[0]}, {coords[2]})") # Coronal
1713 self.coord_displays[2].setText(f"({coords[1]}, {coords[2]})") # Sagittal
1715 # Update global coordinate display in status bar
1716 self.coord_label.setText(QtCore.QCoreApplication.translate(
1717 "NIfTIViewer", "Coordinates") + f": ({coords[0]}, {coords[1]}, {coords[2]})")
1719 # Update value label safely
1720 try:
1721 if self.is_4d:
1722 value = self.img_data[coords[0], coords[1], coords[2], self.current_time]
1723 else:
1724 value = self.img_data[coords[0], coords[1], coords[2]]
1725 self.value_label.setText(QtCore.QCoreApplication.translate(
1726 "NIfTIViewer", "Value") + f": {value:.2f}")
1727 except (IndexError, ValueError):
1728 self.value_label.setText(QtCore.QCoreApplication.translate("NIfTIViewer", "Value") + f": -")
1730 def update_cross_view_lines(self):
1731 """
1732 Update crosshair positions across all views to indicate current voxel location.
1734 Notes:
1735 Crosshair lines are synchronized in all 2D projections (axial, coronal, sagittal),
1736 taking into account stretching factors and coordinate flipping.
1737 """
1738 if self.img_data is None:
1739 return
1741 coords = self.current_coordinates
1743 for i, view in enumerate(self.views):
1744 # Retrieve stretch correction factors (default to 1.0)
1745 stretch_x, stretch_y = self.stretch_factors.get(i, (1.0, 1.0))
1747 if i == 0: # Axial view
1748 x = coords[0] * stretch_x
1749 y = (self.img_data.shape[1] - 1 - coords[1]) * stretch_y
1750 view.set_crosshair_position(x, y)
1752 elif i == 1: # Coronal view
1753 x = coords[0] * stretch_x
1754 y = (self.img_data.shape[2] - 1 - coords[2]) * stretch_y
1755 view.set_crosshair_position(x, y)
1757 elif i == 2: # Sagittal view
1758 x = coords[1] * stretch_x
1759 y = (self.img_data.shape[2] - 1 - coords[2]) * stretch_y
1760 view.set_crosshair_position(x, y)
1762 def update_display(self, plane_idx):
1763 """Update a specific plane display with matplotlib-style rendering in mm scale"""
1764 if self.img_data is None:
1765 return
1767 try:
1768 log.debug(f"Update display: {plane_idx}")
1769 # Select current 3D volume (for 4D data, use the selected time frame)
1770 if self.is_4d:
1771 current_data = self.img_data[..., self.current_time]
1772 else:
1773 current_data = self.img_data
1775 # Get current slice index for the selected plane
1776 slice_idx = self.current_slices[plane_idx]
1778 # Extract the corresponding slice depending on the plane
1779 if plane_idx == 0: # Axial (XY plane)
1780 slice_data = current_data[:, :, slice_idx].T # transpose to match orientation
1781 slice_data = np.flipud(slice_data) # flip vertically for correct visualization
1782 pixel_spacing = self.voxel_sizes[0:2] # spacing in X and Y directions
1784 elif plane_idx == 1: # Coronal (XZ plane)
1785 slice_data = current_data[:, slice_idx, :].T
1786 slice_data = np.flipud(slice_data)
1787 pixel_spacing = (self.voxel_sizes[0], self.voxel_sizes[2]) # X and Z spacing
1789 elif plane_idx == 2: # Sagittal (YZ plane)
1790 slice_data = current_data[slice_idx, :, :].T
1791 slice_data = np.flipud(slice_data)
1792 pixel_spacing = self.voxel_sizes[1:3] # Y and Z spacing
1794 else:
1795 log.error("Plane index out of range")
1796 return # Invalid plane index
1798 automaticROI_slice = _slice(self.automaticROI_data, plane_idx, slice_idx) if self.automaticROI_overlay and self.automaticROI_data is not None else None
1800 # Prepare overlay if available and enabled
1801 overlay_slice = _slice(self.overlay_thresholded_data,plane_idx, slice_idx) if self.overlay_enabled and self.overlay_data is not None and self.overlay_thresholded_data is not None else None
1803 incrementalROI_slice = _slice(self.incrementalROI_data,plane_idx, slice_idx) if self.incrementalROI_enabled and self.incrementalROI_data is not None else None
1805 # Prepare RGBA composite for display
1806 height, width = slice_data.shape
1807 rgba_image = self.apply_colormap_matplotlib(slice_data, self.colormap)
1809 if automaticROI_slice is not None:
1810 rgba_image = self.create_overlay_composite(rgba_image, automaticROI_slice, self.colormap)
1812 if overlay_slice is not None:
1813 rgba_image = self.create_overlay_composite(rgba_image, overlay_slice, self.colormap)
1815 if incrementalROI_slice is not None:
1816 rgba_image = self.create_overlay_composite(rgba_image, incrementalROI_slice, self.colormap)
1818 # Convert RGBA data to 8-bit format for QImage
1819 rgba_data_uint8 = (rgba_image * 255).astype(np.uint8)
1820 qimage = QImage(rgba_data_uint8.data, width, height, width * 4, QImage.Format.Format_RGBA8888)
1822 if qimage is not None:
1823 img_w, img_h = qimage.width(), qimage.height()
1825 # Scale the image according to voxel size ratio (convert to mm scale)
1826 qimage_scaled = qimage.scaled(
1827 int(img_w),
1828 int(img_h * (pixel_spacing[1] / pixel_spacing[0])),
1829 Qt.AspectRatioMode.IgnoreAspectRatio,
1830 Qt.TransformationMode.SmoothTransformation
1831 )
1833 # Store stretch factors for coordinate conversion later
1834 self.stretch_factors[plane_idx] = (1.0, pixel_spacing[1] / pixel_spacing[0])
1836 # Update QGraphicsScene and QGraphicsView with new image
1837 self.pixmap_items[plane_idx].setPixmap(QPixmap.fromImage(qimage_scaled))
1838 self.scenes[plane_idx].setSceneRect(0, 0, qimage_scaled.width(), qimage_scaled.height())
1839 self.views[plane_idx].fitInView(self.scenes[plane_idx].sceneRect(), Qt.AspectRatioMode.KeepAspectRatio)
1840 log.debug("Updated display ended")
1841 except Exception as e:
1842 # Log any display update errors (e.g. shape mismatch or memory issue)
1843 log.error(f"Error updating display {plane_idx}: {e}")
1845 def setup_time_series_plot(self):
1846 """Setup time series plot for 4D data"""
1847 if self.time_plot_canvas is not None:
1848 return # Skip if plot is already initialized
1850 # Hide static info text to make room for plot
1851 self.info_text.hide()
1853 # Initialize matplotlib figure for time series
1854 self.time_plot_figure = Figure(figsize=(3, 3), facecolor='black')
1855 self.time_plot_figure.set_layout_engine('tight')
1857 # Embed matplotlib canvas inside PyQt interface
1858 self.time_plot_canvas = FigureCanvas(self.time_plot_figure)
1859 self.time_plot_axes = self.time_plot_figure.add_subplot(111)
1860 self.time_plot_axes.set_facecolor('black')
1862 # Update section title and add canvas widget to layout
1863 self.fourth_title.setText(QtCore.QCoreApplication.translate("NIfTIViewer", "Tracer Concentration Curve"))
1864 self.fourth_content_layout.addWidget(self.time_plot_canvas)
1866 def hide_time_series_plot(self):
1867 """Hide time series plot for 3D data"""
1868 if self.time_plot_canvas is not None:
1869 # Clean up plot canvas from layout and delete
1870 self.fourth_content_layout.removeWidget(self.time_plot_canvas)
1871 self.time_plot_canvas.setParent(None)
1872 self.time_plot_canvas.deleteLater()
1873 self.time_plot_canvas = None
1874 self.time_plot_axes = None
1875 self.time_plot_figure = None
1877 # Restore title and info text for non-4D files
1878 self.fourth_title.setText(QtCore.QCoreApplication.translate("NIfTIViewer", "Image Information"))
1879 self.info_text.show()
1881 def update_time_series_plot(self):
1882 """Update the time series plot with current voxel or ROI data"""
1883 if not self.is_4d or self.time_plot_canvas is None or self.img_data is None:
1884 return
1886 try:
1887 coords = self.current_coordinates
1888 bool_in_mask = False
1890 # Check if overlay is active and apply ROI-based averaging
1891 if self.overlay_data is not None and self.overlay_enabled:
1892 overlay_max = np.max(self.overlay_data) if np.max(self.overlay_data) > 0 else 1
1893 threshold_value = self.overlay_threshold * overlay_max
1894 threshold_mask = self.overlay_data > threshold_value
1896 # If current voxel is inside the thresholded ROI mask
1897 if threshold_mask[coords[0], coords[1], coords[2]]:
1898 bool_in_mask = True
1899 roi_voxels = self.img_data[threshold_mask, :] # extract time series from ROI voxels
1900 time_series = roi_voxels.mean(axis=0) # mean intensity over ROI
1901 std_series = roi_voxels.std(axis=0) # standard deviation for shaded region
1902 else:
1903 # Outside mask: show only single voxel time series
1904 time_series = self.img_data[coords[0], coords[1], coords[2], :]
1905 std_series = None
1906 else:
1907 # If overlay is not enabled, show voxel intensity over time
1908 time_series = self.img_data[coords[0], coords[1], coords[2], :]
1909 std_series = None
1911 # X-axis values = time points
1912 time_points = np.arange(self.dims[3])
1914 # Clear previous plot content
1915 self.time_plot_axes.clear()
1916 self.time_plot_axes.set_facecolor('black')
1918 # Plot time series curve
1919 self.time_plot_axes.plot(time_points, time_series, 'c-', linewidth=2,
1920 label=QtCore.QCoreApplication.translate("NIfTIViewer", 'Concentration'))
1922 # Optional shaded error region (ROI variability)
1923 if std_series is not None:
1924 self.time_plot_axes.fill_between(time_points, time_series - std_series,
1925 time_series + std_series, alpha=0.2, color='c')
1927 # Add vertical yellow line showing current time index
1928 self.time_indicator_line = self.time_plot_axes.axvline(
1929 x=self.current_time, color='yellow', linewidth=2, alpha=0.8,
1930 label=QtCore.QCoreApplication.translate("NIfTIViewer", 'Current Time')
1931 )
1933 # Set axis labels and title
1934 self.time_plot_axes.set_xlabel(QtCore.QCoreApplication.translate("NIfTIViewer", "Time Point"),
1935 color='white')
1936 self.time_plot_axes.set_ylabel(QtCore.QCoreApplication.translate("NIfTIViewer", "Signal Intensity"),
1937 color='white')
1939 # Title reflects whether inside ROI or single voxel
1940 if bool_in_mask:
1941 self.time_plot_axes.set_title(f'Mean in overlay mask', color='white')
1942 else:
1943 self.time_plot_axes.set_title(f'Voxel ({coords[0]}, {coords[1]}, {coords[2]})', color='white')
1945 # Style axes and legend
1946 self.time_plot_axes.tick_params(colors='white')
1947 self.time_plot_axes.legend()
1948 self.time_plot_axes.grid(True, alpha=0.3, color='gray')
1950 # Redraw updated plot on canvas
1951 self.time_plot_canvas.draw()
1953 except Exception as e:
1954 # Log error if plotting fails (e.g., index error)
1955 log.error(f"Error updating time series plot: {e}")
1957 def apply_colormap_matplotlib(self, data, colormap_name):
1958 """Apply colormap using matplotlib and return QImage"""
1959 try:
1960 # Retrieve selected colormap from matplotlib
1961 cmap = matplotlib.colormaps.get_cmap(colormap_name)
1963 # Apply colormap to normalized image data
1964 colored_data = cmap(data)
1966 return colored_data
1968 except Exception as e:
1969 # Log errors (e.g., invalid colormap name)
1970 log.error(f"Error applying colormap: {e}")
1971 return None
1973 def update_all_displays(self):
1974 """Update all plane displays"""
1975 # Loop over all 3 orthogonal views (axial, coronal, sagittal)
1976 for i in range(3):
1977 self.update_display(i)
1979 # If data is 4D, also update the time-series plot
1980 if self.is_4d:
1981 self.update_time_series_plot()
1983 # Update slice information label in the status bar
1984 if self.img_data is not None:
1985 spatial_dims = self.dims[:3] if self.is_4d else self.dims
1986 # Construct slice position string for each plane (1-based indexing)
1987 slice_info = QtCore.QCoreApplication.translate("NIfTIViewer", "Slices") + \
1988 f": {self.current_slices[0] + 1}/{spatial_dims[2]} | " \
1989 f"{self.current_slices[1] + 1}/{spatial_dims[1]} | " \
1990 f"{self.current_slices[2] + 1}/{spatial_dims[0]}"
1991 # Add current time info if applicable
1992 if self.is_4d:
1993 slice_info += f" | " + QtCore.QCoreApplication.translate("NIfTIViewer", "Time") + \
1994 f": {self.current_time + 1}/{self.dims[3]}"
1995 self.slice_info_label.setText(slice_info)
1997 def create_overlay_composite(self, rgba_image, overlay_slice, colormap):
1998 """Create a composite image with colormap base and red overlay."""
1999 try:
2000 # Convert RGBA base image to float for blending
2001 rgba_image_float = rgba_image.astype(np.float64) # shape (H, W, 4)
2003 if overlay_slice.size > 0:
2005 if np.any(overlay_slice):
2006 # Apply transparency scaling (based on user alpha)
2007 overlay_intensity = overlay_slice * self.overlay_alpha
2009 # Retrieve overlay color from dictionary or default (green)
2010 overlay_color = self.overlay_colors.get(colormap, np.array([0.0, 1.0, 0.0]))
2012 log.debug("Blending overlay color into base image")
2013 # Blend overlay into base image using a numba-accelerated function
2014 rgba_image_float = apply_overlay_numba(rgba_image, overlay_slice,
2015 overlay_intensity, overlay_color)
2016 log.debug("Blended overlay color into base image")
2017 log.debug("Clipping values to a valid range")
2018 # Clip values to valid range [0, 1]
2019 rgba_image_overlay = np.clip(rgba_image_float, 0, 1)
2021 return rgba_image_overlay
2023 except Exception as e:
2024 # Log errors and return unmodified base image as fallback
2025 log.error(f"Error creating overlay composite: {e}")
2026 return rgba_image
2028 def resizeEvent(self, event: QResizeEvent):
2029 """Handle window resize to maintain aspect ratios"""
2030 # Call parent resize handler
2031 super().resizeEvent(event)
2032 # Refit all 2D slice views after short delay to prevent flickering
2033 QTimer.singleShot(100, self.fit_all_views)
2035 def fit_all_views(self):
2036 """Fit all views to their scenes while maintaining aspect ratio"""
2037 for view in self.views:
2038 if view.scene():
2039 # Adjust zoom to fit entire image in view with correct aspect ratio
2040 view.fitInView(view.scene().sceneRect(), Qt.AspectRatioMode.KeepAspectRatio)
2042 def update_automaticROI(self):
2043 """Update the automatic ROI overlay dynamically when parameters change"""
2044 if self.automaticROI_overlay:
2045 self.automaticROI_drawing()
2046 self.update_all_displays()
2048 def automaticROI_clicked(self):
2049 """Handle click on 'Automatic ROI' button to start or reset the ROI tool"""
2050 # Save current voxel coordinates as ROI seed point
2051 self.automaticROI_seed_coordinates = self.current_coordinates
2053 # Compute the maximum allowed ROI radius in millimeters
2054 dims_voxel = self.dims[:3] # voxel counts along X, Y, Z
2055 dims_mm = dims_voxel * self.voxel_sizes # real-world dimensions in mm
2056 max_radius_mm = np.min(dims_mm) / 2 # use half of shortest image dimension
2057 self.automaticROI_radius_slider.setMaximum(int(max_radius_mm))
2059 # Initialize radius slider (32 mm default for new ROI)
2060 self.automaticROI_radius_slider.setValue(
2061 32 if not self.automaticROI_overlay else self.automaticROI_radius_slider.value()
2062 )
2064 # Initialize difference (intensity tolerance) slider
2065 self.automaticROI_diff_slider.setMaximum(1000)
2066 self.automaticROI_diff_slider.setValue(
2067 int(1000 * (16 / 100)) if not self.automaticROI_overlay else self.automaticROI_diff_slider.value()
2068 )
2070 # Show and enable ROI parameter controls
2071 self.automaticROI_sliders_group.setVisible(True)
2072 self.automaticROI_sliders_group.setEnabled(True)
2073 self.automaticROIbtn.setText(QtCore.QCoreApplication.translate("NIfTIViewer", "Reset Origin"))
2074 self.automaticROI_overlay = True
2075 self.ROI_save_btn.setEnabled(True)
2076 self.addOrigin_btn.setEnabled(True)
2077 self.cancelROI_btn.setEnabled(True)
2079 # Generate initial automatic ROI mask
2080 self.automaticROI_drawing()
2082 self.automaticROI_checkbox.setVisible(True)
2083 self.automaticROI_checkbox.setEnabled(True)
2084 # Ensure overlay display is active
2085 self.toggle_automaticROI(True)
2086 #self.overlay_checkbox.setChecked(True)
2087 #self.overlay_checkbox.setEnabled(True)
2089 # Refresh all displays
2090 self.update_all_displays()
2093 def toggle_automaticROI(self, enabled,update_all=True):
2094 """
2095 Enable or disable the overlay for the automatic ROI on top of the base image.
2097 Args:
2098 enabled (bool): Whether to enable (True) or disable (False) the overlay display.
2100 Behavior:
2101 - Toggles transparency and threshold controls.
2102 - Refreshes all active views if overlay data exists.
2104 Returns:
2105 None
2106 """
2108 self.automaticROI_checkbox.setChecked(enabled)
2109 # Update internal overlay state
2110 self.automaticROI_overlay = enabled
2111 # Enable or disable overlay-related UI controls
2112 self.overlay_alpha_slider.setEnabled(enabled or self.overlay_enabled or self.incrementalROI_enabled)
2113 self.overlay_alpha_spin.setEnabled(enabled or self.overlay_enabled or self.incrementalROI_enabled)
2114 self.overlay_threshold_slider.setEnabled(enabled or self.overlay_enabled or self.incrementalROI_enabled)
2115 self.overlay_threshold_spin.setEnabled(enabled or self.overlay_enabled or self.incrementalROI_enabled)
2116 self.ROI_save_btn.setEnabled(enabled or self.overlay_enabled or self.incrementalROI_enabled)
2118 self.automaticROI_sliders_group.setEnabled(enabled)
2119 self.automaticROIbtn.setEnabled(enabled)
2121 if update_all:
2122 # Redraw display if overlay data is available
2123 self.update_all_displays()
2125 # Update time series plot if available
2126 self.update_time_series_plot()
2128 def automaticROI_drawing(self):
2129 """Generate automatic ROI mask around selected seed voxel"""
2130 radius_mm = self.automaticROI_radius_slider.value() # ROI radius in mm
2131 difference = self.automaticROI_diff_slider.value() / 1000 # intensity tolerance
2132 x0, y0, z0 = self.automaticROI_seed_coordinates # seed voxel coordinates
2134 # Select proper 3D volume if data is 4D
2135 img_data = self.img_data[..., self.current_time] if self.is_4d else self.img_data
2137 # Intensity value at the seed voxel
2138 seed_intensity = img_data[x0, y0, z0]
2140 # Convert radius in mm to radius in voxel units per axis
2141 rx_vox = int(np.ceil(radius_mm / self.voxel_sizes[0]))
2142 ry_vox = int(np.ceil(radius_mm / self.voxel_sizes[1]))
2143 rz_vox = int(np.ceil(radius_mm / self.voxel_sizes[2]))
2145 # Compute subvolume limits (ROI bounding box)
2146 x_min, x_max = max(0, x0 - rx_vox), min(img_data.shape[0], x0 + rx_vox + 1)
2147 y_min, y_max = max(0, y0 - ry_vox), min(img_data.shape[1], y0 + ry_vox + 1)
2148 z_min, z_max = max(0, z0 - rz_vox), min(img_data.shape[2], z0 + rz_vox + 1)
2150 # Compute ROI mask using parallelized Numba function
2151 mask = compute_mask_numba_mm(img_data, x0, y0, z0,
2152 radius_mm, self.voxel_sizes,
2153 seed_intensity, difference,
2154 x_min, x_max, y_min, y_max, z_min, z_max)
2156 # Store result as overlay for visualization
2157 self.automaticROI_data = mask
2159 def ROI_save(self):
2160 """Save the automatically generated ROI mask to disk"""
2161 log.debug("Saving ROI mask to disk")
2162 original_path = self.file_path
2163 if not original_path:
2164 QMessageBox.critical(self, "Error", "No file loaded.")
2165 log.critical("No file loaded")
2166 return
2167 # Retrieve workspace path from context
2168 workspace_path = self.context.get("workspace_path")
2169 if not workspace_path:
2170 QMessageBox.critical(self, "Error", "Workspace path is not set.")
2171 log.critical("Workspace path not set")
2172 return
2174 # Try to infer subject identifier (sub-XX) from relative file path
2175 relative_path = os.path.relpath(original_path, workspace_path)
2176 parts = relative_path.split(os.sep)
2177 try:
2178 subject = next(part for part in parts if part.startswith("sub-"))
2179 except StopIteration:
2180 QMessageBox.critical(self, "Error", "Could not determine subject from path.")
2181 log.error("Could not determine subject from path.")
2182 return
2185 save_dir = os.path.join(workspace_path, "derivatives", "manual_masks", subject, "anat")
2186 log.debug(f"Save dir: {save_dir}")
2187 filename = os.path.basename(original_path)
2188 base_name = filename.replace(".nii.gz", "").replace(".nii", "")
2189 log.debug(f"Base filename: {base_name}")
2190 # Prepare filenames and output paths
2191 id = self._get_next_file_id(save_dir)
2192 log.debug(f"Obtained version: {id}")
2193 new_base = f"{base_name}_ROI_v{id}_mask"
2194 new_name = f"{new_base}.nii.gz"
2196 log.debug("Show confirmation dialog")
2197 # Show confirmation dialog before saving
2198 msg = QMessageBox(self)
2199 msg.setIcon(QMessageBox.Icon.Question)
2200 msg.setWindowTitle("Confirm Save")
2201 msg.setText("Do you want to save the automatic ROI?")
2202 msg.setInformativeText(
2203 f"File will be saved as:\n\n{new_name}\n\n"
2204 f"Location:\n{save_dir}\n\n"
2205 )
2206 msg.setStandardButtons(QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No)
2207 msg.setDefaultButton(QMessageBox.StandardButton.Yes)
2209 # Wait for user response
2210 response = msg.exec()
2211 if response != QMessageBox.StandardButton.Yes:
2212 return # User canceled
2214 log.debug("Make dir")
2215 os.makedirs(save_dir, exist_ok=True)
2216 full_save_path = os.path.join(save_dir, new_name)
2217 json_save_path = os.path.join(save_dir, f"{new_base}.json")
2219 origin_dict = {}
2221 total_ROI = np.zeros(self.dims)
2222 if self.overlay_data is not None and self.overlay_enabled and self.overlay_thresholded_data is not None:
2223 total_ROI = np.logical_or(self.overlay_thresholded_data, total_ROI).astype(np.uint8)
2224 origin_dict["Original overlay"] = self.overlay_file_path
2225 origin_dict["Original overlay threshold"] = self.overlay_threshold
2227 if self.incrementalROI_data is not None and self.incrementalROI_enabled:
2228 total_ROI = np.logical_or(self.incrementalROI_data,total_ROI).astype(np.uint8)
2229 origin_dict["Automatic drawing parameters"] = self.incrementalROI_origins
2231 if self.automaticROI_data is not None and self.automaticROI_overlay:
2232 total_ROI = np.logical_or(self.automaticROI_data, total_ROI).astype(np.uint8)
2233 new_params = {
2234 "Seed": list(self.automaticROI_seed_coordinates), # assicurati che sia JSON-safe
2235 "Radius": self.automaticROI_radius_slider.value(),
2236 "Difference": self.automaticROI_diff_slider.value(),
2237 }
2238 if "Automatic drawing parameters" in origin_dict:
2239 origin_dict["Automatic drawing parameters"].append(new_params)
2240 else:
2241 origin_dict["Automatic drawing parameters"] = [new_params]
2243 log.debug("Start thread")
2244 # Start threaded save operation
2245 self.threads.append(SaveNiftiThread(total_ROI, self.affine,
2246 full_save_path, json_save_path,
2247 relative_path, origin_dict))
2248 self.threads[-1].success.connect(self._on_ROI_saved)
2249 self.threads[-1].error.connect(self._on_ROI_saving_error)
2250 self.threads[-1].start()
2252 def _on_ROI_saved(self, path, json_path):
2253 """Callback executed when ROI save completes successfully"""
2254 QMessageBox.information(self,
2255 "ROI Saved",
2256 f"ROI saved in:{path} and metadata saved in:{json_path} successfully!")
2257 log.info(f"ROI saved in:{path} and metadata saved in:{json_path} successfully!")
2259 def _on_ROI_saving_error(self, error):
2260 """Callback executed when ROI saving fails"""
2261 QMessageBox.critical(
2262 self,
2263 "Error when saving ROI",
2264 f"Error when saving: {error}"
2265 )
2266 log.critical(f"Error when saving ROI: {error}")
2268 def _get_next_file_id(self,folder_path):
2269 """
2270 Generates the next available numeric ID based on the files present in the folder.
2272 Looks for files with names matching the pattern 'something_idNUM_other.extension'
2273 (where NUM is an integer), and returns the next available number.
2275 Args:
2276 folder_path (str): Path to the folder to scan.
2278 Returns:
2279 int: The next available numeric ID.
2280 """
2281 pattern = re.compile(r"_v(\d+)_")
2282 ids = []
2283 log.debug("Searching versions")
2284 if os.path.exists(folder_path):
2285 for name in os.listdir(folder_path):
2286 if os.path.isfile(os.path.join(folder_path, name)):
2287 match = pattern.search(name)
2288 if match:
2289 ids.append(int(match.group(1)))
2291 next_id = max(ids) + 1 if ids else 1
2292 log.debug(f"Next v: {next_id}")
2293 return next_id
2294 else:
2295 return 1
2296 def addOrigin_clicked(self):
2297 self.incrementalROI_data = self.incrementalROI_data if self.incrementalROI_data is not None else np.zeros(self.dims)
2299 if self.automaticROI_overlay and self.automaticROI_data is not None:
2300 self.incrementalROI_data = np.logical_or(self.incrementalROI_data, self.automaticROI_data).astype(np.uint8)
2302 #if self.overlay_enabled and self.overlay_data is not None:
2303 # self.incrementalROI_data = np.logical_or(self.incrementalROI_data, self.overlay_data).astype(np.uint8)
2304 self.incrementalROI_checkbox.setVisible(True)
2305 self.incrementalROI_checkbox.setEnabled(True)
2306 self.toggle_incrementalROI(True)
2308 new_params = {
2309 "Seed": list(self.automaticROI_seed_coordinates),
2310 "Radius": self.automaticROI_radius_slider.value(),
2311 "Difference": self.automaticROI_diff_slider.value(),
2312 }
2314 # Mantieni lista incrementale
2315 self.incrementalROI_origins.append(new_params)
2317 def toggle_incrementalROI(self,enabled,update_all=True):
2319 self.incrementalROI_enabled = enabled
2320 self.incrementalROI_checkbox.setChecked(enabled)
2322 # Enable or disable overlay-related UI controls
2323 self.overlay_alpha_slider.setEnabled(enabled or self.automaticROI_overlay or self.overlay_enabled)
2324 self.overlay_alpha_spin.setEnabled(enabled or self.automaticROI_overlay or self.overlay_enabled)
2325 self.overlay_threshold_slider.setEnabled(enabled or self.automaticROI_overlay or self.overlay_enabled)
2326 self.overlay_threshold_spin.setEnabled(enabled or self.automaticROI_overlay or self.overlay_enabled)
2327 self.ROI_save_btn.setEnabled(enabled or self.automaticROI_overlay or self.overlay_enabled)
2329 if update_all:
2330 # Redraw display if overlay data is available
2331 self.update_all_displays()
2333 # Update time series plot if available
2334 self.update_time_series_plot()
2336 def closeEvent(self, event):
2337 """Clean up on application exit"""
2338 # Stop and delete all active threads
2339 if hasattr(self, 'threads'):
2340 for t in self.threads:
2341 if t.isRunning():
2342 t.terminate()
2343 t.wait()
2344 t.deleteLater()
2345 self.threads.clear()
2347 # Clear large data arrays to release memory
2348 self.img_data = None
2349 self.overlay_data = None
2351 # Trigger garbage collection
2352 gc.collect()
2354 # Accept the close event to exit
2355 event.accept()
2357 def reset_overlay(self):
2358 """Reset all overlay-related UI elements and internal variables."""
2359 # Disable the automatic ROI overlay mode
2360 self.automaticROI_overlay = False
2361 # Disable the "Save ROI" button since there’s no active overlay
2362 self.ROI_save_btn.setEnabled(False)
2363 # Clear all overlay-related data
2364 self.automaticROI_data = None
2365 self.overlay_data = None
2366 self.overlay_dims = None
2367 self.overlay_file_path = None
2368 # Hide and disable the overlay parameter sliders group (radius/difference)
2369 self.automaticROI_sliders_group.setVisible(False)
2370 self.automaticROI_sliders_group.setEnabled(False)
2371 # Ensure overlay visualization is turned off
2372 self.toggle_overlay(False,update_all=False)
2373 # Disable and uncheck the overlay checkbox in UI
2374 self.overlay_checkbox.setChecked(False)
2375 self.overlay_checkbox.setEnabled(False)
2376 # Reset overlay info label to default text
2377 self.overlay_info_label.setText(
2378 f"Overlay:\n" +
2379 QtCore.QCoreApplication.translate("NIfTIViewer", "Dimensions")
2380 )
2382 def resetROI(self):
2383 """Reset all ROI-related UI elements and internal variables."""
2384 self.ROI_save_btn.setEnabled(False)
2385 self.cancelROI_btn.setEnabled(False)
2386 self.addOrigin_btn.setEnabled(False)
2388 self.toggle_incrementalROI(False,update_all=False)
2389 self.incrementalROI_checkbox.setEnabled(False)
2390 self.incrementalROI_checkbox.setVisible(False)
2392 self.toggle_automaticROI(False,update_all=False)
2393 self.automaticROI_checkbox.setEnabled(False)
2394 self.automaticROI_checkbox.setVisible(False)
2396 self.incrementalROI_data = None
2397 self.automaticROI_data = None
2399 self.automaticROI_sliders_group.setVisible(False)
2400 self.automaticROI_sliders_group.setEnabled(False)
2402 self.automaticROIbtn.setText(QtCore.QCoreApplication.translate("NIfTIViewer", "Automatic ROI"))
2404 self.automaticROIbtn.setEnabled(True)
2406 self.incrementalROI_origins = []
2410 def pad_volume_to_shape(self, volume, target_shape, constant_value=0):
2411 """
2412 Pad a 3D volume (NumPy array) symmetrically to match a desired target shape.
2414 Args:
2415 volume (np.ndarray): Input 3D volume to pad.
2416 target_shape (tuple[int]): Desired (X, Y, Z) dimensions after padding.
2417 constant_value (int or float, optional): Value used for padding. Defaults to 0.
2419 Returns:
2420 np.ndarray: The padded 3D array with dimensions matching target_shape.
2421 """
2422 current_shape = volume.shape
2423 pads = [] # Store pad widths for each axis
2425 # Compute symmetric padding for each axis
2426 for cur, tgt in zip(current_shape, target_shape):
2427 diff = max(tgt - cur, 0) # Calculate missing voxels along this axis
2428 pad_before = diff // 2 # Padding before the data
2429 pad_after = diff - pad_before # Padding after the data
2430 pads.append((pad_before, pad_after)) # Add as (before, after) pair
2432 # Apply constant padding and return result
2433 return np.pad(volume, pads, mode="constant", constant_values=constant_value)
2435 def handle_scroll(self,plane_idx,delta):
2436 if plane_idx == 0: # Axial (XY plane)
2437 if delta>0:
2438 self.current_coordinates[2] = min(self.current_coordinates[2] + delta,self.dims[2]-1)
2439 self.current_slices[0]= min(self.current_slices[0] + delta,self.dims[2]-1)
2440 elif delta<0:
2441 self.current_coordinates[2] = max(self.current_coordinates[2] + delta,0)
2442 self.current_slices[0] = max(self.current_slices[0] + delta,0)
2443 else:
2444 return
2445 elif plane_idx == 1: # Coronal (XZ plane)
2446 if delta>0:
2447 self.current_coordinates[1] = min(self.current_coordinates[1] + delta,self.dims[1]-1)
2448 self.current_slices[1] = min(self.current_slices[1] + delta,self.dims[1]-1)
2449 elif delta<0:
2450 self.current_coordinates[1] = max(self.current_coordinates[1] + delta,0)
2451 self.current_slices[1] = max(self.current_slices[1] + delta,0)
2452 else:
2453 return
2454 elif plane_idx == 2: # Sagittal (YZ plane)
2455 if delta>0:
2456 self.current_coordinates[0] = min(self.current_coordinates[0] + delta,self.dims[0]-1)
2457 self.current_slices[2] = min(self.current_slices[2] + delta,self.dims[0]-1)
2458 elif delta<0:
2459 self.current_coordinates[0] = max(self.current_coordinates[0] + delta,0)
2460 self.current_slices[2] = max(self.current_slices[2] + delta,0)
2461 else:
2462 return
2463 else:
2464 log.error("Plane index out of range")
2465 return
2466 # Synchronize controls for all planes
2467 for i in range(3):
2468 self.slice_sliders[i].setValue(self.current_slices[i])
2469 self.slice_spins[i].setValue(self.current_slices[i])
2470 self.update_cross_view_lines()
2471 self.update_coordinate_displays()
2474 def _translate_ui(self):
2475 """
2476 Update all UI texts for internationalization (i18n).
2478 This function ensures that all interface elements are dynamically translated
2479 using Qt’s translation system. It is typically called when initializing
2480 or changing the application language.
2481 """
2482 # Set the main window title
2483 self.setWindowTitle(QtCore.QCoreApplication.translate("NIfTIViewer", "NIfTI Image Viewer"))
2485 # Status bar initial message
2486 self.status_bar.showMessage(
2487 QtCore.QCoreApplication.translate("NIfTIViewer", "Ready - Open a NIfTI file to begin"))
2489 # Initialize coordinate and value display labels
2490 self.coord_label.setText(QtCore.QCoreApplication.translate("NIfTIViewer", "Coordinates: (-, -, -)"))
2491 self.value_label.setText(QtCore.QCoreApplication.translate("NIfTIViewer", "Value: -"))
2492 self.slice_info_label.setText(QtCore.QCoreApplication.translate("NIfTIViewer", "Slice: -/-"))
2494 # File open button label
2495 self.open_btn.setText(QtCore.QCoreApplication.translate("NIfTIViewer", "📁 Open NIfTI File"))
2497 # Default file information message
2498 self.file_info_label.setText(QtCore.QCoreApplication.translate("NIfTIViewer", "No file loaded"))
2500 # Plane labels for orthogonal slice views
2501 plane_names = [
2502 QtCore.QCoreApplication.translate("NIfTIViewer", "Axial (Z)"),
2503 QtCore.QCoreApplication.translate("NIfTIViewer", "Coronal (Y)"),
2504 QtCore.QCoreApplication.translate("NIfTIViewer", "Sagittal (X)")
2505 ]
2506 for i, name in enumerate(plane_names):
2507 self.plane_labels[i].setText(QtCore.QCoreApplication.translate("NIfTIViewer", name))
2509 # Time navigation controls
2510 self.time_checkbox.setText(QtCore.QCoreApplication.translate("NIfTIViewer", "Enable 4D Time Navigation"))
2511 self.time_point_label.setText(QtCore.QCoreApplication.translate("NIfTIViewer", "Time Point:"))
2513 # Display options section label
2514 self.display_options_label.setText(QtCore.QCoreApplication.translate("NIfTIViewer", "Display Options:"))
2516 # Populate color map combo box options
2517 colormap_names = ['gray', 'viridis', 'plasma', 'inferno', 'magma', 'hot', 'cool', 'bone']
2518 for i, name in enumerate(colormap_names):
2519 self.colormap_combo.setItemText(i, name)
2521 # Label for colormap and overlay control sections
2522 self.colormap_label.setText(QtCore.QCoreApplication.translate("NIfTIViewer", "Colormap:"))
2523 self.overlay_control_label.setText(QtCore.QCoreApplication.translate("NIfTIViewer", "Overlay Controls:"))
2525 # Overlay loading and visibility controls
2526 self.overlay_btn.setText(QtCore.QCoreApplication.translate("NIfTIViewer", "Load NIfTI Overlay"))
2527 self.overlay_checkbox.setText(QtCore.QCoreApplication.translate("NIfTIViewer", "Show Overlay"))
2528 self.alpha_overlay_label.setText(QtCore.QCoreApplication.translate("NIfTIViewer", "Overlay Transparency:"))
2529 self.overlay_threshold_label.setText(QtCore.QCoreApplication.translate("NIfTIViewer", "Overlay Threshold:"))
2530 self.overlay_info_label.setText(QtCore.QCoreApplication.translate("NIfTIViewer", "No overlay loaded"))
2532 # Titles for image view panels
2533 view_titles = [
2534 QtCore.QCoreApplication.translate("NIfTIViewer", "Axial"),
2535 QtCore.QCoreApplication.translate("NIfTIViewer", "Coronal"),
2536 QtCore.QCoreApplication.translate("NIfTIViewer", "Sagittal")
2537 ]
2538 for i, title in enumerate(view_titles):
2539 self.view_titles_labels[i].setText(title)
2541 # Update fourth view title and general info text
2542 self.fourth_title.setText(QtCore.QCoreApplication.translate("NIfTIViewer", self.fourth_title.text()))
2543 self.info_text.setText(QtCore.QCoreApplication.translate("NIfTIViewer", "No image loaded"))
2545 # If an image file is already loaded, update the displayed metadata
2546 if self.file_path:
2547 filename = os.path.basename(self.file_path)
2548 dims = self.dims
2549 # Distinguish between 3D and 4D datasets
2550 if self.is_4d:
2551 info_text = (
2552 QtCore.QCoreApplication.translate("NIfTIViewer", "File") + f": {filename}\n" +
2553 QtCore.QCoreApplication.translate("NIfTIViewer", "Dimensions") +
2554 f": {dims[0]}×{dims[1]}×{dims[2]}×{dims[3]}\n" +
2555 QtCore.QCoreApplication.translate("NIfTIViewer", "4D Time Series")
2556 )
2557 else:
2558 info_text = (
2559 QtCore.QCoreApplication.translate("NIfTIViewer", "File") + f": {filename}\n" +
2560 QtCore.QCoreApplication.translate("NIfTIViewer", "Dimensions") +
2561 f": {dims[0]}×{dims[1]}×{dims[2]}\n" +
2562 QtCore.QCoreApplication.translate("NIfTIViewer", "3D Volume")
2563 )
2565 # Update file info labels and sidebar text
2566 self.file_info_label.setText(info_text)
2567 self.info_text.setText(info_text)
2568 else:
2569 # Fallback text shown when no NIfTI file is loaded
2570 self.file_info_label.setText(QtCore.QCoreApplication.translate("NIfTIViewer", "No file loaded"))
2571 self.info_text.setText(QtCore.QCoreApplication.translate("NIfTIViewer", "No image loaded"))