Coverage for main/ui/pipeline_patient_selection_page.py: 84%

454 statements  

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

1import json 

2import os 

3import glob 

4 

5from PyQt6 import QtGui 

6from PyQt6.QtGui import QFont 

7from PyQt6.QtWidgets import (QWidget, QVBoxLayout, QLabel, QPushButton, QScrollArea, 

8 QFrame, QGridLayout, QHBoxLayout, QSizePolicy) 

9from PyQt6.QtCore import Qt, QCoreApplication 

10 

11from ui.pipeline_review_page import PipelineReviewPage 

12from utils import resource_path 

13from page import Page 

14from logger import get_logger 

15 

16log = get_logger() 

17 

18 

19class PipelinePatientSelectionPage(Page): 

20 """ 

21 This class scans the workspace for patient directories, verifies if the required 

22 imaging files (FLAIR, skull-stripped, segmentation) are present, and presents 

23 a visual grid of patient cards. Users can select eligible patients to proceed 

24 to the pipeline configuration stage. 

25 

26 Attributes: 

27 context (dict): The application context containing shared data and state. 

28 workspace_path (str): The root directory path containing patient data. 

29 patient_buttons (dict): Maps patient IDs to their selection `QPushButton`. 

30 selected_patients (set): A set of strings containing IDs of currently selected patients. 

31 patient_status (dict): Stores eligibility status and missing file details for each patient. 

32 """ 

33 

34 def __init__(self, context=None, previous_page=None): 

35 """ 

36 Initializes the PipelinePatientSelectionPage. 

37 

38 Args: 

39 context (dict, optional): The shared application context. Defaults to None. 

40 previous_page (Page, optional): The page instance navigated from. Defaults to None. 

41 """ 

42 super().__init__() 

43 

44 self.context = context 

45 self.previous_page = previous_page 

46 self.next_page = None 

47 

48 # Path to the workspace directory containing patient data 

49 self.workspace_path = context["workspace_path"] 

50 

51 # Dictionaries to track patient widgets, selected patients, and their processing status 

52 self.patient_buttons = {} 

53 self.selected_patients = set() 

54 self.patient_status = {} # Stores eligibility and missing data for each patient 

55 

56 # Initialize the user interface 

57 self._setup_ui() 

58 

59 # Set translations (for multilingual UI) 

60 self._translate_ui() 

61 if context and "language_changed" in context: 

62 # Connect language change signal for dynamic translation 

63 context["language_changed"].connect(self._translate_ui) 

64 

65 def _setup_ui(self): 

66 """ 

67 Sets up the main layout and UI components. 

68 

69 This method initializes the title, the bulk selection control buttons, 

70 the scrollable area for the patient grid, and the statistical summary widget. 

71 """ 

72 self.layout = QVBoxLayout(self) 

73 

74 # --- Title --- 

75 self.title = QLabel("Select Patients for Pipeline Analysis") 

76 self.title.setFont(QFont("Arial", 18, QFont.Weight.Bold)) 

77 self.title.setAlignment(Qt.AlignmentFlag.AlignHCenter) 

78 self.layout.addWidget(self.title) 

79 

80 # --- Top buttons (bulk selection controls) --- 

81 top_buttons_layout = QHBoxLayout() 

82 

83 self.select_eligible_btn = QPushButton("Select All Eligible") 

84 self.deselect_all_btn = QPushButton("Deselect All") 

85 self.refresh_btn = QPushButton("Refresh Status") 

86 

87 self.buttons = [self.select_eligible_btn, self.deselect_all_btn, self.refresh_btn] 

88 

89 # Apply a consistent visual style to all buttons 

90 btn_style = """ 

91 QPushButton { 

92 background-color: #e0e0e0; 

93 padding: 10px 20px; 

94 border-radius: 10px; 

95 border: 1px solid #bdc3c7; 

96 font-weight: bold; 

97 margin: 2px; 

98 } 

99 QPushButton:hover { 

100 background-color: #d0d0d0; 

101 } 

102 QPushButton:disabled { 

103 background-color: #f0f0f0; 

104 color: #888888; 

105 } 

106 """ 

107 for btn in self.buttons: 

108 btn.setStyleSheet(btn_style) 

109 

110 # Connect buttons to actions 

111 self.select_eligible_btn.clicked.connect(self._select_all_eligible_patients) 

112 self.deselect_all_btn.clicked.connect(self._deselect_all_patients) 

113 self.refresh_btn.clicked.connect(self._refresh_patient_status) 

114 

115 # Add the buttons to layout with stretch for alignment 

116 top_buttons_layout.addStretch() 

117 top_buttons_layout.addWidget(self.select_eligible_btn) 

118 top_buttons_layout.addWidget(self.deselect_all_btn) 

119 top_buttons_layout.addWidget(self.refresh_btn) 

120 self.layout.addLayout(top_buttons_layout) 

121 

122 # --- Scroll area for displaying patients --- 

123 self.scroll_area = QScrollArea() 

124 self.scroll_area.setWidgetResizable(True) 

125 self.scroll_area.setStyleSheet(""" 

126 QScrollArea { 

127 font-size: 13px; 

128 border: 1px solid #CCCCCC; 

129 border-radius: 10px; 

130 padding: 5px; 

131 } 

132 """) 

133 

134 # Container widget inside scroll area (grid of patient cards) 

135 self.scroll_content = QWidget() 

136 self.grid_layout = QGridLayout(self.scroll_content) 

137 self.scroll_area.setWidget(self.scroll_content) 

138 

139 # --- Summary panel showing total and eligible counts --- 

140 self.summary_widget = self._create_summary_widget() 

141 

142 # Add widgets to the main layout 

143 self.layout.addWidget(self.summary_widget) 

144 self.layout.addWidget(self.scroll_area) 

145 

146 # Number of patient columns per row in grid 

147 self.column_count = 2 

148 

149 # Load patient data and build UI 

150 self._load_patients() 

151 

152 def _create_summary_widget(self): 

153 """ 

154 Creates a modern-style summary widget for displaying patient statistics. 

155 

156 Constructs a `QFrame` containing "pill" style cards that display the 

157 total number of patients, the number of eligible patients, and the 

158 number of ineligible patients. 

159 

160 Returns: 

161 QFrame: The constructed summary widget. 

162 """ 

163 summary_frame = QFrame() 

164 summary_frame.setObjectName("summaryCard") 

165 summary_frame.setStyleSheet(""" 

166 QFrame#summaryCard { 

167 border: 1px solid #e0e0e0; 

168 border-radius: 12px; 

169 background-color: #ffffff; 

170 padding: 0.1em; 

171 } 

172 """) 

173 

174 main_layout = QVBoxLayout(summary_frame) 

175 

176 # Section title 

177 self.title_summary = QLabel(QCoreApplication.translate("PipelinePatientSelectionPage", "Pipeline Requirements Summary")) 

178 self.title_summary.setAlignment(Qt.AlignmentFlag.AlignCenter) 

179 self.title_summary.setStyleSheet("font-size: 16px; font-weight: bold; color: #000000; margin-bottom: 10px;") 

180 main_layout.addWidget(self.title_summary) 

181 

182 # Horizontal layout for summary “pill” cards 

183 stats_layout = QHBoxLayout() 

184 stats_layout.setSpacing(15) 

185 

186 # Create pill cards showing totals 

187 self.total_label = self._create_stat_pill(resource_path("resources/icon_total.png"), 

188 QCoreApplication.translate("PipelinePatientSelectionPage", "Total Patients"), "0") 

189 self.eligible_label = self._create_stat_pill(resource_path("resources/icon_check.png"), 

190 QCoreApplication.translate("PipelinePatientSelectionPage", "Eligible"), "0", color="#27ae60") 

191 self.not_eligible_label = self._create_stat_pill(resource_path("resources/icon_cross.png"), 

192 QCoreApplication.translate("PipelinePatientSelectionPage", "Not Eligible"), "0", color="#c0392b") 

193 

194 # Add pills to layout 

195 for pill in [self.total_label, self.eligible_label, self.not_eligible_label]: 

196 pill.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) 

197 stats_layout.addWidget(pill) 

198 

199 main_layout.addLayout(stats_layout) 

200 

201 return summary_frame 

202 

203 def _create_stat_pill(self, icon_path, label_text, value_text, color="#000000"): 

204 """ 

205 Creates a card-like label with an icon, text, and numeric value. 

206 

207 Args: 

208 icon_path (str): Path to the icon image (currently unused in the simplified implementation). 

209 label_text (str): The description label (e.g., "Eligible"). 

210 value_text (str): The numeric value as a string. 

211 color (str, optional): Hex color code for the numeric value text. Defaults to "#000000". 

212 

213 Returns: 

214 QFrame: A frame widget representing the statistic pill. 

215 """ 

216 pill = QFrame() 

217 pill.setObjectName("summaryCard") 

218 pill.setStyleSheet(f""" 

219 QFrame#summaryCard {{ 

220 border: 1px solid #CCCCCC; 

221 border-radius: 10px; 

222 background-color: #f9f9f9; 

223 }} 

224 """) 

225 

226 layout = QVBoxLayout(pill) 

227 layout.setAlignment(Qt.AlignmentFlag.AlignCenter) 

228 

229 # Text label (e.g., “Total Patients”) 

230 label = QLabel(label_text) 

231 label.setStyleSheet("font-size: 13px; font-weight: bold;") 

232 label.setAlignment(Qt.AlignmentFlag.AlignCenter) 

233 

234 # Numeric value (e.g., “5”) 

235 value = QLabel(value_text) 

236 value.setStyleSheet(f"font-size: 20px; font-weight: bold; color: {color};") 

237 value.setAlignment(Qt.AlignmentFlag.AlignCenter) 

238 

239 layout.addWidget(label) 

240 layout.addWidget(value) 

241 

242 # Keep reference to labels for later updates 

243 pill.label = label 

244 pill.value_label = value 

245 

246 pill.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) 

247 pill.setMinimumHeight(50) 

248 pill.setMaximumHeight(120) 

249 

250 return pill 

251 

252 def _check_patient_requirements(self, patient_path, patient_id): 

253 """ 

254 Checks whether a patient meets all necessary pipeline file requirements. 

255 

256 It looks for the existence of: 

257 1. **FLAIR image**: `anat/*_flair.nii[.gz]` 

258 2. **Skull-stripped image**: `derivatives/skullstrips/.../anat/*_brain.nii[.gz]` 

259 3. **Segmentation/Mask**: `manual_masks` or `deep_learning_seg` 

260 

261 Args: 

262 patient_path (str): The absolute path to the patient's folder. 

263 patient_id (str): The unique identifier of the patient (e.g., 'sub-01'). 

264 

265 Returns: 

266 dict: A dictionary containing: 

267 - `eligible` (bool): True if all files are present. 

268 - `requirements` (dict): Boolean status for each file type ('flair', 'skull_stripping', 'segmentation'). 

269 - `missing_files` (list): List of translated strings describing missing components. 

270 - `segmentation_type` (str or None): The type of segmentation found ("Manual Mask" or "deep_learning_seg Segmentation"). 

271 """ 

272 # Each key corresponds to a necessary preprocessing step 

273 requirements = { 

274 'flair': False, 

275 'skull_stripping': False, 

276 'segmentation': False 

277 } 

278 

279 missing_files = [] # Collect missing components for display 

280 

281 # --- 1. Check for FLAIR anatomical image --- 

282 flair_patterns = [ 

283 os.path.join(patient_path, "anat", "*_flair.nii"), 

284 os.path.join(patient_path, "anat", "*_flair.nii.gz") 

285 ] 

286 

287 flair_found = False 

288 for pattern in flair_patterns: 

289 if glob.glob(pattern): # Search for matching files 

290 flair_found = True 

291 break 

292 

293 requirements['flair'] = flair_found 

294 if not flair_found: 

295 missing_files.append(QCoreApplication.translate( 

296 "PipelinePatientSelectionPage", "FLAIR image (anat/*_flair.nii[.gz])")) 

297 

298 # --- 2. Check for Skull-Stripped image --- 

299 skull_strip_patterns = [ 

300 os.path.join(self.workspace_path, "derivatives", "skullstrips", patient_id, "anat", "*_brain.nii"), 

301 os.path.join(self.workspace_path, "derivatives", "skullstrips", patient_id, "anat", "*_brain.nii.gz") 

302 ] 

303 

304 skull_strip_found = False 

305 for pattern in skull_strip_patterns: 

306 if glob.glob(pattern): 

307 skull_strip_found = True 

308 break 

309 

310 requirements['skull_stripping'] = skull_strip_found 

311 if not skull_strip_found: 

312 missing_files.append(QCoreApplication.translate( 

313 "PipelinePatientSelectionPage", "Skull stripped image (derivatives/skullstrips/.../anat/*_brain.nii[.gz])")) 

314 

315 # --- 3. Check for Segmentation or Manual Mask --- 

316 segmentation_patterns = [ 

317 # Manual masks 

318 os.path.join(self.workspace_path, "derivatives", "manual_masks", patient_id, "anat", "*_mask.nii"), 

319 os.path.join(self.workspace_path, "derivatives", "manual_masks", patient_id, "anat", "*_mask.nii.gz"), 

320 # Deep learning segmentation output 

321 os.path.join(self.workspace_path, "derivatives", "deep_learning_seg", patient_id, "anat", "*_seg.nii"), 

322 os.path.join(self.workspace_path, "derivatives", "deep_learning_seg", patient_id, "anat", "*_seg.nii.gz") 

323 ] 

324 

325 segmentation_found = False 

326 segmentation_type = None 

327 for pattern in segmentation_patterns: 

328 if glob.glob(pattern): 

329 segmentation_found = True 

330 # Identify segmentation source type 

331 if "manual_masks" in pattern: 

332 segmentation_type = "Manual Mask" 

333 elif "deep_learning_seg" in pattern: 

334 segmentation_type = "deep_learning_seg Segmentation" 

335 break 

336 

337 requirements['segmentation'] = segmentation_found 

338 if not segmentation_found: 

339 missing_files.append(QCoreApplication.translate( 

340 "PipelinePatientSelectionPage", "Segmentation (manual_masks/*_mask.nii[.gz] or deep_learning_seg /*_seg.nii[.gz])")) 

341 

342 # --- Final eligibility decision --- 

343 # Patient is eligible if all required files are present 

344 is_eligible = all(requirements.values()) 

345 

346 # Return structured summary of findings 

347 return { 

348 'eligible': is_eligible, 

349 'requirements': requirements, 

350 'missing_files': missing_files, 

351 'segmentation_type': segmentation_type 

352 } 

353 

354 def _load_patients(self): 

355 """ 

356 Loads patient directories and builds the UI grid. 

357 

358 This method: 

359 1. Identifies patient directories in the workspace. 

360 2. Verifies the eligibility of each patient via `_check_patient_requirements`. 

361 3. Populates the `grid_layout` with visual cards created by `_create_patient_frame`. 

362 4. Updates the summary statistics. 

363 """ 

364 # Find all patient folders (sub-*) 

365 patient_dirs = self._find_patient_dirs() 

366 patient_dirs.sort() # Sort alphabetically for visual consistency 

367 

368 # Clear the dictionaries of buttons and patient states 

369 self.patient_buttons.clear() 

370 self.patient_status.clear() 

371 

372 eligible_count = 0 

373 total_count = len(patient_dirs) 

374 

375 # Iterate over all found patients 

376 for i, patient_path in enumerate(patient_dirs): 

377 patient_id = os.path.basename(patient_path) 

378 

379 # Check the required conditions for each patient 

380 status = self._check_patient_requirements(patient_path, patient_id) 

381 self.patient_status[patient_id] = status 

382 

383 # Count eligible patients 

384 if status['eligible']: 

385 eligible_count += 1 

386 

387 # Create a card (frame) for the patient 

388 patient_frame = self._create_patient_frame(patient_id, patient_path, status) 

389 

390 # Insert the card into the grid layout 

391 self.grid_layout.addWidget(patient_frame, i // self.column_count, i % self.column_count) 

392 

393 # Update the statistical summary 

394 self._update_summary(eligible_count, total_count) 

395 

396 def _create_patient_frame(self, patient_id, patient_path, status): 

397 """ 

398 Creates a UI card (QFrame) representing a single patient. 

399 

400 The card displays: 

401 - Patient ID and user icon. 

402 - Eligibility status (Green checkmark or Red cross). 

403 - Detailed checklist of requirements (FLAIR, Skull Strip, Segmentation). 

404 - A "Select" button (disabled if the patient is ineligible). 

405 

406 Args: 

407 patient_id (str): The ID of the patient. 

408 patient_path (str): The file system path to the patient data. 

409 status (dict): The eligibility status dict returned by `_check_patient_requirements`. 

410 

411 Returns: 

412 QFrame: The constructed patient card widget. 

413 """ 

414 patient_frame = QFrame() 

415 patient_frame.setObjectName("patientCard") 

416 patient_frame.setMaximumHeight(140) # Card height limit 

417 patient_frame.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) 

418 

419 # Apply different visual style based on patient eligibility 

420 if status['eligible']: 

421 frame_style = """ 

422 QFrame#patientCard { 

423 border: 2px solid #4CAF50; 

424 border-radius: 10px; 

425 background-color: #f0fff0; 

426 padding: 10px; 

427 margin: 2px; 

428 } 

429 """ 

430 else: 

431 frame_style = """ 

432 QFrame#patientCard { 

433 border: 2px solid #f44336; 

434 border-radius: 10px; 

435 background-color: #fff0f0; 

436 padding: 10px; 

437 margin: 2px; 

438 } 

439 """ 

440 

441 patient_frame.setStyleSheet(frame_style) 

442 

443 # Main horizontal layout (left → right) 

444 patient_layout = QHBoxLayout(patient_frame) 

445 

446 # Left section: profile information 

447 profile_frame = QFrame() 

448 profile_frame.setFixedSize(150, 100) 

449 profile_layout = QVBoxLayout(profile_frame) 

450 profile_layout.setAlignment(Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter) 

451 

452 # User image (icon) 

453 image = QLabel() 

454 pixmap = QtGui.QPixmap(resource_path("resources/user.png")).scaled( 

455 30, 30, Qt.AspectRatioMode.KeepAspectRatio, 

456 Qt.TransformationMode.SmoothTransformation 

457 ) 

458 image.setPixmap(pixmap) 

459 image.setAlignment(Qt.AlignmentFlag.AlignCenter) 

460 

461 # Patient ID label 

462 patient_label = QLabel(f"{patient_id}") 

463 patient_label.setAlignment(Qt.AlignmentFlag.AlignCenter) 

464 patient_label.setStyleSheet("font-weight: bold; font-size: 12px;") 

465 

466 # Status label (Eligible / Not eligible) 

467 if status['eligible']: 

468 status_label = QLabel(QCoreApplication.translate("PipelinePatientSelectionPage", "✓ Ready for Pipeline")) 

469 status_label.setStyleSheet("color: #4CAF50; font-weight: bold; font-size: 10px;") 

470 else: 

471 status_label = QLabel(QCoreApplication.translate("PipelinePatientSelectionPage", "✗ Missing Requirements")) 

472 status_label.setStyleSheet("color: #f44336; font-weight: bold; font-size: 10px;") 

473 status_label.setAlignment(Qt.AlignmentFlag.AlignCenter) 

474 

475 # Add widgets to the profile layout 

476 profile_layout.addWidget(image) 

477 profile_layout.addWidget(patient_label) 

478 profile_layout.addWidget(status_label) 

479 

480 # Center section: requirement details 

481 details_frame = QFrame() 

482 details_layout = QVBoxLayout(details_frame) 

483 details_layout.setAlignment(Qt.AlignmentFlag.AlignVCenter) 

484 

485 # Status indicators for each required step 

486 req_indicators = [] 

487 req_labels = { 

488 'flair': 'FLAIR', 

489 'skull_stripping': 'Skull Strip', 

490 'segmentation': QCoreApplication.translate("PipelinePatientSelectionPage", 'Segmentation') 

491 } 

492 

493 # Display ✓ or ✗ next to each requirement 

494 for req, label in req_labels.items(): 

495 indicator = QLabel() 

496 if status['requirements'][req]: 

497 indicator.setText(f"✓ {label}") 

498 indicator.setStyleSheet("color: #4CAF50; font-size: 10px; padding: 1px;") 

499 else: 

500 indicator.setText(f"✗ {label}") 

501 indicator.setStyleSheet("color: #f44336; font-size: 10px; padding: 1px;") 

502 req_indicators.append(indicator) 

503 details_layout.addWidget(indicator) 

504 

505 # Show segmentation type if available 

506 if status['segmentation_type']: 

507 seg_type_label = QLabel(f"({status['segmentation_type']})") 

508 seg_type_label.setStyleSheet("color: #666666; font-size: 9px; font-style: italic;") 

509 details_layout.addWidget(seg_type_label) 

510 

511 # Right section: selection button 

512 button = QPushButton(QCoreApplication.translate("PipelinePatientSelectionPage", "Select")) 

513 button.setCheckable(True) # Allows to toggle selection 

514 

515 if status['eligible']: 

516 # Style for active (eligible) button 

517 button.setStyleSheet(""" 

518 QPushButton { 

519 border-radius: 12px; 

520 padding: 8px 16px; 

521 background-color: #DADADA; 

522 font-weight: bold; 

523 min-width: 80px; 

524 } 

525 QPushButton:checked { 

526 background-color: #4CAF50; 

527 color: white; 

528 } 

529 QPushButton:hover { 

530 background-color: #c0c0c0; 

531 } 

532 QPushButton:checked:hover { 

533 background-color: #45a049; 

534 } 

535 """) 

536 

537 # Restore previous selection if it exists 

538 is_selected = patient_id in self.selected_patients 

539 button.setChecked(is_selected) 

540 button.setText( 

541 QCoreApplication.translate("PipelinePatientSelectionPage", "Selected") 

542 if is_selected else QCoreApplication.translate("PipelinePatientSelectionPage", "Select") 

543 ) 

544 

545 # Connect button click to selection handler 

546 button.clicked.connect(lambda checked, pid=patient_id, btn=button: self._toggle_patient(pid, checked, btn)) 

547 else: 

548 # If not eligible, disable the button 

549 button.setText(QCoreApplication.translate("PipelinePatientSelectionPage", "Not Eligible")) 

550 button.setEnabled(False) 

551 button.setStyleSheet(""" 

552 QPushButton { 

553 border-radius: 12px; 

554 padding: 8px 16px; 

555 background-color: #f0f0f0; 

556 color: #888888; 

557 font-weight: bold; 

558 min-width: 80px; 

559 } 

560 """) 

561 

562 # Save reference of the button in the dictionary 

563 self.patient_buttons[patient_id] = button 

564 

565 # Assemble all sections into the main layout 

566 patient_layout.addWidget(profile_frame) 

567 patient_layout.addWidget(details_frame) 

568 patient_layout.addStretch() # Flexible space 

569 patient_layout.addWidget(button) 

570 

571 return patient_frame 

572 

573 def _update_summary(self, eligible_count, total_count): 

574 """ 

575 Updates the numeric values in the summary widget. 

576 

577 Args: 

578 eligible_count (int): Number of patients who met all requirements. 

579 total_count (int): Total number of patients found. 

580 """ 

581 self.total_label.value_label.setText(str(total_count)) 

582 self.eligible_label.value_label.setText(str(eligible_count)) 

583 self.not_eligible_label.value_label.setText(str(total_count - eligible_count)) 

584 

585 def _select_all_eligible_patients(self): 

586 """ 

587 Action handler: Automatically selects all patients marked as eligible. 

588 Updates the main application buttons after selection. 

589 """ 

590 for patient_id, button in self.patient_buttons.items(): 

591 if self.patient_status[patient_id]['eligible'] and not button.isChecked(): 

592 button.setChecked(True) 

593 button.setText(QCoreApplication.translate("PipelinePatientSelectionPage", "Selected")) 

594 self.selected_patients.add(patient_id) 

595 # Update main app buttons 

596 if self.context and "update_main_buttons" in self.context: 

597 self.context["update_main_buttons"]() 

598 

599 def _deselect_all_patients(self): 

600 """ 

601 Action handler: Deselects all currently selected patients. 

602 Updates the main application buttons after deselection. 

603 """ 

604 for patient_id, button in self.patient_buttons.items(): 

605 if button.isChecked() and button.isEnabled(): 

606 button.setChecked(False) 

607 button.setText(QCoreApplication.translate("PipelinePatientSelectionPage", "Select")) 

608 self.selected_patients.discard(patient_id) 

609 # Update main buttons 

610 if self.context and "update_main_buttons" in self.context: 

611 self.context["update_main_buttons"]() 

612 

613 def _refresh_patient_status(self): 

614 """ 

615 Action handler: Reloads all patients and re-verifies their status. 

616 

617 This is useful if files have been added to the workspace while the 

618 application was open. It attempts to preserve existing selections if 

619 the patient remains eligible. 

620 """ 

621 # Save current selections 

622 current_selections = self.selected_patients.copy() 

623 

624 # Remove existing widgets from the grid 

625 while self.grid_layout.count(): 

626 item = self.grid_layout.takeAt(0) 

627 widget = item.widget() 

628 if widget: 

629 widget.setParent(None) 

630 widget.deleteLater() 

631 

632 # Reload patients and update status 

633 self._load_patients() 

634 

635 # Restore still-valid selections 

636 valid_selections = set() 

637 for patient_id in current_selections: 

638 if (patient_id in self.patient_status and 

639 self.patient_status[patient_id]['eligible'] and 

640 patient_id in self.patient_buttons): 

641 valid_selections.add(patient_id) 

642 button = self.patient_buttons[patient_id] 

643 button.setChecked(True) 

644 button.setText(QCoreApplication.translate("PipelinePatientSelectionPage", "Selected")) 

645 

646 self.selected_patients = valid_selections 

647 

648 def _toggle_patient(self, patient_id, is_selected, button): 

649 """ 

650 Toggles the selection status of a single patient. 

651 

652 Args: 

653 patient_id (str): The ID of the patient. 

654 is_selected (bool): The new state (True for selected). 

655 button (QPushButton): The button widget triggering the event. 

656 """ 

657 if is_selected: 

658 self.selected_patients.add(patient_id) 

659 button.setText(QCoreApplication.translate("PipelinePatientSelectionPage", "Selected")) 

660 else: 

661 self.selected_patients.discard(patient_id) 

662 button.setText(QCoreApplication.translate("PipelinePatientSelectionPage", "Select")) 

663 # Update main buttons 

664 if self.context and "update_main_buttons" in self.context: 

665 self.context["update_main_buttons"]() 

666 

667 def _find_patient_dirs(self): 

668 """ 

669 Scans the workspace path for patient directories. 

670 

671 It looks for directories starting with "sub-" and excludes "derivatives" 

672 and "pipeline" directories. 

673 

674 Returns: 

675 list: A list of full paths to patient directories. 

676 """ 

677 patient_dirs = [] 

678 

679 for root, dirs, files in os.walk(self.workspace_path): 

680 # Exclude irrelevant folders 

681 if "derivatives" in dirs: 

682 dirs.remove("derivatives") 

683 if "pipeline" in dirs: 

684 dirs.remove("pipeline") 

685 

686 # Add directories starting with "sub-" 

687 for dir_name in dirs: 

688 if dir_name.startswith("sub-"): 

689 full_path = os.path.join(root, dir_name) 

690 patient_dirs.append(full_path) 

691 

692 # Prevent going deeper into sub-* directories 

693 dirs[:] = [d for d in dirs if not d.startswith("sub-")] 

694 

695 return patient_dirs 

696 

697 def _update_column_count(self): 

698 """ 

699 Recalculates the number of grid columns based on the available viewport width. 

700 

701 If the calculated column count differs from the current count, the grid 

702 is reloaded to adjust the layout. 

703 """ 

704 available_width = self.scroll_area.viewport().width() - 40 

705 min_card_width = 400 # Minimum width for a card 

706 

707 new_column_count = max(1, available_width // min_card_width) 

708 

709 # If column count changes, reload the grid 

710 if new_column_count != self.column_count: 

711 self.column_count = new_column_count 

712 self._reload_patient_grid() 

713 

714 def _reload_patient_grid(self): 

715 """ 

716 Rebuilds the patient grid (e.g., after a resize) while maintaining selections. 

717 """ 

718 selected = self.selected_patients.copy() 

719 

720 # Remove existing widgets 

721 while self.grid_layout.count(): 

722 item = self.grid_layout.takeAt(0) 

723 widget = item.widget() 

724 if widget: 

725 widget.setParent(None) 

726 widget.deleteLater() 

727 

728 # Reload patients in the new layout 

729 self._load_patients() 

730 

731 # Restore valid selections 

732 valid_selections = set() 

733 for patient_id in selected: 

734 if (patient_id in self.patient_status and 

735 self.patient_status[patient_id]['eligible'] and 

736 patient_id in self.patient_buttons): 

737 valid_selections.add(patient_id) 

738 button = self.patient_buttons[patient_id] 

739 button.setChecked(True) 

740 button.setText(QCoreApplication.translate("PipelinePatientSelectionPage", "Selected")) 

741 

742 self.selected_patients = valid_selections 

743 

744 def _build_pipeline_config(self): 

745 """ 

746 Generates and saves a JSON file containing the initial pipeline configuration. 

747 

748 This method iterates through selected patients, resolves relative paths for 

749 all required files (MRI, Skull-strip, PET, Masks), and detects ambiguities 

750 (multiple files found) which are flagged as `need_revision`. 

751 

752 The configuration is saved to `workspace/pipeline/XX_config.json`, where XX 

753 is an auto-incrementing sequence number. 

754 

755 Returns: 

756 str: The full path to the generated JSON configuration file. 

757 """ 

758 config = {} # Main dictionary that will store configuration data for all patients 

759 

760 # Iterate over all selected patients 

761 for patient_id in self.selected_patients: 

762 patient_entry = {} # Holds the configuration for a single patient 

763 need_revision = False # Flag indicating if manual review is required due to ambiguities 

764 

765 # ---------------------------------------------------- 

766 # MRI (FLAIR) 

767 # ---------------------------------------------------- 

768 flair_patterns = [ 

769 os.path.join(self.workspace_path, patient_id, "anat", "*_flair.nii"), 

770 os.path.join(self.workspace_path, patient_id, "anat", "*_flair.nii.gz") 

771 ] 

772 flair_files = [] 

773 # Search for FLAIR MRI files matching the defined patterns 

774 for p in flair_patterns: 

775 flair_files.extend(glob.glob(p)) 

776 # If more than one FLAIR file exists, mark for manual revision 

777 if len(flair_files) > 1: 

778 need_revision = True 

779 # Store the relative path of the FLAIR file if found 

780 patient_entry["mri"] = os.path.relpath(flair_files[0], self.workspace_path) if flair_files else None 

781 

782 # ---------------------------------------------------- 

783 # MRI Skull Stripped 

784 # ---------------------------------------------------- 

785 mri_str_patterns = [ 

786 os.path.join(self.workspace_path, "derivatives", "skullstrips", patient_id, "anat", "*_brain.nii"), 

787 os.path.join(self.workspace_path, "derivatives", "skullstrips", patient_id, "anat", "*_brain.nii.gz") 

788 ] 

789 mri_str_files = [] 

790 # Search for preprocessed (skull-stripped) MRI files 

791 for p in mri_str_patterns: 

792 mri_str_files.extend(glob.glob(p)) 

793 if len(mri_str_files) > 1: 

794 need_revision = True 

795 patient_entry["mri_str"] = os.path.relpath(mri_str_files[0], self.workspace_path) if mri_str_files else None 

796 

797 # ---------------------------------------------------- 

798 # Static PET (Session 01) 

799 # ---------------------------------------------------- 

800 pet_patterns = [ 

801 os.path.join(self.workspace_path, patient_id, "ses-01", "pet", "*_pet.nii"), 

802 os.path.join(self.workspace_path, patient_id, "ses-01", "pet", "*_pet.nii.gz") 

803 ] 

804 pet_files = [] 

805 # Locate static PET scans 

806 for p in pet_patterns: 

807 pet_files.extend(glob.glob(p)) 

808 if len(pet_files) > 1: 

809 need_revision = True 

810 patient_entry["pet"] = os.path.relpath(pet_files[0], self.workspace_path) if pet_files else None 

811 

812 # ---------------------------------------------------- 

813 # Dynamic PET (4D, optional) 

814 # ---------------------------------------------------- 

815 pet4d_patterns = [ 

816 os.path.join(self.workspace_path, patient_id, "ses-02", "pet", "*_pet.nii"), 

817 os.path.join(self.workspace_path, patient_id, "ses-02", "pet", "*_pet.nii.gz") 

818 ] 

819 pet4d_files = [] 

820 # Locate dynamic PET files (4D data) 

821 for p in pet4d_patterns: 

822 pet4d_files.extend(glob.glob(p)) 

823 if len(pet4d_files) > 1: 

824 need_revision = True 

825 pet4d_file = pet4d_files[0] if pet4d_files else None 

826 patient_entry["pet4d"] = os.path.relpath(pet4d_files[0], self.workspace_path) if pet4d_files else None 

827 

828 # ---------------------------------------------------- 

829 # PET JSON sidecar (metadata for dynamic PET) 

830 # ---------------------------------------------------- 

831 pet_json_file = None 

832 if pet4d_file: 

833 # Derive the expected .json filename from the PET file prefix 

834 basename = os.path.basename(pet4d_file) 

835 stem_no_ext = basename.split('.')[0] 

836 candidate = os.path.join(os.path.dirname(pet4d_file), stem_no_ext + '.json') 

837 if os.path.exists(candidate): 

838 pet_json_file = candidate 

839 # Store relative path to PET JSON file if it exists 

840 patient_entry["pet4d_json"] = os.path.relpath(pet_json_file, self.workspace_path) if pet_json_file else None 

841 

842 # ---------------------------------------------------- 

843 # Tumor MRI Mask (manual or automated) 

844 # ---------------------------------------------------- 

845 tumor_patterns = [ 

846 # Manual segmentation masks 

847 os.path.join(self.workspace_path, "derivatives", "manual_masks", patient_id, "anat", "*_mask.nii"), 

848 os.path.join(self.workspace_path, "derivatives", "manual_masks", patient_id, "anat", "*_mask.nii.gz"), 

849 # Deep learning-based segmentations 

850 os.path.join(self.workspace_path, "derivatives", "deep_learning_seg", patient_id, "anat", 

851 "*_seg.nii"), 

852 os.path.join(self.workspace_path, "derivatives", "deep_learning_seg", patient_id, "anat", 

853 "*_seg.nii.gz") 

854 ] 

855 tumor_files = [] 

856 # Search for tumor mask files 

857 for p in tumor_patterns: 

858 tumor_files.extend(glob.glob(p)) 

859 if len(tumor_files) > 1: 

860 need_revision = True 

861 patient_entry["tumor_mri"] = os.path.relpath(tumor_files[0], self.workspace_path) if tumor_files else None 

862 

863 # Add the final review flag to indicate if manual review is needed 

864 patient_entry["need_revision"] = need_revision 

865 

866 # Store the patient's configuration in the main config dictionary 

867 config[patient_id] = patient_entry 

868 

869 # ---------------------------------------------------- 

870 # Create and save the pipeline configuration JSON file 

871 # ---------------------------------------------------- 

872 pipeline_dir = os.path.join(self.workspace_path, "pipeline") 

873 

874 # Create the 'pipeline' directory if it doesn't exist 

875 if not os.path.exists(pipeline_dir): 

876 os.makedirs(pipeline_dir) 

877 log.info(f"Created pipeline directory: {pipeline_dir}") 

878 

879 # Determine the next sequential ID number (e.g., 01, 02, 03, ...) 

880 config_id = self._get_next_config_id(pipeline_dir) 

881 

882 # Build the filename using the sequential ID 

883 filename = f"{config_id:02d}_config.json" 

884 output_path = os.path.join(pipeline_dir, filename) 

885 

886 # Save the configuration dictionary as a formatted JSON file 

887 with open(output_path, "w", encoding="utf-8") as f: 

888 json.dump(config, f, indent=4) 

889 

890 # Log the output path for reference 

891 log.info(f"Pipeline configuration saved to: {output_path}") 

892 

893 # Return the full path to the generated configuration file 

894 return output_path 

895 

896 def _get_next_config_id(self, pipeline_dir): 

897 """ 

898 Finds the next available sequential numeric ID for configuration files. 

899 

900 This method scans the pipeline directory for existing configuration files 

901 matching `*_config.json`, extracts their numeric prefixes, and determines 

902 the next sequence number. 

903 

904 Args: 

905 pipeline_dir (str): Path to the pipeline directory where config files are stored. 

906 

907 Returns: 

908 int: The next available numeric ID for a new configuration file. 

909 """ 

910 # Pattern used to search for existing pipeline configuration files 

911 config_pattern = os.path.join(pipeline_dir, "*_config.json") 

912 existing_configs = glob.glob(config_pattern) 

913 

914 # If no config files exist, start numbering from 1 

915 if not existing_configs: 

916 return 1 

917 

918 existing_ids = [] # Stores all numeric IDs extracted from existing filenames 

919 

920 # Iterate through all existing config files to extract their numeric IDs 

921 for config_file in existing_configs: 

922 filename = os.path.basename(config_file) # Example: "12_config.json" 

923 try: 

924 # Extract the numeric part before "_config.json" 

925 id_str = filename.split("_")[0] # → "12" 

926 config_id = int(id_str) # Convert to integer 

927 existing_ids.append(config_id) 

928 except (ValueError, IndexError): 

929 # Skip any files that don't follow the expected naming format 

930 continue 

931 

932 # If no valid numeric IDs were found, default to 1 

933 if not existing_ids: 

934 return 1 

935 

936 # Return the next sequential ID (maximum existing + 1) 

937 return max(existing_ids) + 1 

938 

939 def on_enter(self): 

940 """ 

941 Triggered when the application navigates to this page. 

942 Refreshes the patient status to ensure file availability is up to date. 

943 """ 

944 self._refresh_patient_status() 

945 

946 def is_ready_to_advance(self): 

947 """ 

948 Checks if the user can proceed to the next page. 

949 

950 Returns: 

951 bool: True if at least one patient has been selected, False otherwise. 

952 """ 

953 return len(self.selected_patients) > 0 

954 

955 def is_ready_to_go_back(self): 

956 """ 

957 Checks if the user can navigate back to the previous page. 

958 

959 Returns: 

960 bool: Always True for this page. 

961 """ 

962 return True 

963 

964 def next(self, context): 

965 """ 

966 Proceeds to the next page in the workflow. 

967 

968 This method builds the pipeline configuration file based on the selection 

969 and initializes the `PipelineReviewPage`. 

970 

971 Args: 

972 context (dict): The application context. 

973 

974 Returns: 

975 Page: The instance of the next page to be displayed. 

976 """ 

977 # Generate a new pipeline configuration file before continuing 

978 self._build_pipeline_config() 

979 

980 # If a next page already exists, reuse it 

981 if self.next_page: 

982 self.next_page.on_enter() 

983 return self.next_page 

984 else: 

985 # Otherwise, create a new review page and register it in navigation history 

986 self.next_page = PipelineReviewPage(context, self) 

987 self.context["history"].append(self.next_page) 

988 self.next_page.on_enter() 

989 return self.next_page 

990 

991 def back(self): 

992 """ 

993 Navigates back to the previous page. 

994 

995 Returns: 

996 Page: The previous page instance, or None if no previous page exists. 

997 """ 

998 if self.previous_page: 

999 self.previous_page.on_enter() 

1000 return self.previous_page 

1001 return None 

1002 

1003 def get_selected_patients(self): 

1004 """ 

1005 Retrieves the IDs of all currently selected patients. 

1006 

1007 Returns: 

1008 list: A list of strings representing patient IDs. 

1009 """ 

1010 return list(self.selected_patients) 

1011 

1012 def get_eligible_patients(self): 

1013 """ 

1014 Retrieves the IDs of all patients marked as eligible. 

1015 

1016 Returns: 

1017 list: A list of strings representing eligible patient IDs. 

1018 """ 

1019 return [pid for pid, status in self.patient_status.items() if status['eligible']] 

1020 

1021 def get_patient_status_summary(self): 

1022 """ 

1023 Calculates a statistical summary of patient eligibility. 

1024 

1025 Returns: 

1026 dict: A dictionary containing: 

1027 - 'total': Total number of patients scanned. 

1028 - 'eligible': Number of patients meeting requirements. 

1029 - 'selected': Number of currently selected patients. 

1030 - 'not_eligible': Number of patients missing requirements. 

1031 """ 

1032 total = len(self.patient_status) # Count total patients being tracked 

1033 eligible = len([s for s in self.patient_status.values() if s['eligible']]) # Count eligible ones 

1034 selected = len(self.selected_patients) # Count selected patients 

1035 

1036 # Return a dictionary summarizing all relevant counts 

1037 return { 

1038 'total': total, 

1039 'eligible': eligible, 

1040 'selected': selected, 

1041 'not_eligible': total - eligible 

1042 } 

1043 

1044 def reset_page(self): 

1045 """ 

1046 Resets the page to its initial state. 

1047 

1048 This clears the grid, selections, and internal status data, then re-scans 

1049 the workspace for patients. 

1050 """ 

1051 # Clear all widgets from the grid layout 

1052 while self.grid_layout.count(): 

1053 item = self.grid_layout.takeAt(0) 

1054 widget = item.widget() 

1055 if widget is not None: 

1056 widget.setParent(None) # Detach widget from layout 

1057 widget.deleteLater() # Schedule for deletion 

1058 

1059 # Reset all patient-related states 

1060 self.selected_patients.clear() # Clear selected patients 

1061 self.patient_buttons.clear() # Clear patient button references 

1062 self.patient_status.clear() # Clear patient status dictionary 

1063 

1064 # Reload all patient data from the workspace 

1065 self._load_patients() 

1066 

1067 def resizeEvent(self, event): 

1068 """ 

1069 Handles window resize events to make the UI responsive. 

1070 

1071 Adjusts the number of columns in the grid, font sizes, and button padding 

1072 based on the window height and width. 

1073 

1074 Args: 

1075 event (QResizeEvent): The resize event. 

1076 """ 

1077 super().resizeEvent(event) 

1078 

1079 # Update column count dynamically based on width 

1080 self._update_column_count() 

1081 

1082 height = self.height() # Current window height 

1083 

1084 # Adjust UI style dynamically for small or large window height 

1085 if height < 500: 

1086 # Compact layout for small windows 

1087 self.title.setStyleSheet("font-size: 14px; font-weight: bold; margin-bottom: 5px;") 

1088 btn_padding = "5px 10px" 

1089 font_size_btn = 11 

1090 max_pill_height = 70 

1091 font_size_title = 12 

1092 font_size_value = 14 

1093 font_size_label = 10 

1094 max_pill_height = 60 

1095 else: 

1096 # Normal layout for larger windows 

1097 self.title.setStyleSheet("font-size: 18px; font-weight: bold; margin-bottom: 10px;") 

1098 btn_padding = "10px 20px" 

1099 font_size_btn = 13 

1100 font_size_title = 16 

1101 font_size_value = 20 

1102 font_size_label = 13 

1103 max_pill_height = 100 

1104 

1105 # Update button styles dynamically 

1106 for btn in self.buttons: 

1107 btn.setStyleSheet(f""" 

1108 QPushButton {{ 

1109 background-color: #e0e0e0; 

1110 padding: {btn_padding}; 

1111 border-radius: 10px; 

1112 border: 1px solid #bdc3c7; 

1113 font-weight: bold; 

1114 font-size: {font_size_btn}px; 

1115 margin: 2px; 

1116 }} 

1117 QPushButton:hover {{ 

1118 background-color: #d0d0d0; 

1119 }} 

1120 QPushButton:disabled {{ 

1121 background-color: #f0f0f0; 

1122 color: #888888; 

1123 }} 

1124 """) 

1125 

1126 # Update summary title styling 

1127 self.title_summary.setStyleSheet( 

1128 f"font-size: {font_size_title}px; font-weight: bold; color: #000000; margin-bottom: 8px;" 

1129 ) 

1130 

1131 # Update pill-style status indicators (total, eligible, not eligible) 

1132 for pill in [self.total_label, self.eligible_label, self.not_eligible_label]: 

1133 pill.label.setStyleSheet(f"font-size: {font_size_label}px; font-weight: bold;") 

1134 pill.value_label.setStyleSheet( 

1135 f"font-size: {font_size_value}px; font-weight: bold; color: {pill.value_label.palette().color(pill.value_label.foregroundRole()).name()};" 

1136 ) 

1137 pill.setMaximumHeight(max_pill_height) # Limit vertical space 

1138 

1139 def _translate_ui(self): 

1140 """ 

1141 Translates all UI text elements to the current language. 

1142 

1143 Updates labels and buttons with localized strings using Qt's translation system. 

1144 Triggers a patient list reload to refresh status texts within patient cards. 

1145 """ 

1146 # Set translated texts for main title and control buttons 

1147 self.title.setText( 

1148 QCoreApplication.translate("PipelinePatientSelectionPage", "Select Patients for Pipeline Analysis")) 

1149 self.select_eligible_btn.setText( 

1150 QCoreApplication.translate("PipelinePatientSelectionPage", "Select All Eligible")) 

1151 self.deselect_all_btn.setText(QCoreApplication.translate("PipelinePatientSelectionPage", "Deselect All")) 

1152 self.refresh_btn.setText(QCoreApplication.translate("PipelinePatientSelectionPage", "Refresh Status")) 

1153 self.title_summary.setText( 

1154 QCoreApplication.translate("PipelinePatientSelectionPage", "Pipeline Requirements Summary")) 

1155 

1156 # Set translated labels for patient status summary 

1157 self.total_label.label.setText(QCoreApplication.translate("PipelinePatientSelectionPage", "Total Patients")) 

1158 self.eligible_label.label.setText(QCoreApplication.translate("PipelinePatientSelectionPage", "Eligible")) 

1159 self.not_eligible_label.label.setText( 

1160 QCoreApplication.translate("PipelinePatientSelectionPage", "Not Eligible")) 

1161 

1162 # Reload patient list after updating UI text 

1163 self._load_patients()