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)"