diff --git a/flares.py b/flares.py index 24793ec..881a755 100644 --- a/flares.py +++ b/flares.py @@ -50,8 +50,8 @@ from scipy.spatial.distance import cdist # Backen visualization needed to be defined for pyinstaller import pyvistaqt # type: ignore -import vtkmodules.util.data_model -import vtkmodules.util.execution_model +# import vtkmodules.util.data_model +# import vtkmodules.util.execution_model # External library imports for mne from mne import ( @@ -1108,7 +1108,7 @@ def epochs_calculations(raw_haemo, events, event_dict): evokeds3 = [] colors = [] conditions = list(epochs.event_id.keys()) - cmap = plt.cm.get_cmap("tab10", len(conditions)) + cmap = plt.get_cmap("tab10", len(conditions)) for idx, cond in enumerate(conditions): evoked = epochs[cond].average(picks="hbo") diff --git a/main.py b/main.py index b100fe9..e873a09 100644 --- a/main.py +++ b/main.py @@ -10,6 +10,7 @@ License: GPL-3.0 import os import re import sys +import json import time import shlex import pickle @@ -33,6 +34,7 @@ from mne.io import read_raw_snirf from mne.preprocessing.nirs import source_detector_distances from mne_nirs.io import write_raw_snirf from mne.channels import make_dig_montage +from mne import Annotations from PySide6.QtWidgets import ( QApplication, QWidget, QMessageBox, QVBoxLayout, QHBoxLayout, QTextEdit, QScrollArea, QComboBox, QGridLayout, @@ -624,6 +626,395 @@ class UpdateOptodesWindow(QWidget): +class UpdateEventsWindow(QWidget): + + def __init__(self, parent=None): + super().__init__(parent, Qt.WindowType.Window) + self.setWindowTitle("Update event markers") + 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("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(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 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.") + + 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("Parameter Info") + 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: + 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() + file_b = self.line_edit_file_b.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 + + base_name = os.path.splitext(os.path.basename(file_a))[0] + suggested_name = f"{base_name}_{suffix}.snirf" + + # Open save dialog + save_path, _ = QFileDialog.getSaveFileName( + self, + "Save SNIRF File As", + suggested_name, + "SNIRF Files (*.snirf)" + ) + + if not save_path: + print("Save cancelled.") + return + + if not save_path.lower().endswith(".snirf"): + save_path += ".snirf" + + try: + raw = read_raw_snirf(snirf_path, preload=True) + + # Build new Annotations from shifted BORIS events + onsets = [] + durations = [] + descriptions = [] + + for event in boris_events: + if not isinstance(event, list) or len(event) < 3: + continue + orig_time = event[0] + desc = event[2] + shifted_time = orig_time + time_shift + + onsets.append(shifted_time) + durations.append(0.0) # durations shouldn't matter? + descriptions.append(desc) + + new_annotations = Annotations(onset=onsets, duration=durations, description=descriptions) + + # Replace annotations in raw object + raw.set_annotations(new_annotations) + + # Write a new SNIRF file + write_raw_snirf(raw, suggested_name) + + 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}") + + + 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 + raw = read_raw_snirf(file_a, preload=True) + raw.set_montage(initial_montage) + write_raw_snirf(raw, save_path) + + class ProgressBubble(QWidget): """ A clickable widget displaying a progress bar made of colored rectangles and a label. @@ -2636,6 +3027,7 @@ class MainApplication(QMainWindow): self.about = None self.help = None self.optodes = None + self.events = None self.bubble_widgets = {} self.param_sections = [] self.folder_paths = [] @@ -2868,12 +3260,13 @@ class MainApplication(QMainWindow): ("User Guide", "F1", self.user_guide, resource_path("icons/help_24dp_1F1F1F.svg")), ("Check for Updates", "F5", self.manual_check_for_updates, resource_path("icons/update_24dp_1F1F1F.svg")), ("Update optodes in snirf file...", "F6", self.update_optode_positions, resource_path("icons/update_24dp_1F1F1F.svg")), + ("Update events in snirf file...", "F7", self.update_event_markers, resource_path("icons/update_24dp_1F1F1F.svg")), ("About", "F12", self.about_window, resource_path("icons/info_24dp_1F1F1F.svg")) ] for i, (name, shortcut, slot, icon) in enumerate(options_actions): options_menu.addAction(make_action(name, shortcut, slot, icon=icon)) - if i == 1 or i == 2: # after the first 2 actions (0,1) + if i == 1 or i == 3: # after the first 2 actions (0,1) options_menu.addSeparator() self.statusbar = self.statusBar() @@ -2964,6 +3357,11 @@ class MainApplication(QMainWindow): self.optodes.show() + def update_event_markers(self): + if self.events is None or not self.events.isVisible(): + self.events = UpdateEventsWindow(self) + self.events.show() + def open_file_dialog(self): file_path, _ = QFileDialog.getOpenFileName( self, "Open File", "", "All Files (*);;Text Files (*.txt)"