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"))
log = <Logger GliAAns-UI (WARNING)>
@njit(parallel=True)
def compute_mask_numba_mm( img, x0, y0, z0, radius_mm, voxel_sizes, seed_intensity, diff, x_min, x_max, y_min, y_max, z_min, z_max):
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.

@njit(parallel=True)
def apply_overlay_numba(rgba_image, overlay_mask, overlay_intensity, overlay_color):
 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.

class NiftiViewer(PyQt6.QtWidgets.QMainWindow):
 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.

NiftiViewer(context=None)
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.

threads
context
progress_dialog
img_data
affine
dims
is_4d
current_slices
current_time
current_coordinates
file_path
stretch_factors
voxel_sizes
overlay_data
overlay_dims
overlay_alpha
overlay_threshold
overlay_enabled
overlay_file_path
overlay_max
overlay_thresholded_data
info_text
plane_labels
status_bar
slice_info_label
value_label
coord_label
colormap
overlay_colors
views
scenes
pixmap_items
slice_sliders
slice_spins
slice_labels
coord_displays
time_slider
time_spin
time_checkbox
time_plot_figure
time_plot_canvas
file_info_label
slice_navigation_label
time_point_label
colormap_combo
overlay_threshold_slider
display_options_label
overlay_alpha_slider
overlay_info_label
automaticROI_data
automatic_ROI_label
automaticROIbtn
automaticROI_overlay
automaticROI_radius_slider
automaticROI_radius_label
automaticROI_diff_label
AutomaticROI_diff_slider
automaticROI_sliders_group
automaticROI_seed_coordinates
ROI_save_btn
incrementalROI_data
incrementalROI_checkbox
incrementalROI_enabled
addOrigin_btn
cancelROI_btn
incrementalROI_origins
def init_ui(self):
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.

def create_control_panel(self, parent):
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.

def format_info_text(self, text, max_line_length=35):
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.

def create_image_display(self, parent):
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.

def setup_crosshairs(self):
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

def setup_connections(self):
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

def show_workspace_nii_dialog(self, is_overlay=False):
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.

def open_file(self, file_path=None, is_overlay=False):
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.

def on_file_loaded(self, img_data, dims, affine, is_4d, is_overlay):
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

def on_load_error(self, error_message):
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

def on_load_canceled(self):
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

def initialize_display(self):
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

def toggle_overlay(self, enabled, update_all=True):
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

def update_overlay_alpha(self, value, update_all=True):
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.

def update_overlay_threshold(self, value, update_all=True):
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.

def update_overlay_settings(self, update_all=True):
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.

def slice_changed(self, plane_idx, value):
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.

def time_changed(self, value, update_all=True):
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.

def toggle_time_controls(self, enabled):
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.

def colormap_changed(self, colormap_name, update_all=True):
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.

def handle_click_coordinates(self, view_idx, x, y):
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.

def screen_to_image_coords(self, view_idx, x, y):
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.

def update_coordinates(self, view_idx, x, y):
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.

def update_coordinate_displays(self):
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.

def update_cross_view_lines(self):
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.

def update_display(self, plane_idx):
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

def setup_time_series_plot(self):
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

def hide_time_series_plot(self):
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

def update_time_series_plot(self):
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

def apply_colormap_matplotlib(self, data, colormap_name):
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

def update_all_displays(self):
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

def create_overlay_composite(self, rgba_image, overlay_slice, colormap):
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.

def resizeEvent(self, event: PyQt6.QtGui.QResizeEvent):
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

def fit_all_views(self):
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

def update_automaticROI(self):
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

def automaticROI_clicked(self):
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

def toggle_automaticROI(self, enabled, update_all=True):
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

def automaticROI_drawing(self):
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

def ROI_save(self):
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

def addOrigin_clicked(self):
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)
def toggle_incrementalROI(self, enabled, update_all=True):
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()
def closeEvent(self, event):
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

def reset_overlay(self):
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.

def resetROI(self):
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.

def pad_volume_to_shape(self, volume, target_shape, constant_value=0):
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.

def handle_scroll(self, plane_idx, delta):
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()