Coverage for main/components/nifti_file_dialog.py: 98%
268 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
1from pathlib import Path
3from PyQt6.QtWidgets import (
4 QDialog, QVBoxLayout, QGroupBox, QGridLayout, QLabel, QLineEdit, QComboBox,
5 QCheckBox, QListWidget, QListWidgetItem, QPushButton, QDialogButtonBox,
6 QMessageBox
7)
8from PyQt6.QtCore import Qt, QCoreApplication
9from PyQt6.QtGui import QBrush, QColor
10import os
11from logger import get_logger
13log = get_logger()
15class NiftiFileDialog(QDialog):
16 def __init__(self, context, allow_multiple=None, has_existing_func=None, label=None, forced_filters=None):
17 super().__init__()
18 self.label = label
19 self.setWindowTitle(QCoreApplication.translate("Components", "Select NIfTI Files ({0})").format(self.label))
20 self.resize(700, 650)
22 self.workspace_path = context["workspace_path"]
23 self.allow_multiple = allow_multiple
24 self.has_existing_func = has_existing_func or (lambda *_: False)
25 self.forced_filters = forced_filters or {}
27 self.selected_files = []
28 self.relative_to_absolute = {}
29 self.files_with_flag = set()
31 self._build_ui()
32 self._populate_files()
33 self._connect_signals()
35 # === Public API ===
36 @staticmethod
37 def get_files(context, allow_multiple=True, has_existing_func=None, label="mask",forced_filters=None):
38 dialog = NiftiFileDialog(context, allow_multiple, has_existing_func, label,forced_filters)
39 if dialog.exec() == QDialog.DialogCode.Accepted:
40 return dialog.selected_files
41 return None
43 # === UI Construction ===
44 def _build_ui(self):
45 layout = QVBoxLayout(self)
47 # === Filters ===
48 filter_group = QGroupBox(QCoreApplication.translate("Components", "Filters"))
49 filter_layout = QGridLayout(filter_group)
51 self.search_bar = QLineEdit()
52 self.search_bar.setPlaceholderText(QCoreApplication.translate("Components", "Search files (FLAIR, T1, T2, etc.)"))
53 self.search_bar.setClearButtonEnabled(True)
54 filter_layout.addWidget(QLabel(QCoreApplication.translate("Components", "Search:")), 0, 0)
55 filter_layout.addWidget(self.search_bar, 0, 1, 1, 3)
57 self.subject_combo = QComboBox()
58 self.subject_combo.addItem(QCoreApplication.translate("Components", "All subjects"))
59 self.subject_combo.model().item(0)
60 filter_layout.addWidget(QLabel(QCoreApplication.translate("Components", "Subject:")), 1, 0)
61 filter_layout.addWidget(self.subject_combo, 1, 1)
63 self.session_combo = QComboBox()
64 self.session_combo.addItem(QCoreApplication.translate("Components", "All sessions"))
65 self.session_combo.model().item(0)
66 filter_layout.addWidget(QLabel(QCoreApplication.translate("Components", "Session:")), 1, 2)
67 filter_layout.addWidget(self.session_combo, 1, 3)
69 self.modality_combo = QComboBox()
70 self.modality_combo.addItem(QCoreApplication.translate("Components", "All modalities"))
71 self.modality_combo.model().item(0)
72 filter_layout.addWidget(QLabel(QCoreApplication.translate("Components", "Modality:")), 2, 0)
73 filter_layout.addWidget(self.modality_combo, 2, 1)
75 self.datatype_combo = QComboBox()
76 self.datatype_combo.addItem(QCoreApplication.translate("Components", "All types"))
77 self.datatype_combo.model().item(0)
78 filter_layout.addWidget(QLabel(QCoreApplication.translate("Components", "Data type:")), 2, 2)
79 filter_layout.addWidget(self.datatype_combo, 2, 3)
81 self.no_flag_checkbox = QCheckBox(QCoreApplication.translate("Components", "Show only files without existing {0}s").format(self.label))
82 self.with_flag_checkbox = QCheckBox(QCoreApplication.translate("Components", "Show only files with existing {0}s").format(self.label))
83 if self.label is None:
84 self.no_flag_checkbox.setVisible(False)
85 self.with_flag_checkbox.setVisible(False)
86 filter_layout.addWidget(self.no_flag_checkbox, 3, 0, 1, 2)
87 filter_layout.addWidget(self.with_flag_checkbox, 3, 2, 1, 2)
89 layout.addWidget(filter_group)
91 # === Info label ===
92 self.info_label = QLabel()
93 layout.addWidget(self.info_label)
95 # === File list ===
96 self.file_list = QListWidget()
97 self.file_list.setEditTriggers(QListWidget.EditTrigger.NoEditTriggers)
98 self.file_list.setSelectionMode(
99 QListWidget.SelectionMode.ExtendedSelection if self.allow_multiple else QListWidget.SelectionMode.SingleSelection
100 )
101 self.file_list.setAlternatingRowColors(True)
102 layout.addWidget(self.file_list)
104 # === Buttons ===
105 self.buttons = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel)
107 reset_button = QPushButton(QCoreApplication.translate("Components", "Reset Filters"))
108 select_all_button = QPushButton(QCoreApplication.translate("Components", "Select All Visible"))
109 deselect_all_button = QPushButton(QCoreApplication.translate("Components", "Deselect All"))
111 self.buttons.addButton(reset_button, QDialogButtonBox.ButtonRole.ResetRole)
112 self.buttons.addButton(select_all_button, QDialogButtonBox.ButtonRole.ActionRole)
113 self.buttons.addButton(deselect_all_button, QDialogButtonBox.ButtonRole.ActionRole)
115 layout.addWidget(self.buttons)
117 # === Store buttons for handlers ===
118 self._reset_button = reset_button
119 self._select_all_button = select_all_button
120 self._deselect_all_button = deselect_all_button
122 # === Populate ===
123 def _populate_files(self):
124 self.all_nii_files = []
125 subjects_set, sessions_set, modalities_set, datatypes_set = set(), set(), set(), set()
127 for root, dirs, files in os.walk(self.workspace_path):
128 # dirs[:] = [d for d in dirs if d != "derivatives"]
130 for f in files:
131 if f.endswith((".nii", ".nii.gz")):
132 full_path = os.path.join(root, f)
133 relative_path = os.path.relpath(full_path, self.workspace_path)
134 self.all_nii_files.append(relative_path)
135 self.relative_to_absolute[relative_path] = full_path
137 path_parts = relative_path.split(os.sep)
139 # subject
140 for part in path_parts:
141 if part.startswith("sub-"):
142 subjects_set.add(part)
143 break
145 # session
146 for part in path_parts:
147 if part.startswith("ses-"):
148 sessions_set.add(part)
149 break
151 # modality
152 filename_noext = os.path.splitext(os.path.splitext(f)[0])[0]
153 modality = filename_noext.split("_")[-1] or QCoreApplication.translate("Components", "Unknown")
154 modalities_set.add(modality)
156 # existing flag (mask/skull strip)
157 if self.has_existing_func(full_path, self.workspace_path):
158 self.files_with_flag.add(relative_path)
160 # --- Cerca data type directories ---
161 for subject_dir in sorted(Path(self.workspace_path).glob("sub-*")):
162 if subject_dir.is_dir():
163 # Caso: sub-*/anat, sub-*/func, ecc.
164 for child in subject_dir.iterdir():
165 if child.is_dir() and not child.name.startswith("ses-"):
166 datatypes_set.add(child.name)
168 # Caso: sub-*/ses-*/anat, sub-*/ses-*/func, ecc.
169 for ses_dir in subject_dir.glob("ses-*"):
170 if ses_dir.is_dir():
171 for child in ses_dir.iterdir():
172 if child.is_dir():
173 datatypes_set.add(child.name)
175 self.subject_combo.addItems(sorted(subjects_set))
176 self.session_combo.addItems(sorted(sessions_set))
177 self.modality_combo.addItems(sorted(modalities_set))
178 self.datatype_combo.addItems(sorted(datatypes_set))
180 self._update_info_label(len(self.all_nii_files))
181 self._populate_file_list()
183 if self.forced_filters:
184 self._apply_forced_filters()
186 def _populate_file_list(self):
187 self.file_list.clear()
188 for relative_path in sorted(self.all_nii_files):
189 item = QListWidgetItem(relative_path)
190 if relative_path in self.files_with_flag:
191 item.setForeground(QBrush(QColor(255, 193, 7)))
192 item.setToolTip(QCoreApplication.translate("Components", "{0}\n✓ Existing {1}").format(self.relative_to_absolute[relative_path], self.label))
193 else:
194 item.setToolTip(QCoreApplication.translate("Components", "{0}\n○ No {1}").format(self.relative_to_absolute[relative_path], self.label))
195 self.file_list.addItem(item)
197 # === Helpers ===
198 def _update_info_label(self, visible_count):
199 info_text = QCoreApplication.translate("Components", "Showing {0} of {1} files").format(visible_count, len(self.all_nii_files))
200 if self.files_with_flag:
201 info_text += QCoreApplication.translate("Components", " ({0} with existing {1}s)").format(len(self.files_with_flag), self.label)
202 self.info_label.setText(info_text)
203 self.info_label.setStyleSheet("color: gray; font-size: 10px;")
205 def _apply_filters(self):
206 search_text = self.search_bar.text().lower()
207 selected_subject = self.subject_combo.currentText()
208 selected_session = self.session_combo.currentText()
209 selected_modality = self.modality_combo.currentText()
210 selected_datatype = self.datatype_combo.currentText()
212 show_only_no_flag = self.no_flag_checkbox.isChecked()
213 show_only_with_flag = self.with_flag_checkbox.isChecked()
215 visible_count = 0
216 for i in range(self.file_list.count()):
217 item = self.file_list.item(i)
218 relative_path = item.text()
219 should_show = True
221 if search_text and search_text not in relative_path.lower():
222 should_show = False
223 if selected_subject != QCoreApplication.translate("Components", "All subjects") and selected_subject not in relative_path:
224 should_show = False
225 if selected_session != QCoreApplication.translate("Components", "All sessions") and selected_session not in relative_path:
226 should_show = False
227 if selected_modality != QCoreApplication.translate("Components", "All modalities") and selected_modality not in relative_path:
228 should_show = False
229 if selected_datatype != QCoreApplication.translate("Components", "All types") and f"/{selected_datatype}/" not in relative_path and f"\\{selected_datatype}\\" not in relative_path:
230 should_show = False
232 has_flag = relative_path in self.files_with_flag
233 if show_only_no_flag and has_flag:
234 should_show = False
235 if show_only_with_flag and not has_flag:
236 should_show = False
238 item.setHidden(not should_show)
239 if should_show:
240 visible_count += 1
242 self._update_info_label(visible_count)
244 # === Signals ===
245 def _connect_signals(self):
246 self.search_bar.textChanged.connect(self._apply_filters)
247 self.subject_combo.currentTextChanged.connect(self._apply_filters)
248 self.session_combo.currentTextChanged.connect(self._apply_filters)
249 self.modality_combo.currentTextChanged.connect(self._apply_filters)
250 self.datatype_combo.currentTextChanged.connect(self._apply_filters)
251 self.no_flag_checkbox.toggled.connect(self._apply_filters)
252 self.with_flag_checkbox.toggled.connect(self._apply_filters)
254 def on_no_flag_toggled(checked):
255 if checked:
256 self.with_flag_checkbox.setChecked(False)
257 def on_with_flag_toggled(checked):
258 if checked:
259 self.no_flag_checkbox.setChecked(False)
260 self.no_flag_checkbox.toggled.connect(on_no_flag_toggled)
261 self.with_flag_checkbox.toggled.connect(on_with_flag_toggled)
263 self._reset_button.clicked.connect(self._reset_filters)
264 self._select_all_button.clicked.connect(self._select_all_visible)
265 self._deselect_all_button.clicked.connect(lambda: self.file_list.clearSelection())
267 self.buttons.accepted.connect(self._accept)
268 self.buttons.rejected.connect(self.reject)
270 def _reset_filters(self):
271 self.search_bar.clear()
272 self.subject_combo.setCurrentIndex(0)
273 self.session_combo.setCurrentIndex(0)
274 self.modality_combo.setCurrentIndex(0)
275 self.datatype_combo.setCurrentIndex(0)
276 self.no_flag_checkbox.setChecked(False)
277 self.with_flag_checkbox.setChecked(False)
279 def _select_all_visible(self):
280 for i in range(self.file_list.count()):
281 item = self.file_list.item(i)
282 if not item.isHidden():
283 item.setSelected(True)
285 def _accept(self):
286 selected_items = [item for item in self.file_list.selectedItems() if not item.isHidden()]
287 if not selected_items:
288 QMessageBox.warning(
289 self,
290 QCoreApplication.translate("Components", "No selection"),
291 QCoreApplication.translate("Components", "Please select at least one visible NIfTI file."))
292 log.info("No selection")
293 return
295 selected_files = []
296 warnings = []
298 for item in selected_items:
299 relative_path = item.text()
300 abs_path = self.relative_to_absolute[relative_path]
301 path_parts = abs_path.replace(self.workspace_path, '').strip(os.sep).split(os.sep)
302 subject = next((p for p in path_parts if p.startswith('sub-')), None)
303 session = next((p for p in path_parts if p.startswith('ses-')), None)
304 filename = os.path.basename(abs_path)
305 filename_noext = os.path.splitext(os.path.splitext(filename)[0])[0]
306 modality = filename_noext.split("_")[-1] or QCoreApplication.translate("Components", "Unknown")
307 datatype = next((p for p in path_parts if p in ["anat", "func", "dwi", "fmap", "perf"]), None)
309 has_flag = relative_path in self.files_with_flag
310 if has_flag:
311 warnings.append((abs_path, subject))
313 selected_files.append(abs_path)
315 # Show warnings if needed
316 if warnings:
317 unique_subjects = sorted({s for _, s in warnings if s})
318 msg = QMessageBox(self)
319 msg.setIcon(QMessageBox.Icon.Warning)
320 msg.setWindowTitle(QCoreApplication.translate("Components", "Existing {0} Detected").format(self.label.title()))
321 if len(unique_subjects) == 1:
322 subject_display = unique_subjects[0] or QCoreApplication.translate("Components", "this patient")
323 msg.setText(QCoreApplication.translate("Components", "A {0} already exists for {1}.").format(self.label, subject_display))
324 else:
325 msg.setText(QCoreApplication.translate("Components", "{0}s already exist for {1} patients: {2}").format(self.label.title(), len(unique_subjects), ', '.join(unique_subjects)))
326 msg.setInformativeText(QCoreApplication.translate("Components", "You can still proceed to create additional {0}s. Continue?").format(self.label))
327 msg.setStandardButtons(QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No)
328 if msg.exec() == QMessageBox.StandardButton.No:
329 return
331 self.selected_files = selected_files
332 self.accept()
334 def _apply_forced_filters(self):
335 """Applica i filtri forzati definiti in self.forced_filters."""
336 if "search" in self.forced_filters:
337 self.search_bar.setText(self.forced_filters["search"])
338 self.search_bar.setEnabled(False)
340 def safe_set_combo_value(combo, key):
341 if key in self.forced_filters:
342 value = self.forced_filters[key]
343 index = combo.findText(value)
344 if index >= 0:
345 combo.setCurrentIndex(index)
346 else:
347 combo.addItem(value)
348 combo.setCurrentIndex(combo.count() - 1)
349 combo.setEnabled(False)
351 safe_set_combo_value(self.subject_combo, "subject")
352 safe_set_combo_value(self.session_combo, "session")
353 safe_set_combo_value(self.modality_combo, "modality")
354 safe_set_combo_value(self.datatype_combo, "datatype")
356 if "no_flag" in self.forced_filters:
357 self.no_flag_checkbox.setChecked(bool(self.forced_filters["no_flag"]))
358 self.no_flag_checkbox.setEnabled(False)
360 if "with_flag" in self.forced_filters:
361 self.with_flag_checkbox.setChecked(bool(self.forced_filters["with_flag"]))
362 self.with_flag_checkbox.setEnabled(False)
364 # Riapplica i filtri
365 self._apply_filters()