partial rewrite into multiple files
This commit is contained in:
@@ -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)
|
||||
@@ -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}"
|
||||
@@ -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.<br>"
|
||||
"Utilizing different software and video recordings, it is easy enough to see when an action actually occured in a file.<br>"
|
||||
"The software <a href='https://www.boris.unito.it/'>BORIS</a> is used to add these events to video files, and these events can be applied to the snirf file <br>"
|
||||
"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.<br>"
|
||||
"Utilizing different software and video recordings, it is easy enough to see when an action actually occured in a file.<br>"
|
||||
"The software <a href='https://git.research.dezeeuw.ca/tyler/blazes/'>BLAZES</a> is used to create these events in video files, and these events can be applied to the snirf file <br>"
|
||||
"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}")
|
||||
|
||||
|
||||
@@ -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.<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 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.<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>. Currently only .xlsx files directly exported from a<br>"
|
||||
"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)
|
||||
@@ -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 <a href='https://git.research.dezeeuw.ca/tyler/{APP_NAME}/wiki'>here</a>.", 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)
|
||||
@@ -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"<h2>{APP_NAME.upper()} has been sucessfully updated to version {CURRENT_VERSION}!</h2>", self)
|
||||
else:
|
||||
title_label = QLabel(f"<h2>{APP_NAME.upper()} is currently running version {CURRENT_VERSION}.</h2>", 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("<p style='color: gray;'>Loading latest updates from server...</p>")
|
||||
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"<p style='color: red;'><b>Failed to load content.</b><br>Error: {reply.errorString()}</p>"
|
||||
)
|
||||
reply.deleteLater()
|
||||
Reference in New Issue
Block a user