Coverage for main/ui/nifti_viewer.py: 86%

1279 statements  

« prev     ^ index     » next       coverage.py v7.15.0, created at 2026-07-03 15:51 +0000

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"))