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