Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3d0fbd5c5e | |||
| 3f38f5a978 |
@@ -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")
|
||||
|
||||
435
main.py
435
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,430 @@ 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.<br>"
|
||||
"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.<br>"
|
||||
"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.<br>"
|
||||
"The .txt file should have the fiducials, detectors, and sources clearly labeled, followed by the x, y, and z coordinates seperated by a space.<br>"
|
||||
"An example format of what a digitization text file should look like can be found <a href='custom_link'>by clicking here</a>.")
|
||||
|
||||
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 = []
|
||||
|
||||
label_counts = {}
|
||||
|
||||
used_times = set()
|
||||
|
||||
sfreq = raw.info['sfreq'] # sampling frequency in Hz
|
||||
min_shift = 1.0 / sfreq
|
||||
|
||||
max_attempts = 10
|
||||
|
||||
for event in boris_events:
|
||||
if not isinstance(event, list) or len(event) < 3:
|
||||
continue
|
||||
|
||||
orig_time = event[0]
|
||||
desc = event[2]
|
||||
|
||||
# Count occurrences per event label
|
||||
count = label_counts.get(desc, 0)
|
||||
label_counts[desc] = count + 1
|
||||
|
||||
# Only use 1st, 3rd, 5th... (odd occurrences)
|
||||
if (count % 2) == 0:
|
||||
shifted_time = orig_time + time_shift
|
||||
|
||||
# Ensure unique timestamp by checking and adjusting slightly
|
||||
adjusted_time = shifted_time
|
||||
|
||||
# Try to find a unique timestamp
|
||||
attempts = 0
|
||||
while round(adjusted_time, 6) in used_times and attempts < max_attempts:
|
||||
adjusted_time += min_shift
|
||||
attempts += 1
|
||||
|
||||
if attempts == max_attempts:
|
||||
print(f"Warning: Could not find unique timestamp for event '{desc}' at original time {orig_time:.3f}s. Skipping.")
|
||||
continue # Skip problematic event
|
||||
|
||||
adjusted_time = round(adjusted_time, 6)
|
||||
used_times.add(adjusted_time)
|
||||
|
||||
print(f"Applying event: {desc} @ {adjusted_time:.3f}s (original: {orig_time:.3f}s)")
|
||||
|
||||
onsets.append(adjusted_time)
|
||||
durations.append(0.0)
|
||||
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 +3062,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 +3295,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 +3392,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)"
|
||||
|
||||
Reference in New Issue
Block a user