diff --git a/src/analysis/a_groupfunctionalconnectivity.py b/src/analysis/a_groupfunctionalconnectivity.py new file mode 100644 index 0000000..66df252 --- /dev/null +++ b/src/analysis/a_groupfunctionalconnectivity.py @@ -0,0 +1,178 @@ +import os +from PySide6.QtWidgets import QComboBox, QDialog, QGridLayout, QHBoxLayout, QMessageBox, QPushButton, QScrollArea, QWidget, QVBoxLayout, QLabel +from PySide6.QtCore import QSize + +from src.shared.s_flaresbasewidget import FlaresBaseWidget, ParameterInputDialog + + +class GroupFunctionalConnectivityWidget(FlaresBaseWidget): + def __init__(self, haemo_dict, group, config_dict): + super().__init__("GroupFunctionalConnectivityWidget") + self.setWindowTitle("FLARES Group Viewer") + self.haemo_dict = haemo_dict + self.group = group + self.config_dict = config_dict + self.show_all_events = True + self._updating_checkstates = False + + QMessageBox.warning(self, "Warning - FLARES", f"Functional Connectivity is still in development and the results should currently be taken with a grain of salt. " + "By clicking OK, you accept that the images generated may not be factual.") + + + # Create mappings: file_path -> participant label and dropdown display text + self.participant_map = {} # file_path -> "Participant 1" + self.participant_dropdown_items = [] # "Participant 1 (filename)" + + for i, file_path in enumerate(self.haemo_dict.keys(), start=1): + short_label = f"Participant {i}" + display_label = f"{short_label} ({os.path.basename(file_path)})" + self.participant_map[file_path] = short_label + self.participant_dropdown_items.append(display_label) + + self.layout = QVBoxLayout(self) + self.top_bar = QHBoxLayout() + self.layout.addLayout(self.top_bar) + + self.group_to_paths = {} + for file_path, group_name in self.group.items(): + self.group_to_paths.setdefault(group_name, []).append(file_path) + + self.group_names = sorted(self.group_to_paths.keys()) + + self.group_dropdown = QComboBox() + self.group_dropdown.addItem("") + self.group_dropdown.addItems(self.group_names) + self.group_dropdown.setCurrentIndex(0) + self.group_dropdown.currentIndexChanged.connect(self.update_participant_list_for_group) + + self.participant_dropdown = self._create_multiselect_dropdown(self.participant_dropdown_items) + self.participant_dropdown.currentIndexChanged.connect(self.update_participant_dropdown_label) + self.participant_dropdown.setEnabled(False) + + self.event_dropdown = QComboBox() + self.event_dropdown.addItem("") + + self.index_texts = [ + "0 (Betas)", + #"1 (Significance)", + #"2 (Brain Activity Visualization)", + # "3 (fourth image)", + ] + + self.image_index_dropdown = self._create_multiselect_dropdown(self.index_texts) + self.image_index_dropdown.currentIndexChanged.connect(self.update_image_index_dropdown_label) + + self.submit_button = QPushButton("Submit") + self.submit_button.clicked.connect(self.show_brain_images) + + self.top_bar.addWidget(QLabel("Group:")) + self.top_bar.addWidget(self.group_dropdown) + self.top_bar.addWidget(QLabel("Participants:")) + self.top_bar.addWidget(self.participant_dropdown) + self.top_bar.addWidget(QLabel("Event:")) + self.top_bar.addWidget(self.event_dropdown) + self.top_bar.addWidget(QLabel("Image Indexes:")) + self.top_bar.addWidget(self.image_index_dropdown) + self.top_bar.addWidget(self.submit_button) + + self.scroll = QScrollArea() + self.scroll.setWidgetResizable(True) + self.scroll_content = QWidget() + self.grid_layout = QGridLayout(self.scroll_content) + self.scroll.setWidget(self.scroll_content) + self.layout.addWidget(self.scroll) + + self.thumb_size = QSize(280, 180) + self.showMaximized() + + + def show_brain_images(self): + import src.flares as flares + + selected_event = self.event_dropdown.currentText() + if selected_event == "": + selected_event = None + + selected_display_names = self._get_checked_items(self.participant_dropdown) + selected_file_paths = [] + for display_name in selected_display_names: + for fp, short_label in self.participant_map.items(): + expected_display = f"{short_label} ({os.path.basename(fp)})" + if display_name == expected_display: + selected_file_paths.append(fp) + break + + if selected_event: + valid_paths = [] + for fp in selected_file_paths: + raw = self.haemo_dict.get(fp) + # Check if this participant actually has the event in their annotations + if raw is not None and hasattr(raw, "annotations"): + if selected_event in raw.annotations.description: + valid_paths.append(fp) + + selected_file_paths = valid_paths + + selected_indexes = [ + int(s.split(" ")[0]) for s in self._get_checked_items(self.image_index_dropdown) + ] + + if not selected_file_paths: + print("No participants selected.") + return + + # Only keep indexes 0 and 1 that need parameters + parameterized_indexes = { + 0: [ + { + "key": "n_lines", + "label": "", + "default": "20", + "type": int, + }, + { + "key": "vmin", + "label": "", + "default": "0.9", + "type": float, + }, + ], + } + + # Inject full_text from index_texts + for idx, params_list in parameterized_indexes.items(): + full_text = self.index_texts[idx] if idx < len(self.index_texts) else f"{idx} (No label found)" + for param_info in params_list: + param_info["full_text"] = full_text + + indexes_needing_params = {idx: parameterized_indexes[idx] for idx in selected_indexes if idx in parameterized_indexes} + + param_values = {} + if indexes_needing_params: + dialog = ParameterInputDialog(indexes_needing_params, parent=self) + if dialog.exec_() == QDialog.Accepted: + param_values = dialog.get_values() + if param_values is None: + return + else: + return + + for idx in selected_indexes: + if idx == 0: + params = param_values.get(idx, {}) + n_lines = params.get("n_lines", None) + vmin = params.get("vmin", None) + + if n_lines is None or vmin is None: + print(f"Missing parameters for index {idx}, skipping.") + continue + flares.run_group_functional_connectivity(self.haemo_dict, self.config_dict, selected_file_paths, selected_event, 50, 0.5) + elif idx == 1: + pass + elif idx == 2: + pass + elif idx == 3: + pass + + else: + print(f"No method defined for index {idx}") diff --git a/src/analysis/a_participant.py b/src/analysis/a_participant.py new file mode 100644 index 0000000..ef87851 --- /dev/null +++ b/src/analysis/a_participant.py @@ -0,0 +1,155 @@ +import os +from pathlib import Path +from datetime import datetime + +from PySide6.QtWidgets import QComboBox, QFrame, QGridLayout, QHBoxLayout, QListView, QMessageBox, QPushButton, QScrollArea, QSpinBox, QWidget, QVBoxLayout, QLabel, QLineEdit +from PySide6.QtCore import QThread, Signal, Qt, QTimer, QEvent, QSize, QPoint, QUrl +from PySide6.QtGui import QAction, QDesktopServices, QKeySequence, QIcon, QIntValidator, QDoubleValidator, QPixmap, QStandardItemModel, QStandardItem, QImage + +from src.shared.s_flaresbasewidget import ClickableLabel, FlaresBaseWidget + + +class ParticipantViewerWidget(FlaresBaseWidget): + def __init__(self, haemo_dict, fig_bytes_dict): + super().__init__("ParticipantViewer") + self.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose) + self.setWindowTitle("FLARES Participant Viewer") + self.haemo_dict = haemo_dict + self.fig_bytes_dict = fig_bytes_dict + + # Create mappings: file_path -> participant label and dropdown display text + self.participant_map = {} # file_path -> "Participant 1" + self.participant_dropdown_items = [] # "Participant 1 (filename)" + + for i, file_path in enumerate(self.haemo_dict.keys(), start=1): + short_label = f"Participant {i}" + display_label = f"{short_label} ({os.path.basename(file_path)})" + self.participant_map[file_path] = short_label + self.participant_dropdown_items.append(display_label) + + self.layout = QVBoxLayout(self) + self.top_bar = QHBoxLayout() + self.layout.addLayout(self.top_bar) + + self.participant_dropdown = self._create_multiselect_dropdown(self.participant_dropdown_items) + self.participant_dropdown.currentIndexChanged.connect(self.update_participant_dropdown_label) + + first_fig_dict = next(iter(self.fig_bytes_dict.values())) + image_label_items = list(first_fig_dict.keys()) + + self.image_index_dropdown = self._create_multiselect_dropdown(image_label_items) + self.image_index_dropdown.currentIndexChanged.connect(self.update_image_index_dropdown_label) + + self.submit_button = QPushButton("Submit") + self.submit_button.clicked.connect(self.show_selected_images) + + self.top_bar.addWidget(QLabel("Participants:")) + self.top_bar.addWidget(self.participant_dropdown) + self.top_bar.addWidget(QLabel("Image Indexes:")) + self.top_bar.addWidget(self.image_index_dropdown) + self.top_bar.addWidget(self.submit_button) + + self.scroll = QScrollArea() + self.scroll.setWidgetResizable(True) + self.scroll_content = QWidget() + self.grid_layout = QGridLayout(self.scroll_content) + self.scroll.setWidget(self.scroll_content) + self.layout.addWidget(self.scroll) + + self.thumb_size = QSize(280, 180) + + self.save_button = QPushButton("Save Displayed Images") + self.save_button.clicked.connect(self.save_displayed_images) + self.top_bar.addWidget(self.save_button) + + self.showMaximized() + + + + def show_selected_images(self): + # Clear previous images + while self.grid_layout.count(): + item = self.grid_layout.takeAt(0) + widget = item.widget() + if widget: + widget.deleteLater() + + selected_display_names = self._get_checked_items(self.participant_dropdown) + # Map from display names back to file paths + selected_file_paths = [] + for display_name in selected_display_names: + # Find file_path by matching display name + for fp, short_label in self.participant_map.items(): + expected_display = f"{short_label} ({os.path.basename(fp)})" + if display_name == expected_display: + selected_file_paths.append(fp) + break + + selected_labels = self._get_checked_items(self.image_index_dropdown) + + row, col = 0, 0 + for file_path in selected_file_paths: + fig_list = self.fig_bytes_dict.get(file_path, []) + participant_label = self.participant_map[file_path] + for label in selected_labels: + fig_bytes = fig_list.get(label) + if not fig_bytes: + continue + + full_pixmap = QPixmap() + full_pixmap.loadFromData(fig_bytes) + + thumbnail_pixmap = full_pixmap.scaled( + self.thumb_size, + Qt.AspectRatioMode.KeepAspectRatio, + Qt.TransformationMode.SmoothTransformation + ) + + container = QWidget() + hlayout = QHBoxLayout(container) + hlayout.setContentsMargins(0, 0, 0, 0) + hlayout.setSpacing(0) + hlayout.setAlignment(Qt.AlignmentFlag.AlignCenter) + + image_label = ClickableLabel(full_pixmap, thumbnail_pixmap) + image_label.setToolTip(f"{participant_label}\n{label}") + hlayout.addWidget(image_label) + + self.grid_layout.addWidget(container, row, col) + + col += 1 + if col >= 6: + col = 0 + row += 1 + + # Update dropdown labels after display + self.update_participant_dropdown_label() + self.update_image_index_dropdown_label() + + + def save_displayed_images(self): + # Ensure the folder exists + save_dir = Path("individual_images") + save_dir.mkdir(exist_ok=True) + + selected_display_names = self._get_checked_items(self.participant_dropdown) + selected_image_labels = self._get_checked_items(self.image_index_dropdown) + + for display_name in selected_display_names: + # Match display name to file path + for file_path, short_label in self.participant_map.items(): + expected_display = f"{short_label} ({os.path.basename(file_path)})" + if display_name == expected_display: + fig_dict = self.fig_bytes_dict.get(file_path, {}) + for label in selected_image_labels: + if label not in fig_dict: + continue + fig_bytes = fig_dict[label] + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + filename = f"{os.path.basename(file_path)}_{label}_{timestamp}.png" + output_path = save_dir / filename + with open(output_path, "wb") as f: + f.write(fig_bytes) + break # file_path matched; stop loop + + QMessageBox.information(self, "Save Complete", f"Images saved to {save_dir.resolve()}") \ No newline at end of file diff --git a/src/analysis/a_participantbrain.py b/src/analysis/a_participantbrain.py new file mode 100644 index 0000000..fa532f6 --- /dev/null +++ b/src/analysis/a_participantbrain.py @@ -0,0 +1,193 @@ +import os + +from PySide6.QtWidgets import QComboBox, QDialog, QFrame, QGridLayout, QHBoxLayout, QListView, QMessageBox, QPushButton, QScrollArea, QSpinBox, QWidget, QVBoxLayout, QLabel, QLineEdit +from PySide6.QtCore import QThread, Signal, Qt, QTimer, QEvent, QSize, QPoint, QUrl + +from src.shared.s_flaresbasewidget import FlaresBaseWidget, ParameterInputDialog + + +class ParticipantBrainViewerWidget(FlaresBaseWidget): + def __init__(self, haemo_dict, cha_dict): + super().__init__("ParticipantBrainViewer") + self.setWindowTitle("FLARES Participant Brain Viewer") + self.haemo_dict = haemo_dict + self.cha_dict = cha_dict + + # Create mappings: file_path -> participant label and dropdown display text + self.participant_map = {} # file_path -> "Participant 1" + self.participant_dropdown_items = [] # "Participant 1 (filename)" + + for i, file_path in enumerate(self.haemo_dict.keys(), start=1): + short_label = f"Participant {i}" + display_label = f"{short_label} ({os.path.basename(file_path)})" + self.participant_map[file_path] = short_label + self.participant_dropdown_items.append(display_label) + + self.layout = QVBoxLayout(self) + self.top_bar = QHBoxLayout() + self.layout.addLayout(self.top_bar) + + self.participant_dropdown = self._create_multiselect_dropdown(self.participant_dropdown_items) + self.participant_dropdown.currentIndexChanged.connect(self.update_participant_dropdown_label) + + self.event_dropdown = QComboBox() + self.event_dropdown.addItem("") + + + self.index_texts = [ + "0 (Brain Landmarks)", + "1 (Brain Activity Visualization)", + # "2 (third image)", + # "3 (fourth image)", + ] + + self.image_index_dropdown = self._create_multiselect_dropdown(self.index_texts) + self.image_index_dropdown.currentIndexChanged.connect(self.update_image_index_dropdown_label) + + self.submit_button = QPushButton("Submit") + self.submit_button.clicked.connect(self.show_brain_images) + + self.top_bar.addWidget(QLabel("Participants:")) + self.top_bar.addWidget(self.participant_dropdown) + self.top_bar.addWidget(QLabel("Event:")) + self.top_bar.addWidget(self.event_dropdown) + self.top_bar.addWidget(QLabel("Image Indexes:")) + self.top_bar.addWidget(self.image_index_dropdown) + self.top_bar.addWidget(self.submit_button) + + self.scroll = QScrollArea() + self.scroll.setWidgetResizable(True) + self.scroll_content = QWidget() + self.grid_layout = QGridLayout(self.scroll_content) + self.scroll.setWidget(self.scroll_content) + self.layout.addWidget(self.scroll) + + self.thumb_size = QSize(280, 180) + self.showMaximized() + + + def show_brain_images(self): + import src.flares as flares + + selected_event = self.event_dropdown.currentText() + if selected_event == "": + selected_event = None + + selected_display_names = self._get_checked_items(self.participant_dropdown) + selected_file_paths = [] + for display_name in selected_display_names: + for fp, short_label in self.participant_map.items(): + expected_display = f"{short_label} ({os.path.basename(fp)})" + if display_name == expected_display: + selected_file_paths.append(fp) + break + + selected_indexes = [ + int(s.split(" ")[0]) for s in self._get_checked_items(self.image_index_dropdown) + ] + + + parameterized_indexes = { + 0: [ + { + "key": "show_optodes", + "label": "Determine what is rendered above the brain. Valid values are 'sensors', 'labels', 'none', 'all'.", + "default": "all", + "type": str, + }, + { + "key": "show_brodmann", + "label": "Show common brodmann areas on the brain.", + "default": "True", + "type": bool, + } + ], + 1: [ + { + "key": "show_optodes", + "label": "Determine what is rendered above the brain. Valid values are 'sensors', 'labels', 'none', 'all'.", + "default": "all", + "type": str, + }, + { + "key": "t_or_theta", + "label": "Specify if t values or theta values should be plotted. Valid values are 't', 'theta'", + "default": "theta", + "type": str, + }, + { + "key": "show_text", + "label": "Display informative text on the top left corner. THIS DOES NOT WORK AND SHOULD BE LEFT AT FALSE", + "default": "False", + "type": bool, + }, + { + "key": "brain_bounds", + "label": "Graph Upper/Lower Limit", + "default": "1.0", + "type": float, + } + ], + } + + # Inject full_text from index_texts + for idx, params_list in parameterized_indexes.items(): + full_text = self.index_texts[idx] if idx < len(self.index_texts) else f"{idx} (No label found)" + for param_info in params_list: + param_info["full_text"] = full_text + + indexes_needing_params = {idx: parameterized_indexes[idx] for idx in selected_indexes if idx in parameterized_indexes} + + param_values = {} + if indexes_needing_params: + dialog = ParameterInputDialog(indexes_needing_params, parent=self) + if dialog.exec_() == QDialog.Accepted: + param_values = dialog.get_values() + if param_values is None: + return + else: + return + + # Pass the necessary arguments to each method + for file_path in selected_file_paths: + haemo_obj = self.haemo_dict.get(file_path) + + if selected_event: + participant_events = set(haemo_obj.annotations.description) + if selected_event not in participant_events: + print(f"Skipping {self.participant_map[file_path]}: Event '{selected_event}' not found.") + continue + + if haemo_obj is None: + raise Exception("How did we get here?") + + cha = self.cha_dict.get(file_path) + + for idx in selected_indexes: + if idx == 0: + + params = param_values.get(idx, {}) + show_optodes = params.get("show_optodes", None) + show_brodmann = params.get("show_brodmann", None) + + if show_optodes is None or show_brodmann is None: + print(f"Missing parameters for index {idx}, skipping.") + continue + + flares.brain_landmarks_3d(haemo_obj, show_optodes, show_brodmann) + + elif idx == 1: + params = param_values.get(idx, {}) + show_optodes = params.get("show_optodes", None) + t_or_theta = params.get("t_or_theta", None) + show_text = params.get("show_text", None) + brain_bounds = params.get("brain_bounds", None) + + if show_optodes is None or t_or_theta is None or show_text is None or brain_bounds is None: + print(f"Missing parameters for index {idx}, skipping.") + continue + + flares.brain_3d_visualization(haemo_obj, cha, selected_event, t_or_theta=t_or_theta, show_optodes=show_optodes, show_text=show_text, brain_bounds=brain_bounds) + + else: + print(f"No method defined for index {idx}") \ No newline at end of file diff --git a/src/analysis/a_participantfunctionalconnectivity.py b/src/analysis/a_participantfunctionalconnectivity.py new file mode 100644 index 0000000..af3c2be --- /dev/null +++ b/src/analysis/a_participantfunctionalconnectivity.py @@ -0,0 +1,230 @@ +import os + +from PySide6.QtWidgets import QComboBox, QDialog, QFrame, QGridLayout, QHBoxLayout, QListView, QMessageBox, QPushButton, QScrollArea, QSpinBox, QWidget, QVBoxLayout, QLabel, QLineEdit +from PySide6.QtCore import QThread, Signal, Qt, QTimer, QEvent, QSize, QPoint, QUrl + +from src.shared.s_flaresbasewidget import FlaresBaseWidget, ParameterInputDialog + +class ParticipantFunctionalConnectivityWidget(FlaresBaseWidget): + def __init__(self, haemo_dict, epochs_dict): + super().__init__("FunctionalConnectivityWidget") + self.setWindowTitle("FLARES Functional Connectivity Viewer [BETA]") + self.haemo_dict = haemo_dict + self.epochs_dict = epochs_dict + + QMessageBox.warning(self, "Warning - FLARES", f"Functional Connectivity is still in development and the results should currently be taken with a grain of salt. " + "By clicking OK, you accept that the images generated may not be factual.") + + # Create mappings: file_path -> participant label and dropdown display text + self.participant_map = {} # file_path -> "Participant 1" + self.participant_dropdown_items = [] # "Participant 1 (filename)" + + for i, file_path in enumerate(self.haemo_dict.keys(), start=1): + short_label = f"Participant {i}" + display_label = f"{short_label} ({os.path.basename(file_path)})" + self.participant_map[file_path] = short_label + self.participant_dropdown_items.append(display_label) + + self.layout = QVBoxLayout(self) + self.top_bar = QHBoxLayout() + self.layout.addLayout(self.top_bar) + + self.participant_dropdown = self._create_multiselect_dropdown(self.participant_dropdown_items) + self.participant_dropdown.currentIndexChanged.connect(self.update_participant_dropdown_label) + + self.event_dropdown = QComboBox() + self.event_dropdown.addItem("") + + + self.index_texts = [ + "0 (Spectral Connectivity Epochs)", + "1 (Envelope Correlation)", + "2 (Betas)", + "3 (Spectral Connectivity Epochs)", + ] + + self.image_index_dropdown = self._create_multiselect_dropdown(self.index_texts) + self.image_index_dropdown.currentIndexChanged.connect(self.update_image_index_dropdown_label) + + self.submit_button = QPushButton("Submit") + self.submit_button.clicked.connect(self.show_brain_images) + + self.top_bar.addWidget(QLabel("Participants:")) + self.top_bar.addWidget(self.participant_dropdown) + self.top_bar.addWidget(QLabel("Event:")) + self.top_bar.addWidget(self.event_dropdown) + self.top_bar.addWidget(QLabel("Image Indexes:")) + self.top_bar.addWidget(self.image_index_dropdown) + self.top_bar.addWidget(self.submit_button) + + self.scroll = QScrollArea() + self.scroll.setWidgetResizable(True) + self.scroll_content = QWidget() + self.grid_layout = QGridLayout(self.scroll_content) + self.scroll.setWidget(self.scroll_content) + self.layout.addWidget(self.scroll) + + self.thumb_size = QSize(280, 180) + self.showMaximized() + + + def show_brain_images(self): + import src.flares as flares + + selected_event = self.event_dropdown.currentText() + if selected_event == "": + selected_event = None + + selected_display_names = self._get_checked_items(self.participant_dropdown) + selected_file_paths = [] + for display_name in selected_display_names: + for fp, short_label in self.participant_map.items(): + expected_display = f"{short_label} ({os.path.basename(fp)})" + if display_name == expected_display: + selected_file_paths.append(fp) + break + + selected_indexes = [ + int(s.split(" ")[0]) for s in self._get_checked_items(self.image_index_dropdown) + ] + + + parameterized_indexes = { + 0: [ + { + "key": "n_lines", + "label": "", + "default": "20", + "type": int, + }, + { + "key": "vmin", + "label": "", + "default": "0.9", + "type": float, + }, + ], + 1: [ + { + "key": "n_lines", + "label": "", + "default": "20", + "type": int, + }, + { + "key": "vmin", + "label": "", + "default": "0.9", + "type": float, + }, + + ], + 2: [ + { + "key": "n_lines", + "label": "", + "default": "20", + "type": int, + }, + { + "key": "vmin", + "label": "", + "default": "0.9", + "type": float, + }, + + ], + 3: [ + { + "key": "n_lines", + "label": "", + "default": "20", + "type": int, + }, + { + "key": "vmin", + "label": "", + "default": "0.9", + "type": float, + }, + + ], + } + + # Inject full_text from index_texts + for idx, params_list in parameterized_indexes.items(): + full_text = self.index_texts[idx] if idx < len(self.index_texts) else f"{idx} (No label found)" + for param_info in params_list: + param_info["full_text"] = full_text + + indexes_needing_params = {idx: parameterized_indexes[idx] for idx in selected_indexes if idx in parameterized_indexes} + + param_values = {} + if indexes_needing_params: + dialog = ParameterInputDialog(indexes_needing_params, parent=self) + if dialog.exec_() == QDialog.Accepted: + param_values = dialog.get_values() + if param_values is None: + return + else: + return + + # Pass the necessary arguments to each method + for file_path in selected_file_paths: + haemo_obj = self.haemo_dict.get(file_path) + epochs_obj = self.epochs_dict.get(file_path) + + if selected_event: + participant_events = set(haemo_obj.annotations.description) + if selected_event not in participant_events: + print(f"Skipping {self.participant_map[file_path]}: Event '{selected_event}' not found.") + continue + + if haemo_obj is None: + raise Exception("How did we get here?") + + + for idx in selected_indexes: + if idx == 0: + + params = param_values.get(idx, {}) + n_lines = params.get("n_lines", None) + vmin = params.get("vmin", None) + + if n_lines is None or vmin is None: + print(f"Missing parameters for index {idx}, skipping.") + continue + flares.functional_connectivity_spectral_epochs(epochs_obj, n_lines, vmin) + + elif idx == 1: + params = param_values.get(idx, {}) + n_lines = params.get("n_lines", None) + vmin = params.get("vmin", None) + + if n_lines is None or vmin is None: + print(f"Missing parameters for index {idx}, skipping.") + continue + flares.functional_connectivity_envelope(epochs_obj, n_lines, vmin) + + elif idx == 2: + params = param_values.get(idx, {}) + n_lines = params.get("n_lines", None) + vmin = params.get("vmin", None) + + if n_lines is None or vmin is None: + print(f"Missing parameters for index {idx}, skipping.") + continue + flares.functional_connectivity_betas(haemo_obj, n_lines, vmin, selected_event) + + elif idx == 3: + params = param_values.get(idx, {}) + n_lines = params.get("n_lines", None) + vmin = params.get("vmin", None) + + if n_lines is None or vmin is None: + print(f"Missing parameters for index {idx}, skipping.") + continue + flares.functional_connectivity_spectral_time(epochs_obj, n_lines, vmin) + + else: + print(f"No method defined for index {idx}") diff --git a/flares.py b/src/flares.py similarity index 100% rename from flares.py rename to src/flares.py diff --git a/flares_updater.py b/src/flares_updater.py similarity index 100% rename from flares_updater.py rename to src/flares_updater.py diff --git a/main.py b/src/main.py similarity index 65% rename from main.py rename to src/main.py index cf7b318..7ab8121 100644 --- a/main.py +++ b/src/main.py @@ -30,7 +30,13 @@ import numpy as np import pandas as pd import psutil -from updater import finish_update_if_needed, UpdateManager, LocalPendingUpdateCheckThread +from src.analysis.a_groupfunctionalconnectivity import GroupFunctionalConnectivityWidget +from src.analysis.a_participant import ParticipantViewerWidget +from src.analysis.a_participantbrain import ParticipantBrainViewerWidget +from src.analysis.a_participantfunctionalconnectivity import ParticipantFunctionalConnectivityWidget +from src.shared.s_flaresbasewidget import ParamSection, ParameterInputDialog +from src.shared.s_shared import API_URL, API_URL_SECONDARY, APP_NAME, CURRENT_VERSION, PIPELINE_STAGES, PLATFORM_NAME +from src.updater import finish_update_if_needed, UpdateManager, LocalPendingUpdateCheckThread from mne.io import read_raw_snirf from mne.preprocessing.nirs import source_detector_distances @@ -48,12 +54,12 @@ from PySide6.QtGui import QAction, QDesktopServices, QKeySequence, QIcon, QIntVa from PySide6.QtSvgWidgets import QSvgWidget # needed to show svgs when app is not frozen from PySide6.QtNetwork import QNetworkAccessManager, QNetworkRequest - -CURRENT_VERSION = "1.5.0" -APP_NAME = "flares" -API_URL = f"https://git.research.dezeeuw.ca/api/v1/repos/tyler/{APP_NAME}/releases" -API_URL_SECONDARY = f"https://git.research2.dezeeuw.ca/api/v1/repos/tyler/{APP_NAME}/releases" -PLATFORM_NAME = platform.system().lower() +from src.window.w_about import AboutWindow +from src.window.w_terminal import TerminalWindow +from src.window.w_updateevents import EventUpdateMode, UpdateEventsBlazesWindow, UpdateEventsWindow +from src.window.w_updateoptodes import UpdateOptodesWindow +from src.window.w_userguide import UserGuideWindow +from src.window.w_welcome import WelcomeDialog DEFAULT_CONFIG = """ @@ -85,36 +91,6 @@ folding_bypass = false """ -PIPELINE_STAGES = [ - "Preprocessing", - "Trimming", - "Verify Optode Placement", - "Short/Long Channels", - "Heart Rate", - "Scalp Coupling Index", - "Signal to Noise Ratio", - "Peak Spectral Power", - "Cross Validation", - "Median Absolute Deviation", - "Power Spectral Density Noise", - "Channel Variance", - "Bad Channels Handling", - "Optical Density", - "Temporal Derivative Distribution Repair Filtering", - "Wavelet Filtering", - "Haemoglobin Concentration", - "Enhance Negative Correlation", - "Filter", - "Extracting Events", - "Epoch Calculations", - "Design Matrix", - "General Linear Model", - "Generate GLM Results", - "Generate Channel Significance", - "Generate Channel, Region of Interest, and Contrast Results", - "Compute Contrast Results", - "Finishing Up" -] # Selectable parameters on the right side of the window SECTIONS = [ @@ -325,69 +301,7 @@ SECTIONS = [ -class WelcomeDialog(QDialog): - def __init__(self, parent=None, direct=True): - super().__init__(parent) - self.setWindowTitle(f"What's New - {APP_NAME.upper()}") - self.setMinimumSize(550, 450) - self.resize(800, 500) - - # Main Layout - layout = QVBoxLayout(self) - - # Header Layout (Logo + App Name) - header_layout = QHBoxLayout() - logo_label = QLabel(self) - logo_label.setPixmap(QIcon(resource_path("icons/main.ico")).pixmap(48, 48)) # Fits cleanly in a header - if direct: - title_label = QLabel(f"

{APP_NAME.upper()} has been sucessfully updated to version {CURRENT_VERSION}!

", self) - else: - title_label = QLabel(f"

{APP_NAME.upper()} is currently running version {CURRENT_VERSION}.

", self) - header_layout.addWidget(logo_label) - header_layout.addWidget(title_label) - header_layout.addStretch() - layout.addLayout(header_layout) - - # Text Browser Area (Automatically converts Markdown syntax into clean formatted UI text) - self.text_browser = QTextBrowser(self) - self.text_browser.setHtml("

Loading latest updates from server...

") - self.text_browser.setOpenLinks(False) # Don't open links inside the viewer - self.text_browser.anchorClicked.connect(QDesktopServices.openUrl) - layout.addWidget(self.text_browser) - - # Footer Controls Layout - footer_layout = QHBoxLayout() - - ok_button = QPushButton("OK", self) - ok_button.setDefault(True) - ok_button.clicked.connect(self.accept) # Closes the dialog with a success signal - - footer_layout.addStretch() - footer_layout.addWidget(ok_button) - layout.addLayout(footer_layout) - - # Fetch markdown from the web asynchronously - self.network_manager = QNetworkAccessManager(self) - self.network_manager.finished.connect(self._on_download_complete) - - md_url = "https://git.research.dezeeuw.ca/tyler/flares/raw/branch/main/changelog_major.md" - self.network_manager.get(QNetworkRequest(QUrl(md_url))) - - - def _on_download_complete(self, reply): - """Processes the downloaded markdown and drops it into the view frame.""" - if reply.error() == reply.NetworkError.NoError: - raw_bytes = reply.readAll() - # Convert raw bytes to standard text string - markdown_text = str(raw_bytes, encoding='utf-8') - # Qt's QTextBrowser natively renders markdown arrays beautifully! - self.text_browser.setMarkdown(markdown_text) - else: - self.text_browser.setHtml( - f"

Failed to load content.
Error: {reply.errorString()}

" - ) - reply.deleteLater() @@ -428,1227 +342,19 @@ class SavingOverlay(QDialog): -class TerminalWindow(QWidget): - def __init__(self, parent=None): - super().__init__(parent, Qt.WindowType.Window) - self.setWindowTitle(f"Terminal - {APP_NAME.upper()}") - self.output_area = QTextEdit() - self.output_area.setReadOnly(True) - - self.input_line = QLineEdit() - self.input_line.returnPressed.connect(self.handle_command) - - layout = QVBoxLayout() - layout.addWidget(self.output_area) - layout.addWidget(self.input_line) - self.setLayout(layout) - - self.commands = { - "hello": self.cmd_hello, - "help": self.cmd_help, - "version": self.cmd_version, - } - - def handle_command(self): - command_text = self.input_line.text() - self.input_line.clear() - - self.output_area.append(f"> {command_text}") - parts = command_text.strip().split() - if not parts: - return - - command_name = parts[0] - args = parts[1:] - - func = self.commands.get(command_name) - if func: - try: - result = func(*args) - if result: - self.output_area.append(str(result)) - except Exception as e: - self.output_area.append(f"[Error] {e}") - else: - self.output_area.append(f"[Unknown command] '{command_name}'") - - - def cmd_hello(self, *args): - return "Hello from the terminal!" - - def cmd_help(self, *args): - return f"Available commands: {', '.join(self.commands.keys())}" - - def cmd_version(self, *args): - return f"{CURRENT_VERSION}" -class AboutWindow(QWidget): - """ - Simple About window displaying basic application information. - Args: - parent (QWidget, optional): Parent widget of this window. Defaults to None. - """ - def __init__(self, parent=None): - super().__init__(parent, Qt.WindowType.Window) - self.setWindowTitle(f"About {APP_NAME.upper()}") - self.resize(250, 100) - layout = QVBoxLayout() - label = QLabel(f"About {APP_NAME.upper()}", self) - label2 = QLabel("fNIRS Lightweight Analysis, Research, & Evaluation Suite", self) - label3 = QLabel(f"{APP_NAME.upper()} is licensed under the GPL-3.0 licence. For more information, visit https://www.gnu.org/licenses/gpl-3.0.en.html", self) - label4 = QLabel(f"Version v{CURRENT_VERSION}") - - layout.addWidget(label) - layout.addWidget(label2) - layout.addWidget(label3) - layout.addWidget(label4) - self.setLayout(layout) -class UserGuideWindow(QWidget): - """ - Simple User Guide window displaying basic information on how to use the software. - Args: - parent (QWidget, optional): Parent widget of this window. Defaults to None. - """ - def __init__(self, parent=None): - super().__init__(parent, Qt.WindowType.Window) - self.setWindowTitle(f"User Guide - {APP_NAME.upper()}") - self.resize(250, 100) - - layout = QVBoxLayout() - label = QLabel("Progress Bar Stages:", self) - label2_text = "\n".join(f"Stage {idx + 1}: {name}" for idx, name in enumerate(PIPELINE_STAGES)) + "\n" - label2 = QLabel(label2_text, self) - - label3 = QLabel(f"For more information, visit the Git wiki page here.", self) - label3.setTextFormat(Qt.TextFormat.RichText) - label3.setTextInteractionFlags(Qt.TextInteractionFlag.TextBrowserInteraction) - label3.setOpenExternalLinks(True) - layout.addWidget(label) - layout.addWidget(label2) - layout.addWidget(label3) - - self.setLayout(layout) - - - -class UpdateOptodesWindow(QWidget): - - def __init__(self, parent=None): - super().__init__(parent, Qt.WindowType.Window) - self.setWindowTitle("Update optode positions") - self.resize(760, 200) - - self.label_file_a = QLabel("SNIRF file:") - self.line_edit_file_a = QLineEdit() - self.line_edit_file_a.setReadOnly(True) - self.btn_browse_a = QPushButton("Browse .snirf") - self.btn_browse_a.clicked.connect(self.browse_file_a) - - self.label_file_b = QLabel("Text file:") - self.line_edit_file_b = QLineEdit() - self.line_edit_file_b.setReadOnly(True) - self.btn_browse_b = QPushButton("Browse .txt/.xlsx") - self.btn_browse_b.clicked.connect(self.browse_file_b) - - self.label_suffix = QLabel("Suffix to append to filename:") - self.line_edit_suffix = QLineEdit() - self.line_edit_suffix.setText("flare") - - self.btn_clear = QPushButton("Clear") - self.btn_go = QPushButton("Go") - self.btn_clear.clicked.connect(self.clear_files) - self.btn_go.clicked.connect(self.go_action) - - # --- - layout = QVBoxLayout() - self.description = QLabel() - self.description.setTextFormat(Qt.TextFormat.RichText) - self.description.setTextInteractionFlags(Qt.TextInteractionFlag.TextBrowserInteraction) - self.description.setOpenExternalLinks(False) # Handle the click internally - - self.description.setText("Some software when creating snirf files will insert a template of optode positions as the correct position of the optodes for the participant.
" - "This is rarely correct as each head differs slightly in shape or size, and a lot of calculations require the optodes to be in the correct location.
" - "Using a .txt or .xlsx file, we can update the positions in the snirf file to match those of a digitization system such as one from Polhemus or elsewhere.
" - "The .txt file should have the fiducials, detectors, and sources clearly labeled, followed by the x, y, and z coordinates seperated by a space.
" - "An example format of what a digitization text file should look like can be found by clicking here. Currently only .xlsx files directly exported from a
" - "Polhemus system are supported.") - - self.description.linkActivated.connect(self.handle_link_click) - layout.addWidget(self.description) - - help_text_a = "Select the SNIRF (.snirf) file to update with new optode positions." - - file_a_layout = QHBoxLayout() - - # Help button on the left - help_btn_a = QPushButton("?") - help_btn_a.setFixedWidth(25) - help_btn_a.setToolTip(help_text_a) - help_btn_a.clicked.connect(lambda _, text=help_text_a: self.show_help_popup(text)) - file_a_layout.addWidget(help_btn_a) - - # Container for label + line_edit + browse button with tooltip - file_a_container = QWidget() - file_a_container_layout = QHBoxLayout() - file_a_container_layout.setContentsMargins(0, 0, 0, 0) - file_a_container_layout.addWidget(self.label_file_a) - file_a_container_layout.addWidget(self.line_edit_file_a) - file_a_container_layout.addWidget(self.btn_browse_a) - file_a_container.setLayout(file_a_container_layout) - file_a_container.setToolTip(help_text_a) - - file_a_layout.addWidget(file_a_container) - layout.addLayout(file_a_layout) - - help_text_b = "Provide a .txt file with labeled optodes (e.g., nz, rpa, lpa, d1, s1) and their x, y, z coordinates, or a .xlsx file from a Polhemius system." - - file_b_layout = QHBoxLayout() - - help_btn_b = QPushButton("?") - help_btn_b.setFixedWidth(25) - help_btn_b.setToolTip(help_text_b) - help_btn_b.clicked.connect(lambda _, text=help_text_b: self.show_help_popup(text)) - file_b_layout.addWidget(help_btn_b) - - file_b_container = QWidget() - file_b_container_layout = QHBoxLayout() - file_b_container_layout.setContentsMargins(0, 0, 0, 0) - file_b_container_layout.addWidget(self.label_file_b) - file_b_container_layout.addWidget(self.line_edit_file_b) - file_b_container_layout.addWidget(self.btn_browse_b) - file_b_container.setLayout(file_b_container_layout) - file_b_container.setToolTip(help_text_b) - - file_b_layout.addWidget(file_b_container) - layout.addLayout(file_b_layout) - - - help_text_suffix = "This text will be appended to the original filename when saving. Default is 'flare'." - - suffix_layout = QHBoxLayout() - - help_btn_suffix = QPushButton("?") - help_btn_suffix.setFixedWidth(25) - help_btn_suffix.setToolTip(help_text_suffix) - help_btn_suffix.clicked.connect(lambda _, text=help_text_suffix: self.show_help_popup(text)) - suffix_layout.addWidget(help_btn_suffix) - - suffix_container = QWidget() - suffix_container_layout = QHBoxLayout() - suffix_container_layout.setContentsMargins(0, 0, 0, 0) - suffix_container_layout.addWidget(self.label_suffix) - suffix_container_layout.addWidget(self.line_edit_suffix) - suffix_container.setLayout(suffix_container_layout) - suffix_container.setToolTip(help_text_suffix) - - suffix_layout.addWidget(suffix_container) - layout.addLayout(suffix_layout) - - buttons_layout = QHBoxLayout() - buttons_layout.addStretch() - buttons_layout.addWidget(self.btn_clear) - buttons_layout.addWidget(self.btn_go) - layout.addLayout(buttons_layout) - - self.setLayout(layout) - - def show_help_popup(self, text): - msg = QMessageBox(self) - msg.setWindowTitle("Parameter Info - FLARES") - msg.setText(text) - msg.exec() - - def handle_link_click(self, link): - if link == "custom_link": - msg = QMessageBox(self) - msg.setWindowTitle("Example Digitization File") - - text = "nz: -1.91 85.175 -31.1525\n" \ - "rpa: 80.3825 -17.1925 -57.2775\n" \ - "lpa: -81.815 -17.1925 -57.965\n" \ - "d1: 0.01 -97.5175 62.5875\n" \ - "d2: 25.125 -103.415 45.045\n" \ - "d3: 49.095 -97.9025 30.2075\n" \ - "s1: 0.01 -112.43 32.595\n" \ - "s2: 30.325 -84.3125 71.8975\n" \ - "s3: 0.01 -70.6875 89.0925\n" - msg.setText(text) - msg.exec() - - def browse_file_a(self): - file_path, _ = QFileDialog.getOpenFileName(self, "Select SNIRF File", "", "SNIRF Files (*.snirf)") - if file_path: - self.line_edit_file_a.setText(file_path) - - def browse_file_b(self): - file_path, _ = QFileDialog.getOpenFileName(self, "Select File", "", "Supported Files (*.txt *.xlsx)") - if file_path: - self.line_edit_file_b.setText(file_path) - - def clear_files(self): - self.line_edit_file_a.clear() - self.line_edit_file_b.clear() - - def go_action(self): - file_a = self.line_edit_file_a.text() - file_b = self.line_edit_file_b.text() - suffix = self.line_edit_suffix.text().strip() or "flare" - - if not file_a: - QMessageBox.critical(self, "Missing File", "Please select a SNIRF file.") - return - if not file_b: - QMessageBox.critical(self, "Missing File", "Please select a TXT file.") - return - - # Get original filename without extension - base_name = os.path.splitext(os.path.basename(file_a))[0] - suggested_name = f"{base_name}_{suffix}.snirf" - - # Open save dialog with default name - save_path, _ = QFileDialog.getSaveFileName( - self, - "Save SNIRF File As", - suggested_name, - "SNIRF Files (*.snirf)" - ) - - if not save_path: - print("Save cancelled.") - return - - # Ensure .snirf extension - if not save_path.lower().endswith(".snirf"): - save_path += ".snirf" - - try: - self.update_optode_positions(file_a=file_a, file_b=file_b, save_path=save_path) - except Exception as e: - QMessageBox.critical(self, "Error", f"Failed to write file:\n{e}") - return - - QMessageBox.information(self, "File Saved", f"File was saved to:\n{save_path}") - - def update_optode_positions(self, file_a, file_b, save_path): - - fiducials = {} - ch_positions = {} - - extension = Path(file_b).suffix - - # Read the lines from the optode file - if extension == '.txt': - with open(file_b, 'r') as f: - for line in f: - if line.strip(): - # Split by the semicolon and convert to meters - ch_name, coords_str = line.split(":") - coords = np.array(list(map(float, coords_str.strip().split()))) * 0.001 - - # The key we have is a fiducial - if ch_name.lower() in ['lpa', 'nz', 'rpa']: - fiducials[ch_name.lower()] = coords - - # The key we have is a source or detector - else: - ch_positions[ch_name.upper()] = coords - - elif extension == '.xlsx': - - # TODO: Bad! Why assume sheet1 has the contents? - df = pd.read_excel(file_b, sheet_name='Sheet1') - - def _get_block_data(df, block_id, row_mapping, scale=0.001): - """Isolates a block, cleans numeric data, and returns a scaled dictionary.""" - # 1. Isolate and clean - block = df[df['block_id'] == block_id].iloc[:, [1, 2, 3]].copy() - block = block.apply(pd.to_numeric, errors='coerce') - - # 2. Extract into dictionary based on mapping - result = {} - - # If row_mapping is a dict (like {0: 'nz'}), use it directly - if isinstance(row_mapping, dict): - for row_idx, key in row_mapping.items(): - if row_idx < len(block): - result[key] = block.iloc[row_idx].to_numpy(dtype=float) * scale - - # If row_mapping is a string prefix (like 'D' or 'S'), auto-generate keys - elif isinstance(row_mapping, str): - for i in range(len(block)): - result[f"{row_mapping}{i+1}"] = block.iloc[i].to_numpy(dtype=float) * scale - - return result - - # Identify blocks - is_empty = df.isnull().all(axis=1) - df['block_id'] = is_empty.cumsum() - clean_df = df[~is_empty].copy() - - # Process Block 2: Landmarks - fiducials = _get_block_data(clean_df, 2, {0: 'nz', 2: 'rpa', 3: 'lpa'}) - - # Process Block 3: D-Points - d_points = _get_block_data(clean_df, 3, 'D') - - # Process Block 4: S-Points - s_points = _get_block_data(clean_df, 4, 'S') - - ch_positions = {**d_points, **s_points} - - # Create montage with updated coords in head space - initial_montage = make_dig_montage(ch_pos=ch_positions, nasion=fiducials.get('nz'), lpa=fiducials.get('lpa'), rpa=fiducials.get('rpa'), coord_frame='head') # type: ignore - - # Read the SNIRF file, set the montage, and write it back - raw = read_raw_snirf(file_a, preload=True) - raw.set_montage(initial_montage) - write_raw_snirf(raw, save_path) - - - -class EventUpdateMode(Enum): - WRITE_SNIRF = auto() # destructive - WRITE_JSON = auto() # non-destructive - - - -class UpdateEventsWindow(QWidget): - def __init__(self, parent=None, mode=EventUpdateMode.WRITE_SNIRF, caller=None): - super().__init__(parent, Qt.WindowType.Window) - - self.mode = mode - self.caller = caller or self.__class__.__name__ - self.setWindowTitle("Update event markers") - self.resize(760, 200) - - print("INIT MODE:", mode) - - self.label_file_a = QLabel("SNIRF file:") - self.line_edit_file_a = QLineEdit() - self.line_edit_file_a.setReadOnly(True) - self.btn_browse_a = QPushButton("Browse .snirf") - self.btn_browse_a.clicked.connect(self.browse_file_a) - - self.label_file_b = QLabel("BORIS file:") - self.line_edit_file_b = QLineEdit() - self.line_edit_file_b.setReadOnly(True) - self.btn_browse_b = QPushButton("Browse .boris") - self.btn_browse_b.clicked.connect(self.browse_file_b) - - self.label_suffix = QLabel("Filename in BORIS project file:") - self.combo_suffix = QComboBox() - self.combo_suffix.setEditable(False) - self.combo_suffix.currentIndexChanged.connect(self.on_observation_selected) - - self.label_events = QLabel("Events in selected observation:") - self.combo_events = QComboBox() - self.combo_events.setEnabled(False) - - self.label_snirf_events = QLabel("Events in SNIRF file:") - self.combo_snirf_events = QComboBox() - self.combo_snirf_events.setEnabled(False) - - self.btn_clear = QPushButton("Clear") - self.btn_go = QPushButton("Go") - self.btn_clear.clicked.connect(self.clear_files) - self.btn_go.clicked.connect(self.go_action) - - # --- - layout = QVBoxLayout() - self.description = QLabel() - self.description.setTextFormat(Qt.TextFormat.RichText) - self.description.setTextInteractionFlags(Qt.TextInteractionFlag.TextBrowserInteraction) - self.description.setOpenExternalLinks(True) - - self.description.setText("The events that are present in a snirf file may not be the events that are to be studied and examined.
" - "Utilizing different software and video recordings, it is easy enough to see when an action actually occured in a file.
" - "The software BORIS is used to add these events to video files, and these events can be applied to the snirf file
" - "selected below by selecting the correct BORIS observation and time syncing it to an event that it shares with the snirf file.") - - layout.addWidget(self.description) - - help_text_a = "Select the SNIRF (.snirf) file to update with new event markers." - - file_a_layout = QHBoxLayout() - - # Help button on the left - help_btn_a = QPushButton("?") - help_btn_a.setFixedWidth(25) - help_btn_a.setToolTip(help_text_a) - help_btn_a.clicked.connect(lambda _, text=help_text_a: self.show_help_popup(text)) - file_a_layout.addWidget(help_btn_a) - - # Container for label + line_edit + browse button with tooltip - file_a_container = QWidget() - file_a_container_layout = QHBoxLayout() - file_a_container_layout.setContentsMargins(0, 0, 0, 0) - file_a_container_layout.addWidget(self.label_file_a) - file_a_container_layout.addWidget(self.line_edit_file_a) - file_a_container_layout.addWidget(self.btn_browse_a) - file_a_container.setLayout(file_a_container_layout) - file_a_container.setToolTip(help_text_a) - - file_a_layout.addWidget(file_a_container) - layout.addLayout(file_a_layout) - - help_text_b = "Provide a .boris project file that contains events for this participant." - - file_b_layout = QHBoxLayout() - - help_btn_b = QPushButton("?") - help_btn_b.setFixedWidth(25) - help_btn_b.setToolTip(help_text_b) - help_btn_b.clicked.connect(lambda _, text=help_text_b: self.show_help_popup(text)) - file_b_layout.addWidget(help_btn_b) - - file_b_container = QWidget() - file_b_container_layout = QHBoxLayout() - file_b_container_layout.setContentsMargins(0, 0, 0, 0) - file_b_container_layout.addWidget(self.label_file_b) - file_b_container_layout.addWidget(self.line_edit_file_b) - file_b_container_layout.addWidget(self.btn_browse_b) - file_b_container.setLayout(file_b_container_layout) - file_b_container.setToolTip(help_text_b) - - file_b_layout.addWidget(file_b_container) - layout.addLayout(file_b_layout) - - help_text_suffix = "This participant from the .boris project file matches the .snirf file." - - suffix_layout = QHBoxLayout() - - help_btn_suffix = QPushButton("?") - help_btn_suffix.setFixedWidth(25) - help_btn_suffix.setToolTip(help_text_suffix) - help_btn_suffix.clicked.connect(lambda _, text=help_text_suffix: self.show_help_popup(text)) - suffix_layout.addWidget(help_btn_suffix) - - suffix_container = QWidget() - suffix_container_layout = QHBoxLayout() - suffix_container_layout.setContentsMargins(0, 0, 0, 0) - suffix_container_layout.addWidget(self.label_suffix) - suffix_container_layout.addWidget(self.combo_suffix) - suffix_container.setLayout(suffix_container_layout) - suffix_container.setToolTip(help_text_suffix) - - suffix_layout.addWidget(suffix_container) - layout.addLayout(suffix_layout) - - help_text_suffix = "The events extracted from the BORIS project file for the selected observation." - - suffix2_layout = QHBoxLayout() - - help_btn_suffix = QPushButton("?") - help_btn_suffix.setFixedWidth(25) - help_btn_suffix.setToolTip(help_text_suffix) - help_btn_suffix.clicked.connect(lambda _, text=help_text_suffix: self.show_help_popup(text)) - suffix2_layout.addWidget(help_btn_suffix) - - suffix2_container = QWidget() - suffix2_container_layout = QHBoxLayout() - suffix2_container_layout.setContentsMargins(0, 0, 0, 0) - suffix2_container_layout.addWidget(self.label_events) - suffix2_container_layout.addWidget(self.combo_events) - suffix2_container.setLayout(suffix2_container_layout) - suffix2_container.setToolTip(help_text_suffix) - - suffix2_layout.addWidget(suffix2_container) - layout.addLayout(suffix2_layout) - - snirf_events_layout = QHBoxLayout() - - help_text_snirf_events = "The event markers extracted from the SNIRF file." - help_btn_snirf_events = QPushButton("?") - help_btn_snirf_events.setFixedWidth(25) - help_btn_snirf_events.setToolTip(help_text_snirf_events) - help_btn_snirf_events.clicked.connect(lambda _, text=help_text_snirf_events: self.show_help_popup(text)) - snirf_events_layout.addWidget(help_btn_snirf_events) - - snirf_events_container = QWidget() - snirf_events_container_layout = QHBoxLayout() - snirf_events_container_layout.setContentsMargins(0, 0, 0, 0) - snirf_events_container_layout.addWidget(self.label_snirf_events) - snirf_events_container_layout.addWidget(self.combo_snirf_events) - snirf_events_container.setLayout(snirf_events_container_layout) - snirf_events_container.setToolTip(help_text_snirf_events) - - snirf_events_layout.addWidget(snirf_events_container) - layout.addLayout(snirf_events_layout) - - buttons_layout = QHBoxLayout() - buttons_layout.addStretch() - buttons_layout.addWidget(self.btn_clear) - buttons_layout.addWidget(self.btn_go) - layout.addLayout(buttons_layout) - - self.setLayout(layout) - - def show_help_popup(self, text): - msg = QMessageBox(self) - msg.setWindowTitle(f"Parameter Info - {APP_NAME.upper()}") - msg.setText(text) - msg.exec() - - def browse_file_a(self): - file_path, _ = QFileDialog.getOpenFileName(self, "Select SNIRF File", "", "SNIRF Files (*.snirf)") - if file_path: - self.line_edit_file_a.setText(file_path) - try: - # TODO: Bad! read_raw_snirf doesnt release memory properly! Should be spawned in a seperate process and killed once completed - raw = read_raw_snirf(file_path, preload=False) - annotations = raw.annotations - - # Build individual event entries - event_entries = [] - for onset, description in zip(annotations.onset, annotations.description): - event_str = f"{description} @ {onset:.3f}s" - event_entries.append(event_str) - - if not event_entries: - QMessageBox.information(self, "No Events", "No events found in SNIRF file.") - self.combo_snirf_events.clear() - self.combo_snirf_events.setEnabled(False) - return - - self.combo_snirf_events.clear() - self.combo_snirf_events.addItems(event_entries) - self.combo_snirf_events.setEnabled(True) - - except Exception as e: - QMessageBox.warning(self, "Error", f"Could not read SNIRF file with MNE:\n{str(e)}") - self.combo_snirf_events.clear() - self.combo_snirf_events.setEnabled(False) - - def browse_file_b(self): - file_path, _ = QFileDialog.getOpenFileName(self, "Select BORIS File", "", "BORIS project Files (*.boris)") - if file_path: - self.line_edit_file_b.setText(file_path) - - try: - with open(file_path, 'r', encoding='utf-8') as f: - data = json.load(f) - self.boris_data = data - - observation_keys = self.extract_boris_observation_keys(data) - self.combo_suffix.clear() - self.combo_suffix.addItems(observation_keys) - - except (json.JSONDecodeError, FileNotFoundError, KeyError) as e: - QMessageBox.warning(self, "Error", f"Failed to parse BORIS file:\n{e}") - - def extract_boris_observation_keys(self, data): - if "observations" not in data: - raise KeyError("Missing 'observations' key in BORIS file.") - - observations = data["observations"] - if not isinstance(observations, dict): - raise TypeError("'observations' must be a dictionary.") - - return list(observations.keys()) - - def on_observation_selected(self): - selected_obs = self.combo_suffix.currentText() - if not selected_obs or not hasattr(self, 'boris_data'): - self.combo_events.clear() - self.combo_events.setEnabled(False) - return - - try: - events = self.boris_data["observations"][selected_obs]["events"] - except (KeyError, TypeError): - self.combo_events.clear() - self.combo_events.setEnabled(False) - return - - event_entries = [] - for event in events: - if isinstance(event, list) and len(event) >= 3: - timestamp = event[0] - label = event[2] - display = f"{label} @ {timestamp:.3f}" - event_entries.append(display) - - self.combo_events.clear() - self.combo_events.addItems(event_entries) - self.combo_events.setEnabled(bool(event_entries)) - - def clear_files(self): - self.line_edit_file_a.clear() - self.line_edit_file_b.clear() - - def go_action(self): - - file_a = self.line_edit_file_a.text() - suffix = "flare" - - if not hasattr(self, "boris_data") or self.combo_events.count() == 0 or self.combo_snirf_events.count() == 0: - QMessageBox.warning(self, "Missing data", "Please make sure a BORIS and SNIRF event are selected.") - return - - # Extract BORIS anchor - try: - boris_label, boris_time_str = self.combo_events.currentText().split(" @ ") - boris_anchor_time = float(boris_time_str.replace("s", "").strip()) - except Exception as e: - QMessageBox.critical(self, "BORIS Event Error", f"Could not parse BORIS anchor event:\n{e}") - return - - # Extract SNIRF anchor - try: - snirf_label, snirf_time_str = self.combo_snirf_events.currentText().split(" @ ") - snirf_anchor_time = float(snirf_time_str.replace("s", "").strip()) - except Exception as e: - QMessageBox.critical(self, "SNIRF Event Error", f"Could not parse SNIRF anchor event:\n{e}") - return - - time_shift = snirf_anchor_time - boris_anchor_time - - selected_obs = self.combo_suffix.currentText() - if not selected_obs or selected_obs not in self.boris_data["observations"]: - QMessageBox.warning(self, "Invalid selection", "Selected observation not found in BORIS file.") - return - - boris_events = self.boris_data["observations"][selected_obs].get("events", []) - if not boris_events: - QMessageBox.warning(self, "No BORIS events", "No events found in selected BORIS observation.") - return - - snirf_path = self.line_edit_file_a.text() - if not snirf_path: - QMessageBox.warning(self, "No SNIRF file", "Please select a SNIRF file.") - return - - boris_obs = self.boris_data["observations"][selected_obs] - - # --- Extract videos + delays --- - files = boris_obs.get("file", {}) - offsets = boris_obs.get("media_info", {}).get("offset", {}) - - videos = {} - for key, path in files.items(): - if path: # only include videos that exist - delay = offsets.get(key, 0.0) # default 0 if missing - videos[key] = {"file": path, "delay": delay} - - base_name = os.path.splitext(os.path.basename(file_a))[0] - - if self.mode == EventUpdateMode.WRITE_SNIRF: - # Open save dialog for SNIRF - base_name = os.path.splitext(os.path.basename(file_a))[0] - suggested_name = f"{base_name}_{suffix}.snirf" - save_path, _ = QFileDialog.getSaveFileName( - self, - "Save SNIRF File As", - suggested_name, - "SNIRF Files (*.snirf)" - ) - if not save_path: - print("SNIRF save cancelled.") - return - if not save_path.lower().endswith(".snirf"): - save_path += ".snirf" - - try: - raw = read_raw_snirf(file_a, preload=True) - - # --- Align BORIS events to SNIRF --- - boris_events = boris_obs.get("events", []) - onsets, durations, descriptions = [], [], [] - open_events = {} # label -> list of start times - label_counts = {} - used_times = set() - sfreq = raw.info['sfreq'] - min_shift = 1.0 / sfreq - max_attempts = 10 - - for event in boris_events: - if not isinstance(event, list) or len(event) < 3: - continue - event_time = event[0] - label = event[2] - count = label_counts.get(label, 0) + 1 - label_counts[label] = count - - if label not in open_events: - open_events[label] = [] - - if count % 2 == 1: - open_events[label].append(event_time) - else: - if open_events[label]: - start_time = open_events[label].pop(0) - duration = event_time - start_time - if duration <= 0: - continue - - adjusted_time = start_time + time_shift - attempts = 0 - while round(adjusted_time, 6) in used_times and attempts < max_attempts: - adjusted_time += min_shift - attempts += 1 - if attempts == max_attempts: - continue - - adjusted_time = round(adjusted_time, 6) - used_times.add(adjusted_time) - onsets.append(adjusted_time) - durations.append(duration) - descriptions.append(label) - # Handle unmatched starts - for label, starts in open_events.items(): - for start_time in starts: - adjusted_time = start_time + time_shift - attempts = 0 - while round(adjusted_time, 6) in used_times and attempts < max_attempts: - adjusted_time += min_shift - attempts += 1 - if attempts == max_attempts: - continue - adjusted_time = round(adjusted_time, 6) - used_times.add(adjusted_time) - onsets.append(adjusted_time) - durations.append(0.0) - descriptions.append(label) - - new_annotations = Annotations(onset=onsets, duration=durations, description=descriptions) - raw.set_annotations(new_annotations) - write_raw_snirf(raw, save_path) - QMessageBox.information(self, "Success", "SNIRF file updated with aligned BORIS events.") - - except Exception as e: - QMessageBox.critical(self, "Error", f"Failed to update SNIRF file:\n{e}") - - elif self.mode == EventUpdateMode.WRITE_JSON: - # Open save dialog for JSON - base_name = os.path.splitext(os.path.basename(file_a))[0] - suggested_name = f"{base_name}_{suffix}_alignment.json" - save_path, _ = QFileDialog.getSaveFileName( - self, - "Save Event Alignment JSON As", - suggested_name, - "JSON Files (*.json)" - ) - if not save_path: - print("JSON save cancelled.") - return - if not save_path.lower().endswith(".json"): - save_path += ".json" - - # Build JSON dict - json_data = { - "observation": selected_obs, - "snirf_anchor": {"label": snirf_label, "time": snirf_anchor_time}, - "boris_anchor": {"label": boris_label, "time": boris_anchor_time}, - "time_shift": time_shift, - "videos": videos - } - - # Write JSON - try: - with open(save_path, "w", encoding="utf-8") as f: - json.dump(json_data, f, indent=4) - QMessageBox.information(self, "Success", f"Event alignment saved to:\n{save_path}") - except Exception as e: - QMessageBox.critical(self, "Error", f"Failed to write JSON:\n{e}") - - - def update_optode_positions(self, file_a, file_b, save_path): - - fiducials = {} - ch_positions = {} - - # Read the lines from the optode file - with open(file_b, 'r') as f: - for line in f: - if line.strip(): - # Split by the semicolon and convert to meters - ch_name, coords_str = line.split(":") - coords = np.array(list(map(float, coords_str.strip().split()))) * 0.001 - - # The key we have is a fiducial - if ch_name.lower() in ['lpa', 'nz', 'rpa']: - fiducials[ch_name.lower()] = coords - - # The key we have is a source or detector - else: - ch_positions[ch_name.upper()] = coords - - # Create montage with updated coords in head space - initial_montage = make_dig_montage(ch_pos=ch_positions, nasion=fiducials.get('nz'), lpa=fiducials.get('lpa'), rpa=fiducials.get('rpa'), coord_frame='head') # type: ignore - - # Read the SNIRF file, set the montage, and write it back - # TODO: Bad! read_raw_snirf doesnt release memory properly! Should be spawned in a seperate process and killed once completed - raw = read_raw_snirf(file_a, preload=True) - raw.set_montage(initial_montage) - write_raw_snirf(raw, save_path) - - - def _apply_events_to_snirf(self, raw, new_annotations, save_path): - raw.set_annotations(new_annotations) - write_raw_snirf(raw, save_path) - - def _write_event_mapping_json( - self, - file_a, - file_b, - selected_obs, - snirf_anchor, - boris_anchor, - time_shift, - mapped_events, - save_path - ): - - payload = { - "source": { - "called_from": self.caller, - "snirf_file": os.path.basename(file_a), - "boris_file": os.path.basename(file_b), - "observation": selected_obs - }, - "alignment": { - "snirf_anchor": snirf_anchor, - "boris_anchor": boris_anchor, - "time_shift_seconds": time_shift - }, - "events": mapped_events, - "created_at": datetime.utcnow().isoformat() + "Z" - } - - with open(save_path, "w", encoding="utf-8") as f: - json.dump(payload, f, indent=2) - - return save_path - - - -class UpdateEventsBlazesWindow(QWidget): - - def __init__(self, parent=None, mode=EventUpdateMode.WRITE_SNIRF, caller=None): - super().__init__(parent, Qt.WindowType.Window) - - self.mode = mode - self.caller = caller or self.__class__.__name__ - self.setWindowTitle("Update event markers (BLAZES)") - self.resize(760, 200) - - self.label_file_a = QLabel("SNIRF file:") - self.line_edit_file_a = QLineEdit() - self.line_edit_file_a.setReadOnly(True) - self.btn_browse_a = QPushButton("Browse .snirf") - self.btn_browse_a.clicked.connect(self.browse_file_a) - - self.label_file_b = QLabel("BLAZES file:") - self.line_edit_file_b = QLineEdit() - self.line_edit_file_b.setReadOnly(True) - self.btn_browse_b = QPushButton("Browse .blaze") - self.btn_browse_b.clicked.connect(self.browse_file_b) - - self.label_events = QLabel("Events in selected blazes file:") - self.combo_events = QComboBox() - self.combo_events.setEnabled(False) - - self.label_snirf_events = QLabel("Events in SNIRF file:") - self.combo_snirf_events = QComboBox() - self.combo_snirf_events.setEnabled(False) - - self.btn_clear = QPushButton("Clear") - self.btn_go = QPushButton("Go") - self.btn_clear.clicked.connect(self.clear_files) - self.btn_go.clicked.connect(self.go_action) - - # --- - layout = QVBoxLayout() - self.description = QLabel() - self.description.setTextFormat(Qt.TextFormat.RichText) - self.description.setTextInteractionFlags(Qt.TextInteractionFlag.TextBrowserInteraction) - self.description.setOpenExternalLinks(True) - - self.description.setText("The events that are present in a snirf file may not be the events that are to be studied and examined.
" - "Utilizing different software and video recordings, it is easy enough to see when an action actually occured in a file.
" - "The software BLAZES is used to create these events in video files, and these events can be applied to the snirf file
" - "selected below by time syncing it to an event that it shares with the snirf file.") - - layout.addWidget(self.description) - - help_text_a = "Select the SNIRF (.snirf) file to update with new event markers." - - file_a_layout = QHBoxLayout() - - # Help button on the left - help_btn_a = QPushButton("?") - help_btn_a.setFixedWidth(25) - help_btn_a.setToolTip(help_text_a) - help_btn_a.clicked.connect(lambda _, text=help_text_a: self.show_help_popup(text)) - file_a_layout.addWidget(help_btn_a) - - # Container for label + line_edit + browse button with tooltip - file_a_container = QWidget() - file_a_container_layout = QHBoxLayout() - file_a_container_layout.setContentsMargins(0, 0, 0, 0) - file_a_container_layout.addWidget(self.label_file_a) - file_a_container_layout.addWidget(self.line_edit_file_a) - file_a_container_layout.addWidget(self.btn_browse_a) - file_a_container.setLayout(file_a_container_layout) - file_a_container.setToolTip(help_text_a) - - file_a_layout.addWidget(file_a_container) - layout.addLayout(file_a_layout) - - help_text_b = "Provide a .blaze output file that contains events for this participant." - - file_b_layout = QHBoxLayout() - - help_btn_b = QPushButton("?") - help_btn_b.setFixedWidth(25) - help_btn_b.setToolTip(help_text_b) - help_btn_b.clicked.connect(lambda _, text=help_text_b: self.show_help_popup(text)) - file_b_layout.addWidget(help_btn_b) - - file_b_container = QWidget() - file_b_container_layout = QHBoxLayout() - file_b_container_layout.setContentsMargins(0, 0, 0, 0) - file_b_container_layout.addWidget(self.label_file_b) - file_b_container_layout.addWidget(self.line_edit_file_b) - file_b_container_layout.addWidget(self.btn_browse_b) - file_b_container.setLayout(file_b_container_layout) - file_b_container.setToolTip(help_text_b) - - file_b_layout.addWidget(file_b_container) - layout.addLayout(file_b_layout) - - help_text_suffix = "The events extracted from the blaze file." - - suffix2_layout = QHBoxLayout() - - help_btn_suffix = QPushButton("?") - help_btn_suffix.setFixedWidth(25) - help_btn_suffix.setToolTip(help_text_suffix) - help_btn_suffix.clicked.connect(lambda _, text=help_text_suffix: self.show_help_popup(text)) - suffix2_layout.addWidget(help_btn_suffix) - - suffix2_container = QWidget() - suffix2_container_layout = QHBoxLayout() - suffix2_container_layout.setContentsMargins(0, 0, 0, 0) - suffix2_container_layout.addWidget(self.label_events) - suffix2_container_layout.addWidget(self.combo_events) - suffix2_container.setLayout(suffix2_container_layout) - suffix2_container.setToolTip(help_text_suffix) - - suffix2_layout.addWidget(suffix2_container) - layout.addLayout(suffix2_layout) - - snirf_events_layout = QHBoxLayout() - - help_text_snirf_events = "The event markers extracted from the SNIRF file." - help_btn_snirf_events = QPushButton("?") - help_btn_snirf_events.setFixedWidth(25) - help_btn_snirf_events.setToolTip(help_text_snirf_events) - help_btn_snirf_events.clicked.connect(lambda _, text=help_text_snirf_events: self.show_help_popup(text)) - snirf_events_layout.addWidget(help_btn_snirf_events) - - snirf_events_container = QWidget() - snirf_events_container_layout = QHBoxLayout() - snirf_events_container_layout.setContentsMargins(0, 0, 0, 0) - snirf_events_container_layout.addWidget(self.label_snirf_events) - snirf_events_container_layout.addWidget(self.combo_snirf_events) - snirf_events_container.setLayout(snirf_events_container_layout) - snirf_events_container.setToolTip(help_text_snirf_events) - - snirf_events_layout.addWidget(snirf_events_container) - layout.addLayout(snirf_events_layout) - - buttons_layout = QHBoxLayout() - buttons_layout.addStretch() - buttons_layout.addWidget(self.btn_clear) - buttons_layout.addWidget(self.btn_go) - layout.addLayout(buttons_layout) - - self.setLayout(layout) - - - def show_help_popup(self, text): - msg = QMessageBox(self) - msg.setWindowTitle(f"Parameter Info - {APP_NAME.upper()}") - msg.setText(text) - msg.exec() - - def browse_file_a(self): - file_path, _ = QFileDialog.getOpenFileName(self, "Select SNIRF File", "", "SNIRF Files (*.snirf)") - if file_path: - self.line_edit_file_a.setText(file_path) - try: - # TODO: Bad! read_raw_snirf doesnt release memory properly! Should be spawned in a seperate process and killed once completed - raw = read_raw_snirf(file_path, preload=False) - annotations = raw.annotations - - # Build individual event entries - event_entries = [] - for onset, description in zip(annotations.onset, annotations.description): - event_str = f"{description} @ {onset:.3f}s" - event_entries.append(event_str) - - if not event_entries: - QMessageBox.information(self, "No Events", "No events found in SNIRF file.") - self.combo_snirf_events.clear() - self.combo_snirf_events.setEnabled(False) - return - - self.combo_snirf_events.clear() - self.combo_snirf_events.addItems(event_entries) - self.combo_snirf_events.setEnabled(True) - - except Exception as e: - QMessageBox.warning(self, "Error", f"Could not read SNIRF file with MNE:\n{str(e)}") - self.combo_snirf_events.clear() - self.combo_snirf_events.setEnabled(False) - - def browse_file_b(self): - file_path, _ = QFileDialog.getOpenFileName(self, "Select JSON Timeline File", "", "JSON Files (*.json)") - if file_path: - self.line_edit_file_b.setText(file_path) - - try: - with open(file_path, 'r', encoding='utf-8') as f: - data = json.load(f) - self.json_data = data - - obs_keys = self.extract_json_observation_strings(data) - self.combo_events.clear() - if obs_keys: - self.combo_events.addItems(obs_keys) - self.combo_events.setEnabled(True) - else: - QMessageBox.information(self, "No Events", "No events found in JSON file.") - self.combo_events.setEnabled(False) - - except (json.JSONDecodeError, FileNotFoundError, KeyError, TypeError) as e: - QMessageBox.warning(self, "Error", f"Failed to parse JSON file:\n{e}") - self.combo_events.clear() - self.combo_events.setEnabled(False) - - - def extract_json_observation_strings(self, data): - if "events" not in data: - raise KeyError("Missing 'events' key in JSON file.") - - event_strings = [] - - # The new format is a flat list chronologically ordered - for event in data["events"]: - track_name = event.get("track_name", "Unknown") - onset = event.get("start_sec", 0.0) - - # Formatting to match your SNIRF style: "Event Name @ 0.000s" - display_str = f"{track_name} @ {onset:.3f}s" - event_strings.append(display_str) - - return event_strings - - - def clear_files(self): - self.line_edit_file_a.clear() - self.line_edit_file_b.clear() - - - def go_action(self): - file_a = self.line_edit_file_a.text() - file_b = self.line_edit_file_b.text() - suffix = APP_NAME - - if not hasattr(self, "json_data") or self.combo_events.count() == 0 or self.combo_snirf_events.count() == 0: - QMessageBox.warning(self, "Missing data", "Please make sure a JSON and SNIRF event are selected.") - return - - try: - json_text = self.combo_events.currentText() - _, json_time_str = json_text.split(" @ ") - json_anchor_time = float(json_time_str.replace("s", "").strip()) - except Exception as e: - QMessageBox.critical(self, "JSON Event Error", f"Could not parse JSON anchor:\n{e}") - return - - try: - snirf_text = self.combo_snirf_events.currentText() - _, snirf_time_str = snirf_text.split(" @ ") - snirf_anchor_time = float(snirf_time_str.replace("s", "").strip()) - except Exception as e: - QMessageBox.critical(self, "SNIRF Event Error", f"Could not parse SNIRF anchor:\n{e}") - return - - time_shift = snirf_anchor_time - json_anchor_time - - onsets, durations, descriptions = [], [], [] - skipped_count = 0 - - try: - events_list = self.json_data.get("events", []) - - for event in events_list: - track_name = event.get("track_name", "Unknown") - clean_name = track_name.replace("AI: ", "").strip() - - original_start = event.get("start_sec", 0.0) - original_end = event.get("end_sec", original_start) - duration = original_end - original_start - - # FILTER: Minimum 0.1s duration - if duration < 0.1: - skipped_count += 1 - continue - - # Apply shift - adjusted_onset = original_start + time_shift - - onsets.append(round(adjusted_onset, 6)) - durations.append(round(duration, 6)) - descriptions.append(clean_name) - - except Exception as e: - QMessageBox.critical(self, "Track Error", f"Failed to process tracks: {e}") - return - - if not onsets: - QMessageBox.warning(self, "No Data", f"No events met the 0.1s threshold. (Skipped {skipped_count})") - return - - if self.mode == EventUpdateMode.WRITE_SNIRF: - suggested_name = f"{os.path.splitext(os.path.basename(file_a))[0]}_{suffix}.snirf" - save_path, _ = QFileDialog.getSaveFileName(self, "Save SNIRF", suggested_name, "SNIRF Files (*.snirf)") - - if not save_path: return - if not save_path.lower().endswith(".snirf"): save_path += ".snirf" - - try: - raw = read_raw_snirf(file_a, preload=True) - - # Create annotations - new_annotations = Annotations( - onset=onsets, - duration=durations, - description=descriptions - ) - - # Replace existing annotations with the new aligned JSON tracks - raw.set_annotations(new_annotations) - - write_raw_snirf(raw, save_path) - QMessageBox.information(self, "Success", - f"Aligned {len(onsets)} events.\n(Filtered out {skipped_count} short events)") - except Exception as e: - QMessageBox.critical(self, "Error", f"Failed to update SNIRF file:\n{e}") @@ -1758,500 +464,6 @@ class ProgressBubble(QWidget): self.spinner_idx += 1 -class ParamSection(QWidget): - """ - A widget section that dynamically creates labeled input fields from parameter metadata. - - Args: - section_data (dict): Dictionary containing section title and list of parameter info. - Expected format: - { - "title": str, - "params": [ - { - "name": str, - "type": type, - "default": any, - "help": str (optional) - }, - ... - ] - } - """ - - def __init__(self, section_data, global_widgets): - super().__init__() - layout = QVBoxLayout() - self.setLayout(layout) - self.widgets = global_widgets - self.dependencies = [] - self.selected_path = None - - - # Title label - title_label = QLabel(section_data["title"]) - title_label.setStyleSheet("font-weight: bold; font-size: 14px; margin-top: 10px; margin-bottom: 5px;") - layout.addWidget(title_label) - - # Horizontal line - line = QFrame() - line.setFrameShape(QFrame.Shape.HLine) - line.setFrameShadow(QFrame.Shadow.Sunken) - layout.addWidget(line) - - for param in section_data["params"]: - h_layout = QHBoxLayout() - - label = QLabel(param["name"]) - - label.setToolTip(param.get("help", "")) - - help_text = param.get("help", "") - - help_btn = QPushButton("?") - help_btn.setFixedWidth(25) - help_btn.setToolTip(help_text) - help_btn.clicked.connect(lambda _, text=help_text: self.show_help_popup(text)) - - h_layout.addWidget(help_btn) - - h_layout.addWidget(label) - h_layout.setStretch(0, 1) - h_layout.setStretch(1, 6) - - default_val = param["default"] - - # Create input widget based on type - if param["type"] == bool: - widget = QComboBox() - widget.addItems(["True", "False"]) - widget.setCurrentText(str(default_val)) - widget.currentTextChanged.connect(lambda val, p=param["name"]: self.check_if_changed(p, val)) - widget.currentTextChanged.connect(self.notify_global_update) - elif param["type"] == int: - widget = QLineEdit() - widget.setValidator(QIntValidator()) - widget.setText(str(default_val)) - widget.textChanged.connect(lambda val, p=param["name"]: self.check_if_changed(p, val)) - elif param["type"] == float: - widget = QLineEdit() - widget.setValidator(QDoubleValidator()) - widget.setText(str(default_val)) - widget.textChanged.connect(lambda val, p=param["name"]: self.check_if_changed(p, val)) - elif param["type"] == list: - if param.get("exclusive", True): - widget = QComboBox() - widget.addItems(param.get("options", [])) - widget.setCurrentText(str(default_val)) - widget.currentTextChanged.connect(lambda val, p=param["name"]: self.check_if_changed(p, val)) - widget.currentTextChanged.connect(self.notify_global_update) - else: - widget = self._create_multiselect_dropdown(None) - elif param["type"] == range: - widget = QSpinBox() - widget.setRange(0, 999) #NOTE: will this be a high enough limit? - # If default is "None" or range(15), handle it gracefully: - if isinstance(default_val, range): - widget.setValue(default_val.stop) - elif str(default_val).isdigit(): - widget.setValue(int(default_val)) - else: - widget.setValue(15) # Default fallback - widget.valueChanged.connect(lambda val, p=param["name"]: self.check_if_changed(p, val)) - else: - widget = QLineEdit() - widget.setText(str(default_val)) - - if "depends_on" in param: - self.dependencies.append({ - "child_name": param["name"], - "parent_name": param["depends_on"], - "depends_value": param.get("depends_value", "True") - }) - - widget.setToolTip(help_text) - - h_layout.addWidget(widget) - h_layout.setStretch(2, 3) - - layout.addLayout(h_layout) - self.widgets[param["name"]] = { - "widget": widget, - "label": label, - "default": default_val, - "type": param["type"], - "h_layout": h_layout - } - - self.update_dependencies() - - - def has_any_changes(self): - """Returns True if any parameter in this section differs from its default.""" - for name, info in self.widgets.items(): - default = info["default"] - - current_val = self.get_param_values().get(name) - - if str(current_val) != str(default): - return True - return False - - - def check_if_changed(self, param_name, current_value): - """Toggles bold font on the label if the value differs from default.""" - info = self.widgets.get(param_name) - if not info: - return - - label = info["label"] - default = info["default"] - - is_changed = False - - if info["type"] == list: - # If it's an exclusive ComboBox, current_value is a string. - # We wrap it in a list to compare it to the default list. - if isinstance(current_value, str): - normalized_current = [current_value] - else: - normalized_current = current_value # Already a list from multi-select - - # Ensure default is a list for comparison - normalized_default = default if isinstance(default, list) else [default] - - # Use sorted to ensure order doesn't matter - is_changed = sorted(normalized_current) != sorted(normalized_default) - - # 2. Handle Range (SpinBox) - elif info["type"] == range: - ref = default.stop if isinstance(default, range) else default - try: - is_changed = int(current_value) != int(ref) - except (ValueError, TypeError): - is_changed = True - - # 3. Standard Comparison (bool, int, float, str) - else: - is_changed = str(current_value) != str(default) - - # Update Font - font = label.font() - font.setBold(is_changed) - label.setFont(font) - - # Optional: Change color to make it even more obvious - if is_changed: - label.setStyleSheet("color: #3498db; font-weight: bold;") # Nice Blue - else: - label.setStyleSheet("color: none; font-weight: normal;") - - def notify_global_update(self): - """ - Since dependencies can cross sections, we need to tell - all sections to refresh their enabled/disabled states. - """ - # If you have a reference to the parent container, call its update. - # Otherwise, you can iterate through the known param_sections: - for section in self.parent().findChildren(ParamSection): - section.update_dependencies() - - def update_dependencies(self): - """Disables/Enables widgets based on parent selection values.""" - for dep in self.dependencies: - child_info = self.widgets.get(dep["child_name"]) - parent_info = self.widgets.get(dep["parent_name"]) - - if child_info and parent_info: - parent_widget = parent_info["widget"] - - # Get current value of parent (works for both bool-combos and list-combos) - current_parent_value = parent_widget.currentText() - - # Check if it matches the required value - is_active = (current_parent_value == dep["depends_value"]) - - # Toggle the entire row (Button, Label, and Input) - h_layout = child_info["h_layout"] - for i in range(h_layout.count()): - item = h_layout.itemAt(i).widget() - if item: - item.setEnabled(is_active) - - def _create_multiselect_dropdown(self, items): - combo = FullClickComboBox() - combo.setView(QListView()) - model = QStandardItemModel() - combo.setModel(model) - combo.setEditable(True) - combo.lineEdit().setReadOnly(True) - combo.lineEdit().setPlaceholderText("Select...") - - dummy_item = QStandardItem("") - dummy_item.setFlags(Qt.ItemIsEnabled) - model.appendRow(dummy_item) - - toggle_item = QStandardItem("Toggle Select All") - toggle_item.setFlags(Qt.ItemIsUserCheckable | Qt.ItemIsEnabled) - toggle_item.setData(Qt.Unchecked, Qt.CheckStateRole) - model.appendRow(toggle_item) - - if items is not None: - for item in items: - standard_item = QStandardItem(item) - standard_item.setFlags(Qt.ItemIsUserCheckable | Qt.ItemIsEnabled) - standard_item.setData(Qt.Unchecked, Qt.CheckStateRole) - model.appendRow(standard_item) - - combo.setInsertPolicy(QComboBox.NoInsert) - - - def on_view_clicked(index): - item = model.itemFromIndex(index) - if item.isCheckable(): - new_state = Qt.Checked if item.checkState() == Qt.Unchecked else Qt.Unchecked - item.setCheckState(new_state) - - combo.view().pressed.connect(on_view_clicked) - - self._updating_checkstates = False - - def on_item_changed(item): - if self._updating_checkstates: - return - self._updating_checkstates = True - - normal_items = [model.item(i) for i in range(2, model.rowCount())] # skip dummy and toggle - - if item == toggle_item: - all_checked = all(i.checkState() == Qt.Checked for i in normal_items) - if all_checked: - for i in normal_items: - i.setCheckState(Qt.Unchecked) - toggle_item.setCheckState(Qt.Unchecked) - else: - for i in normal_items: - i.setCheckState(Qt.Checked) - toggle_item.setCheckState(Qt.Checked) - - elif item == dummy_item: - pass - - else: - # When normal items change, update toggle item - all_checked = all(i.checkState() == Qt.Checked for i in normal_items) - toggle_item.setCheckState(Qt.Checked if all_checked else Qt.Unchecked) - - self._updating_checkstates = False - - for param_name, info in self.widgets.items(): - if info["widget"] == combo: - self.update_dropdown_label(param_name) - break - - model.itemChanged.connect(on_item_changed) - - combo.setInsertPolicy(QComboBox.NoInsert) - return combo - - def show_help_popup(self, text): - msg = QMessageBox(self) - msg.setWindowTitle(f"Parameter Info - {APP_NAME.upper()}") - msg.setText(text) - msg.exec() - - - def get_param_values(self): - values = {} - for name, info in self.widgets.items(): - widget = info["widget"] - expected_type = info["type"] - - if name == "SHORT_CHANNEL_REGRESSION": - # If the widget is disabled (greyed out), force False - if not widget.isEnabled(): - values[name] = False - continue - - if expected_type == bool: - values[name] = widget.currentText() == "True" - elif expected_type == list: - if isinstance(widget, FullClickComboBox): - values[name] = [x.strip() for x in widget.lineEdit().text().split(",") if x.strip()] - elif isinstance(widget, QComboBox): - values[name] = widget.currentText() - elif expected_type == range: - if isinstance(widget, QSpinBox): - # Convert the integer N into range(N) - values[name] = range(widget.value()) - else: - values[name] = range(15) # Fallback - else: - raw_text = widget.text() - try: - if expected_type == int: - values[name] = int(raw_text) - elif expected_type == float: - values[name] = float(raw_text) - elif expected_type == str: - values[name] = raw_text - else: - values[name] = raw_text # Fallback - except Exception as e: - raise ValueError(f"Invalid value for {name}: {raw_text}") from e - - return values - - def update_dropdown_items(self, param_name, new_items): - """ - Updates the items in a multi-select dropdown parameter field. - - Args: - param_name (str): The parameter name (must match one in self.widgets). - new_items (list): The new items to populate in the dropdown. - """ - widget_info = self.widgets.get(param_name) - #print("[ParamSection] Current widget keys:", list(self.widgets.keys())) - - if not widget_info: - print(f"[ParamSection] No widget found for param '{param_name}'") - return - - widget = widget_info["widget"] - if not isinstance(widget, FullClickComboBox): - print(f"[ParamSection] Widget for param '{param_name}' is not a FullClickComboBox") - return - - # Replace the model on the existing widget - new_model = QStandardItemModel() - - dummy_item = QStandardItem("") - dummy_item.setFlags(Qt.ItemIsEnabled) - new_model.appendRow(dummy_item) - - toggle_item = QStandardItem("Toggle Select All") - toggle_item.setFlags(Qt.ItemIsUserCheckable | Qt.ItemIsEnabled) - toggle_item.setData(Qt.Unchecked, Qt.CheckStateRole) - new_model.appendRow(toggle_item) - - for item_text in new_items: - item = QStandardItem(item_text) - item.setFlags(Qt.ItemIsUserCheckable | Qt.ItemIsEnabled) - item.setData(Qt.Unchecked, Qt.CheckStateRole) - new_model.appendRow(item) - - widget.setModel(new_model) - widget.setView(QListView()) # Reset view to refresh properly - - def on_view_clicked(index): - item = new_model.itemFromIndex(index) - if item.isCheckable(): - new_state = Qt.Checked if item.checkState() == Qt.Unchecked else Qt.Unchecked - item.setCheckState(new_state) - - widget.view().pressed.connect(on_view_clicked) - - def on_item_changed(item): - if getattr(self, "_updating_checkstates", False): - return - self._updating_checkstates = True - - normal_items = [new_model.item(i) for i in range(2, new_model.rowCount())] - if item == toggle_item: - all_checked = all(i.checkState() == Qt.Checked for i in normal_items) - for i in normal_items: - i.setCheckState(Qt.Unchecked if all_checked else Qt.Checked) - toggle_item.setCheckState(Qt.Unchecked if all_checked else Qt.Checked) - else: - all_checked = all(i.checkState() == Qt.Checked for i in normal_items) - toggle_item.setCheckState(Qt.Checked if all_checked else Qt.Unchecked) - - self._updating_checkstates = False - - for param_name, info in self.widgets.items(): - if info["widget"] == widget: - self.update_dropdown_label(param_name) - break - - new_model.itemChanged.connect(on_item_changed) - widget.lineEdit().setText("") - - - def _get_checked_items(self, combo): - checked = [] - model = combo.model() - for i in range(model.rowCount()): - item = model.item(i) - if item.text() in ("", "Toggle Select All"): - continue - if item.checkState() == Qt.Checked: - checked.append(item.text()) - return checked - - def update_dropdown_label(self, param_name): - widget_info = self.widgets.get(param_name) - if not widget_info: - print(f"[ParamSection] No widget found for param '{param_name}'") - return - - widget = widget_info["widget"] - if not isinstance(widget, FullClickComboBox): - print(f"[ParamSection] Widget for param '{param_name}' is not a FullClickComboBox") - return - - selected = self._get_checked_items(widget) - if not selected: - widget.lineEdit().setText("") - else: - # You can customize how you display selected items here: - widget.lineEdit().setText(", ".join(selected)) - - # def update_annotation_dropdown_from_loaded_files(self, bubble_widgets, button1): - # file_paths = [bubble.file_path for bubble in bubble_widgets.values()] - # if not file_paths: - # return - - # # 1. Start the UI immediately - # progress = QProgressDialog("Accessing Workers...", "Cancel", 0, len(file_paths), self) - # progress.setWindowModality(Qt.WindowModality.WindowModal) - # progress.setMinimumDuration(0) - # progress.setValue(0) - - # # Force the UI to draw the window NOW before we start the loop - # progress.show() - # QApplication.processEvents() - - # annotation_sets = [] - - # # 2. Use the persistent executor (don't use 'with' here!) - # for i, path in enumerate(file_paths): - # progress.setValue(i) - # progress.setLabelText(f"Reading file {i+1} of {len(file_paths)}...") - # QApplication.processEvents() # Keeps the UI snappy - - # if progress.wasCanceled(): - # break - - # # This call is now nearly instant because the process is already warm - # future = self.file_executor.submit(_extract_annotations, path) - # try: - # labels_list = future.result() - # if labels_list: - # annotation_sets.append(set(labels_list)) - # except Exception as e: - # print(f"Worker Error: {e}") - - # progress.setValue(len(file_paths)) - - # # 3. Final Logic - # if not annotation_sets: - # self.update_dropdown_items("REMOVE_EVENTS", []) - # button1.setVisible(False) - # return - - # common = set.intersection(*annotation_sets) if len(annotation_sets) > 1 else annotation_sets[0] - # self.update_dropdown_items("REMOVE_EVENTS", sorted(list(common))) - class FullClickComboBox(QComboBox): @@ -2627,150 +839,7 @@ class FlaresBaseWidget(QWidget): -class ParticipantViewerWidget(FlaresBaseWidget): - def __init__(self, haemo_dict, fig_bytes_dict): - super().__init__("ParticipantViewer") - self.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose) - self.setWindowTitle("FLARES Participant Viewer") - self.haemo_dict = haemo_dict - self.fig_bytes_dict = fig_bytes_dict - # Create mappings: file_path -> participant label and dropdown display text - self.participant_map = {} # file_path -> "Participant 1" - self.participant_dropdown_items = [] # "Participant 1 (filename)" - - for i, file_path in enumerate(self.haemo_dict.keys(), start=1): - short_label = f"Participant {i}" - display_label = f"{short_label} ({os.path.basename(file_path)})" - self.participant_map[file_path] = short_label - self.participant_dropdown_items.append(display_label) - - self.layout = QVBoxLayout(self) - self.top_bar = QHBoxLayout() - self.layout.addLayout(self.top_bar) - - self.participant_dropdown = self._create_multiselect_dropdown(self.participant_dropdown_items) - self.participant_dropdown.currentIndexChanged.connect(self.update_participant_dropdown_label) - - first_fig_dict = next(iter(self.fig_bytes_dict.values())) - image_label_items = list(first_fig_dict.keys()) - - self.image_index_dropdown = self._create_multiselect_dropdown(image_label_items) - self.image_index_dropdown.currentIndexChanged.connect(self.update_image_index_dropdown_label) - - self.submit_button = QPushButton("Submit") - self.submit_button.clicked.connect(self.show_selected_images) - - self.top_bar.addWidget(QLabel("Participants:")) - self.top_bar.addWidget(self.participant_dropdown) - self.top_bar.addWidget(QLabel("Image Indexes:")) - self.top_bar.addWidget(self.image_index_dropdown) - self.top_bar.addWidget(self.submit_button) - - self.scroll = QScrollArea() - self.scroll.setWidgetResizable(True) - self.scroll_content = QWidget() - self.grid_layout = QGridLayout(self.scroll_content) - self.scroll.setWidget(self.scroll_content) - self.layout.addWidget(self.scroll) - - self.thumb_size = QSize(280, 180) - - self.save_button = QPushButton("Save Displayed Images") - self.save_button.clicked.connect(self.save_displayed_images) - self.top_bar.addWidget(self.save_button) - - self.showMaximized() - - - - def show_selected_images(self): - # Clear previous images - while self.grid_layout.count(): - item = self.grid_layout.takeAt(0) - widget = item.widget() - if widget: - widget.deleteLater() - - selected_display_names = self._get_checked_items(self.participant_dropdown) - # Map from display names back to file paths - selected_file_paths = [] - for display_name in selected_display_names: - # Find file_path by matching display name - for fp, short_label in self.participant_map.items(): - expected_display = f"{short_label} ({os.path.basename(fp)})" - if display_name == expected_display: - selected_file_paths.append(fp) - break - - selected_labels = self._get_checked_items(self.image_index_dropdown) - - row, col = 0, 0 - for file_path in selected_file_paths: - fig_list = self.fig_bytes_dict.get(file_path, []) - participant_label = self.participant_map[file_path] - for label in selected_labels: - fig_bytes = fig_list.get(label) - if not fig_bytes: - continue - - full_pixmap = QPixmap() - full_pixmap.loadFromData(fig_bytes) - - thumbnail_pixmap = full_pixmap.scaled( - self.thumb_size, - Qt.AspectRatioMode.KeepAspectRatio, - Qt.TransformationMode.SmoothTransformation - ) - - container = QWidget() - hlayout = QHBoxLayout(container) - hlayout.setContentsMargins(0, 0, 0, 0) - hlayout.setSpacing(0) - hlayout.setAlignment(Qt.AlignmentFlag.AlignCenter) - - image_label = ClickableLabel(full_pixmap, thumbnail_pixmap) - image_label.setToolTip(f"{participant_label}\n{label}") - hlayout.addWidget(image_label) - - self.grid_layout.addWidget(container, row, col) - - col += 1 - if col >= 6: - col = 0 - row += 1 - - # Update dropdown labels after display - self.update_participant_dropdown_label() - self.update_image_index_dropdown_label() - - - def save_displayed_images(self): - # Ensure the folder exists - save_dir = Path("individual_images") - save_dir.mkdir(exist_ok=True) - - selected_display_names = self._get_checked_items(self.participant_dropdown) - selected_image_labels = self._get_checked_items(self.image_index_dropdown) - - for display_name in selected_display_names: - # Match display name to file path - for file_path, short_label in self.participant_map.items(): - expected_display = f"{short_label} ({os.path.basename(file_path)})" - if display_name == expected_display: - fig_dict = self.fig_bytes_dict.get(file_path, {}) - for label in selected_image_labels: - if label not in fig_dict: - continue - fig_bytes = fig_dict[label] - timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") - filename = f"{os.path.basename(file_path)}_{label}_{timestamp}.png" - output_path = save_dir / filename - with open(output_path, "wb") as f: - f.write(fig_bytes) - break # file_path matched; stop loop - - QMessageBox.information(self, "Save Complete", f"Images saved to {save_dir.resolve()}") @@ -2799,590 +868,10 @@ class ClickableLabel(QLabel): -class ParticipantBrainViewerWidget(FlaresBaseWidget): - def __init__(self, haemo_dict, cha_dict): - super().__init__("ParticipantBrainViewer") - self.setWindowTitle("FLARES Participant Brain Viewer") - self.haemo_dict = haemo_dict - self.cha_dict = cha_dict - # Create mappings: file_path -> participant label and dropdown display text - self.participant_map = {} # file_path -> "Participant 1" - self.participant_dropdown_items = [] # "Participant 1 (filename)" - for i, file_path in enumerate(self.haemo_dict.keys(), start=1): - short_label = f"Participant {i}" - display_label = f"{short_label} ({os.path.basename(file_path)})" - self.participant_map[file_path] = short_label - self.participant_dropdown_items.append(display_label) - self.layout = QVBoxLayout(self) - self.top_bar = QHBoxLayout() - self.layout.addLayout(self.top_bar) - self.participant_dropdown = self._create_multiselect_dropdown(self.participant_dropdown_items) - self.participant_dropdown.currentIndexChanged.connect(self.update_participant_dropdown_label) - - self.event_dropdown = QComboBox() - self.event_dropdown.addItem("") - - - self.index_texts = [ - "0 (Brain Landmarks)", - "1 (Brain Activity Visualization)", - # "2 (third image)", - # "3 (fourth image)", - ] - - self.image_index_dropdown = self._create_multiselect_dropdown(self.index_texts) - self.image_index_dropdown.currentIndexChanged.connect(self.update_image_index_dropdown_label) - - self.submit_button = QPushButton("Submit") - self.submit_button.clicked.connect(self.show_brain_images) - - self.top_bar.addWidget(QLabel("Participants:")) - self.top_bar.addWidget(self.participant_dropdown) - self.top_bar.addWidget(QLabel("Event:")) - self.top_bar.addWidget(self.event_dropdown) - self.top_bar.addWidget(QLabel("Image Indexes:")) - self.top_bar.addWidget(self.image_index_dropdown) - self.top_bar.addWidget(self.submit_button) - - self.scroll = QScrollArea() - self.scroll.setWidgetResizable(True) - self.scroll_content = QWidget() - self.grid_layout = QGridLayout(self.scroll_content) - self.scroll.setWidget(self.scroll_content) - self.layout.addWidget(self.scroll) - - self.thumb_size = QSize(280, 180) - self.showMaximized() - - - def show_brain_images(self): - import flares - - selected_event = self.event_dropdown.currentText() - if selected_event == "": - selected_event = None - - selected_display_names = self._get_checked_items(self.participant_dropdown) - selected_file_paths = [] - for display_name in selected_display_names: - for fp, short_label in self.participant_map.items(): - expected_display = f"{short_label} ({os.path.basename(fp)})" - if display_name == expected_display: - selected_file_paths.append(fp) - break - - selected_indexes = [ - int(s.split(" ")[0]) for s in self._get_checked_items(self.image_index_dropdown) - ] - - - parameterized_indexes = { - 0: [ - { - "key": "show_optodes", - "label": "Determine what is rendered above the brain. Valid values are 'sensors', 'labels', 'none', 'all'.", - "default": "all", - "type": str, - }, - { - "key": "show_brodmann", - "label": "Show common brodmann areas on the brain.", - "default": "True", - "type": bool, - } - ], - 1: [ - { - "key": "show_optodes", - "label": "Determine what is rendered above the brain. Valid values are 'sensors', 'labels', 'none', 'all'.", - "default": "all", - "type": str, - }, - { - "key": "t_or_theta", - "label": "Specify if t values or theta values should be plotted. Valid values are 't', 'theta'", - "default": "theta", - "type": str, - }, - { - "key": "show_text", - "label": "Display informative text on the top left corner. THIS DOES NOT WORK AND SHOULD BE LEFT AT FALSE", - "default": "False", - "type": bool, - }, - { - "key": "brain_bounds", - "label": "Graph Upper/Lower Limit", - "default": "1.0", - "type": float, - } - ], - } - - # Inject full_text from index_texts - for idx, params_list in parameterized_indexes.items(): - full_text = self.index_texts[idx] if idx < len(self.index_texts) else f"{idx} (No label found)" - for param_info in params_list: - param_info["full_text"] = full_text - - indexes_needing_params = {idx: parameterized_indexes[idx] for idx in selected_indexes if idx in parameterized_indexes} - - param_values = {} - if indexes_needing_params: - dialog = ParameterInputDialog(indexes_needing_params, parent=self) - if dialog.exec_() == QDialog.Accepted: - param_values = dialog.get_values() - if param_values is None: - return - else: - return - - # Pass the necessary arguments to each method - for file_path in selected_file_paths: - haemo_obj = self.haemo_dict.get(file_path) - - if selected_event: - participant_events = set(haemo_obj.annotations.description) - if selected_event not in participant_events: - print(f"Skipping {self.participant_map[file_path]}: Event '{selected_event}' not found.") - continue - - if haemo_obj is None: - raise Exception("How did we get here?") - - cha = self.cha_dict.get(file_path) - - for idx in selected_indexes: - if idx == 0: - - params = param_values.get(idx, {}) - show_optodes = params.get("show_optodes", None) - show_brodmann = params.get("show_brodmann", None) - - if show_optodes is None or show_brodmann is None: - print(f"Missing parameters for index {idx}, skipping.") - continue - - flares.brain_landmarks_3d(haemo_obj, show_optodes, show_brodmann) - - elif idx == 1: - params = param_values.get(idx, {}) - show_optodes = params.get("show_optodes", None) - t_or_theta = params.get("t_or_theta", None) - show_text = params.get("show_text", None) - brain_bounds = params.get("brain_bounds", None) - - if show_optodes is None or t_or_theta is None or show_text is None or brain_bounds is None: - print(f"Missing parameters for index {idx}, skipping.") - continue - - flares.brain_3d_visualization(haemo_obj, cha, selected_event, t_or_theta=t_or_theta, show_optodes=show_optodes, show_text=show_text, brain_bounds=brain_bounds) - - else: - print(f"No method defined for index {idx}") - - -class ParticipantFunctionalConnectivityWidget(FlaresBaseWidget): - def __init__(self, haemo_dict, epochs_dict): - super().__init__("FunctionalConnectivityWidget") - self.setWindowTitle("FLARES Functional Connectivity Viewer [BETA]") - self.haemo_dict = haemo_dict - self.epochs_dict = epochs_dict - - QMessageBox.warning(self, "Warning - FLARES", f"Functional Connectivity is still in development and the results should currently be taken with a grain of salt. " - "By clicking OK, you accept that the images generated may not be factual.") - - # Create mappings: file_path -> participant label and dropdown display text - self.participant_map = {} # file_path -> "Participant 1" - self.participant_dropdown_items = [] # "Participant 1 (filename)" - - for i, file_path in enumerate(self.haemo_dict.keys(), start=1): - short_label = f"Participant {i}" - display_label = f"{short_label} ({os.path.basename(file_path)})" - self.participant_map[file_path] = short_label - self.participant_dropdown_items.append(display_label) - - self.layout = QVBoxLayout(self) - self.top_bar = QHBoxLayout() - self.layout.addLayout(self.top_bar) - - self.participant_dropdown = self._create_multiselect_dropdown(self.participant_dropdown_items) - self.participant_dropdown.currentIndexChanged.connect(self.update_participant_dropdown_label) - - self.event_dropdown = QComboBox() - self.event_dropdown.addItem("") - - - self.index_texts = [ - "0 (Spectral Connectivity Epochs)", - "1 (Envelope Correlation)", - "2 (Betas)", - "3 (Spectral Connectivity Epochs)", - ] - - self.image_index_dropdown = self._create_multiselect_dropdown(self.index_texts) - self.image_index_dropdown.currentIndexChanged.connect(self.update_image_index_dropdown_label) - - self.submit_button = QPushButton("Submit") - self.submit_button.clicked.connect(self.show_brain_images) - - self.top_bar.addWidget(QLabel("Participants:")) - self.top_bar.addWidget(self.participant_dropdown) - self.top_bar.addWidget(QLabel("Event:")) - self.top_bar.addWidget(self.event_dropdown) - self.top_bar.addWidget(QLabel("Image Indexes:")) - self.top_bar.addWidget(self.image_index_dropdown) - self.top_bar.addWidget(self.submit_button) - - self.scroll = QScrollArea() - self.scroll.setWidgetResizable(True) - self.scroll_content = QWidget() - self.grid_layout = QGridLayout(self.scroll_content) - self.scroll.setWidget(self.scroll_content) - self.layout.addWidget(self.scroll) - - self.thumb_size = QSize(280, 180) - self.showMaximized() - - - def show_brain_images(self): - import flares - - selected_event = self.event_dropdown.currentText() - if selected_event == "": - selected_event = None - - selected_display_names = self._get_checked_items(self.participant_dropdown) - selected_file_paths = [] - for display_name in selected_display_names: - for fp, short_label in self.participant_map.items(): - expected_display = f"{short_label} ({os.path.basename(fp)})" - if display_name == expected_display: - selected_file_paths.append(fp) - break - - selected_indexes = [ - int(s.split(" ")[0]) for s in self._get_checked_items(self.image_index_dropdown) - ] - - - parameterized_indexes = { - 0: [ - { - "key": "n_lines", - "label": "", - "default": "20", - "type": int, - }, - { - "key": "vmin", - "label": "", - "default": "0.9", - "type": float, - }, - ], - 1: [ - { - "key": "n_lines", - "label": "", - "default": "20", - "type": int, - }, - { - "key": "vmin", - "label": "", - "default": "0.9", - "type": float, - }, - - ], - 2: [ - { - "key": "n_lines", - "label": "", - "default": "20", - "type": int, - }, - { - "key": "vmin", - "label": "", - "default": "0.9", - "type": float, - }, - - ], - 3: [ - { - "key": "n_lines", - "label": "", - "default": "20", - "type": int, - }, - { - "key": "vmin", - "label": "", - "default": "0.9", - "type": float, - }, - - ], - } - - # Inject full_text from index_texts - for idx, params_list in parameterized_indexes.items(): - full_text = self.index_texts[idx] if idx < len(self.index_texts) else f"{idx} (No label found)" - for param_info in params_list: - param_info["full_text"] = full_text - - indexes_needing_params = {idx: parameterized_indexes[idx] for idx in selected_indexes if idx in parameterized_indexes} - - param_values = {} - if indexes_needing_params: - dialog = ParameterInputDialog(indexes_needing_params, parent=self) - if dialog.exec_() == QDialog.Accepted: - param_values = dialog.get_values() - if param_values is None: - return - else: - return - - # Pass the necessary arguments to each method - for file_path in selected_file_paths: - haemo_obj = self.haemo_dict.get(file_path) - epochs_obj = self.epochs_dict.get(file_path) - - if selected_event: - participant_events = set(haemo_obj.annotations.description) - if selected_event not in participant_events: - print(f"Skipping {self.participant_map[file_path]}: Event '{selected_event}' not found.") - continue - - if haemo_obj is None: - raise Exception("How did we get here?") - - - for idx in selected_indexes: - if idx == 0: - - params = param_values.get(idx, {}) - n_lines = params.get("n_lines", None) - vmin = params.get("vmin", None) - - if n_lines is None or vmin is None: - print(f"Missing parameters for index {idx}, skipping.") - continue - flares.functional_connectivity_spectral_epochs(epochs_obj, n_lines, vmin) - - elif idx == 1: - params = param_values.get(idx, {}) - n_lines = params.get("n_lines", None) - vmin = params.get("vmin", None) - - if n_lines is None or vmin is None: - print(f"Missing parameters for index {idx}, skipping.") - continue - flares.functional_connectivity_envelope(epochs_obj, n_lines, vmin) - - elif idx == 2: - params = param_values.get(idx, {}) - n_lines = params.get("n_lines", None) - vmin = params.get("vmin", None) - - if n_lines is None or vmin is None: - print(f"Missing parameters for index {idx}, skipping.") - continue - flares.functional_connectivity_betas(haemo_obj, n_lines, vmin, selected_event) - - elif idx == 3: - params = param_values.get(idx, {}) - n_lines = params.get("n_lines", None) - vmin = params.get("vmin", None) - - if n_lines is None or vmin is None: - print(f"Missing parameters for index {idx}, skipping.") - continue - flares.functional_connectivity_spectral_time(epochs_obj, n_lines, vmin) - - else: - print(f"No method defined for index {idx}") - - - -class GroupFunctionalConnectivityWidget(FlaresBaseWidget): - def __init__(self, haemo_dict, group, config_dict): - super().__init__("GroupFunctionalConnectivityWidget") - self.setWindowTitle("FLARES Group Viewer") - self.haemo_dict = haemo_dict - self.group = group - self.config_dict = config_dict - self.show_all_events = True - self._updating_checkstates = False - - QMessageBox.warning(self, "Warning - FLARES", f"Functional Connectivity is still in development and the results should currently be taken with a grain of salt. " - "By clicking OK, you accept that the images generated may not be factual.") - - - # Create mappings: file_path -> participant label and dropdown display text - self.participant_map = {} # file_path -> "Participant 1" - self.participant_dropdown_items = [] # "Participant 1 (filename)" - - for i, file_path in enumerate(self.haemo_dict.keys(), start=1): - short_label = f"Participant {i}" - display_label = f"{short_label} ({os.path.basename(file_path)})" - self.participant_map[file_path] = short_label - self.participant_dropdown_items.append(display_label) - - self.layout = QVBoxLayout(self) - self.top_bar = QHBoxLayout() - self.layout.addLayout(self.top_bar) - - self.group_to_paths = {} - for file_path, group_name in self.group.items(): - self.group_to_paths.setdefault(group_name, []).append(file_path) - - self.group_names = sorted(self.group_to_paths.keys()) - - self.group_dropdown = QComboBox() - self.group_dropdown.addItem("") - self.group_dropdown.addItems(self.group_names) - self.group_dropdown.setCurrentIndex(0) - self.group_dropdown.currentIndexChanged.connect(self.update_participant_list_for_group) - - self.participant_dropdown = self._create_multiselect_dropdown(self.participant_dropdown_items) - self.participant_dropdown.currentIndexChanged.connect(self.update_participant_dropdown_label) - self.participant_dropdown.setEnabled(False) - - self.event_dropdown = QComboBox() - self.event_dropdown.addItem("") - - self.index_texts = [ - "0 (Betas)", - #"1 (Significance)", - #"2 (Brain Activity Visualization)", - # "3 (fourth image)", - ] - - self.image_index_dropdown = self._create_multiselect_dropdown(self.index_texts) - self.image_index_dropdown.currentIndexChanged.connect(self.update_image_index_dropdown_label) - - self.submit_button = QPushButton("Submit") - self.submit_button.clicked.connect(self.show_brain_images) - - self.top_bar.addWidget(QLabel("Group:")) - self.top_bar.addWidget(self.group_dropdown) - self.top_bar.addWidget(QLabel("Participants:")) - self.top_bar.addWidget(self.participant_dropdown) - self.top_bar.addWidget(QLabel("Event:")) - self.top_bar.addWidget(self.event_dropdown) - self.top_bar.addWidget(QLabel("Image Indexes:")) - self.top_bar.addWidget(self.image_index_dropdown) - self.top_bar.addWidget(self.submit_button) - - self.scroll = QScrollArea() - self.scroll.setWidgetResizable(True) - self.scroll_content = QWidget() - self.grid_layout = QGridLayout(self.scroll_content) - self.scroll.setWidget(self.scroll_content) - self.layout.addWidget(self.scroll) - - self.thumb_size = QSize(280, 180) - self.showMaximized() - - - def show_brain_images(self): - import flares - - selected_event = self.event_dropdown.currentText() - if selected_event == "": - selected_event = None - - selected_display_names = self._get_checked_items(self.participant_dropdown) - selected_file_paths = [] - for display_name in selected_display_names: - for fp, short_label in self.participant_map.items(): - expected_display = f"{short_label} ({os.path.basename(fp)})" - if display_name == expected_display: - selected_file_paths.append(fp) - break - - if selected_event: - valid_paths = [] - for fp in selected_file_paths: - raw = self.haemo_dict.get(fp) - # Check if this participant actually has the event in their annotations - if raw is not None and hasattr(raw, "annotations"): - if selected_event in raw.annotations.description: - valid_paths.append(fp) - - selected_file_paths = valid_paths - - selected_indexes = [ - int(s.split(" ")[0]) for s in self._get_checked_items(self.image_index_dropdown) - ] - - if not selected_file_paths: - print("No participants selected.") - return - - # Only keep indexes 0 and 1 that need parameters - parameterized_indexes = { - 0: [ - { - "key": "n_lines", - "label": "", - "default": "20", - "type": int, - }, - { - "key": "vmin", - "label": "", - "default": "0.9", - "type": float, - }, - ], - } - - # Inject full_text from index_texts - for idx, params_list in parameterized_indexes.items(): - full_text = self.index_texts[idx] if idx < len(self.index_texts) else f"{idx} (No label found)" - for param_info in params_list: - param_info["full_text"] = full_text - - indexes_needing_params = {idx: parameterized_indexes[idx] for idx in selected_indexes if idx in parameterized_indexes} - - param_values = {} - if indexes_needing_params: - dialog = ParameterInputDialog(indexes_needing_params, parent=self) - if dialog.exec_() == QDialog.Accepted: - param_values = dialog.get_values() - if param_values is None: - return - else: - return - - for idx in selected_indexes: - if idx == 0: - params = param_values.get(idx, {}) - n_lines = params.get("n_lines", None) - vmin = params.get("vmin", None) - - if n_lines is None or vmin is None: - print(f"Missing parameters for index {idx}, skipping.") - continue - flares.run_group_functional_connectivity(self.haemo_dict, self.config_dict, selected_file_paths, selected_event, 50, 0.5) - elif idx == 1: - pass - elif idx == 2: - pass - elif idx == 3: - pass - - else: - print(f"No method defined for index {idx}") @@ -3418,7 +907,7 @@ def single_participant_worker(file_path, raw_data, result_queue, progress_queue) """ Runs inside its own dedicated process """ p_name = os.path.basename(file_path) try: - import flares + import src.flares as flares # Perform the heavy fold_channels logic channel_results = flares.fold_channels(raw_data, p_name, progress_queue) @@ -4584,161 +2073,9 @@ class ExportDataAsCSVViewerWidget(FlaresBaseWidget): # win.show() -class ClickableLabel(QLabel): - def __init__(self, full_pixmap: QPixmap, thumbnail_pixmap: QPixmap): - super().__init__() - self._pixmap_full = full_pixmap - self.setPixmap(thumbnail_pixmap) - self.setAlignment(Qt.AlignmentFlag.AlignCenter) - self.setFixedSize(thumbnail_pixmap.size()) - self.setStyleSheet("border: 1px solid gray; margin: 2px;") - - def mousePressEvent(self, event): - viewer = QWidget() - viewer.setWindowTitle("Expanded View") - layout = QVBoxLayout(viewer) - label = QLabel() - label.setPixmap(self._pixmap_full) - label.setAlignment(Qt.AlignmentFlag.AlignCenter) - layout.addWidget(label) - viewer.resize(1000, 800) - viewer.show() - self._expanded_viewer = viewer # keep reference alive -class ParameterInputDialog(QDialog): - def __init__(self, params_dict, parent=None): - """ - params_dict format: - { - idx: [ - { - "key": "p_val", - "label": "Significance threshold P-value (e.g. 0.05)", - "default": "0.05", - "type": float, - }, - { - "key": "graph_scale", - "label": "Graph scale factor", - "default": "1", - "type": int, - } - ], - ... - } - """ - super().__init__(parent) - self.setWindowTitle("Input Parameters") - self.params_dict = params_dict - self.inputs = {} # {(idx, param_key): QLineEdit} - - layout = QVBoxLayout(self) - intro_label = QLabel( - "Some methods require parameters to continue:\n" - "Clicking OK will simply use default values if input is left empty." - ) - layout.addWidget(intro_label) - - for idx, param_list in params_dict.items(): - full_text = param_list[0].get('full_text', f"Index [{idx}]") - group_label = QLabel(f"{full_text} requires parameters:") - group_label.setStyleSheet("font-weight: bold; margin-top: 10px;") - layout.addWidget(group_label) - - for param_info in param_list: - label = QLabel(param_info["label"]) - layout.addWidget(label) - - line_edit = QLineEdit(self) - line_edit.setPlaceholderText(str(param_info.get("default", ""))) - layout.addWidget(line_edit) - - self.inputs[(idx, param_info["key"])] = line_edit - - # Buttons - btn_layout = QHBoxLayout() - ok_btn = QPushButton("OK", self) - cancel_btn = QPushButton("Cancel", self) - btn_layout.addWidget(ok_btn) - btn_layout.addWidget(cancel_btn) - layout.addLayout(btn_layout) - - ok_btn.clicked.connect(self.accept) - cancel_btn.clicked.connect(self.reject) - - def get_values(self): - """ - Validate and return values dict in form: - { - idx: { - param_key: value, - ... - }, - ... - } - Returns None if validation fails (error dialog shown). - """ - values = {} - for (idx, param_key), line_edit in self.inputs.items(): - text = line_edit.text().strip() - - # Find param info dict - param_info = None - for p in self.params_dict[idx]: - if p['key'] == param_key: - param_info = p - break - if param_info is None: - # This shouldn't happen, but just in case: - self._show_error(f"Internal error: No param info for index {idx} key '{param_key}'") - return None - - if not text: - text = str(param_info.get('default', '')) - - param_type = param_info.get('type', str) - - try: - if param_type == int: - val = int(text) - elif param_type == float: - val = float(text) - elif param_type == bool: - # Convert common bool strings to bool - val_lower = text.lower() - if val_lower in ('true', '1', 'yes', 'y'): - val = True - elif val_lower in ('false', '0', 'no', 'n'): - val = False - else: - raise ValueError(f"Invalid bool value: {text}") - elif param_type == str: - val = text - else: - val = text # fallback - except (ValueError, TypeError): - self._show_error( - f"Invalid input for index {idx} parameter '{param_key}': '{text}'\n" - f"Expected type: {param_type.__name__}" - ) - return None - - if idx not in values: - values[idx] = {} - values[idx][param_key] = val - - return values - - def _show_error(self, message): - error_box = QMessageBox(self) - error_box.setIcon(QMessageBox.Critical) - error_box.setWindowTitle("Input Error") - error_box.setText(message) - error_box.exec_() - - class GroupViewerWidget(FlaresBaseWidget): @@ -4823,7 +2160,7 @@ class GroupViewerWidget(FlaresBaseWidget): def show_brain_images(self): - import flares + import src.flares as flares selected_event = self.event_dropdown.currentText() if selected_event == "": @@ -5181,7 +2518,7 @@ class GroupBrainViewerWidget(FlaresBaseWidget): return file_paths def show_brain_images(self): - import flares + import src.flares as flares selected_event = self.event_dropdown.currentText() if selected_event == "": @@ -7373,7 +4710,7 @@ def run_gui_entry_wrapper(config, gui_queue, progress_queue, ack_queue): """ try: - import flares + import src.flares as flares flares.gui_entry(config, gui_queue, progress_queue, ack_queue) gui_queue.close() # gui_queue.join_thread() diff --git a/src/shared/s_flaresbasewidget.py b/src/shared/s_flaresbasewidget.py new file mode 100644 index 0000000..425acf4 --- /dev/null +++ b/src/shared/s_flaresbasewidget.py @@ -0,0 +1,1030 @@ +import os + +from PySide6.QtWidgets import QApplication, QComboBox, QDialog, QHBoxLayout, QLabel, QLineEdit, QListView, QMessageBox, QPushButton, QVBoxLayout, QWidget +from PySide6.QtGui import QStandardItemModel, QStandardItem, QPixmap +from PySide6.QtCore import QEvent, Qt + +class ClickableLabel(QLabel): + def __init__(self, full_pixmap: QPixmap, thumbnail_pixmap: QPixmap): + super().__init__() + self._pixmap_full = full_pixmap + self.setPixmap(thumbnail_pixmap) + self.setAlignment(Qt.AlignmentFlag.AlignCenter) + self.setFixedSize(thumbnail_pixmap.size()) + self.setStyleSheet("border: 1px solid gray; margin: 2px;") + + def mousePressEvent(self, event): + viewer = QWidget() + viewer.setWindowTitle("Expanded View") + layout = QVBoxLayout(viewer) + label = QLabel() + label.setPixmap(self._pixmap_full) + label.setAlignment(Qt.AlignmentFlag.AlignCenter) + layout.addWidget(label) + viewer.resize(1000, 800) + viewer.show() + self._expanded_viewer = viewer # keep reference alive + + +class ParameterInputDialog(QDialog): + def __init__(self, params_dict, parent=None): + """ + params_dict format: + { + idx: [ + { + "key": "p_val", + "label": "Significance threshold P-value (e.g. 0.05)", + "default": "0.05", + "type": float, + }, + { + "key": "graph_scale", + "label": "Graph scale factor", + "default": "1", + "type": int, + } + ], + ... + } + """ + super().__init__(parent) + self.setWindowTitle("Input Parameters") + self.params_dict = params_dict + self.inputs = {} # {(idx, param_key): QLineEdit} + + layout = QVBoxLayout(self) + intro_label = QLabel( + "Some methods require parameters to continue:\n" + "Clicking OK will simply use default values if input is left empty." + ) + layout.addWidget(intro_label) + + for idx, param_list in params_dict.items(): + full_text = param_list[0].get('full_text', f"Index [{idx}]") + group_label = QLabel(f"{full_text} requires parameters:") + group_label.setStyleSheet("font-weight: bold; margin-top: 10px;") + layout.addWidget(group_label) + + for param_info in param_list: + label = QLabel(param_info["label"]) + layout.addWidget(label) + + line_edit = QLineEdit(self) + line_edit.setPlaceholderText(str(param_info.get("default", ""))) + layout.addWidget(line_edit) + + self.inputs[(idx, param_info["key"])] = line_edit + + # Buttons + btn_layout = QHBoxLayout() + ok_btn = QPushButton("OK", self) + cancel_btn = QPushButton("Cancel", self) + btn_layout.addWidget(ok_btn) + btn_layout.addWidget(cancel_btn) + layout.addLayout(btn_layout) + + ok_btn.clicked.connect(self.accept) + cancel_btn.clicked.connect(self.reject) + + def get_values(self): + """ + Validate and return values dict in form: + { + idx: { + param_key: value, + ... + }, + ... + } + Returns None if validation fails (error dialog shown). + """ + values = {} + for (idx, param_key), line_edit in self.inputs.items(): + text = line_edit.text().strip() + + # Find param info dict + param_info = None + for p in self.params_dict[idx]: + if p['key'] == param_key: + param_info = p + break + if param_info is None: + # This shouldn't happen, but just in case: + self._show_error(f"Internal error: No param info for index {idx} key '{param_key}'") + return None + + if not text: + text = str(param_info.get('default', '')) + + param_type = param_info.get('type', str) + + try: + if param_type == int: + val = int(text) + elif param_type == float: + val = float(text) + elif param_type == bool: + # Convert common bool strings to bool + val_lower = text.lower() + if val_lower in ('true', '1', 'yes', 'y'): + val = True + elif val_lower in ('false', '0', 'no', 'n'): + val = False + else: + raise ValueError(f"Invalid bool value: {text}") + elif param_type == str: + val = text + else: + val = text # fallback + except (ValueError, TypeError): + self._show_error( + f"Invalid input for index {idx} parameter '{param_key}': '{text}'\n" + f"Expected type: {param_type.__name__}" + ) + return None + + if idx not in values: + values[idx] = {} + values[idx][param_key] = val + + return values + + def _show_error(self, message): + error_box = QMessageBox(self) + error_box.setIcon(QMessageBox.Critical) + error_box.setWindowTitle("Input Error") + error_box.setText(message) + error_box.exec_() + + +class FullClickComboBox(QComboBox): + def __init__(self, parent=None): + super().__init__(parent) + self.setEditable(True) + self.lineEdit().setReadOnly(True) + self.lineEdit().installEventFilter(self) + + def eventFilter(self, obj, event): + if obj == self.lineEdit(): + + if event.type() == QEvent.MouseButtonPress: + return True + + if event.type() == QEvent.MouseButtonRelease: + self.showPopup() + return True + + return super().eventFilter(obj, event) + + +from PySide6.QtWidgets import QComboBox, QFrame, QHBoxLayout, QListView, QMessageBox, QPushButton, QSpinBox, QWidget, QVBoxLayout, QLabel, QLineEdit +from PySide6.QtGui import QIntValidator, QDoubleValidator, QStandardItemModel, QStandardItem + +from PySide6.QtCore import Qt + +from src.shared.s_shared import APP_NAME +from src.shared.s_flaresbasewidget import FullClickComboBox + +class ParamSection(QWidget): + """ + A widget section that dynamically creates labeled input fields from parameter metadata. + + Args: + section_data (dict): Dictionary containing section title and list of parameter info. + Expected format: + { + "title": str, + "params": [ + { + "name": str, + "type": type, + "default": any, + "help": str (optional) + }, + ... + ] + } + """ + + def __init__(self, section_data, global_widgets): + super().__init__() + layout = QVBoxLayout() + self.setLayout(layout) + self.widgets = global_widgets + self.dependencies = [] + self.selected_path = None + + + # Title label + title_label = QLabel(section_data["title"]) + title_label.setStyleSheet("font-weight: bold; font-size: 14px; margin-top: 10px; margin-bottom: 5px;") + layout.addWidget(title_label) + + # Horizontal line + line = QFrame() + line.setFrameShape(QFrame.Shape.HLine) + line.setFrameShadow(QFrame.Shadow.Sunken) + layout.addWidget(line) + + for param in section_data["params"]: + h_layout = QHBoxLayout() + + label = QLabel(param["name"]) + + label.setToolTip(param.get("help", "")) + + help_text = param.get("help", "") + + help_btn = QPushButton("?") + help_btn.setFixedWidth(25) + help_btn.setToolTip(help_text) + help_btn.clicked.connect(lambda _, text=help_text: self.show_help_popup(text)) + + h_layout.addWidget(help_btn) + + h_layout.addWidget(label) + h_layout.setStretch(0, 1) + h_layout.setStretch(1, 6) + + default_val = param["default"] + + # Create input widget based on type + if param["type"] == bool: + widget = QComboBox() + widget.addItems(["True", "False"]) + widget.setCurrentText(str(default_val)) + widget.currentTextChanged.connect(lambda val, p=param["name"]: self.check_if_changed(p, val)) + widget.currentTextChanged.connect(self.notify_global_update) + elif param["type"] == int: + widget = QLineEdit() + widget.setValidator(QIntValidator()) + widget.setText(str(default_val)) + widget.textChanged.connect(lambda val, p=param["name"]: self.check_if_changed(p, val)) + elif param["type"] == float: + widget = QLineEdit() + widget.setValidator(QDoubleValidator()) + widget.setText(str(default_val)) + widget.textChanged.connect(lambda val, p=param["name"]: self.check_if_changed(p, val)) + elif param["type"] == list: + if param.get("exclusive", True): + widget = QComboBox() + widget.addItems(param.get("options", [])) + widget.setCurrentText(str(default_val)) + widget.currentTextChanged.connect(lambda val, p=param["name"]: self.check_if_changed(p, val)) + widget.currentTextChanged.connect(self.notify_global_update) + else: + widget = self._create_multiselect_dropdown(None) + elif param["type"] == range: + widget = QSpinBox() + widget.setRange(0, 999) #NOTE: will this be a high enough limit? + # If default is "None" or range(15), handle it gracefully: + if isinstance(default_val, range): + widget.setValue(default_val.stop) + elif str(default_val).isdigit(): + widget.setValue(int(default_val)) + else: + widget.setValue(15) # Default fallback + widget.valueChanged.connect(lambda val, p=param["name"]: self.check_if_changed(p, val)) + else: + widget = QLineEdit() + widget.setText(str(default_val)) + + if "depends_on" in param: + self.dependencies.append({ + "child_name": param["name"], + "parent_name": param["depends_on"], + "depends_value": param.get("depends_value", "True") + }) + + widget.setToolTip(help_text) + + h_layout.addWidget(widget) + h_layout.setStretch(2, 3) + + layout.addLayout(h_layout) + self.widgets[param["name"]] = { + "widget": widget, + "label": label, + "default": default_val, + "type": param["type"], + "h_layout": h_layout + } + + self.update_dependencies() + + + def has_any_changes(self): + """Returns True if any parameter in this section differs from its default.""" + for name, info in self.widgets.items(): + default = info["default"] + + current_val = self.get_param_values().get(name) + + if str(current_val) != str(default): + return True + return False + + + def check_if_changed(self, param_name, current_value): + """Toggles bold font on the label if the value differs from default.""" + info = self.widgets.get(param_name) + if not info: + return + + label = info["label"] + default = info["default"] + + is_changed = False + + if info["type"] == list: + # If it's an exclusive ComboBox, current_value is a string. + # We wrap it in a list to compare it to the default list. + if isinstance(current_value, str): + normalized_current = [current_value] + else: + normalized_current = current_value # Already a list from multi-select + + # Ensure default is a list for comparison + normalized_default = default if isinstance(default, list) else [default] + + # Use sorted to ensure order doesn't matter + is_changed = sorted(normalized_current) != sorted(normalized_default) + + # 2. Handle Range (SpinBox) + elif info["type"] == range: + ref = default.stop if isinstance(default, range) else default + try: + is_changed = int(current_value) != int(ref) + except (ValueError, TypeError): + is_changed = True + + # 3. Standard Comparison (bool, int, float, str) + else: + is_changed = str(current_value) != str(default) + + # Update Font + font = label.font() + font.setBold(is_changed) + label.setFont(font) + + # Optional: Change color to make it even more obvious + if is_changed: + label.setStyleSheet("color: #3498db; font-weight: bold;") # Nice Blue + else: + label.setStyleSheet("color: none; font-weight: normal;") + + def notify_global_update(self): + """ + Since dependencies can cross sections, we need to tell + all sections to refresh their enabled/disabled states. + """ + # If you have a reference to the parent container, call its update. + # Otherwise, you can iterate through the known param_sections: + for section in self.parent().findChildren(ParamSection): + section.update_dependencies() + + def update_dependencies(self): + """Disables/Enables widgets based on parent selection values.""" + for dep in self.dependencies: + child_info = self.widgets.get(dep["child_name"]) + parent_info = self.widgets.get(dep["parent_name"]) + + if child_info and parent_info: + parent_widget = parent_info["widget"] + + # Get current value of parent (works for both bool-combos and list-combos) + current_parent_value = parent_widget.currentText() + + # Check if it matches the required value + is_active = (current_parent_value == dep["depends_value"]) + + # Toggle the entire row (Button, Label, and Input) + h_layout = child_info["h_layout"] + for i in range(h_layout.count()): + item = h_layout.itemAt(i).widget() + if item: + item.setEnabled(is_active) + + def _create_multiselect_dropdown(self, items): + combo = FullClickComboBox() + combo.setView(QListView()) + model = QStandardItemModel() + combo.setModel(model) + combo.setEditable(True) + combo.lineEdit().setReadOnly(True) + combo.lineEdit().setPlaceholderText("Select...") + + dummy_item = QStandardItem("") + dummy_item.setFlags(Qt.ItemIsEnabled) + model.appendRow(dummy_item) + + toggle_item = QStandardItem("Toggle Select All") + toggle_item.setFlags(Qt.ItemIsUserCheckable | Qt.ItemIsEnabled) + toggle_item.setData(Qt.Unchecked, Qt.CheckStateRole) + model.appendRow(toggle_item) + + if items is not None: + for item in items: + standard_item = QStandardItem(item) + standard_item.setFlags(Qt.ItemIsUserCheckable | Qt.ItemIsEnabled) + standard_item.setData(Qt.Unchecked, Qt.CheckStateRole) + model.appendRow(standard_item) + + combo.setInsertPolicy(QComboBox.NoInsert) + + + def on_view_clicked(index): + item = model.itemFromIndex(index) + if item.isCheckable(): + new_state = Qt.Checked if item.checkState() == Qt.Unchecked else Qt.Unchecked + item.setCheckState(new_state) + + combo.view().pressed.connect(on_view_clicked) + + self._updating_checkstates = False + + def on_item_changed(item): + if self._updating_checkstates: + return + self._updating_checkstates = True + + normal_items = [model.item(i) for i in range(2, model.rowCount())] # skip dummy and toggle + + if item == toggle_item: + all_checked = all(i.checkState() == Qt.Checked for i in normal_items) + if all_checked: + for i in normal_items: + i.setCheckState(Qt.Unchecked) + toggle_item.setCheckState(Qt.Unchecked) + else: + for i in normal_items: + i.setCheckState(Qt.Checked) + toggle_item.setCheckState(Qt.Checked) + + elif item == dummy_item: + pass + + else: + # When normal items change, update toggle item + all_checked = all(i.checkState() == Qt.Checked for i in normal_items) + toggle_item.setCheckState(Qt.Checked if all_checked else Qt.Unchecked) + + self._updating_checkstates = False + + for param_name, info in self.widgets.items(): + if info["widget"] == combo: + self.update_dropdown_label(param_name) + break + + model.itemChanged.connect(on_item_changed) + + combo.setInsertPolicy(QComboBox.NoInsert) + return combo + + def show_help_popup(self, text): + msg = QMessageBox(self) + msg.setWindowTitle(f"Parameter Info - {APP_NAME.upper()}") + msg.setText(text) + msg.exec() + + + def get_param_values(self): + values = {} + for name, info in self.widgets.items(): + widget = info["widget"] + expected_type = info["type"] + + if name == "SHORT_CHANNEL_REGRESSION": + # If the widget is disabled (greyed out), force False + if not widget.isEnabled(): + values[name] = False + continue + + if expected_type == bool: + values[name] = widget.currentText() == "True" + elif expected_type == list: + if isinstance(widget, FullClickComboBox): + values[name] = [x.strip() for x in widget.lineEdit().text().split(",") if x.strip()] + elif isinstance(widget, QComboBox): + values[name] = widget.currentText() + elif expected_type == range: + if isinstance(widget, QSpinBox): + # Convert the integer N into range(N) + values[name] = range(widget.value()) + else: + values[name] = range(15) # Fallback + else: + raw_text = widget.text() + try: + if expected_type == int: + values[name] = int(raw_text) + elif expected_type == float: + values[name] = float(raw_text) + elif expected_type == str: + values[name] = raw_text + else: + values[name] = raw_text # Fallback + except Exception as e: + raise ValueError(f"Invalid value for {name}: {raw_text}") from e + + return values + + def update_dropdown_items(self, param_name, new_items): + """ + Updates the items in a multi-select dropdown parameter field. + + Args: + param_name (str): The parameter name (must match one in self.widgets). + new_items (list): The new items to populate in the dropdown. + """ + widget_info = self.widgets.get(param_name) + #print("[ParamSection] Current widget keys:", list(self.widgets.keys())) + + if not widget_info: + print(f"[ParamSection] No widget found for param '{param_name}'") + return + + widget = widget_info["widget"] + if not isinstance(widget, FullClickComboBox): + print(f"[ParamSection] Widget for param '{param_name}' is not a FullClickComboBox") + return + + # Replace the model on the existing widget + new_model = QStandardItemModel() + + dummy_item = QStandardItem("") + dummy_item.setFlags(Qt.ItemIsEnabled) + new_model.appendRow(dummy_item) + + toggle_item = QStandardItem("Toggle Select All") + toggle_item.setFlags(Qt.ItemIsUserCheckable | Qt.ItemIsEnabled) + toggle_item.setData(Qt.Unchecked, Qt.CheckStateRole) + new_model.appendRow(toggle_item) + + for item_text in new_items: + item = QStandardItem(item_text) + item.setFlags(Qt.ItemIsUserCheckable | Qt.ItemIsEnabled) + item.setData(Qt.Unchecked, Qt.CheckStateRole) + new_model.appendRow(item) + + widget.setModel(new_model) + widget.setView(QListView()) # Reset view to refresh properly + + def on_view_clicked(index): + item = new_model.itemFromIndex(index) + if item.isCheckable(): + new_state = Qt.Checked if item.checkState() == Qt.Unchecked else Qt.Unchecked + item.setCheckState(new_state) + + widget.view().pressed.connect(on_view_clicked) + + def on_item_changed(item): + if getattr(self, "_updating_checkstates", False): + return + self._updating_checkstates = True + + normal_items = [new_model.item(i) for i in range(2, new_model.rowCount())] + if item == toggle_item: + all_checked = all(i.checkState() == Qt.Checked for i in normal_items) + for i in normal_items: + i.setCheckState(Qt.Unchecked if all_checked else Qt.Checked) + toggle_item.setCheckState(Qt.Unchecked if all_checked else Qt.Checked) + else: + all_checked = all(i.checkState() == Qt.Checked for i in normal_items) + toggle_item.setCheckState(Qt.Checked if all_checked else Qt.Unchecked) + + self._updating_checkstates = False + + for param_name, info in self.widgets.items(): + if info["widget"] == widget: + self.update_dropdown_label(param_name) + break + + new_model.itemChanged.connect(on_item_changed) + widget.lineEdit().setText("") + + + def _get_checked_items(self, combo): + checked = [] + model = combo.model() + for i in range(model.rowCount()): + item = model.item(i) + if item.text() in ("", "Toggle Select All"): + continue + if item.checkState() == Qt.Checked: + checked.append(item.text()) + return checked + + def update_dropdown_label(self, param_name): + widget_info = self.widgets.get(param_name) + if not widget_info: + print(f"[ParamSection] No widget found for param '{param_name}'") + return + + widget = widget_info["widget"] + if not isinstance(widget, FullClickComboBox): + print(f"[ParamSection] Widget for param '{param_name}' is not a FullClickComboBox") + return + + selected = self._get_checked_items(widget) + if not selected: + widget.lineEdit().setText("") + else: + # You can customize how you display selected items here: + widget.lineEdit().setText(", ".join(selected)) + + # def update_annotation_dropdown_from_loaded_files(self, bubble_widgets, button1): + # file_paths = [bubble.file_path for bubble in bubble_widgets.values()] + # if not file_paths: + # return + + # # 1. Start the UI immediately + # progress = QProgressDialog("Accessing Workers...", "Cancel", 0, len(file_paths), self) + # progress.setWindowModality(Qt.WindowModality.WindowModal) + # progress.setMinimumDuration(0) + # progress.setValue(0) + + # # Force the UI to draw the window NOW before we start the loop + # progress.show() + # QApplication.processEvents() + + # annotation_sets = [] + + # # 2. Use the persistent executor (don't use 'with' here!) + # for i, path in enumerate(file_paths): + # progress.setValue(i) + # progress.setLabelText(f"Reading file {i+1} of {len(file_paths)}...") + # QApplication.processEvents() # Keeps the UI snappy + + # if progress.wasCanceled(): + # break + + # # This call is now nearly instant because the process is already warm + # future = self.file_executor.submit(_extract_annotations, path) + # try: + # labels_list = future.result() + # if labels_list: + # annotation_sets.append(set(labels_list)) + # except Exception as e: + # print(f"Worker Error: {e}") + + # progress.setValue(len(file_paths)) + + # # 3. Final Logic + # if not annotation_sets: + # self.update_dropdown_items("REMOVE_EVENTS", []) + # button1.setVisible(False) + # return + + # common = set.intersection(*annotation_sets) if len(annotation_sets) > 1 else annotation_sets[0] + # self.update_dropdown_items("REMOVE_EVENTS", sorted(list(common))) + + +class FlaresBaseWidget(QWidget): + def __init__(self, caller): + super().__init__() + self.caller = caller + self.haemo_dict = None + self._updating_checkstates = False + self.participant_map = {} + self.show_all_events = True + + # These will be defined by the children, but we'll + # initialize them as None so the code doesn't crash. + self.participant_dropdown = None + self.event_dropdown = None + self.image_index_dropdown = None + + + def _create_multiselect_dropdown(self, items): + combo = FullClickComboBox() + combo.setView(QListView()) + model = QStandardItemModel() + combo.setModel(model) + combo.setEditable(True) + combo.lineEdit().setReadOnly(True) + combo.lineEdit().setPlaceholderText("Select...") + + # Setup internal items + dummy = QStandardItem("") + dummy.setFlags(Qt.ItemIsEnabled) + model.appendRow(dummy) + + toggle = QStandardItem("Toggle Select All") + toggle.setFlags(Qt.ItemIsUserCheckable | Qt.ItemIsEnabled) + toggle.setData(Qt.Unchecked, Qt.CheckStateRole) + model.appendRow(toggle) + + for text in items: + item = QStandardItem(text) + item.setFlags(Qt.ItemIsUserCheckable | Qt.ItemIsEnabled) + item.setData(Qt.Unchecked, Qt.CheckStateRole) + model.appendRow(item) + + # Handle clicking the view directly + def on_view_clicked(index): + item = model.itemFromIndex(index) + if item.isCheckable(): + new_state = Qt.Checked if item.checkState() == Qt.Unchecked else Qt.Unchecked + item.setCheckState(new_state) + combo.view().pressed.connect(on_view_clicked) + + # Logic for "Select All" and Signal Propagation + def on_item_changed(item): + if getattr(self, '_updating_checkstates', False): + return + self._updating_checkstates = True + + normal_items = [model.item(i) for i in range(2, model.rowCount())] + + if item == toggle: + state = toggle.checkState() + for i in normal_items: + i.setCheckState(state) + else: + all_checked = all(i.checkState() == Qt.Checked for i in normal_items) + toggle.setCheckState(Qt.Checked if all_checked else Qt.Unchecked) + + # Trigger the widget's update logic via the existing signal + combo.currentIndexChanged.emit(combo.currentIndex()) + self._updating_checkstates = False + + model.itemChanged.connect(on_item_changed) + combo.setInsertPolicy(QComboBox.NoInsert) + return combo + + + # def _get_checked_items(self, combo): + # model = combo.model() + # checked = [] + # for i in range(2, model.rowCount()): # Start at 2 to skip dummy/toggle + # item = model.item(i) + # if item.checkState() == Qt.Checked: + # checked.append(item.text()) + # return checked + + def _get_checked_items(self, combo=None): + target = combo if combo is not None else getattr(self, 'participant_dropdown', None) + + if target is None or target.model() is None: + return [] + + model = target.model() + checked_items = [] + + # Exclusion list: any item text that should never be treated as data + forbidden = {"Toggle All", "Select All", "", "Toggle"} + + for row in range(model.rowCount()): + item = model.item(row) + if item.checkState() == Qt.CheckState.Checked: + text = item.text() + # Only add if it's not a 'UI control' item + if text not in forbidden and not text.startswith("Toggle"): + checked_items.append(text) + + return checked_items + + + def update_participant_dropdown_label(self, combo=None): + """ + Handles label updates for ANY participant dropdown. + If 'combo' is None, it defaults to the standard self.participant_dropdown. + """ + if isinstance(combo, int): + combo = None + + # 1. Figure out which dropdown we are talking to + target_combo = combo if combo is not None else getattr(self, "participant_dropdown", None) + + if target_combo is None: + return # Safety check: nothing to update + + # 2. Get the checked items and format the text + selected = self._get_checked_items(target_combo) + if not selected: + target_combo.lineEdit().setText("") + else: + # Extract just "Participant N" + selected_short = [s.split(" ")[0] + " " + s.split(" ")[1] for s in selected] + target_combo.lineEdit().setText(", ".join(selected_short)) + + # 3. Conditional trigger for event updates + # We only update events if we aren't in one of the excluded viewers + excluded_viewers = { + "ParticipantViewer", + "ParticipantFoldChannels", + "ExportDataAsCSVViewer", + } + + if getattr(self, "caller", None) not in excluded_viewers: + self._update_event_dropdown() + + + def update_image_index_dropdown_label(self): + selected = self._get_checked_items(self.image_index_dropdown) + if not selected: + self.image_index_dropdown.lineEdit().setText("") + else: + # Only show the index part + index_labels = [s.split(" ")[0] for s in selected] + self.image_index_dropdown.lineEdit().setText(", ".join(index_labels)) + + + def _update_event_dropdown(self): + is_split_group = hasattr(self, 'participant_dropdown_a') and hasattr(self, 'participant_dropdown_b') + + bypass = False + main_win = next((w for w in QApplication.topLevelWidgets() + if w.objectName() == "MainApplication" or hasattr(w, "missing_events_bypass")), None) + if main_win: + bypass = getattr(main_win, "missing_events_bypass", False) + + if is_split_group: + names_a = self._get_checked_items(self.participant_dropdown_a) + names_b = self._get_checked_items(self.participant_dropdown_b) + + if not names_a or not names_b: + self._clear_event_dropdown() + return + + map_a = getattr(self, 'participant_map_a', {}) + rev_a = {f"{l} ({os.path.basename(fp)})": fp for fp, l in map_a.items()} + sets_a = [] + for n in names_a: + raw = self.haemo_dict.get(rev_a.get(n)) + if raw and hasattr(raw, "annotations"): + sets_a.append(set(raw.annotations.description)) + + map_b = getattr(self, 'participant_map_b', {}) + rev_b = {f"{l} ({os.path.basename(fp)})": fp for fp, l in map_b.items()} + sets_b = [] + for n in names_b: + raw = self.haemo_dict.get(rev_b.get(n)) + if raw and hasattr(raw, "annotations"): + sets_b.append(set(raw.annotations.description)) + + if not sets_a or not sets_b: + self._clear_event_dropdown() + return + + if not bypass: + final_annotations = set.intersection(*(sets_a + sets_b)) + else: + all_events_a = {event for s in sets_a for event in s} + all_events_b = {event for s in sets_b for event in s} + + valid_a = set() + for event in all_events_a: + count = sum(1 for s in sets_a if event in s) + if count >= 2: + valid_a.add(event) + + valid_b = set() + for event in all_events_b: + count = sum(1 for s in sets_b if event in s) + if count >= 2: + valid_b.add(event) + + final_annotations = valid_a.intersection(valid_b) + + else: + names = self._get_checked_items(self.participant_dropdown) + if not names: + self._clear_event_dropdown() + return + + map_single = getattr(self, 'participant_map', {}) + rev_single = {f"{l} ({os.path.basename(fp)})": fp for fp, l in map_single.items()} + all_sets = [] + for n in names: + raw = self.haemo_dict.get(rev_single.get(n)) + if raw and hasattr(raw, "annotations"): + all_sets.append(set(raw.annotations.description)) + + if not all_sets: + self._clear_event_dropdown() + return + + if not bypass: + final_annotations = set.intersection(*all_sets) + else: + final_annotations = set.union(*all_sets) + + self.event_dropdown.clear() + self.event_dropdown.addItem("") + for ann in sorted(final_annotations): + self.event_dropdown.addItem(ann) + + def _clear_event_dropdown(self): + if hasattr(self, 'event_dropdown'): + self.event_dropdown.clear() + self.event_dropdown.addItem("") + + + def _connect_select_all_toggle(self, toggle_item, model): + """Helper function to connect the Select All functionality.""" + normal_items = [model.item(i) for i in range(2, model.rowCount())] # skip dummy and toggle + + def on_item_changed(item): + if self._updating_checkstates: + return + self._updating_checkstates = True + + if item == toggle_item: + all_checked = all(i.checkState() == Qt.Checked for i in normal_items) + if all_checked: + for i in normal_items: + i.setCheckState(Qt.Unchecked) + toggle_item.setCheckState(Qt.Unchecked) + else: + for i in normal_items: + i.setCheckState(Qt.Checked) + toggle_item.setCheckState(Qt.Checked) + + else: + # When normal items change, update toggle item + all_checked = all(i.checkState() == Qt.Checked for i in normal_items) + toggle_item.setCheckState(Qt.Checked if all_checked else Qt.Unchecked) + + if hasattr(self, 'participant_dropdown_a') and model == self.participant_dropdown_a.model(): + self.update_participant_dropdown_label(self.participant_dropdown_a) + elif hasattr(self, 'participant_dropdown_b') and model == self.participant_dropdown_b.model(): + self.update_participant_dropdown_label(self.participant_dropdown_b) + + # Update label text immediately after change + if self.participant_dropdown: + self.update_participant_dropdown_label() + + self._updating_checkstates = False + + model.itemChanged.connect(on_item_changed) + + + + def update_participant_list_for_group(self, group_name=None, combo=None): + + target_combo = combo if combo is not None else getattr(self, "participant_dropdown", None) + if not target_combo: + return + + if isinstance(group_name, int) and combo is None: + target_group = self.group_dropdown.currentText() + elif group_name is not None: + target_group = group_name + else: + # If we have no group_name, look up the text from the correct dropdown + if hasattr(self, 'participant_dropdown_a') and target_combo is self.participant_dropdown_a: + target_group = self.group_a_dropdown.currentText() + elif hasattr(self, 'participant_dropdown_b') and target_combo is self.participant_dropdown_b: + target_group = self.group_b_dropdown.currentText() + else: + target_group = self.group_dropdown.currentText() + + if hasattr(self, 'participant_dropdown_a') and target_combo is self.participant_dropdown_a: + self.participant_map_a = {} + active_map = self.participant_map_a + elif hasattr(self, 'participant_dropdown_b') and target_combo is self.participant_dropdown_b: + self.participant_map_b = {} + active_map = self.participant_map_b + else: + self.participant_map = {} + active_map = self.participant_map + + # 4. Refresh the Model + model = target_combo.model() + model.clear() + + for text in ["", "Toggle Select All"]: + item = QStandardItem(str(text)) + if text == "Toggle Select All": + item.setFlags(Qt.ItemIsUserCheckable | Qt.ItemIsEnabled) + item.setData(Qt.Unchecked, Qt.CheckStateRole) + toggle_ref = item + else: + item.setFlags(Qt.ItemIsEnabled) + model.appendRow(item) + + # 5. Populate Data + if str(target_group) == "": + target_combo.setEnabled(False) + self.update_participant_dropdown_label(combo=target_combo) + return + + target_combo.setEnabled(True) + # Get file paths (handles target_group as int or str) + group_file_paths = self.group_to_paths.get(target_group, []) + + for i, file_path in enumerate(group_file_paths, start=1): + short_label = f"Participant {i}" + display_label = f"{short_label} ({os.path.basename(file_path)})" + active_map[file_path] = short_label + + item = QStandardItem(display_label) + item.setFlags(Qt.ItemIsUserCheckable | Qt.ItemIsEnabled) + item.setData(Qt.Unchecked, Qt.CheckStateRole) + model.appendRow(item) + + self._connect_select_all_toggle(toggle_ref, model) + self.update_participant_dropdown_label(combo=target_combo) diff --git a/src/shared/s_shared.py b/src/shared/s_shared.py new file mode 100644 index 0000000..4db4875 --- /dev/null +++ b/src/shared/s_shared.py @@ -0,0 +1,53 @@ +import sys +import os +import platform + +CURRENT_VERSION = "1.5.0" +APP_NAME = "flares" +API_URL = f"https://git.research.dezeeuw.ca/api/v1/repos/tyler/{APP_NAME}/releases" +API_URL_SECONDARY = f"https://git.research2.dezeeuw.ca/api/v1/repos/tyler/{APP_NAME}/releases" +PLATFORM_NAME = platform.system().lower() + +PIPELINE_STAGES = [ + "Preprocessing", + "Trimming", + "Verify Optode Placement", + "Short/Long Channels", + "Heart Rate", + "Scalp Coupling Index", + "Signal to Noise Ratio", + "Peak Spectral Power", + "Cross Validation", + "Median Absolute Deviation", + "Power Spectral Density Noise", + "Channel Variance", + "Bad Channels Handling", + "Optical Density", + "Temporal Derivative Distribution Repair Filtering", + "Wavelet Filtering", + "Haemoglobin Concentration", + "Enhance Negative Correlation", + "Filter", + "Extracting Events", + "Epoch Calculations", + "Design Matrix", + "General Linear Model", + "Generate GLM Results", + "Generate Channel Significance", + "Generate Channel, Region of Interest, and Contrast Results", + "Compute Contrast Results", + "Finishing Up" +] + +def resource_path(relative_path): + """ + Get absolute path to resource regardless of running directly or packaged using PyInstaller + """ + + if hasattr(sys, '_MEIPASS'): + # PyInstaller bundle path + base_path = sys._MEIPASS + else: + base_path = os.path.dirname(os.path.abspath(__file__)) + + return os.path.join(base_path, relative_path) \ No newline at end of file diff --git a/updater.py b/src/updater.py similarity index 100% rename from updater.py rename to src/updater.py diff --git a/src/window/w_about.py b/src/window/w_about.py new file mode 100644 index 0000000..5a89e71 --- /dev/null +++ b/src/window/w_about.py @@ -0,0 +1,30 @@ +from PySide6.QtWidgets import QWidget, QVBoxLayout, QLabel +from PySide6.QtCore import Qt + +from src.shared.s_shared import APP_NAME, CURRENT_VERSION + +class AboutWindow(QWidget): + """ + Simple About window displaying basic application information. + + Args: + parent (QWidget, optional): Parent widget of this window. Defaults to None. + """ + + def __init__(self, parent=None): + super().__init__(parent, Qt.WindowType.Window) + self.setWindowTitle(f"About {APP_NAME.upper()}") + self.resize(250, 100) + + layout = QVBoxLayout() + label = QLabel(f"About {APP_NAME.upper()}", self) + label2 = QLabel("fNIRS Lightweight Analysis, Research, & Evaluation Suite", self) + label3 = QLabel(f"{APP_NAME.upper()} is licensed under the GPL-3.0 licence. For more information, visit https://www.gnu.org/licenses/gpl-3.0.en.html", self) + label4 = QLabel(f"Version v{CURRENT_VERSION}") + + layout.addWidget(label) + layout.addWidget(label2) + layout.addWidget(label3) + layout.addWidget(label4) + + self.setLayout(layout) \ No newline at end of file diff --git a/src/window/w_terminal.py b/src/window/w_terminal.py new file mode 100644 index 0000000..7a9f34d --- /dev/null +++ b/src/window/w_terminal.py @@ -0,0 +1,59 @@ +from PySide6.QtWidgets import QWidget, QVBoxLayout, QTextEdit, QLineEdit +from PySide6.QtCore import Qt + +from src.shared.s_shared import APP_NAME, CURRENT_VERSION + +class TerminalWindow(QWidget): + def __init__(self, parent=None): + super().__init__(parent, Qt.WindowType.Window) + self.setWindowTitle(f"Terminal - {APP_NAME.upper()}") + + self.output_area = QTextEdit() + self.output_area.setReadOnly(True) + + self.input_line = QLineEdit() + self.input_line.returnPressed.connect(self.handle_command) + + layout = QVBoxLayout() + layout.addWidget(self.output_area) + layout.addWidget(self.input_line) + self.setLayout(layout) + + self.commands = { + "hello": self.cmd_hello, + "help": self.cmd_help, + "version": self.cmd_version, + } + + def handle_command(self): + command_text = self.input_line.text() + self.input_line.clear() + + self.output_area.append(f"> {command_text}") + parts = command_text.strip().split() + if not parts: + return + + command_name = parts[0] + args = parts[1:] + + func = self.commands.get(command_name) + if func: + try: + result = func(*args) + if result: + self.output_area.append(str(result)) + except Exception as e: + self.output_area.append(f"[Error] {e}") + else: + self.output_area.append(f"[Unknown command] '{command_name}'") + + + def cmd_hello(self, *args): + return "Hello from the terminal!" + + def cmd_help(self, *args): + return f"Available commands: {', '.join(self.commands.keys())}" + + def cmd_version(self, *args): + return f"{CURRENT_VERSION}" \ No newline at end of file diff --git a/src/window/w_updateevents.py b/src/window/w_updateevents.py new file mode 100644 index 0000000..4a5c421 --- /dev/null +++ b/src/window/w_updateevents.py @@ -0,0 +1,846 @@ +from PySide6.QtWidgets import QWidget, QVBoxLayout, QLabel, QLineEdit, QPushButton, QComboBox, QHBoxLayout, QMessageBox, QFileDialog +from PySide6.QtCore import Qt +from enum import Enum, auto +import json +import os +from mne.io import read_raw_snirf +from mne_nirs.io import write_raw_snirf +import numpy as np +from datetime import datetime +from mne import Annotations + +from src.shared.s_shared import APP_NAME + +class EventUpdateMode(Enum): + WRITE_SNIRF = auto() # destructive + WRITE_JSON = auto() # non-destructive + + + +class UpdateEventsWindow(QWidget): + def __init__(self, parent=None, mode=EventUpdateMode.WRITE_SNIRF, caller=None): + super().__init__(parent, Qt.WindowType.Window) + + self.mode = mode + self.caller = caller or self.__class__.__name__ + self.setWindowTitle("Update event markers") + self.resize(760, 200) + + print("INIT MODE:", mode) + + self.label_file_a = QLabel("SNIRF file:") + self.line_edit_file_a = QLineEdit() + self.line_edit_file_a.setReadOnly(True) + self.btn_browse_a = QPushButton("Browse .snirf") + self.btn_browse_a.clicked.connect(self.browse_file_a) + + self.label_file_b = QLabel("BORIS file:") + self.line_edit_file_b = QLineEdit() + self.line_edit_file_b.setReadOnly(True) + self.btn_browse_b = QPushButton("Browse .boris") + self.btn_browse_b.clicked.connect(self.browse_file_b) + + self.label_suffix = QLabel("Filename in BORIS project file:") + self.combo_suffix = QComboBox() + self.combo_suffix.setEditable(False) + self.combo_suffix.currentIndexChanged.connect(self.on_observation_selected) + + self.label_events = QLabel("Events in selected observation:") + self.combo_events = QComboBox() + self.combo_events.setEnabled(False) + + self.label_snirf_events = QLabel("Events in SNIRF file:") + self.combo_snirf_events = QComboBox() + self.combo_snirf_events.setEnabled(False) + + self.btn_clear = QPushButton("Clear") + self.btn_go = QPushButton("Go") + self.btn_clear.clicked.connect(self.clear_files) + self.btn_go.clicked.connect(self.go_action) + + # --- + layout = QVBoxLayout() + self.description = QLabel() + self.description.setTextFormat(Qt.TextFormat.RichText) + self.description.setTextInteractionFlags(Qt.TextInteractionFlag.TextBrowserInteraction) + self.description.setOpenExternalLinks(True) + + self.description.setText("The events that are present in a snirf file may not be the events that are to be studied and examined.
" + "Utilizing different software and video recordings, it is easy enough to see when an action actually occured in a file.
" + "The software BORIS is used to add these events to video files, and these events can be applied to the snirf file
" + "selected below by selecting the correct BORIS observation and time syncing it to an event that it shares with the snirf file.") + + layout.addWidget(self.description) + + help_text_a = "Select the SNIRF (.snirf) file to update with new event markers." + + file_a_layout = QHBoxLayout() + + # Help button on the left + help_btn_a = QPushButton("?") + help_btn_a.setFixedWidth(25) + help_btn_a.setToolTip(help_text_a) + help_btn_a.clicked.connect(lambda _, text=help_text_a: self.show_help_popup(text)) + file_a_layout.addWidget(help_btn_a) + + # Container for label + line_edit + browse button with tooltip + file_a_container = QWidget() + file_a_container_layout = QHBoxLayout() + file_a_container_layout.setContentsMargins(0, 0, 0, 0) + file_a_container_layout.addWidget(self.label_file_a) + file_a_container_layout.addWidget(self.line_edit_file_a) + file_a_container_layout.addWidget(self.btn_browse_a) + file_a_container.setLayout(file_a_container_layout) + file_a_container.setToolTip(help_text_a) + + file_a_layout.addWidget(file_a_container) + layout.addLayout(file_a_layout) + + help_text_b = "Provide a .boris project file that contains events for this participant." + + file_b_layout = QHBoxLayout() + + help_btn_b = QPushButton("?") + help_btn_b.setFixedWidth(25) + help_btn_b.setToolTip(help_text_b) + help_btn_b.clicked.connect(lambda _, text=help_text_b: self.show_help_popup(text)) + file_b_layout.addWidget(help_btn_b) + + file_b_container = QWidget() + file_b_container_layout = QHBoxLayout() + file_b_container_layout.setContentsMargins(0, 0, 0, 0) + file_b_container_layout.addWidget(self.label_file_b) + file_b_container_layout.addWidget(self.line_edit_file_b) + file_b_container_layout.addWidget(self.btn_browse_b) + file_b_container.setLayout(file_b_container_layout) + file_b_container.setToolTip(help_text_b) + + file_b_layout.addWidget(file_b_container) + layout.addLayout(file_b_layout) + + help_text_suffix = "This participant from the .boris project file matches the .snirf file." + + suffix_layout = QHBoxLayout() + + help_btn_suffix = QPushButton("?") + help_btn_suffix.setFixedWidth(25) + help_btn_suffix.setToolTip(help_text_suffix) + help_btn_suffix.clicked.connect(lambda _, text=help_text_suffix: self.show_help_popup(text)) + suffix_layout.addWidget(help_btn_suffix) + + suffix_container = QWidget() + suffix_container_layout = QHBoxLayout() + suffix_container_layout.setContentsMargins(0, 0, 0, 0) + suffix_container_layout.addWidget(self.label_suffix) + suffix_container_layout.addWidget(self.combo_suffix) + suffix_container.setLayout(suffix_container_layout) + suffix_container.setToolTip(help_text_suffix) + + suffix_layout.addWidget(suffix_container) + layout.addLayout(suffix_layout) + + help_text_suffix = "The events extracted from the BORIS project file for the selected observation." + + suffix2_layout = QHBoxLayout() + + help_btn_suffix = QPushButton("?") + help_btn_suffix.setFixedWidth(25) + help_btn_suffix.setToolTip(help_text_suffix) + help_btn_suffix.clicked.connect(lambda _, text=help_text_suffix: self.show_help_popup(text)) + suffix2_layout.addWidget(help_btn_suffix) + + suffix2_container = QWidget() + suffix2_container_layout = QHBoxLayout() + suffix2_container_layout.setContentsMargins(0, 0, 0, 0) + suffix2_container_layout.addWidget(self.label_events) + suffix2_container_layout.addWidget(self.combo_events) + suffix2_container.setLayout(suffix2_container_layout) + suffix2_container.setToolTip(help_text_suffix) + + suffix2_layout.addWidget(suffix2_container) + layout.addLayout(suffix2_layout) + + snirf_events_layout = QHBoxLayout() + + help_text_snirf_events = "The event markers extracted from the SNIRF file." + help_btn_snirf_events = QPushButton("?") + help_btn_snirf_events.setFixedWidth(25) + help_btn_snirf_events.setToolTip(help_text_snirf_events) + help_btn_snirf_events.clicked.connect(lambda _, text=help_text_snirf_events: self.show_help_popup(text)) + snirf_events_layout.addWidget(help_btn_snirf_events) + + snirf_events_container = QWidget() + snirf_events_container_layout = QHBoxLayout() + snirf_events_container_layout.setContentsMargins(0, 0, 0, 0) + snirf_events_container_layout.addWidget(self.label_snirf_events) + snirf_events_container_layout.addWidget(self.combo_snirf_events) + snirf_events_container.setLayout(snirf_events_container_layout) + snirf_events_container.setToolTip(help_text_snirf_events) + + snirf_events_layout.addWidget(snirf_events_container) + layout.addLayout(snirf_events_layout) + + buttons_layout = QHBoxLayout() + buttons_layout.addStretch() + buttons_layout.addWidget(self.btn_clear) + buttons_layout.addWidget(self.btn_go) + layout.addLayout(buttons_layout) + + self.setLayout(layout) + + def show_help_popup(self, text): + msg = QMessageBox(self) + msg.setWindowTitle(f"Parameter Info - {APP_NAME.upper()}") + msg.setText(text) + msg.exec() + + def browse_file_a(self): + file_path, _ = QFileDialog.getOpenFileName(self, "Select SNIRF File", "", "SNIRF Files (*.snirf)") + if file_path: + self.line_edit_file_a.setText(file_path) + try: + # TODO: Bad! read_raw_snirf doesnt release memory properly! Should be spawned in a seperate process and killed once completed + raw = read_raw_snirf(file_path, preload=False) + annotations = raw.annotations + + # Build individual event entries + event_entries = [] + for onset, description in zip(annotations.onset, annotations.description): + event_str = f"{description} @ {onset:.3f}s" + event_entries.append(event_str) + + if not event_entries: + QMessageBox.information(self, "No Events", "No events found in SNIRF file.") + self.combo_snirf_events.clear() + self.combo_snirf_events.setEnabled(False) + return + + self.combo_snirf_events.clear() + self.combo_snirf_events.addItems(event_entries) + self.combo_snirf_events.setEnabled(True) + + except Exception as e: + QMessageBox.warning(self, "Error", f"Could not read SNIRF file with MNE:\n{str(e)}") + self.combo_snirf_events.clear() + self.combo_snirf_events.setEnabled(False) + + def browse_file_b(self): + file_path, _ = QFileDialog.getOpenFileName(self, "Select BORIS File", "", "BORIS project Files (*.boris)") + if file_path: + self.line_edit_file_b.setText(file_path) + + try: + with open(file_path, 'r', encoding='utf-8') as f: + data = json.load(f) + self.boris_data = data + + observation_keys = self.extract_boris_observation_keys(data) + self.combo_suffix.clear() + self.combo_suffix.addItems(observation_keys) + + except (json.JSONDecodeError, FileNotFoundError, KeyError) as e: + QMessageBox.warning(self, "Error", f"Failed to parse BORIS file:\n{e}") + + def extract_boris_observation_keys(self, data): + if "observations" not in data: + raise KeyError("Missing 'observations' key in BORIS file.") + + observations = data["observations"] + if not isinstance(observations, dict): + raise TypeError("'observations' must be a dictionary.") + + return list(observations.keys()) + + def on_observation_selected(self): + selected_obs = self.combo_suffix.currentText() + if not selected_obs or not hasattr(self, 'boris_data'): + self.combo_events.clear() + self.combo_events.setEnabled(False) + return + + try: + events = self.boris_data["observations"][selected_obs]["events"] + except (KeyError, TypeError): + self.combo_events.clear() + self.combo_events.setEnabled(False) + return + + event_entries = [] + for event in events: + if isinstance(event, list) and len(event) >= 3: + timestamp = event[0] + label = event[2] + display = f"{label} @ {timestamp:.3f}" + event_entries.append(display) + + self.combo_events.clear() + self.combo_events.addItems(event_entries) + self.combo_events.setEnabled(bool(event_entries)) + + def clear_files(self): + self.line_edit_file_a.clear() + self.line_edit_file_b.clear() + + def go_action(self): + + file_a = self.line_edit_file_a.text() + suffix = "flare" + + if not hasattr(self, "boris_data") or self.combo_events.count() == 0 or self.combo_snirf_events.count() == 0: + QMessageBox.warning(self, "Missing data", "Please make sure a BORIS and SNIRF event are selected.") + return + + # Extract BORIS anchor + try: + boris_label, boris_time_str = self.combo_events.currentText().split(" @ ") + boris_anchor_time = float(boris_time_str.replace("s", "").strip()) + except Exception as e: + QMessageBox.critical(self, "BORIS Event Error", f"Could not parse BORIS anchor event:\n{e}") + return + + # Extract SNIRF anchor + try: + snirf_label, snirf_time_str = self.combo_snirf_events.currentText().split(" @ ") + snirf_anchor_time = float(snirf_time_str.replace("s", "").strip()) + except Exception as e: + QMessageBox.critical(self, "SNIRF Event Error", f"Could not parse SNIRF anchor event:\n{e}") + return + + time_shift = snirf_anchor_time - boris_anchor_time + + selected_obs = self.combo_suffix.currentText() + if not selected_obs or selected_obs not in self.boris_data["observations"]: + QMessageBox.warning(self, "Invalid selection", "Selected observation not found in BORIS file.") + return + + boris_events = self.boris_data["observations"][selected_obs].get("events", []) + if not boris_events: + QMessageBox.warning(self, "No BORIS events", "No events found in selected BORIS observation.") + return + + snirf_path = self.line_edit_file_a.text() + if not snirf_path: + QMessageBox.warning(self, "No SNIRF file", "Please select a SNIRF file.") + return + + boris_obs = self.boris_data["observations"][selected_obs] + + # --- Extract videos + delays --- + files = boris_obs.get("file", {}) + offsets = boris_obs.get("media_info", {}).get("offset", {}) + + videos = {} + for key, path in files.items(): + if path: # only include videos that exist + delay = offsets.get(key, 0.0) # default 0 if missing + videos[key] = {"file": path, "delay": delay} + + base_name = os.path.splitext(os.path.basename(file_a))[0] + + if self.mode == EventUpdateMode.WRITE_SNIRF: + # Open save dialog for SNIRF + base_name = os.path.splitext(os.path.basename(file_a))[0] + suggested_name = f"{base_name}_{suffix}.snirf" + save_path, _ = QFileDialog.getSaveFileName( + self, + "Save SNIRF File As", + suggested_name, + "SNIRF Files (*.snirf)" + ) + if not save_path: + print("SNIRF save cancelled.") + return + if not save_path.lower().endswith(".snirf"): + save_path += ".snirf" + + try: + raw = read_raw_snirf(file_a, preload=True) + + # --- Align BORIS events to SNIRF --- + boris_events = boris_obs.get("events", []) + onsets, durations, descriptions = [], [], [] + open_events = {} # label -> list of start times + label_counts = {} + used_times = set() + sfreq = raw.info['sfreq'] + min_shift = 1.0 / sfreq + max_attempts = 10 + + for event in boris_events: + if not isinstance(event, list) or len(event) < 3: + continue + event_time = event[0] + label = event[2] + count = label_counts.get(label, 0) + 1 + label_counts[label] = count + + if label not in open_events: + open_events[label] = [] + + if count % 2 == 1: + open_events[label].append(event_time) + else: + if open_events[label]: + start_time = open_events[label].pop(0) + duration = event_time - start_time + if duration <= 0: + continue + + adjusted_time = start_time + time_shift + attempts = 0 + while round(adjusted_time, 6) in used_times and attempts < max_attempts: + adjusted_time += min_shift + attempts += 1 + if attempts == max_attempts: + continue + + adjusted_time = round(adjusted_time, 6) + used_times.add(adjusted_time) + onsets.append(adjusted_time) + durations.append(duration) + descriptions.append(label) + # Handle unmatched starts + for label, starts in open_events.items(): + for start_time in starts: + adjusted_time = start_time + time_shift + attempts = 0 + while round(adjusted_time, 6) in used_times and attempts < max_attempts: + adjusted_time += min_shift + attempts += 1 + if attempts == max_attempts: + continue + adjusted_time = round(adjusted_time, 6) + used_times.add(adjusted_time) + onsets.append(adjusted_time) + durations.append(0.0) + descriptions.append(label) + + new_annotations = Annotations(onset=onsets, duration=durations, description=descriptions) + raw.set_annotations(new_annotations) + write_raw_snirf(raw, save_path) + QMessageBox.information(self, "Success", "SNIRF file updated with aligned BORIS events.") + + except Exception as e: + QMessageBox.critical(self, "Error", f"Failed to update SNIRF file:\n{e}") + + elif self.mode == EventUpdateMode.WRITE_JSON: + # Open save dialog for JSON + base_name = os.path.splitext(os.path.basename(file_a))[0] + suggested_name = f"{base_name}_{suffix}_alignment.json" + save_path, _ = QFileDialog.getSaveFileName( + self, + "Save Event Alignment JSON As", + suggested_name, + "JSON Files (*.json)" + ) + if not save_path: + print("JSON save cancelled.") + return + if not save_path.lower().endswith(".json"): + save_path += ".json" + + # Build JSON dict + json_data = { + "observation": selected_obs, + "snirf_anchor": {"label": snirf_label, "time": snirf_anchor_time}, + "boris_anchor": {"label": boris_label, "time": boris_anchor_time}, + "time_shift": time_shift, + "videos": videos + } + + # Write JSON + try: + with open(save_path, "w", encoding="utf-8") as f: + json.dump(json_data, f, indent=4) + QMessageBox.information(self, "Success", f"Event alignment saved to:\n{save_path}") + except Exception as e: + QMessageBox.critical(self, "Error", f"Failed to write JSON:\n{e}") + + + def update_optode_positions(self, file_a, file_b, save_path): + + fiducials = {} + ch_positions = {} + + # Read the lines from the optode file + with open(file_b, 'r') as f: + for line in f: + if line.strip(): + # Split by the semicolon and convert to meters + ch_name, coords_str = line.split(":") + coords = np.array(list(map(float, coords_str.strip().split()))) * 0.001 + + # The key we have is a fiducial + if ch_name.lower() in ['lpa', 'nz', 'rpa']: + fiducials[ch_name.lower()] = coords + + # The key we have is a source or detector + else: + ch_positions[ch_name.upper()] = coords + + # Create montage with updated coords in head space + initial_montage = make_dig_montage(ch_pos=ch_positions, nasion=fiducials.get('nz'), lpa=fiducials.get('lpa'), rpa=fiducials.get('rpa'), coord_frame='head') # type: ignore + + # Read the SNIRF file, set the montage, and write it back + # TODO: Bad! read_raw_snirf doesnt release memory properly! Should be spawned in a seperate process and killed once completed + raw = read_raw_snirf(file_a, preload=True) + raw.set_montage(initial_montage) + write_raw_snirf(raw, save_path) + + + def _apply_events_to_snirf(self, raw, new_annotations, save_path): + raw.set_annotations(new_annotations) + write_raw_snirf(raw, save_path) + + def _write_event_mapping_json( + self, + file_a, + file_b, + selected_obs, + snirf_anchor, + boris_anchor, + time_shift, + mapped_events, + save_path + ): + + payload = { + "source": { + "called_from": self.caller, + "snirf_file": os.path.basename(file_a), + "boris_file": os.path.basename(file_b), + "observation": selected_obs + }, + "alignment": { + "snirf_anchor": snirf_anchor, + "boris_anchor": boris_anchor, + "time_shift_seconds": time_shift + }, + "events": mapped_events, + "created_at": datetime.utcnow().isoformat() + "Z" + } + + with open(save_path, "w", encoding="utf-8") as f: + json.dump(payload, f, indent=2) + + return save_path + + + +class UpdateEventsBlazesWindow(QWidget): + + def __init__(self, parent=None, mode=EventUpdateMode.WRITE_SNIRF, caller=None): + super().__init__(parent, Qt.WindowType.Window) + + self.mode = mode + self.caller = caller or self.__class__.__name__ + self.setWindowTitle("Update event markers (BLAZES)") + self.resize(760, 200) + + self.label_file_a = QLabel("SNIRF file:") + self.line_edit_file_a = QLineEdit() + self.line_edit_file_a.setReadOnly(True) + self.btn_browse_a = QPushButton("Browse .snirf") + self.btn_browse_a.clicked.connect(self.browse_file_a) + + self.label_file_b = QLabel("BLAZES file:") + self.line_edit_file_b = QLineEdit() + self.line_edit_file_b.setReadOnly(True) + self.btn_browse_b = QPushButton("Browse .blaze") + self.btn_browse_b.clicked.connect(self.browse_file_b) + + self.label_events = QLabel("Events in selected blazes file:") + self.combo_events = QComboBox() + self.combo_events.setEnabled(False) + + self.label_snirf_events = QLabel("Events in SNIRF file:") + self.combo_snirf_events = QComboBox() + self.combo_snirf_events.setEnabled(False) + + self.btn_clear = QPushButton("Clear") + self.btn_go = QPushButton("Go") + self.btn_clear.clicked.connect(self.clear_files) + self.btn_go.clicked.connect(self.go_action) + + # --- + layout = QVBoxLayout() + self.description = QLabel() + self.description.setTextFormat(Qt.TextFormat.RichText) + self.description.setTextInteractionFlags(Qt.TextInteractionFlag.TextBrowserInteraction) + self.description.setOpenExternalLinks(True) + + self.description.setText("The events that are present in a snirf file may not be the events that are to be studied and examined.
" + "Utilizing different software and video recordings, it is easy enough to see when an action actually occured in a file.
" + "The software BLAZES is used to create these events in video files, and these events can be applied to the snirf file
" + "selected below by time syncing it to an event that it shares with the snirf file.") + + layout.addWidget(self.description) + + help_text_a = "Select the SNIRF (.snirf) file to update with new event markers." + + file_a_layout = QHBoxLayout() + + # Help button on the left + help_btn_a = QPushButton("?") + help_btn_a.setFixedWidth(25) + help_btn_a.setToolTip(help_text_a) + help_btn_a.clicked.connect(lambda _, text=help_text_a: self.show_help_popup(text)) + file_a_layout.addWidget(help_btn_a) + + # Container for label + line_edit + browse button with tooltip + file_a_container = QWidget() + file_a_container_layout = QHBoxLayout() + file_a_container_layout.setContentsMargins(0, 0, 0, 0) + file_a_container_layout.addWidget(self.label_file_a) + file_a_container_layout.addWidget(self.line_edit_file_a) + file_a_container_layout.addWidget(self.btn_browse_a) + file_a_container.setLayout(file_a_container_layout) + file_a_container.setToolTip(help_text_a) + + file_a_layout.addWidget(file_a_container) + layout.addLayout(file_a_layout) + + help_text_b = "Provide a .blaze output file that contains events for this participant." + + file_b_layout = QHBoxLayout() + + help_btn_b = QPushButton("?") + help_btn_b.setFixedWidth(25) + help_btn_b.setToolTip(help_text_b) + help_btn_b.clicked.connect(lambda _, text=help_text_b: self.show_help_popup(text)) + file_b_layout.addWidget(help_btn_b) + + file_b_container = QWidget() + file_b_container_layout = QHBoxLayout() + file_b_container_layout.setContentsMargins(0, 0, 0, 0) + file_b_container_layout.addWidget(self.label_file_b) + file_b_container_layout.addWidget(self.line_edit_file_b) + file_b_container_layout.addWidget(self.btn_browse_b) + file_b_container.setLayout(file_b_container_layout) + file_b_container.setToolTip(help_text_b) + + file_b_layout.addWidget(file_b_container) + layout.addLayout(file_b_layout) + + help_text_suffix = "The events extracted from the blaze file." + + suffix2_layout = QHBoxLayout() + + help_btn_suffix = QPushButton("?") + help_btn_suffix.setFixedWidth(25) + help_btn_suffix.setToolTip(help_text_suffix) + help_btn_suffix.clicked.connect(lambda _, text=help_text_suffix: self.show_help_popup(text)) + suffix2_layout.addWidget(help_btn_suffix) + + suffix2_container = QWidget() + suffix2_container_layout = QHBoxLayout() + suffix2_container_layout.setContentsMargins(0, 0, 0, 0) + suffix2_container_layout.addWidget(self.label_events) + suffix2_container_layout.addWidget(self.combo_events) + suffix2_container.setLayout(suffix2_container_layout) + suffix2_container.setToolTip(help_text_suffix) + + suffix2_layout.addWidget(suffix2_container) + layout.addLayout(suffix2_layout) + + snirf_events_layout = QHBoxLayout() + + help_text_snirf_events = "The event markers extracted from the SNIRF file." + help_btn_snirf_events = QPushButton("?") + help_btn_snirf_events.setFixedWidth(25) + help_btn_snirf_events.setToolTip(help_text_snirf_events) + help_btn_snirf_events.clicked.connect(lambda _, text=help_text_snirf_events: self.show_help_popup(text)) + snirf_events_layout.addWidget(help_btn_snirf_events) + + snirf_events_container = QWidget() + snirf_events_container_layout = QHBoxLayout() + snirf_events_container_layout.setContentsMargins(0, 0, 0, 0) + snirf_events_container_layout.addWidget(self.label_snirf_events) + snirf_events_container_layout.addWidget(self.combo_snirf_events) + snirf_events_container.setLayout(snirf_events_container_layout) + snirf_events_container.setToolTip(help_text_snirf_events) + + snirf_events_layout.addWidget(snirf_events_container) + layout.addLayout(snirf_events_layout) + + buttons_layout = QHBoxLayout() + buttons_layout.addStretch() + buttons_layout.addWidget(self.btn_clear) + buttons_layout.addWidget(self.btn_go) + layout.addLayout(buttons_layout) + + self.setLayout(layout) + + + def show_help_popup(self, text): + msg = QMessageBox(self) + msg.setWindowTitle(f"Parameter Info - {APP_NAME.upper()}") + msg.setText(text) + msg.exec() + + def browse_file_a(self): + file_path, _ = QFileDialog.getOpenFileName(self, "Select SNIRF File", "", "SNIRF Files (*.snirf)") + if file_path: + self.line_edit_file_a.setText(file_path) + try: + # TODO: Bad! read_raw_snirf doesnt release memory properly! Should be spawned in a seperate process and killed once completed + raw = read_raw_snirf(file_path, preload=False) + annotations = raw.annotations + + # Build individual event entries + event_entries = [] + for onset, description in zip(annotations.onset, annotations.description): + event_str = f"{description} @ {onset:.3f}s" + event_entries.append(event_str) + + if not event_entries: + QMessageBox.information(self, "No Events", "No events found in SNIRF file.") + self.combo_snirf_events.clear() + self.combo_snirf_events.setEnabled(False) + return + + self.combo_snirf_events.clear() + self.combo_snirf_events.addItems(event_entries) + self.combo_snirf_events.setEnabled(True) + + except Exception as e: + QMessageBox.warning(self, "Error", f"Could not read SNIRF file with MNE:\n{str(e)}") + self.combo_snirf_events.clear() + self.combo_snirf_events.setEnabled(False) + + def browse_file_b(self): + file_path, _ = QFileDialog.getOpenFileName(self, "Select JSON Timeline File", "", "JSON Files (*.json)") + if file_path: + self.line_edit_file_b.setText(file_path) + + try: + with open(file_path, 'r', encoding='utf-8') as f: + data = json.load(f) + self.json_data = data + + obs_keys = self.extract_json_observation_strings(data) + self.combo_events.clear() + if obs_keys: + self.combo_events.addItems(obs_keys) + self.combo_events.setEnabled(True) + else: + QMessageBox.information(self, "No Events", "No events found in JSON file.") + self.combo_events.setEnabled(False) + + except (json.JSONDecodeError, FileNotFoundError, KeyError, TypeError) as e: + QMessageBox.warning(self, "Error", f"Failed to parse JSON file:\n{e}") + self.combo_events.clear() + self.combo_events.setEnabled(False) + + + def extract_json_observation_strings(self, data): + if "events" not in data: + raise KeyError("Missing 'events' key in JSON file.") + + event_strings = [] + + # The new format is a flat list chronologically ordered + for event in data["events"]: + track_name = event.get("track_name", "Unknown") + onset = event.get("start_sec", 0.0) + + # Formatting to match your SNIRF style: "Event Name @ 0.000s" + display_str = f"{track_name} @ {onset:.3f}s" + event_strings.append(display_str) + + return event_strings + + + def clear_files(self): + self.line_edit_file_a.clear() + self.line_edit_file_b.clear() + + + def go_action(self): + file_a = self.line_edit_file_a.text() + file_b = self.line_edit_file_b.text() + suffix = APP_NAME + + if not hasattr(self, "json_data") or self.combo_events.count() == 0 or self.combo_snirf_events.count() == 0: + QMessageBox.warning(self, "Missing data", "Please make sure a JSON and SNIRF event are selected.") + return + + try: + json_text = self.combo_events.currentText() + _, json_time_str = json_text.split(" @ ") + json_anchor_time = float(json_time_str.replace("s", "").strip()) + except Exception as e: + QMessageBox.critical(self, "JSON Event Error", f"Could not parse JSON anchor:\n{e}") + return + + try: + snirf_text = self.combo_snirf_events.currentText() + _, snirf_time_str = snirf_text.split(" @ ") + snirf_anchor_time = float(snirf_time_str.replace("s", "").strip()) + except Exception as e: + QMessageBox.critical(self, "SNIRF Event Error", f"Could not parse SNIRF anchor:\n{e}") + return + + time_shift = snirf_anchor_time - json_anchor_time + + onsets, durations, descriptions = [], [], [] + skipped_count = 0 + + try: + events_list = self.json_data.get("events", []) + + for event in events_list: + track_name = event.get("track_name", "Unknown") + clean_name = track_name.replace("AI: ", "").strip() + + original_start = event.get("start_sec", 0.0) + original_end = event.get("end_sec", original_start) + duration = original_end - original_start + + # FILTER: Minimum 0.1s duration + if duration < 0.1: + skipped_count += 1 + continue + + # Apply shift + adjusted_onset = original_start + time_shift + + onsets.append(round(adjusted_onset, 6)) + durations.append(round(duration, 6)) + descriptions.append(clean_name) + + except Exception as e: + QMessageBox.critical(self, "Track Error", f"Failed to process tracks: {e}") + return + + if not onsets: + QMessageBox.warning(self, "No Data", f"No events met the 0.1s threshold. (Skipped {skipped_count})") + return + + if self.mode == EventUpdateMode.WRITE_SNIRF: + suggested_name = f"{os.path.splitext(os.path.basename(file_a))[0]}_{suffix}.snirf" + save_path, _ = QFileDialog.getSaveFileName(self, "Save SNIRF", suggested_name, "SNIRF Files (*.snirf)") + + if not save_path: return + if not save_path.lower().endswith(".snirf"): save_path += ".snirf" + + try: + raw = read_raw_snirf(file_a, preload=True) + + # Create annotations + new_annotations = Annotations( + onset=onsets, + duration=durations, + description=descriptions + ) + + # Replace existing annotations with the new aligned JSON tracks + raw.set_annotations(new_annotations) + + write_raw_snirf(raw, save_path) + QMessageBox.information(self, "Success", + f"Aligned {len(onsets)} events.\n(Filtered out {skipped_count} short events)") + except Exception as e: + QMessageBox.critical(self, "Error", f"Failed to update SNIRF file:\n{e}") + + diff --git a/src/window/w_updateoptodes.py b/src/window/w_updateoptodes.py new file mode 100644 index 0000000..b4ae12d --- /dev/null +++ b/src/window/w_updateoptodes.py @@ -0,0 +1,281 @@ +import os +from pathlib import Path +import pandas as pd +import numpy as np +from mne.io import read_raw_snirf +from mne_nirs.io import write_raw_snirf +from PySide6.QtWidgets import QWidget, QVBoxLayout, QLabel, QHBoxLayout, QMessageBox, QLineEdit, QPushButton, QFileDialog +from PySide6.QtCore import Qt + +class UpdateOptodesWindow(QWidget): + + def __init__(self, parent=None): + super().__init__(parent, Qt.WindowType.Window) + self.setWindowTitle("Update optode positions") + self.resize(760, 200) + + self.label_file_a = QLabel("SNIRF file:") + self.line_edit_file_a = QLineEdit() + self.line_edit_file_a.setReadOnly(True) + self.btn_browse_a = QPushButton("Browse .snirf") + self.btn_browse_a.clicked.connect(self.browse_file_a) + + self.label_file_b = QLabel("Text file:") + self.line_edit_file_b = QLineEdit() + self.line_edit_file_b.setReadOnly(True) + self.btn_browse_b = QPushButton("Browse .txt/.xlsx") + self.btn_browse_b.clicked.connect(self.browse_file_b) + + self.label_suffix = QLabel("Suffix to append to filename:") + self.line_edit_suffix = QLineEdit() + self.line_edit_suffix.setText("flare") + + self.btn_clear = QPushButton("Clear") + self.btn_go = QPushButton("Go") + self.btn_clear.clicked.connect(self.clear_files) + self.btn_go.clicked.connect(self.go_action) + + # --- + layout = QVBoxLayout() + self.description = QLabel() + self.description.setTextFormat(Qt.TextFormat.RichText) + self.description.setTextInteractionFlags(Qt.TextInteractionFlag.TextBrowserInteraction) + self.description.setOpenExternalLinks(False) # Handle the click internally + + self.description.setText("Some software when creating snirf files will insert a template of optode positions as the correct position of the optodes for the participant.
" + "This is rarely correct as each head differs slightly in shape or size, and a lot of calculations require the optodes to be in the correct location.
" + "Using a .txt or .xlsx file, we can update the positions in the snirf file to match those of a digitization system such as one from Polhemus or elsewhere.
" + "The .txt file should have the fiducials, detectors, and sources clearly labeled, followed by the x, y, and z coordinates seperated by a space.
" + "An example format of what a digitization text file should look like can be found by clicking here. Currently only .xlsx files directly exported from a
" + "Polhemus system are supported.") + + self.description.linkActivated.connect(self.handle_link_click) + layout.addWidget(self.description) + + help_text_a = "Select the SNIRF (.snirf) file to update with new optode positions." + + file_a_layout = QHBoxLayout() + + # Help button on the left + help_btn_a = QPushButton("?") + help_btn_a.setFixedWidth(25) + help_btn_a.setToolTip(help_text_a) + help_btn_a.clicked.connect(lambda _, text=help_text_a: self.show_help_popup(text)) + file_a_layout.addWidget(help_btn_a) + + # Container for label + line_edit + browse button with tooltip + file_a_container = QWidget() + file_a_container_layout = QHBoxLayout() + file_a_container_layout.setContentsMargins(0, 0, 0, 0) + file_a_container_layout.addWidget(self.label_file_a) + file_a_container_layout.addWidget(self.line_edit_file_a) + file_a_container_layout.addWidget(self.btn_browse_a) + file_a_container.setLayout(file_a_container_layout) + file_a_container.setToolTip(help_text_a) + + file_a_layout.addWidget(file_a_container) + layout.addLayout(file_a_layout) + + help_text_b = "Provide a .txt file with labeled optodes (e.g., nz, rpa, lpa, d1, s1) and their x, y, z coordinates, or a .xlsx file from a Polhemius system." + + file_b_layout = QHBoxLayout() + + help_btn_b = QPushButton("?") + help_btn_b.setFixedWidth(25) + help_btn_b.setToolTip(help_text_b) + help_btn_b.clicked.connect(lambda _, text=help_text_b: self.show_help_popup(text)) + file_b_layout.addWidget(help_btn_b) + + file_b_container = QWidget() + file_b_container_layout = QHBoxLayout() + file_b_container_layout.setContentsMargins(0, 0, 0, 0) + file_b_container_layout.addWidget(self.label_file_b) + file_b_container_layout.addWidget(self.line_edit_file_b) + file_b_container_layout.addWidget(self.btn_browse_b) + file_b_container.setLayout(file_b_container_layout) + file_b_container.setToolTip(help_text_b) + + file_b_layout.addWidget(file_b_container) + layout.addLayout(file_b_layout) + + + help_text_suffix = "This text will be appended to the original filename when saving. Default is 'flare'." + + suffix_layout = QHBoxLayout() + + help_btn_suffix = QPushButton("?") + help_btn_suffix.setFixedWidth(25) + help_btn_suffix.setToolTip(help_text_suffix) + help_btn_suffix.clicked.connect(lambda _, text=help_text_suffix: self.show_help_popup(text)) + suffix_layout.addWidget(help_btn_suffix) + + suffix_container = QWidget() + suffix_container_layout = QHBoxLayout() + suffix_container_layout.setContentsMargins(0, 0, 0, 0) + suffix_container_layout.addWidget(self.label_suffix) + suffix_container_layout.addWidget(self.line_edit_suffix) + suffix_container.setLayout(suffix_container_layout) + suffix_container.setToolTip(help_text_suffix) + + suffix_layout.addWidget(suffix_container) + layout.addLayout(suffix_layout) + + buttons_layout = QHBoxLayout() + buttons_layout.addStretch() + buttons_layout.addWidget(self.btn_clear) + buttons_layout.addWidget(self.btn_go) + layout.addLayout(buttons_layout) + + self.setLayout(layout) + + def show_help_popup(self, text): + msg = QMessageBox(self) + msg.setWindowTitle("Parameter Info - FLARES") + msg.setText(text) + msg.exec() + + def handle_link_click(self, link): + if link == "custom_link": + msg = QMessageBox(self) + msg.setWindowTitle("Example Digitization File") + + text = "nz: -1.91 85.175 -31.1525\n" \ + "rpa: 80.3825 -17.1925 -57.2775\n" \ + "lpa: -81.815 -17.1925 -57.965\n" \ + "d1: 0.01 -97.5175 62.5875\n" \ + "d2: 25.125 -103.415 45.045\n" \ + "d3: 49.095 -97.9025 30.2075\n" \ + "s1: 0.01 -112.43 32.595\n" \ + "s2: 30.325 -84.3125 71.8975\n" \ + "s3: 0.01 -70.6875 89.0925\n" + msg.setText(text) + msg.exec() + + def browse_file_a(self): + file_path, _ = QFileDialog.getOpenFileName(self, "Select SNIRF File", "", "SNIRF Files (*.snirf)") + if file_path: + self.line_edit_file_a.setText(file_path) + + def browse_file_b(self): + file_path, _ = QFileDialog.getOpenFileName(self, "Select File", "", "Supported Files (*.txt *.xlsx)") + if file_path: + self.line_edit_file_b.setText(file_path) + + def clear_files(self): + self.line_edit_file_a.clear() + self.line_edit_file_b.clear() + + def go_action(self): + file_a = self.line_edit_file_a.text() + file_b = self.line_edit_file_b.text() + suffix = self.line_edit_suffix.text().strip() or "flare" + + if not file_a: + QMessageBox.critical(self, "Missing File", "Please select a SNIRF file.") + return + if not file_b: + QMessageBox.critical(self, "Missing File", "Please select a TXT file.") + return + + # Get original filename without extension + base_name = os.path.splitext(os.path.basename(file_a))[0] + suggested_name = f"{base_name}_{suffix}.snirf" + + # Open save dialog with default name + save_path, _ = QFileDialog.getSaveFileName( + self, + "Save SNIRF File As", + suggested_name, + "SNIRF Files (*.snirf)" + ) + + if not save_path: + print("Save cancelled.") + return + + # Ensure .snirf extension + if not save_path.lower().endswith(".snirf"): + save_path += ".snirf" + + try: + self.update_optode_positions(file_a=file_a, file_b=file_b, save_path=save_path) + except Exception as e: + QMessageBox.critical(self, "Error", f"Failed to write file:\n{e}") + return + + QMessageBox.information(self, "File Saved", f"File was saved to:\n{save_path}") + + def update_optode_positions(self, file_a, file_b, save_path): + + fiducials = {} + ch_positions = {} + + extension = Path(file_b).suffix + + # Read the lines from the optode file + if extension == '.txt': + with open(file_b, 'r') as f: + for line in f: + if line.strip(): + # Split by the semicolon and convert to meters + ch_name, coords_str = line.split(":") + coords = np.array(list(map(float, coords_str.strip().split()))) * 0.001 + + # The key we have is a fiducial + if ch_name.lower() in ['lpa', 'nz', 'rpa']: + fiducials[ch_name.lower()] = coords + + # The key we have is a source or detector + else: + ch_positions[ch_name.upper()] = coords + + elif extension == '.xlsx': + + # TODO: Bad! Why assume sheet1 has the contents? + df = pd.read_excel(file_b, sheet_name='Sheet1') + + def _get_block_data(df, block_id, row_mapping, scale=0.001): + """Isolates a block, cleans numeric data, and returns a scaled dictionary.""" + # 1. Isolate and clean + block = df[df['block_id'] == block_id].iloc[:, [1, 2, 3]].copy() + block = block.apply(pd.to_numeric, errors='coerce') + + # 2. Extract into dictionary based on mapping + result = {} + + # If row_mapping is a dict (like {0: 'nz'}), use it directly + if isinstance(row_mapping, dict): + for row_idx, key in row_mapping.items(): + if row_idx < len(block): + result[key] = block.iloc[row_idx].to_numpy(dtype=float) * scale + + # If row_mapping is a string prefix (like 'D' or 'S'), auto-generate keys + elif isinstance(row_mapping, str): + for i in range(len(block)): + result[f"{row_mapping}{i+1}"] = block.iloc[i].to_numpy(dtype=float) * scale + + return result + + # Identify blocks + is_empty = df.isnull().all(axis=1) + df['block_id'] = is_empty.cumsum() + clean_df = df[~is_empty].copy() + + # Process Block 2: Landmarks + fiducials = _get_block_data(clean_df, 2, {0: 'nz', 2: 'rpa', 3: 'lpa'}) + + # Process Block 3: D-Points + d_points = _get_block_data(clean_df, 3, 'D') + + # Process Block 4: S-Points + s_points = _get_block_data(clean_df, 4, 'S') + + ch_positions = {**d_points, **s_points} + + # Create montage with updated coords in head space + initial_montage = make_dig_montage(ch_pos=ch_positions, nasion=fiducials.get('nz'), lpa=fiducials.get('lpa'), rpa=fiducials.get('rpa'), coord_frame='head') # type: ignore + + # Read the SNIRF file, set the montage, and write it back + raw = read_raw_snirf(file_a, preload=True) + raw.set_montage(initial_montage) + write_raw_snirf(raw, save_path) diff --git a/src/window/w_userguide.py b/src/window/w_userguide.py new file mode 100644 index 0000000..cc8c067 --- /dev/null +++ b/src/window/w_userguide.py @@ -0,0 +1,33 @@ +from PySide6.QtWidgets import QWidget, QVBoxLayout, QLabel +from PySide6.QtCore import Qt + +from src.shared.s_shared import APP_NAME, PIPELINE_STAGES + + +class UserGuideWindow(QWidget): + """ + Simple User Guide window displaying basic information on how to use the software. + + Args: + parent (QWidget, optional): Parent widget of this window. Defaults to None. + """ + + def __init__(self, parent=None): + super().__init__(parent, Qt.WindowType.Window) + self.setWindowTitle(f"User Guide - {APP_NAME.upper()}") + self.resize(250, 100) + + layout = QVBoxLayout() + label = QLabel("Progress Bar Stages:", self) + label2_text = "\n".join(f"Stage {idx + 1}: {name}" for idx, name in enumerate(PIPELINE_STAGES)) + "\n" + label2 = QLabel(label2_text, self) + + label3 = QLabel(f"For more information, visit the Git wiki page here.", self) + label3.setTextFormat(Qt.TextFormat.RichText) + label3.setTextInteractionFlags(Qt.TextInteractionFlag.TextBrowserInteraction) + label3.setOpenExternalLinks(True) + layout.addWidget(label) + layout.addWidget(label2) + layout.addWidget(label3) + + self.setLayout(layout) \ No newline at end of file diff --git a/src/window/w_welcome.py b/src/window/w_welcome.py new file mode 100644 index 0000000..58e498e --- /dev/null +++ b/src/window/w_welcome.py @@ -0,0 +1,70 @@ +from PySide6.QtWidgets import QTextBrowser, QVBoxLayout, QLabel, QDialog, QHBoxLayout, QPushButton +from PySide6.QtGui import QDesktopServices, QIcon +from PySide6.QtCore import QUrl +from PySide6.QtNetwork import QNetworkAccessManager, QNetworkRequest + +from src.shared.s_shared import APP_NAME, CURRENT_VERSION, resource_path + +class WelcomeDialog(QDialog): + def __init__(self, parent=None, direct=True): + super().__init__(parent) + self.setWindowTitle(f"What's New - {APP_NAME.upper()}") + self.setMinimumSize(550, 450) + self.resize(800, 500) + + # Main Layout + layout = QVBoxLayout(self) + + # Header Layout (Logo + App Name) + header_layout = QHBoxLayout() + logo_label = QLabel(self) + logo_label.setPixmap(QIcon(resource_path("icons/main.ico")).pixmap(48, 48)) # Fits cleanly in a header + if direct: + title_label = QLabel(f"

{APP_NAME.upper()} has been sucessfully updated to version {CURRENT_VERSION}!

", self) + else: + title_label = QLabel(f"

{APP_NAME.upper()} is currently running version {CURRENT_VERSION}.

", self) + + header_layout.addWidget(logo_label) + header_layout.addWidget(title_label) + header_layout.addStretch() + layout.addLayout(header_layout) + + # Text Browser Area (Automatically converts Markdown syntax into clean formatted UI text) + self.text_browser = QTextBrowser(self) + self.text_browser.setHtml("

Loading latest updates from server...

") + self.text_browser.setOpenLinks(False) # Don't open links inside the viewer + self.text_browser.anchorClicked.connect(QDesktopServices.openUrl) + layout.addWidget(self.text_browser) + + # Footer Controls Layout + footer_layout = QHBoxLayout() + + ok_button = QPushButton("OK", self) + ok_button.setDefault(True) + ok_button.clicked.connect(self.accept) # Closes the dialog with a success signal + + footer_layout.addStretch() + footer_layout.addWidget(ok_button) + layout.addLayout(footer_layout) + + # Fetch markdown from the web asynchronously + self.network_manager = QNetworkAccessManager(self) + self.network_manager.finished.connect(self._on_download_complete) + + md_url = "https://git.research.dezeeuw.ca/tyler/flares/raw/branch/main/changelog_major.md" + self.network_manager.get(QNetworkRequest(QUrl(md_url))) + + + def _on_download_complete(self, reply): + """Processes the downloaded markdown and drops it into the view frame.""" + if reply.error() == reply.NetworkError.NoError: + raw_bytes = reply.readAll() + # Convert raw bytes to standard text string + markdown_text = str(raw_bytes, encoding='utf-8') + # Qt's QTextBrowser natively renders markdown arrays beautifully! + self.text_browser.setMarkdown(markdown_text) + else: + self.text_browser.setHtml( + f"

Failed to load content.
Error: {reply.errorString()}

" + ) + reply.deleteLater() \ No newline at end of file