ui.pipeline_patient_selection_page
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()
20class PipelinePatientSelectionPage(Page): 21 """ 22 This class scans the workspace for patient directories, verifies if the required 23 imaging files (FLAIR, skull-stripped, segmentation) are present, and presents 24 a visual grid of patient cards. Users can select eligible patients to proceed 25 to the pipeline configuration stage. 26 27 Attributes: 28 context (dict): The application context containing shared data and state. 29 workspace_path (str): The root directory path containing patient data. 30 patient_buttons (dict): Maps patient IDs to their selection `QPushButton`. 31 selected_patients (set): A set of strings containing IDs of currently selected patients. 32 patient_status (dict): Stores eligibility status and missing file details for each patient. 33 """ 34 35 def __init__(self, context=None, previous_page=None): 36 """ 37 Initializes the PipelinePatientSelectionPage. 38 39 Args: 40 context (dict, optional): The shared application context. Defaults to None. 41 previous_page (Page, optional): The page instance navigated from. Defaults to None. 42 """ 43 super().__init__() 44 45 self.context = context 46 self.previous_page = previous_page 47 self.next_page = None 48 49 # Path to the workspace directory containing patient data 50 self.workspace_path = context["workspace_path"] 51 52 # Dictionaries to track patient widgets, selected patients, and their processing status 53 self.patient_buttons = {} 54 self.selected_patients = set() 55 self.patient_status = {} # Stores eligibility and missing data for each patient 56 57 # Initialize the user interface 58 self._setup_ui() 59 60 # Set translations (for multilingual UI) 61 self._translate_ui() 62 if context and "language_changed" in context: 63 # Connect language change signal for dynamic translation 64 context["language_changed"].connect(self._translate_ui) 65 66 def _setup_ui(self): 67 """ 68 Sets up the main layout and UI components. 69 70 This method initializes the title, the bulk selection control buttons, 71 the scrollable area for the patient grid, and the statistical summary widget. 72 """ 73 self.layout = QVBoxLayout(self) 74 75 # --- Title --- 76 self.title = QLabel("Select Patients for Pipeline Analysis") 77 self.title.setFont(QFont("Arial", 18, QFont.Weight.Bold)) 78 self.title.setAlignment(Qt.AlignmentFlag.AlignHCenter) 79 self.layout.addWidget(self.title) 80 81 # --- Top buttons (bulk selection controls) --- 82 top_buttons_layout = QHBoxLayout() 83 84 self.select_eligible_btn = QPushButton("Select All Eligible") 85 self.deselect_all_btn = QPushButton("Deselect All") 86 self.refresh_btn = QPushButton("Refresh Status") 87 88 self.buttons = [self.select_eligible_btn, self.deselect_all_btn, self.refresh_btn] 89 90 # Apply a consistent visual style to all buttons 91 btn_style = """ 92 QPushButton { 93 background-color: #e0e0e0; 94 padding: 10px 20px; 95 border-radius: 10px; 96 border: 1px solid #bdc3c7; 97 font-weight: bold; 98 margin: 2px; 99 } 100 QPushButton:hover { 101 background-color: #d0d0d0; 102 } 103 QPushButton:disabled { 104 background-color: #f0f0f0; 105 color: #888888; 106 } 107 """ 108 for btn in self.buttons: 109 btn.setStyleSheet(btn_style) 110 111 # Connect buttons to actions 112 self.select_eligible_btn.clicked.connect(self._select_all_eligible_patients) 113 self.deselect_all_btn.clicked.connect(self._deselect_all_patients) 114 self.refresh_btn.clicked.connect(self._refresh_patient_status) 115 116 # Add the buttons to layout with stretch for alignment 117 top_buttons_layout.addStretch() 118 top_buttons_layout.addWidget(self.select_eligible_btn) 119 top_buttons_layout.addWidget(self.deselect_all_btn) 120 top_buttons_layout.addWidget(self.refresh_btn) 121 self.layout.addLayout(top_buttons_layout) 122 123 # --- Scroll area for displaying patients --- 124 self.scroll_area = QScrollArea() 125 self.scroll_area.setWidgetResizable(True) 126 self.scroll_area.setStyleSheet(""" 127 QScrollArea { 128 font-size: 13px; 129 border: 1px solid #CCCCCC; 130 border-radius: 10px; 131 padding: 5px; 132 } 133 """) 134 135 # Container widget inside scroll area (grid of patient cards) 136 self.scroll_content = QWidget() 137 self.grid_layout = QGridLayout(self.scroll_content) 138 self.scroll_area.setWidget(self.scroll_content) 139 140 # --- Summary panel showing total and eligible counts --- 141 self.summary_widget = self._create_summary_widget() 142 143 # Add widgets to the main layout 144 self.layout.addWidget(self.summary_widget) 145 self.layout.addWidget(self.scroll_area) 146 147 # Number of patient columns per row in grid 148 self.column_count = 2 149 150 # Load patient data and build UI 151 self._load_patients() 152 153 def _create_summary_widget(self): 154 """ 155 Creates a modern-style summary widget for displaying patient statistics. 156 157 Constructs a `QFrame` containing "pill" style cards that display the 158 total number of patients, the number of eligible patients, and the 159 number of ineligible patients. 160 161 Returns: 162 QFrame: The constructed summary widget. 163 """ 164 summary_frame = QFrame() 165 summary_frame.setObjectName("summaryCard") 166 summary_frame.setStyleSheet(""" 167 QFrame#summaryCard { 168 border: 1px solid #e0e0e0; 169 border-radius: 12px; 170 background-color: #ffffff; 171 padding: 0.1em; 172 } 173 """) 174 175 main_layout = QVBoxLayout(summary_frame) 176 177 # Section title 178 self.title_summary = QLabel(QCoreApplication.translate("PipelinePatientSelectionPage", "Pipeline Requirements Summary")) 179 self.title_summary.setAlignment(Qt.AlignmentFlag.AlignCenter) 180 self.title_summary.setStyleSheet("font-size: 16px; font-weight: bold; color: #000000; margin-bottom: 10px;") 181 main_layout.addWidget(self.title_summary) 182 183 # Horizontal layout for summary “pill” cards 184 stats_layout = QHBoxLayout() 185 stats_layout.setSpacing(15) 186 187 # Create pill cards showing totals 188 self.total_label = self._create_stat_pill(resource_path("resources/icon_total.png"), 189 QCoreApplication.translate("PipelinePatientSelectionPage", "Total Patients"), "0") 190 self.eligible_label = self._create_stat_pill(resource_path("resources/icon_check.png"), 191 QCoreApplication.translate("PipelinePatientSelectionPage", "Eligible"), "0", color="#27ae60") 192 self.not_eligible_label = self._create_stat_pill(resource_path("resources/icon_cross.png"), 193 QCoreApplication.translate("PipelinePatientSelectionPage", "Not Eligible"), "0", color="#c0392b") 194 195 # Add pills to layout 196 for pill in [self.total_label, self.eligible_label, self.not_eligible_label]: 197 pill.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) 198 stats_layout.addWidget(pill) 199 200 main_layout.addLayout(stats_layout) 201 202 return summary_frame 203 204 def _create_stat_pill(self, icon_path, label_text, value_text, color="#000000"): 205 """ 206 Creates a card-like label with an icon, text, and numeric value. 207 208 Args: 209 icon_path (str): Path to the icon image (currently unused in the simplified implementation). 210 label_text (str): The description label (e.g., "Eligible"). 211 value_text (str): The numeric value as a string. 212 color (str, optional): Hex color code for the numeric value text. Defaults to "#000000". 213 214 Returns: 215 QFrame: A frame widget representing the statistic pill. 216 """ 217 pill = QFrame() 218 pill.setObjectName("summaryCard") 219 pill.setStyleSheet(f""" 220 QFrame#summaryCard {{ 221 border: 1px solid #CCCCCC; 222 border-radius: 10px; 223 background-color: #f9f9f9; 224 }} 225 """) 226 227 layout = QVBoxLayout(pill) 228 layout.setAlignment(Qt.AlignmentFlag.AlignCenter) 229 230 # Text label (e.g., “Total Patients”) 231 label = QLabel(label_text) 232 label.setStyleSheet("font-size: 13px; font-weight: bold;") 233 label.setAlignment(Qt.AlignmentFlag.AlignCenter) 234 235 # Numeric value (e.g., “5”) 236 value = QLabel(value_text) 237 value.setStyleSheet(f"font-size: 20px; font-weight: bold; color: {color};") 238 value.setAlignment(Qt.AlignmentFlag.AlignCenter) 239 240 layout.addWidget(label) 241 layout.addWidget(value) 242 243 # Keep reference to labels for later updates 244 pill.label = label 245 pill.value_label = value 246 247 pill.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) 248 pill.setMinimumHeight(50) 249 pill.setMaximumHeight(120) 250 251 return pill 252 253 def _check_patient_requirements(self, patient_path, patient_id): 254 """ 255 Checks whether a patient meets all necessary pipeline file requirements. 256 257 It looks for the existence of: 258 1. **FLAIR image**: `anat/*_flair.nii[.gz]` 259 2. **Skull-stripped image**: `derivatives/skullstrips/.../anat/*_brain.nii[.gz]` 260 3. **Segmentation/Mask**: `manual_masks` or `deep_learning_seg` 261 262 Args: 263 patient_path (str): The absolute path to the patient's folder. 264 patient_id (str): The unique identifier of the patient (e.g., 'sub-01'). 265 266 Returns: 267 dict: A dictionary containing: 268 - `eligible` (bool): True if all files are present. 269 - `requirements` (dict): Boolean status for each file type ('flair', 'skull_stripping', 'segmentation'). 270 - `missing_files` (list): List of translated strings describing missing components. 271 - `segmentation_type` (str or None): The type of segmentation found ("Manual Mask" or "deep_learning_seg Segmentation"). 272 """ 273 # Each key corresponds to a necessary preprocessing step 274 requirements = { 275 'flair': False, 276 'skull_stripping': False, 277 'segmentation': False 278 } 279 280 missing_files = [] # Collect missing components for display 281 282 # --- 1. Check for FLAIR anatomical image --- 283 flair_patterns = [ 284 os.path.join(patient_path, "anat", "*_flair.nii"), 285 os.path.join(patient_path, "anat", "*_flair.nii.gz") 286 ] 287 288 flair_found = False 289 for pattern in flair_patterns: 290 if glob.glob(pattern): # Search for matching files 291 flair_found = True 292 break 293 294 requirements['flair'] = flair_found 295 if not flair_found: 296 missing_files.append(QCoreApplication.translate( 297 "PipelinePatientSelectionPage", "FLAIR image (anat/*_flair.nii[.gz])")) 298 299 # --- 2. Check for Skull-Stripped image --- 300 skull_strip_patterns = [ 301 os.path.join(self.workspace_path, "derivatives", "skullstrips", patient_id, "anat", "*_brain.nii"), 302 os.path.join(self.workspace_path, "derivatives", "skullstrips", patient_id, "anat", "*_brain.nii.gz") 303 ] 304 305 skull_strip_found = False 306 for pattern in skull_strip_patterns: 307 if glob.glob(pattern): 308 skull_strip_found = True 309 break 310 311 requirements['skull_stripping'] = skull_strip_found 312 if not skull_strip_found: 313 missing_files.append(QCoreApplication.translate( 314 "PipelinePatientSelectionPage", "Skull stripped image (derivatives/skullstrips/.../anat/*_brain.nii[.gz])")) 315 316 # --- 3. Check for Segmentation or Manual Mask --- 317 segmentation_patterns = [ 318 # Manual masks 319 os.path.join(self.workspace_path, "derivatives", "manual_masks", patient_id, "anat", "*_mask.nii"), 320 os.path.join(self.workspace_path, "derivatives", "manual_masks", patient_id, "anat", "*_mask.nii.gz"), 321 # Deep learning segmentation output 322 os.path.join(self.workspace_path, "derivatives", "deep_learning_seg", patient_id, "anat", "*_seg.nii"), 323 os.path.join(self.workspace_path, "derivatives", "deep_learning_seg", patient_id, "anat", "*_seg.nii.gz") 324 ] 325 326 segmentation_found = False 327 segmentation_type = None 328 for pattern in segmentation_patterns: 329 if glob.glob(pattern): 330 segmentation_found = True 331 # Identify segmentation source type 332 if "manual_masks" in pattern: 333 segmentation_type = "Manual Mask" 334 elif "deep_learning_seg" in pattern: 335 segmentation_type = "deep_learning_seg Segmentation" 336 break 337 338 requirements['segmentation'] = segmentation_found 339 if not segmentation_found: 340 missing_files.append(QCoreApplication.translate( 341 "PipelinePatientSelectionPage", "Segmentation (manual_masks/*_mask.nii[.gz] or deep_learning_seg /*_seg.nii[.gz])")) 342 343 # --- Final eligibility decision --- 344 # Patient is eligible if all required files are present 345 is_eligible = all(requirements.values()) 346 347 # Return structured summary of findings 348 return { 349 'eligible': is_eligible, 350 'requirements': requirements, 351 'missing_files': missing_files, 352 'segmentation_type': segmentation_type 353 } 354 355 def _load_patients(self): 356 """ 357 Loads patient directories and builds the UI grid. 358 359 This method: 360 1. Identifies patient directories in the workspace. 361 2. Verifies the eligibility of each patient via `_check_patient_requirements`. 362 3. Populates the `grid_layout` with visual cards created by `_create_patient_frame`. 363 4. Updates the summary statistics. 364 """ 365 # Find all patient folders (sub-*) 366 patient_dirs = self._find_patient_dirs() 367 patient_dirs.sort() # Sort alphabetically for visual consistency 368 369 # Clear the dictionaries of buttons and patient states 370 self.patient_buttons.clear() 371 self.patient_status.clear() 372 373 eligible_count = 0 374 total_count = len(patient_dirs) 375 376 # Iterate over all found patients 377 for i, patient_path in enumerate(patient_dirs): 378 patient_id = os.path.basename(patient_path) 379 380 # Check the required conditions for each patient 381 status = self._check_patient_requirements(patient_path, patient_id) 382 self.patient_status[patient_id] = status 383 384 # Count eligible patients 385 if status['eligible']: 386 eligible_count += 1 387 388 # Create a card (frame) for the patient 389 patient_frame = self._create_patient_frame(patient_id, patient_path, status) 390 391 # Insert the card into the grid layout 392 self.grid_layout.addWidget(patient_frame, i // self.column_count, i % self.column_count) 393 394 # Update the statistical summary 395 self._update_summary(eligible_count, total_count) 396 397 def _create_patient_frame(self, patient_id, patient_path, status): 398 """ 399 Creates a UI card (QFrame) representing a single patient. 400 401 The card displays: 402 - Patient ID and user icon. 403 - Eligibility status (Green checkmark or Red cross). 404 - Detailed checklist of requirements (FLAIR, Skull Strip, Segmentation). 405 - A "Select" button (disabled if the patient is ineligible). 406 407 Args: 408 patient_id (str): The ID of the patient. 409 patient_path (str): The file system path to the patient data. 410 status (dict): The eligibility status dict returned by `_check_patient_requirements`. 411 412 Returns: 413 QFrame: The constructed patient card widget. 414 """ 415 patient_frame = QFrame() 416 patient_frame.setObjectName("patientCard") 417 patient_frame.setMaximumHeight(140) # Card height limit 418 patient_frame.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) 419 420 # Apply different visual style based on patient eligibility 421 if status['eligible']: 422 frame_style = """ 423 QFrame#patientCard { 424 border: 2px solid #4CAF50; 425 border-radius: 10px; 426 background-color: #f0fff0; 427 padding: 10px; 428 margin: 2px; 429 } 430 """ 431 else: 432 frame_style = """ 433 QFrame#patientCard { 434 border: 2px solid #f44336; 435 border-radius: 10px; 436 background-color: #fff0f0; 437 padding: 10px; 438 margin: 2px; 439 } 440 """ 441 442 patient_frame.setStyleSheet(frame_style) 443 444 # Main horizontal layout (left → right) 445 patient_layout = QHBoxLayout(patient_frame) 446 447 # Left section: profile information 448 profile_frame = QFrame() 449 profile_frame.setFixedSize(150, 100) 450 profile_layout = QVBoxLayout(profile_frame) 451 profile_layout.setAlignment(Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter) 452 453 # User image (icon) 454 image = QLabel() 455 pixmap = QtGui.QPixmap(resource_path("resources/user.png")).scaled( 456 30, 30, Qt.AspectRatioMode.KeepAspectRatio, 457 Qt.TransformationMode.SmoothTransformation 458 ) 459 image.setPixmap(pixmap) 460 image.setAlignment(Qt.AlignmentFlag.AlignCenter) 461 462 # Patient ID label 463 patient_label = QLabel(f"{patient_id}") 464 patient_label.setAlignment(Qt.AlignmentFlag.AlignCenter) 465 patient_label.setStyleSheet("font-weight: bold; font-size: 12px;") 466 467 # Status label (Eligible / Not eligible) 468 if status['eligible']: 469 status_label = QLabel(QCoreApplication.translate("PipelinePatientSelectionPage", "✓ Ready for Pipeline")) 470 status_label.setStyleSheet("color: #4CAF50; font-weight: bold; font-size: 10px;") 471 else: 472 status_label = QLabel(QCoreApplication.translate("PipelinePatientSelectionPage", "✗ Missing Requirements")) 473 status_label.setStyleSheet("color: #f44336; font-weight: bold; font-size: 10px;") 474 status_label.setAlignment(Qt.AlignmentFlag.AlignCenter) 475 476 # Add widgets to the profile layout 477 profile_layout.addWidget(image) 478 profile_layout.addWidget(patient_label) 479 profile_layout.addWidget(status_label) 480 481 # Center section: requirement details 482 details_frame = QFrame() 483 details_layout = QVBoxLayout(details_frame) 484 details_layout.setAlignment(Qt.AlignmentFlag.AlignVCenter) 485 486 # Status indicators for each required step 487 req_indicators = [] 488 req_labels = { 489 'flair': 'FLAIR', 490 'skull_stripping': 'Skull Strip', 491 'segmentation': QCoreApplication.translate("PipelinePatientSelectionPage", 'Segmentation') 492 } 493 494 # Display ✓ or ✗ next to each requirement 495 for req, label in req_labels.items(): 496 indicator = QLabel() 497 if status['requirements'][req]: 498 indicator.setText(f"✓ {label}") 499 indicator.setStyleSheet("color: #4CAF50; font-size: 10px; padding: 1px;") 500 else: 501 indicator.setText(f"✗ {label}") 502 indicator.setStyleSheet("color: #f44336; font-size: 10px; padding: 1px;") 503 req_indicators.append(indicator) 504 details_layout.addWidget(indicator) 505 506 # Show segmentation type if available 507 if status['segmentation_type']: 508 seg_type_label = QLabel(f"({status['segmentation_type']})") 509 seg_type_label.setStyleSheet("color: #666666; font-size: 9px; font-style: italic;") 510 details_layout.addWidget(seg_type_label) 511 512 # Right section: selection button 513 button = QPushButton(QCoreApplication.translate("PipelinePatientSelectionPage", "Select")) 514 button.setCheckable(True) # Allows to toggle selection 515 516 if status['eligible']: 517 # Style for active (eligible) button 518 button.setStyleSheet(""" 519 QPushButton { 520 border-radius: 12px; 521 padding: 8px 16px; 522 background-color: #DADADA; 523 font-weight: bold; 524 min-width: 80px; 525 } 526 QPushButton:checked { 527 background-color: #4CAF50; 528 color: white; 529 } 530 QPushButton:hover { 531 background-color: #c0c0c0; 532 } 533 QPushButton:checked:hover { 534 background-color: #45a049; 535 } 536 """) 537 538 # Restore previous selection if it exists 539 is_selected = patient_id in self.selected_patients 540 button.setChecked(is_selected) 541 button.setText( 542 QCoreApplication.translate("PipelinePatientSelectionPage", "Selected") 543 if is_selected else QCoreApplication.translate("PipelinePatientSelectionPage", "Select") 544 ) 545 546 # Connect button click to selection handler 547 button.clicked.connect(lambda checked, pid=patient_id, btn=button: self._toggle_patient(pid, checked, btn)) 548 else: 549 # If not eligible, disable the button 550 button.setText(QCoreApplication.translate("PipelinePatientSelectionPage", "Not Eligible")) 551 button.setEnabled(False) 552 button.setStyleSheet(""" 553 QPushButton { 554 border-radius: 12px; 555 padding: 8px 16px; 556 background-color: #f0f0f0; 557 color: #888888; 558 font-weight: bold; 559 min-width: 80px; 560 } 561 """) 562 563 # Save reference of the button in the dictionary 564 self.patient_buttons[patient_id] = button 565 566 # Assemble all sections into the main layout 567 patient_layout.addWidget(profile_frame) 568 patient_layout.addWidget(details_frame) 569 patient_layout.addStretch() # Flexible space 570 patient_layout.addWidget(button) 571 572 return patient_frame 573 574 def _update_summary(self, eligible_count, total_count): 575 """ 576 Updates the numeric values in the summary widget. 577 578 Args: 579 eligible_count (int): Number of patients who met all requirements. 580 total_count (int): Total number of patients found. 581 """ 582 self.total_label.value_label.setText(str(total_count)) 583 self.eligible_label.value_label.setText(str(eligible_count)) 584 self.not_eligible_label.value_label.setText(str(total_count - eligible_count)) 585 586 def _select_all_eligible_patients(self): 587 """ 588 Action handler: Automatically selects all patients marked as eligible. 589 Updates the main application buttons after selection. 590 """ 591 for patient_id, button in self.patient_buttons.items(): 592 if self.patient_status[patient_id]['eligible'] and not button.isChecked(): 593 button.setChecked(True) 594 button.setText(QCoreApplication.translate("PipelinePatientSelectionPage", "Selected")) 595 self.selected_patients.add(patient_id) 596 # Update main app buttons 597 if self.context and "update_main_buttons" in self.context: 598 self.context["update_main_buttons"]() 599 600 def _deselect_all_patients(self): 601 """ 602 Action handler: Deselects all currently selected patients. 603 Updates the main application buttons after deselection. 604 """ 605 for patient_id, button in self.patient_buttons.items(): 606 if button.isChecked() and button.isEnabled(): 607 button.setChecked(False) 608 button.setText(QCoreApplication.translate("PipelinePatientSelectionPage", "Select")) 609 self.selected_patients.discard(patient_id) 610 # Update main buttons 611 if self.context and "update_main_buttons" in self.context: 612 self.context["update_main_buttons"]() 613 614 def _refresh_patient_status(self): 615 """ 616 Action handler: Reloads all patients and re-verifies their status. 617 618 This is useful if files have been added to the workspace while the 619 application was open. It attempts to preserve existing selections if 620 the patient remains eligible. 621 """ 622 # Save current selections 623 current_selections = self.selected_patients.copy() 624 625 # Remove existing widgets from the grid 626 while self.grid_layout.count(): 627 item = self.grid_layout.takeAt(0) 628 widget = item.widget() 629 if widget: 630 widget.setParent(None) 631 widget.deleteLater() 632 633 # Reload patients and update status 634 self._load_patients() 635 636 # Restore still-valid selections 637 valid_selections = set() 638 for patient_id in current_selections: 639 if (patient_id in self.patient_status and 640 self.patient_status[patient_id]['eligible'] and 641 patient_id in self.patient_buttons): 642 valid_selections.add(patient_id) 643 button = self.patient_buttons[patient_id] 644 button.setChecked(True) 645 button.setText(QCoreApplication.translate("PipelinePatientSelectionPage", "Selected")) 646 647 self.selected_patients = valid_selections 648 649 def _toggle_patient(self, patient_id, is_selected, button): 650 """ 651 Toggles the selection status of a single patient. 652 653 Args: 654 patient_id (str): The ID of the patient. 655 is_selected (bool): The new state (True for selected). 656 button (QPushButton): The button widget triggering the event. 657 """ 658 if is_selected: 659 self.selected_patients.add(patient_id) 660 button.setText(QCoreApplication.translate("PipelinePatientSelectionPage", "Selected")) 661 else: 662 self.selected_patients.discard(patient_id) 663 button.setText(QCoreApplication.translate("PipelinePatientSelectionPage", "Select")) 664 # Update main buttons 665 if self.context and "update_main_buttons" in self.context: 666 self.context["update_main_buttons"]() 667 668 def _find_patient_dirs(self): 669 """ 670 Scans the workspace path for patient directories. 671 672 It looks for directories starting with "sub-" and excludes "derivatives" 673 and "pipeline" directories. 674 675 Returns: 676 list: A list of full paths to patient directories. 677 """ 678 patient_dirs = [] 679 680 for root, dirs, files in os.walk(self.workspace_path): 681 # Exclude irrelevant folders 682 if "derivatives" in dirs: 683 dirs.remove("derivatives") 684 if "pipeline" in dirs: 685 dirs.remove("pipeline") 686 687 # Add directories starting with "sub-" 688 for dir_name in dirs: 689 if dir_name.startswith("sub-"): 690 full_path = os.path.join(root, dir_name) 691 patient_dirs.append(full_path) 692 693 # Prevent going deeper into sub-* directories 694 dirs[:] = [d for d in dirs if not d.startswith("sub-")] 695 696 return patient_dirs 697 698 def _update_column_count(self): 699 """ 700 Recalculates the number of grid columns based on the available viewport width. 701 702 If the calculated column count differs from the current count, the grid 703 is reloaded to adjust the layout. 704 """ 705 available_width = self.scroll_area.viewport().width() - 40 706 min_card_width = 400 # Minimum width for a card 707 708 new_column_count = max(1, available_width // min_card_width) 709 710 # If column count changes, reload the grid 711 if new_column_count != self.column_count: 712 self.column_count = new_column_count 713 self._reload_patient_grid() 714 715 def _reload_patient_grid(self): 716 """ 717 Rebuilds the patient grid (e.g., after a resize) while maintaining selections. 718 """ 719 selected = self.selected_patients.copy() 720 721 # Remove existing widgets 722 while self.grid_layout.count(): 723 item = self.grid_layout.takeAt(0) 724 widget = item.widget() 725 if widget: 726 widget.setParent(None) 727 widget.deleteLater() 728 729 # Reload patients in the new layout 730 self._load_patients() 731 732 # Restore valid selections 733 valid_selections = set() 734 for patient_id in selected: 735 if (patient_id in self.patient_status and 736 self.patient_status[patient_id]['eligible'] and 737 patient_id in self.patient_buttons): 738 valid_selections.add(patient_id) 739 button = self.patient_buttons[patient_id] 740 button.setChecked(True) 741 button.setText(QCoreApplication.translate("PipelinePatientSelectionPage", "Selected")) 742 743 self.selected_patients = valid_selections 744 745 def _build_pipeline_config(self): 746 """ 747 Generates and saves a JSON file containing the initial pipeline configuration. 748 749 This method iterates through selected patients, resolves relative paths for 750 all required files (MRI, Skull-strip, PET, Masks), and detects ambiguities 751 (multiple files found) which are flagged as `need_revision`. 752 753 The configuration is saved to `workspace/pipeline/XX_config.json`, where XX 754 is an auto-incrementing sequence number. 755 756 Returns: 757 str: The full path to the generated JSON configuration file. 758 """ 759 config = {} # Main dictionary that will store configuration data for all patients 760 761 # Iterate over all selected patients 762 for patient_id in self.selected_patients: 763 patient_entry = {} # Holds the configuration for a single patient 764 need_revision = False # Flag indicating if manual review is required due to ambiguities 765 766 # ---------------------------------------------------- 767 # MRI (FLAIR) 768 # ---------------------------------------------------- 769 flair_patterns = [ 770 os.path.join(self.workspace_path, patient_id, "anat", "*_flair.nii"), 771 os.path.join(self.workspace_path, patient_id, "anat", "*_flair.nii.gz") 772 ] 773 flair_files = [] 774 # Search for FLAIR MRI files matching the defined patterns 775 for p in flair_patterns: 776 flair_files.extend(glob.glob(p)) 777 # If more than one FLAIR file exists, mark for manual revision 778 if len(flair_files) > 1: 779 need_revision = True 780 # Store the relative path of the FLAIR file if found 781 patient_entry["mri"] = os.path.relpath(flair_files[0], self.workspace_path) if flair_files else None 782 783 # ---------------------------------------------------- 784 # MRI Skull Stripped 785 # ---------------------------------------------------- 786 mri_str_patterns = [ 787 os.path.join(self.workspace_path, "derivatives", "skullstrips", patient_id, "anat", "*_brain.nii"), 788 os.path.join(self.workspace_path, "derivatives", "skullstrips", patient_id, "anat", "*_brain.nii.gz") 789 ] 790 mri_str_files = [] 791 # Search for preprocessed (skull-stripped) MRI files 792 for p in mri_str_patterns: 793 mri_str_files.extend(glob.glob(p)) 794 if len(mri_str_files) > 1: 795 need_revision = True 796 patient_entry["mri_str"] = os.path.relpath(mri_str_files[0], self.workspace_path) if mri_str_files else None 797 798 # ---------------------------------------------------- 799 # Static PET (Session 01) 800 # ---------------------------------------------------- 801 pet_patterns = [ 802 os.path.join(self.workspace_path, patient_id, "ses-01", "pet", "*_pet.nii"), 803 os.path.join(self.workspace_path, patient_id, "ses-01", "pet", "*_pet.nii.gz") 804 ] 805 pet_files = [] 806 # Locate static PET scans 807 for p in pet_patterns: 808 pet_files.extend(glob.glob(p)) 809 if len(pet_files) > 1: 810 need_revision = True 811 patient_entry["pet"] = os.path.relpath(pet_files[0], self.workspace_path) if pet_files else None 812 813 # ---------------------------------------------------- 814 # Dynamic PET (4D, optional) 815 # ---------------------------------------------------- 816 pet4d_patterns = [ 817 os.path.join(self.workspace_path, patient_id, "ses-02", "pet", "*_pet.nii"), 818 os.path.join(self.workspace_path, patient_id, "ses-02", "pet", "*_pet.nii.gz") 819 ] 820 pet4d_files = [] 821 # Locate dynamic PET files (4D data) 822 for p in pet4d_patterns: 823 pet4d_files.extend(glob.glob(p)) 824 if len(pet4d_files) > 1: 825 need_revision = True 826 pet4d_file = pet4d_files[0] if pet4d_files else None 827 patient_entry["pet4d"] = os.path.relpath(pet4d_files[0], self.workspace_path) if pet4d_files else None 828 829 # ---------------------------------------------------- 830 # PET JSON sidecar (metadata for dynamic PET) 831 # ---------------------------------------------------- 832 pet_json_file = None 833 if pet4d_file: 834 # Derive the expected .json filename from the PET file prefix 835 basename = os.path.basename(pet4d_file) 836 stem_no_ext = basename.split('.')[0] 837 candidate = os.path.join(os.path.dirname(pet4d_file), stem_no_ext + '.json') 838 if os.path.exists(candidate): 839 pet_json_file = candidate 840 # Store relative path to PET JSON file if it exists 841 patient_entry["pet4d_json"] = os.path.relpath(pet_json_file, self.workspace_path) if pet_json_file else None 842 843 # ---------------------------------------------------- 844 # Tumor MRI Mask (manual or automated) 845 # ---------------------------------------------------- 846 tumor_patterns = [ 847 # Manual segmentation masks 848 os.path.join(self.workspace_path, "derivatives", "manual_masks", patient_id, "anat", "*_mask.nii"), 849 os.path.join(self.workspace_path, "derivatives", "manual_masks", patient_id, "anat", "*_mask.nii.gz"), 850 # Deep learning-based segmentations 851 os.path.join(self.workspace_path, "derivatives", "deep_learning_seg", patient_id, "anat", 852 "*_seg.nii"), 853 os.path.join(self.workspace_path, "derivatives", "deep_learning_seg", patient_id, "anat", 854 "*_seg.nii.gz") 855 ] 856 tumor_files = [] 857 # Search for tumor mask files 858 for p in tumor_patterns: 859 tumor_files.extend(glob.glob(p)) 860 if len(tumor_files) > 1: 861 need_revision = True 862 patient_entry["tumor_mri"] = os.path.relpath(tumor_files[0], self.workspace_path) if tumor_files else None 863 864 # Add the final review flag to indicate if manual review is needed 865 patient_entry["need_revision"] = need_revision 866 867 # Store the patient's configuration in the main config dictionary 868 config[patient_id] = patient_entry 869 870 # ---------------------------------------------------- 871 # Create and save the pipeline configuration JSON file 872 # ---------------------------------------------------- 873 pipeline_dir = os.path.join(self.workspace_path, "pipeline") 874 875 # Create the 'pipeline' directory if it doesn't exist 876 if not os.path.exists(pipeline_dir): 877 os.makedirs(pipeline_dir) 878 log.info(f"Created pipeline directory: {pipeline_dir}") 879 880 # Determine the next sequential ID number (e.g., 01, 02, 03, ...) 881 config_id = self._get_next_config_id(pipeline_dir) 882 883 # Build the filename using the sequential ID 884 filename = f"{config_id:02d}_config.json" 885 output_path = os.path.join(pipeline_dir, filename) 886 887 # Save the configuration dictionary as a formatted JSON file 888 with open(output_path, "w", encoding="utf-8") as f: 889 json.dump(config, f, indent=4) 890 891 # Log the output path for reference 892 log.info(f"Pipeline configuration saved to: {output_path}") 893 894 # Return the full path to the generated configuration file 895 return output_path 896 897 def _get_next_config_id(self, pipeline_dir): 898 """ 899 Finds the next available sequential numeric ID for configuration files. 900 901 This method scans the pipeline directory for existing configuration files 902 matching `*_config.json`, extracts their numeric prefixes, and determines 903 the next sequence number. 904 905 Args: 906 pipeline_dir (str): Path to the pipeline directory where config files are stored. 907 908 Returns: 909 int: The next available numeric ID for a new configuration file. 910 """ 911 # Pattern used to search for existing pipeline configuration files 912 config_pattern = os.path.join(pipeline_dir, "*_config.json") 913 existing_configs = glob.glob(config_pattern) 914 915 # If no config files exist, start numbering from 1 916 if not existing_configs: 917 return 1 918 919 existing_ids = [] # Stores all numeric IDs extracted from existing filenames 920 921 # Iterate through all existing config files to extract their numeric IDs 922 for config_file in existing_configs: 923 filename = os.path.basename(config_file) # Example: "12_config.json" 924 try: 925 # Extract the numeric part before "_config.json" 926 id_str = filename.split("_")[0] # → "12" 927 config_id = int(id_str) # Convert to integer 928 existing_ids.append(config_id) 929 except (ValueError, IndexError): 930 # Skip any files that don't follow the expected naming format 931 continue 932 933 # If no valid numeric IDs were found, default to 1 934 if not existing_ids: 935 return 1 936 937 # Return the next sequential ID (maximum existing + 1) 938 return max(existing_ids) + 1 939 940 def on_enter(self): 941 """ 942 Triggered when the application navigates to this page. 943 Refreshes the patient status to ensure file availability is up to date. 944 """ 945 self._refresh_patient_status() 946 947 def is_ready_to_advance(self): 948 """ 949 Checks if the user can proceed to the next page. 950 951 Returns: 952 bool: True if at least one patient has been selected, False otherwise. 953 """ 954 return len(self.selected_patients) > 0 955 956 def is_ready_to_go_back(self): 957 """ 958 Checks if the user can navigate back to the previous page. 959 960 Returns: 961 bool: Always True for this page. 962 """ 963 return True 964 965 def next(self, context): 966 """ 967 Proceeds to the next page in the workflow. 968 969 This method builds the pipeline configuration file based on the selection 970 and initializes the `PipelineReviewPage`. 971 972 Args: 973 context (dict): The application context. 974 975 Returns: 976 Page: The instance of the next page to be displayed. 977 """ 978 # Generate a new pipeline configuration file before continuing 979 self._build_pipeline_config() 980 981 # If a next page already exists, reuse it 982 if self.next_page: 983 self.next_page.on_enter() 984 return self.next_page 985 else: 986 # Otherwise, create a new review page and register it in navigation history 987 self.next_page = PipelineReviewPage(context, self) 988 self.context["history"].append(self.next_page) 989 self.next_page.on_enter() 990 return self.next_page 991 992 def back(self): 993 """ 994 Navigates back to the previous page. 995 996 Returns: 997 Page: The previous page instance, or None if no previous page exists. 998 """ 999 if self.previous_page: 1000 self.previous_page.on_enter() 1001 return self.previous_page 1002 return None 1003 1004 def get_selected_patients(self): 1005 """ 1006 Retrieves the IDs of all currently selected patients. 1007 1008 Returns: 1009 list: A list of strings representing patient IDs. 1010 """ 1011 return list(self.selected_patients) 1012 1013 def get_eligible_patients(self): 1014 """ 1015 Retrieves the IDs of all patients marked as eligible. 1016 1017 Returns: 1018 list: A list of strings representing eligible patient IDs. 1019 """ 1020 return [pid for pid, status in self.patient_status.items() if status['eligible']] 1021 1022 def get_patient_status_summary(self): 1023 """ 1024 Calculates a statistical summary of patient eligibility. 1025 1026 Returns: 1027 dict: A dictionary containing: 1028 - 'total': Total number of patients scanned. 1029 - 'eligible': Number of patients meeting requirements. 1030 - 'selected': Number of currently selected patients. 1031 - 'not_eligible': Number of patients missing requirements. 1032 """ 1033 total = len(self.patient_status) # Count total patients being tracked 1034 eligible = len([s for s in self.patient_status.values() if s['eligible']]) # Count eligible ones 1035 selected = len(self.selected_patients) # Count selected patients 1036 1037 # Return a dictionary summarizing all relevant counts 1038 return { 1039 'total': total, 1040 'eligible': eligible, 1041 'selected': selected, 1042 'not_eligible': total - eligible 1043 } 1044 1045 def reset_page(self): 1046 """ 1047 Resets the page to its initial state. 1048 1049 This clears the grid, selections, and internal status data, then re-scans 1050 the workspace for patients. 1051 """ 1052 # Clear all widgets from the grid layout 1053 while self.grid_layout.count(): 1054 item = self.grid_layout.takeAt(0) 1055 widget = item.widget() 1056 if widget is not None: 1057 widget.setParent(None) # Detach widget from layout 1058 widget.deleteLater() # Schedule for deletion 1059 1060 # Reset all patient-related states 1061 self.selected_patients.clear() # Clear selected patients 1062 self.patient_buttons.clear() # Clear patient button references 1063 self.patient_status.clear() # Clear patient status dictionary 1064 1065 # Reload all patient data from the workspace 1066 self._load_patients() 1067 1068 def resizeEvent(self, event): 1069 """ 1070 Handles window resize events to make the UI responsive. 1071 1072 Adjusts the number of columns in the grid, font sizes, and button padding 1073 based on the window height and width. 1074 1075 Args: 1076 event (QResizeEvent): The resize event. 1077 """ 1078 super().resizeEvent(event) 1079 1080 # Update column count dynamically based on width 1081 self._update_column_count() 1082 1083 height = self.height() # Current window height 1084 1085 # Adjust UI style dynamically for small or large window height 1086 if height < 500: 1087 # Compact layout for small windows 1088 self.title.setStyleSheet("font-size: 14px; font-weight: bold; margin-bottom: 5px;") 1089 btn_padding = "5px 10px" 1090 font_size_btn = 11 1091 max_pill_height = 70 1092 font_size_title = 12 1093 font_size_value = 14 1094 font_size_label = 10 1095 max_pill_height = 60 1096 else: 1097 # Normal layout for larger windows 1098 self.title.setStyleSheet("font-size: 18px; font-weight: bold; margin-bottom: 10px;") 1099 btn_padding = "10px 20px" 1100 font_size_btn = 13 1101 font_size_title = 16 1102 font_size_value = 20 1103 font_size_label = 13 1104 max_pill_height = 100 1105 1106 # Update button styles dynamically 1107 for btn in self.buttons: 1108 btn.setStyleSheet(f""" 1109 QPushButton {{ 1110 background-color: #e0e0e0; 1111 padding: {btn_padding}; 1112 border-radius: 10px; 1113 border: 1px solid #bdc3c7; 1114 font-weight: bold; 1115 font-size: {font_size_btn}px; 1116 margin: 2px; 1117 }} 1118 QPushButton:hover {{ 1119 background-color: #d0d0d0; 1120 }} 1121 QPushButton:disabled {{ 1122 background-color: #f0f0f0; 1123 color: #888888; 1124 }} 1125 """) 1126 1127 # Update summary title styling 1128 self.title_summary.setStyleSheet( 1129 f"font-size: {font_size_title}px; font-weight: bold; color: #000000; margin-bottom: 8px;" 1130 ) 1131 1132 # Update pill-style status indicators (total, eligible, not eligible) 1133 for pill in [self.total_label, self.eligible_label, self.not_eligible_label]: 1134 pill.label.setStyleSheet(f"font-size: {font_size_label}px; font-weight: bold;") 1135 pill.value_label.setStyleSheet( 1136 f"font-size: {font_size_value}px; font-weight: bold; color: {pill.value_label.palette().color(pill.value_label.foregroundRole()).name()};" 1137 ) 1138 pill.setMaximumHeight(max_pill_height) # Limit vertical space 1139 1140 def _translate_ui(self): 1141 """ 1142 Translates all UI text elements to the current language. 1143 1144 Updates labels and buttons with localized strings using Qt's translation system. 1145 Triggers a patient list reload to refresh status texts within patient cards. 1146 """ 1147 # Set translated texts for main title and control buttons 1148 self.title.setText( 1149 QCoreApplication.translate("PipelinePatientSelectionPage", "Select Patients for Pipeline Analysis")) 1150 self.select_eligible_btn.setText( 1151 QCoreApplication.translate("PipelinePatientSelectionPage", "Select All Eligible")) 1152 self.deselect_all_btn.setText(QCoreApplication.translate("PipelinePatientSelectionPage", "Deselect All")) 1153 self.refresh_btn.setText(QCoreApplication.translate("PipelinePatientSelectionPage", "Refresh Status")) 1154 self.title_summary.setText( 1155 QCoreApplication.translate("PipelinePatientSelectionPage", "Pipeline Requirements Summary")) 1156 1157 # Set translated labels for patient status summary 1158 self.total_label.label.setText(QCoreApplication.translate("PipelinePatientSelectionPage", "Total Patients")) 1159 self.eligible_label.label.setText(QCoreApplication.translate("PipelinePatientSelectionPage", "Eligible")) 1160 self.not_eligible_label.label.setText( 1161 QCoreApplication.translate("PipelinePatientSelectionPage", "Not Eligible")) 1162 1163 # Reload patient list after updating UI text 1164 self._load_patients()
This class scans the workspace for patient directories, verifies if the required imaging files (FLAIR, skull-stripped, segmentation) are present, and presents a visual grid of patient cards. Users can select eligible patients to proceed to the pipeline configuration stage.
Attributes:
context (dict): The application context containing shared data and state.
workspace_path (str): The root directory path containing patient data.
patient_buttons (dict): Maps patient IDs to their selection QPushButton.
selected_patients (set): A set of strings containing IDs of currently selected patients.
patient_status (dict): Stores eligibility status and missing file details for each patient.
35 def __init__(self, context=None, previous_page=None): 36 """ 37 Initializes the PipelinePatientSelectionPage. 38 39 Args: 40 context (dict, optional): The shared application context. Defaults to None. 41 previous_page (Page, optional): The page instance navigated from. Defaults to None. 42 """ 43 super().__init__() 44 45 self.context = context 46 self.previous_page = previous_page 47 self.next_page = None 48 49 # Path to the workspace directory containing patient data 50 self.workspace_path = context["workspace_path"] 51 52 # Dictionaries to track patient widgets, selected patients, and their processing status 53 self.patient_buttons = {} 54 self.selected_patients = set() 55 self.patient_status = {} # Stores eligibility and missing data for each patient 56 57 # Initialize the user interface 58 self._setup_ui() 59 60 # Set translations (for multilingual UI) 61 self._translate_ui() 62 if context and "language_changed" in context: 63 # Connect language change signal for dynamic translation 64 context["language_changed"].connect(self._translate_ui)
Initializes the PipelinePatientSelectionPage.
Args: context (dict, optional): The shared application context. Defaults to None. previous_page (Page, optional): The page instance navigated from. Defaults to None.
940 def on_enter(self): 941 """ 942 Triggered when the application navigates to this page. 943 Refreshes the patient status to ensure file availability is up to date. 944 """ 945 self._refresh_patient_status()
Triggered when the application navigates to this page. Refreshes the patient status to ensure file availability is up to date.
947 def is_ready_to_advance(self): 948 """ 949 Checks if the user can proceed to the next page. 950 951 Returns: 952 bool: True if at least one patient has been selected, False otherwise. 953 """ 954 return len(self.selected_patients) > 0
Checks if the user can proceed to the next page.
Returns: bool: True if at least one patient has been selected, False otherwise.
956 def is_ready_to_go_back(self): 957 """ 958 Checks if the user can navigate back to the previous page. 959 960 Returns: 961 bool: Always True for this page. 962 """ 963 return True
Checks if the user can navigate back to the previous page.
Returns: bool: Always True for this page.
965 def next(self, context): 966 """ 967 Proceeds to the next page in the workflow. 968 969 This method builds the pipeline configuration file based on the selection 970 and initializes the `PipelineReviewPage`. 971 972 Args: 973 context (dict): The application context. 974 975 Returns: 976 Page: The instance of the next page to be displayed. 977 """ 978 # Generate a new pipeline configuration file before continuing 979 self._build_pipeline_config() 980 981 # If a next page already exists, reuse it 982 if self.next_page: 983 self.next_page.on_enter() 984 return self.next_page 985 else: 986 # Otherwise, create a new review page and register it in navigation history 987 self.next_page = PipelineReviewPage(context, self) 988 self.context["history"].append(self.next_page) 989 self.next_page.on_enter() 990 return self.next_page
Proceeds to the next page in the workflow.
This method builds the pipeline configuration file based on the selection
and initializes the PipelineReviewPage.
Args: context (dict): The application context.
Returns: Page: The instance of the next page to be displayed.
992 def back(self): 993 """ 994 Navigates back to the previous page. 995 996 Returns: 997 Page: The previous page instance, or None if no previous page exists. 998 """ 999 if self.previous_page: 1000 self.previous_page.on_enter() 1001 return self.previous_page 1002 return None
Navigates back to the previous page.
Returns: Page: The previous page instance, or None if no previous page exists.
1004 def get_selected_patients(self): 1005 """ 1006 Retrieves the IDs of all currently selected patients. 1007 1008 Returns: 1009 list: A list of strings representing patient IDs. 1010 """ 1011 return list(self.selected_patients)
Retrieves the IDs of all currently selected patients.
Returns: list: A list of strings representing patient IDs.
1013 def get_eligible_patients(self): 1014 """ 1015 Retrieves the IDs of all patients marked as eligible. 1016 1017 Returns: 1018 list: A list of strings representing eligible patient IDs. 1019 """ 1020 return [pid for pid, status in self.patient_status.items() if status['eligible']]
Retrieves the IDs of all patients marked as eligible.
Returns: list: A list of strings representing eligible patient IDs.
1022 def get_patient_status_summary(self): 1023 """ 1024 Calculates a statistical summary of patient eligibility. 1025 1026 Returns: 1027 dict: A dictionary containing: 1028 - 'total': Total number of patients scanned. 1029 - 'eligible': Number of patients meeting requirements. 1030 - 'selected': Number of currently selected patients. 1031 - 'not_eligible': Number of patients missing requirements. 1032 """ 1033 total = len(self.patient_status) # Count total patients being tracked 1034 eligible = len([s for s in self.patient_status.values() if s['eligible']]) # Count eligible ones 1035 selected = len(self.selected_patients) # Count selected patients 1036 1037 # Return a dictionary summarizing all relevant counts 1038 return { 1039 'total': total, 1040 'eligible': eligible, 1041 'selected': selected, 1042 'not_eligible': total - eligible 1043 }
Calculates a statistical summary of patient eligibility.
Returns: dict: A dictionary containing: - 'total': Total number of patients scanned. - 'eligible': Number of patients meeting requirements. - 'selected': Number of currently selected patients. - 'not_eligible': Number of patients missing requirements.
1045 def reset_page(self): 1046 """ 1047 Resets the page to its initial state. 1048 1049 This clears the grid, selections, and internal status data, then re-scans 1050 the workspace for patients. 1051 """ 1052 # Clear all widgets from the grid layout 1053 while self.grid_layout.count(): 1054 item = self.grid_layout.takeAt(0) 1055 widget = item.widget() 1056 if widget is not None: 1057 widget.setParent(None) # Detach widget from layout 1058 widget.deleteLater() # Schedule for deletion 1059 1060 # Reset all patient-related states 1061 self.selected_patients.clear() # Clear selected patients 1062 self.patient_buttons.clear() # Clear patient button references 1063 self.patient_status.clear() # Clear patient status dictionary 1064 1065 # Reload all patient data from the workspace 1066 self._load_patients()
Resets the page to its initial state.
This clears the grid, selections, and internal status data, then re-scans the workspace for patients.
1068 def resizeEvent(self, event): 1069 """ 1070 Handles window resize events to make the UI responsive. 1071 1072 Adjusts the number of columns in the grid, font sizes, and button padding 1073 based on the window height and width. 1074 1075 Args: 1076 event (QResizeEvent): The resize event. 1077 """ 1078 super().resizeEvent(event) 1079 1080 # Update column count dynamically based on width 1081 self._update_column_count() 1082 1083 height = self.height() # Current window height 1084 1085 # Adjust UI style dynamically for small or large window height 1086 if height < 500: 1087 # Compact layout for small windows 1088 self.title.setStyleSheet("font-size: 14px; font-weight: bold; margin-bottom: 5px;") 1089 btn_padding = "5px 10px" 1090 font_size_btn = 11 1091 max_pill_height = 70 1092 font_size_title = 12 1093 font_size_value = 14 1094 font_size_label = 10 1095 max_pill_height = 60 1096 else: 1097 # Normal layout for larger windows 1098 self.title.setStyleSheet("font-size: 18px; font-weight: bold; margin-bottom: 10px;") 1099 btn_padding = "10px 20px" 1100 font_size_btn = 13 1101 font_size_title = 16 1102 font_size_value = 20 1103 font_size_label = 13 1104 max_pill_height = 100 1105 1106 # Update button styles dynamically 1107 for btn in self.buttons: 1108 btn.setStyleSheet(f""" 1109 QPushButton {{ 1110 background-color: #e0e0e0; 1111 padding: {btn_padding}; 1112 border-radius: 10px; 1113 border: 1px solid #bdc3c7; 1114 font-weight: bold; 1115 font-size: {font_size_btn}px; 1116 margin: 2px; 1117 }} 1118 QPushButton:hover {{ 1119 background-color: #d0d0d0; 1120 }} 1121 QPushButton:disabled {{ 1122 background-color: #f0f0f0; 1123 color: #888888; 1124 }} 1125 """) 1126 1127 # Update summary title styling 1128 self.title_summary.setStyleSheet( 1129 f"font-size: {font_size_title}px; font-weight: bold; color: #000000; margin-bottom: 8px;" 1130 ) 1131 1132 # Update pill-style status indicators (total, eligible, not eligible) 1133 for pill in [self.total_label, self.eligible_label, self.not_eligible_label]: 1134 pill.label.setStyleSheet(f"font-size: {font_size_label}px; font-weight: bold;") 1135 pill.value_label.setStyleSheet( 1136 f"font-size: {font_size_value}px; font-weight: bold; color: {pill.value_label.palette().color(pill.value_label.foregroundRole()).name()};" 1137 ) 1138 pill.setMaximumHeight(max_pill_height) # Limit vertical space
Handles window resize events to make the UI responsive.
Adjusts the number of columns in the grid, font sizes, and button padding based on the window height and width.
Args: event (QResizeEvent): The resize event.