Coverage for main/ui/pipeline_patient_selection_page.py: 84%
454 statements
« prev ^ index » next coverage.py v7.15.0, created at 2026-07-03 15:51 +0000
« prev ^ index » next coverage.py v7.15.0, created at 2026-07-03 15:51 +0000
1import json
2import os
3import glob
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
11from ui.pipeline_review_page import PipelineReviewPage
12from utils import resource_path
13from page import Page
14from logger import get_logger
16log = get_logger()
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.
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 """
34 def __init__(self, context=None, previous_page=None):
35 """
36 Initializes the PipelinePatientSelectionPage.
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__()
44 self.context = context
45 self.previous_page = previous_page
46 self.next_page = None
48 # Path to the workspace directory containing patient data
49 self.workspace_path = context["workspace_path"]
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
56 # Initialize the user interface
57 self._setup_ui()
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)
65 def _setup_ui(self):
66 """
67 Sets up the main layout and UI components.
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)
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)
80 # --- Top buttons (bulk selection controls) ---
81 top_buttons_layout = QHBoxLayout()
83 self.select_eligible_btn = QPushButton("Select All Eligible")
84 self.deselect_all_btn = QPushButton("Deselect All")
85 self.refresh_btn = QPushButton("Refresh Status")
87 self.buttons = [self.select_eligible_btn, self.deselect_all_btn, self.refresh_btn]
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)
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)
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)
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 """)
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)
139 # --- Summary panel showing total and eligible counts ---
140 self.summary_widget = self._create_summary_widget()
142 # Add widgets to the main layout
143 self.layout.addWidget(self.summary_widget)
144 self.layout.addWidget(self.scroll_area)
146 # Number of patient columns per row in grid
147 self.column_count = 2
149 # Load patient data and build UI
150 self._load_patients()
152 def _create_summary_widget(self):
153 """
154 Creates a modern-style summary widget for displaying patient statistics.
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.
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 """)
174 main_layout = QVBoxLayout(summary_frame)
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)
182 # Horizontal layout for summary “pill” cards
183 stats_layout = QHBoxLayout()
184 stats_layout.setSpacing(15)
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")
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)
199 main_layout.addLayout(stats_layout)
201 return summary_frame
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.
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".
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 """)
226 layout = QVBoxLayout(pill)
227 layout.setAlignment(Qt.AlignmentFlag.AlignCenter)
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)
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)
239 layout.addWidget(label)
240 layout.addWidget(value)
242 # Keep reference to labels for later updates
243 pill.label = label
244 pill.value_label = value
246 pill.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
247 pill.setMinimumHeight(50)
248 pill.setMaximumHeight(120)
250 return pill
252 def _check_patient_requirements(self, patient_path, patient_id):
253 """
254 Checks whether a patient meets all necessary pipeline file requirements.
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`
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').
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 }
279 missing_files = [] # Collect missing components for display
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 ]
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
293 requirements['flair'] = flair_found
294 if not flair_found:
295 missing_files.append(QCoreApplication.translate(
296 "PipelinePatientSelectionPage", "FLAIR image (anat/*_flair.nii[.gz])"))
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 ]
304 skull_strip_found = False
305 for pattern in skull_strip_patterns:
306 if glob.glob(pattern):
307 skull_strip_found = True
308 break
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])"))
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 ]
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
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])"))
342 # --- Final eligibility decision ---
343 # Patient is eligible if all required files are present
344 is_eligible = all(requirements.values())
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 }
354 def _load_patients(self):
355 """
356 Loads patient directories and builds the UI grid.
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
368 # Clear the dictionaries of buttons and patient states
369 self.patient_buttons.clear()
370 self.patient_status.clear()
372 eligible_count = 0
373 total_count = len(patient_dirs)
375 # Iterate over all found patients
376 for i, patient_path in enumerate(patient_dirs):
377 patient_id = os.path.basename(patient_path)
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
383 # Count eligible patients
384 if status['eligible']:
385 eligible_count += 1
387 # Create a card (frame) for the patient
388 patient_frame = self._create_patient_frame(patient_id, patient_path, status)
390 # Insert the card into the grid layout
391 self.grid_layout.addWidget(patient_frame, i // self.column_count, i % self.column_count)
393 # Update the statistical summary
394 self._update_summary(eligible_count, total_count)
396 def _create_patient_frame(self, patient_id, patient_path, status):
397 """
398 Creates a UI card (QFrame) representing a single patient.
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).
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`.
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)
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 """
441 patient_frame.setStyleSheet(frame_style)
443 # Main horizontal layout (left → right)
444 patient_layout = QHBoxLayout(patient_frame)
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)
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)
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;")
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)
475 # Add widgets to the profile layout
476 profile_layout.addWidget(image)
477 profile_layout.addWidget(patient_label)
478 profile_layout.addWidget(status_label)
480 # Center section: requirement details
481 details_frame = QFrame()
482 details_layout = QVBoxLayout(details_frame)
483 details_layout.setAlignment(Qt.AlignmentFlag.AlignVCenter)
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 }
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)
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)
511 # Right section: selection button
512 button = QPushButton(QCoreApplication.translate("PipelinePatientSelectionPage", "Select"))
513 button.setCheckable(True) # Allows to toggle selection
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 """)
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 )
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 """)
562 # Save reference of the button in the dictionary
563 self.patient_buttons[patient_id] = button
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)
571 return patient_frame
573 def _update_summary(self, eligible_count, total_count):
574 """
575 Updates the numeric values in the summary widget.
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))
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"]()
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"]()
613 def _refresh_patient_status(self):
614 """
615 Action handler: Reloads all patients and re-verifies their status.
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()
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()
632 # Reload patients and update status
633 self._load_patients()
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"))
646 self.selected_patients = valid_selections
648 def _toggle_patient(self, patient_id, is_selected, button):
649 """
650 Toggles the selection status of a single patient.
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"]()
667 def _find_patient_dirs(self):
668 """
669 Scans the workspace path for patient directories.
671 It looks for directories starting with "sub-" and excludes "derivatives"
672 and "pipeline" directories.
674 Returns:
675 list: A list of full paths to patient directories.
676 """
677 patient_dirs = []
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")
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)
692 # Prevent going deeper into sub-* directories
693 dirs[:] = [d for d in dirs if not d.startswith("sub-")]
695 return patient_dirs
697 def _update_column_count(self):
698 """
699 Recalculates the number of grid columns based on the available viewport width.
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
707 new_column_count = max(1, available_width // min_card_width)
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()
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()
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()
728 # Reload patients in the new layout
729 self._load_patients()
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"))
742 self.selected_patients = valid_selections
744 def _build_pipeline_config(self):
745 """
746 Generates and saves a JSON file containing the initial pipeline configuration.
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`.
752 The configuration is saved to `workspace/pipeline/XX_config.json`, where XX
753 is an auto-incrementing sequence number.
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
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
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
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
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
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
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
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
863 # Add the final review flag to indicate if manual review is needed
864 patient_entry["need_revision"] = need_revision
866 # Store the patient's configuration in the main config dictionary
867 config[patient_id] = patient_entry
869 # ----------------------------------------------------
870 # Create and save the pipeline configuration JSON file
871 # ----------------------------------------------------
872 pipeline_dir = os.path.join(self.workspace_path, "pipeline")
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}")
879 # Determine the next sequential ID number (e.g., 01, 02, 03, ...)
880 config_id = self._get_next_config_id(pipeline_dir)
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)
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)
890 # Log the output path for reference
891 log.info(f"Pipeline configuration saved to: {output_path}")
893 # Return the full path to the generated configuration file
894 return output_path
896 def _get_next_config_id(self, pipeline_dir):
897 """
898 Finds the next available sequential numeric ID for configuration files.
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.
904 Args:
905 pipeline_dir (str): Path to the pipeline directory where config files are stored.
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)
914 # If no config files exist, start numbering from 1
915 if not existing_configs:
916 return 1
918 existing_ids = [] # Stores all numeric IDs extracted from existing filenames
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
932 # If no valid numeric IDs were found, default to 1
933 if not existing_ids:
934 return 1
936 # Return the next sequential ID (maximum existing + 1)
937 return max(existing_ids) + 1
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()
946 def is_ready_to_advance(self):
947 """
948 Checks if the user can proceed to the next page.
950 Returns:
951 bool: True if at least one patient has been selected, False otherwise.
952 """
953 return len(self.selected_patients) > 0
955 def is_ready_to_go_back(self):
956 """
957 Checks if the user can navigate back to the previous page.
959 Returns:
960 bool: Always True for this page.
961 """
962 return True
964 def next(self, context):
965 """
966 Proceeds to the next page in the workflow.
968 This method builds the pipeline configuration file based on the selection
969 and initializes the `PipelineReviewPage`.
971 Args:
972 context (dict): The application context.
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()
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
991 def back(self):
992 """
993 Navigates back to the previous page.
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
1003 def get_selected_patients(self):
1004 """
1005 Retrieves the IDs of all currently selected patients.
1007 Returns:
1008 list: A list of strings representing patient IDs.
1009 """
1010 return list(self.selected_patients)
1012 def get_eligible_patients(self):
1013 """
1014 Retrieves the IDs of all patients marked as eligible.
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']]
1021 def get_patient_status_summary(self):
1022 """
1023 Calculates a statistical summary of patient eligibility.
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
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 }
1044 def reset_page(self):
1045 """
1046 Resets the page to its initial state.
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
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
1064 # Reload all patient data from the workspace
1065 self._load_patients()
1067 def resizeEvent(self, event):
1068 """
1069 Handles window resize events to make the UI responsive.
1071 Adjusts the number of columns in the grid, font sizes, and button padding
1072 based on the window height and width.
1074 Args:
1075 event (QResizeEvent): The resize event.
1076 """
1077 super().resizeEvent(event)
1079 # Update column count dynamically based on width
1080 self._update_column_count()
1082 height = self.height() # Current window height
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
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 """)
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 )
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
1139 def _translate_ui(self):
1140 """
1141 Translates all UI text elements to the current language.
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"))
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"))
1162 # Reload patient list after updating UI text
1163 self._load_patients()