partial rewrite into multiple files
This commit is contained in:
@@ -0,0 +1,178 @@
|
|||||||
|
import os
|
||||||
|
from PySide6.QtWidgets import QComboBox, QDialog, QGridLayout, QHBoxLayout, QMessageBox, QPushButton, QScrollArea, QWidget, QVBoxLayout, QLabel
|
||||||
|
from PySide6.QtCore import QSize
|
||||||
|
|
||||||
|
from src.shared.s_flaresbasewidget import FlaresBaseWidget, ParameterInputDialog
|
||||||
|
|
||||||
|
|
||||||
|
class GroupFunctionalConnectivityWidget(FlaresBaseWidget):
|
||||||
|
def __init__(self, haemo_dict, group, config_dict):
|
||||||
|
super().__init__("GroupFunctionalConnectivityWidget")
|
||||||
|
self.setWindowTitle("FLARES Group Viewer")
|
||||||
|
self.haemo_dict = haemo_dict
|
||||||
|
self.group = group
|
||||||
|
self.config_dict = config_dict
|
||||||
|
self.show_all_events = True
|
||||||
|
self._updating_checkstates = False
|
||||||
|
|
||||||
|
QMessageBox.warning(self, "Warning - FLARES", f"Functional Connectivity is still in development and the results should currently be taken with a grain of salt. "
|
||||||
|
"By clicking OK, you accept that the images generated may not be factual.")
|
||||||
|
|
||||||
|
|
||||||
|
# Create mappings: file_path -> participant label and dropdown display text
|
||||||
|
self.participant_map = {} # file_path -> "Participant 1"
|
||||||
|
self.participant_dropdown_items = [] # "Participant 1 (filename)"
|
||||||
|
|
||||||
|
for i, file_path in enumerate(self.haemo_dict.keys(), start=1):
|
||||||
|
short_label = f"Participant {i}"
|
||||||
|
display_label = f"{short_label} ({os.path.basename(file_path)})"
|
||||||
|
self.participant_map[file_path] = short_label
|
||||||
|
self.participant_dropdown_items.append(display_label)
|
||||||
|
|
||||||
|
self.layout = QVBoxLayout(self)
|
||||||
|
self.top_bar = QHBoxLayout()
|
||||||
|
self.layout.addLayout(self.top_bar)
|
||||||
|
|
||||||
|
self.group_to_paths = {}
|
||||||
|
for file_path, group_name in self.group.items():
|
||||||
|
self.group_to_paths.setdefault(group_name, []).append(file_path)
|
||||||
|
|
||||||
|
self.group_names = sorted(self.group_to_paths.keys())
|
||||||
|
|
||||||
|
self.group_dropdown = QComboBox()
|
||||||
|
self.group_dropdown.addItem("<None Selected>")
|
||||||
|
self.group_dropdown.addItems(self.group_names)
|
||||||
|
self.group_dropdown.setCurrentIndex(0)
|
||||||
|
self.group_dropdown.currentIndexChanged.connect(self.update_participant_list_for_group)
|
||||||
|
|
||||||
|
self.participant_dropdown = self._create_multiselect_dropdown(self.participant_dropdown_items)
|
||||||
|
self.participant_dropdown.currentIndexChanged.connect(self.update_participant_dropdown_label)
|
||||||
|
self.participant_dropdown.setEnabled(False)
|
||||||
|
|
||||||
|
self.event_dropdown = QComboBox()
|
||||||
|
self.event_dropdown.addItem("<None Selected>")
|
||||||
|
|
||||||
|
self.index_texts = [
|
||||||
|
"0 (Betas)",
|
||||||
|
#"1 (Significance)",
|
||||||
|
#"2 (Brain Activity Visualization)",
|
||||||
|
# "3 (fourth image)",
|
||||||
|
]
|
||||||
|
|
||||||
|
self.image_index_dropdown = self._create_multiselect_dropdown(self.index_texts)
|
||||||
|
self.image_index_dropdown.currentIndexChanged.connect(self.update_image_index_dropdown_label)
|
||||||
|
|
||||||
|
self.submit_button = QPushButton("Submit")
|
||||||
|
self.submit_button.clicked.connect(self.show_brain_images)
|
||||||
|
|
||||||
|
self.top_bar.addWidget(QLabel("Group:"))
|
||||||
|
self.top_bar.addWidget(self.group_dropdown)
|
||||||
|
self.top_bar.addWidget(QLabel("Participants:"))
|
||||||
|
self.top_bar.addWidget(self.participant_dropdown)
|
||||||
|
self.top_bar.addWidget(QLabel("Event:"))
|
||||||
|
self.top_bar.addWidget(self.event_dropdown)
|
||||||
|
self.top_bar.addWidget(QLabel("Image Indexes:"))
|
||||||
|
self.top_bar.addWidget(self.image_index_dropdown)
|
||||||
|
self.top_bar.addWidget(self.submit_button)
|
||||||
|
|
||||||
|
self.scroll = QScrollArea()
|
||||||
|
self.scroll.setWidgetResizable(True)
|
||||||
|
self.scroll_content = QWidget()
|
||||||
|
self.grid_layout = QGridLayout(self.scroll_content)
|
||||||
|
self.scroll.setWidget(self.scroll_content)
|
||||||
|
self.layout.addWidget(self.scroll)
|
||||||
|
|
||||||
|
self.thumb_size = QSize(280, 180)
|
||||||
|
self.showMaximized()
|
||||||
|
|
||||||
|
|
||||||
|
def show_brain_images(self):
|
||||||
|
import src.flares as flares
|
||||||
|
|
||||||
|
selected_event = self.event_dropdown.currentText()
|
||||||
|
if selected_event == "<None Selected>":
|
||||||
|
selected_event = None
|
||||||
|
|
||||||
|
selected_display_names = self._get_checked_items(self.participant_dropdown)
|
||||||
|
selected_file_paths = []
|
||||||
|
for display_name in selected_display_names:
|
||||||
|
for fp, short_label in self.participant_map.items():
|
||||||
|
expected_display = f"{short_label} ({os.path.basename(fp)})"
|
||||||
|
if display_name == expected_display:
|
||||||
|
selected_file_paths.append(fp)
|
||||||
|
break
|
||||||
|
|
||||||
|
if selected_event:
|
||||||
|
valid_paths = []
|
||||||
|
for fp in selected_file_paths:
|
||||||
|
raw = self.haemo_dict.get(fp)
|
||||||
|
# Check if this participant actually has the event in their annotations
|
||||||
|
if raw is not None and hasattr(raw, "annotations"):
|
||||||
|
if selected_event in raw.annotations.description:
|
||||||
|
valid_paths.append(fp)
|
||||||
|
|
||||||
|
selected_file_paths = valid_paths
|
||||||
|
|
||||||
|
selected_indexes = [
|
||||||
|
int(s.split(" ")[0]) for s in self._get_checked_items(self.image_index_dropdown)
|
||||||
|
]
|
||||||
|
|
||||||
|
if not selected_file_paths:
|
||||||
|
print("No participants selected.")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Only keep indexes 0 and 1 that need parameters
|
||||||
|
parameterized_indexes = {
|
||||||
|
0: [
|
||||||
|
{
|
||||||
|
"key": "n_lines",
|
||||||
|
"label": "<Description>",
|
||||||
|
"default": "20",
|
||||||
|
"type": int,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "vmin",
|
||||||
|
"label": "<Description>",
|
||||||
|
"default": "0.9",
|
||||||
|
"type": float,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
# Inject full_text from index_texts
|
||||||
|
for idx, params_list in parameterized_indexes.items():
|
||||||
|
full_text = self.index_texts[idx] if idx < len(self.index_texts) else f"{idx} (No label found)"
|
||||||
|
for param_info in params_list:
|
||||||
|
param_info["full_text"] = full_text
|
||||||
|
|
||||||
|
indexes_needing_params = {idx: parameterized_indexes[idx] for idx in selected_indexes if idx in parameterized_indexes}
|
||||||
|
|
||||||
|
param_values = {}
|
||||||
|
if indexes_needing_params:
|
||||||
|
dialog = ParameterInputDialog(indexes_needing_params, parent=self)
|
||||||
|
if dialog.exec_() == QDialog.Accepted:
|
||||||
|
param_values = dialog.get_values()
|
||||||
|
if param_values is None:
|
||||||
|
return
|
||||||
|
else:
|
||||||
|
return
|
||||||
|
|
||||||
|
for idx in selected_indexes:
|
||||||
|
if idx == 0:
|
||||||
|
params = param_values.get(idx, {})
|
||||||
|
n_lines = params.get("n_lines", None)
|
||||||
|
vmin = params.get("vmin", None)
|
||||||
|
|
||||||
|
if n_lines is None or vmin is None:
|
||||||
|
print(f"Missing parameters for index {idx}, skipping.")
|
||||||
|
continue
|
||||||
|
flares.run_group_functional_connectivity(self.haemo_dict, self.config_dict, selected_file_paths, selected_event, 50, 0.5)
|
||||||
|
elif idx == 1:
|
||||||
|
pass
|
||||||
|
elif idx == 2:
|
||||||
|
pass
|
||||||
|
elif idx == 3:
|
||||||
|
pass
|
||||||
|
|
||||||
|
else:
|
||||||
|
print(f"No method defined for index {idx}")
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from PySide6.QtWidgets import QComboBox, QFrame, QGridLayout, QHBoxLayout, QListView, QMessageBox, QPushButton, QScrollArea, QSpinBox, QWidget, QVBoxLayout, QLabel, QLineEdit
|
||||||
|
from PySide6.QtCore import QThread, Signal, Qt, QTimer, QEvent, QSize, QPoint, QUrl
|
||||||
|
from PySide6.QtGui import QAction, QDesktopServices, QKeySequence, QIcon, QIntValidator, QDoubleValidator, QPixmap, QStandardItemModel, QStandardItem, QImage
|
||||||
|
|
||||||
|
from src.shared.s_flaresbasewidget import ClickableLabel, FlaresBaseWidget
|
||||||
|
|
||||||
|
|
||||||
|
class ParticipantViewerWidget(FlaresBaseWidget):
|
||||||
|
def __init__(self, haemo_dict, fig_bytes_dict):
|
||||||
|
super().__init__("ParticipantViewer")
|
||||||
|
self.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose)
|
||||||
|
self.setWindowTitle("FLARES Participant Viewer")
|
||||||
|
self.haemo_dict = haemo_dict
|
||||||
|
self.fig_bytes_dict = fig_bytes_dict
|
||||||
|
|
||||||
|
# Create mappings: file_path -> participant label and dropdown display text
|
||||||
|
self.participant_map = {} # file_path -> "Participant 1"
|
||||||
|
self.participant_dropdown_items = [] # "Participant 1 (filename)"
|
||||||
|
|
||||||
|
for i, file_path in enumerate(self.haemo_dict.keys(), start=1):
|
||||||
|
short_label = f"Participant {i}"
|
||||||
|
display_label = f"{short_label} ({os.path.basename(file_path)})"
|
||||||
|
self.participant_map[file_path] = short_label
|
||||||
|
self.participant_dropdown_items.append(display_label)
|
||||||
|
|
||||||
|
self.layout = QVBoxLayout(self)
|
||||||
|
self.top_bar = QHBoxLayout()
|
||||||
|
self.layout.addLayout(self.top_bar)
|
||||||
|
|
||||||
|
self.participant_dropdown = self._create_multiselect_dropdown(self.participant_dropdown_items)
|
||||||
|
self.participant_dropdown.currentIndexChanged.connect(self.update_participant_dropdown_label)
|
||||||
|
|
||||||
|
first_fig_dict = next(iter(self.fig_bytes_dict.values()))
|
||||||
|
image_label_items = list(first_fig_dict.keys())
|
||||||
|
|
||||||
|
self.image_index_dropdown = self._create_multiselect_dropdown(image_label_items)
|
||||||
|
self.image_index_dropdown.currentIndexChanged.connect(self.update_image_index_dropdown_label)
|
||||||
|
|
||||||
|
self.submit_button = QPushButton("Submit")
|
||||||
|
self.submit_button.clicked.connect(self.show_selected_images)
|
||||||
|
|
||||||
|
self.top_bar.addWidget(QLabel("Participants:"))
|
||||||
|
self.top_bar.addWidget(self.participant_dropdown)
|
||||||
|
self.top_bar.addWidget(QLabel("Image Indexes:"))
|
||||||
|
self.top_bar.addWidget(self.image_index_dropdown)
|
||||||
|
self.top_bar.addWidget(self.submit_button)
|
||||||
|
|
||||||
|
self.scroll = QScrollArea()
|
||||||
|
self.scroll.setWidgetResizable(True)
|
||||||
|
self.scroll_content = QWidget()
|
||||||
|
self.grid_layout = QGridLayout(self.scroll_content)
|
||||||
|
self.scroll.setWidget(self.scroll_content)
|
||||||
|
self.layout.addWidget(self.scroll)
|
||||||
|
|
||||||
|
self.thumb_size = QSize(280, 180)
|
||||||
|
|
||||||
|
self.save_button = QPushButton("Save Displayed Images")
|
||||||
|
self.save_button.clicked.connect(self.save_displayed_images)
|
||||||
|
self.top_bar.addWidget(self.save_button)
|
||||||
|
|
||||||
|
self.showMaximized()
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def show_selected_images(self):
|
||||||
|
# Clear previous images
|
||||||
|
while self.grid_layout.count():
|
||||||
|
item = self.grid_layout.takeAt(0)
|
||||||
|
widget = item.widget()
|
||||||
|
if widget:
|
||||||
|
widget.deleteLater()
|
||||||
|
|
||||||
|
selected_display_names = self._get_checked_items(self.participant_dropdown)
|
||||||
|
# Map from display names back to file paths
|
||||||
|
selected_file_paths = []
|
||||||
|
for display_name in selected_display_names:
|
||||||
|
# Find file_path by matching display name
|
||||||
|
for fp, short_label in self.participant_map.items():
|
||||||
|
expected_display = f"{short_label} ({os.path.basename(fp)})"
|
||||||
|
if display_name == expected_display:
|
||||||
|
selected_file_paths.append(fp)
|
||||||
|
break
|
||||||
|
|
||||||
|
selected_labels = self._get_checked_items(self.image_index_dropdown)
|
||||||
|
|
||||||
|
row, col = 0, 0
|
||||||
|
for file_path in selected_file_paths:
|
||||||
|
fig_list = self.fig_bytes_dict.get(file_path, [])
|
||||||
|
participant_label = self.participant_map[file_path]
|
||||||
|
for label in selected_labels:
|
||||||
|
fig_bytes = fig_list.get(label)
|
||||||
|
if not fig_bytes:
|
||||||
|
continue
|
||||||
|
|
||||||
|
full_pixmap = QPixmap()
|
||||||
|
full_pixmap.loadFromData(fig_bytes)
|
||||||
|
|
||||||
|
thumbnail_pixmap = full_pixmap.scaled(
|
||||||
|
self.thumb_size,
|
||||||
|
Qt.AspectRatioMode.KeepAspectRatio,
|
||||||
|
Qt.TransformationMode.SmoothTransformation
|
||||||
|
)
|
||||||
|
|
||||||
|
container = QWidget()
|
||||||
|
hlayout = QHBoxLayout(container)
|
||||||
|
hlayout.setContentsMargins(0, 0, 0, 0)
|
||||||
|
hlayout.setSpacing(0)
|
||||||
|
hlayout.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||||
|
|
||||||
|
image_label = ClickableLabel(full_pixmap, thumbnail_pixmap)
|
||||||
|
image_label.setToolTip(f"{participant_label}\n{label}")
|
||||||
|
hlayout.addWidget(image_label)
|
||||||
|
|
||||||
|
self.grid_layout.addWidget(container, row, col)
|
||||||
|
|
||||||
|
col += 1
|
||||||
|
if col >= 6:
|
||||||
|
col = 0
|
||||||
|
row += 1
|
||||||
|
|
||||||
|
# Update dropdown labels after display
|
||||||
|
self.update_participant_dropdown_label()
|
||||||
|
self.update_image_index_dropdown_label()
|
||||||
|
|
||||||
|
|
||||||
|
def save_displayed_images(self):
|
||||||
|
# Ensure the folder exists
|
||||||
|
save_dir = Path("individual_images")
|
||||||
|
save_dir.mkdir(exist_ok=True)
|
||||||
|
|
||||||
|
selected_display_names = self._get_checked_items(self.participant_dropdown)
|
||||||
|
selected_image_labels = self._get_checked_items(self.image_index_dropdown)
|
||||||
|
|
||||||
|
for display_name in selected_display_names:
|
||||||
|
# Match display name to file path
|
||||||
|
for file_path, short_label in self.participant_map.items():
|
||||||
|
expected_display = f"{short_label} ({os.path.basename(file_path)})"
|
||||||
|
if display_name == expected_display:
|
||||||
|
fig_dict = self.fig_bytes_dict.get(file_path, {})
|
||||||
|
for label in selected_image_labels:
|
||||||
|
if label not in fig_dict:
|
||||||
|
continue
|
||||||
|
fig_bytes = fig_dict[label]
|
||||||
|
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||||
|
filename = f"{os.path.basename(file_path)}_{label}_{timestamp}.png"
|
||||||
|
output_path = save_dir / filename
|
||||||
|
with open(output_path, "wb") as f:
|
||||||
|
f.write(fig_bytes)
|
||||||
|
break # file_path matched; stop loop
|
||||||
|
|
||||||
|
QMessageBox.information(self, "Save Complete", f"Images saved to {save_dir.resolve()}")
|
||||||
@@ -0,0 +1,193 @@
|
|||||||
|
import os
|
||||||
|
|
||||||
|
from PySide6.QtWidgets import QComboBox, QDialog, QFrame, QGridLayout, QHBoxLayout, QListView, QMessageBox, QPushButton, QScrollArea, QSpinBox, QWidget, QVBoxLayout, QLabel, QLineEdit
|
||||||
|
from PySide6.QtCore import QThread, Signal, Qt, QTimer, QEvent, QSize, QPoint, QUrl
|
||||||
|
|
||||||
|
from src.shared.s_flaresbasewidget import FlaresBaseWidget, ParameterInputDialog
|
||||||
|
|
||||||
|
|
||||||
|
class ParticipantBrainViewerWidget(FlaresBaseWidget):
|
||||||
|
def __init__(self, haemo_dict, cha_dict):
|
||||||
|
super().__init__("ParticipantBrainViewer")
|
||||||
|
self.setWindowTitle("FLARES Participant Brain Viewer")
|
||||||
|
self.haemo_dict = haemo_dict
|
||||||
|
self.cha_dict = cha_dict
|
||||||
|
|
||||||
|
# Create mappings: file_path -> participant label and dropdown display text
|
||||||
|
self.participant_map = {} # file_path -> "Participant 1"
|
||||||
|
self.participant_dropdown_items = [] # "Participant 1 (filename)"
|
||||||
|
|
||||||
|
for i, file_path in enumerate(self.haemo_dict.keys(), start=1):
|
||||||
|
short_label = f"Participant {i}"
|
||||||
|
display_label = f"{short_label} ({os.path.basename(file_path)})"
|
||||||
|
self.participant_map[file_path] = short_label
|
||||||
|
self.participant_dropdown_items.append(display_label)
|
||||||
|
|
||||||
|
self.layout = QVBoxLayout(self)
|
||||||
|
self.top_bar = QHBoxLayout()
|
||||||
|
self.layout.addLayout(self.top_bar)
|
||||||
|
|
||||||
|
self.participant_dropdown = self._create_multiselect_dropdown(self.participant_dropdown_items)
|
||||||
|
self.participant_dropdown.currentIndexChanged.connect(self.update_participant_dropdown_label)
|
||||||
|
|
||||||
|
self.event_dropdown = QComboBox()
|
||||||
|
self.event_dropdown.addItem("<None Selected>")
|
||||||
|
|
||||||
|
|
||||||
|
self.index_texts = [
|
||||||
|
"0 (Brain Landmarks)",
|
||||||
|
"1 (Brain Activity Visualization)",
|
||||||
|
# "2 (third image)",
|
||||||
|
# "3 (fourth image)",
|
||||||
|
]
|
||||||
|
|
||||||
|
self.image_index_dropdown = self._create_multiselect_dropdown(self.index_texts)
|
||||||
|
self.image_index_dropdown.currentIndexChanged.connect(self.update_image_index_dropdown_label)
|
||||||
|
|
||||||
|
self.submit_button = QPushButton("Submit")
|
||||||
|
self.submit_button.clicked.connect(self.show_brain_images)
|
||||||
|
|
||||||
|
self.top_bar.addWidget(QLabel("Participants:"))
|
||||||
|
self.top_bar.addWidget(self.participant_dropdown)
|
||||||
|
self.top_bar.addWidget(QLabel("Event:"))
|
||||||
|
self.top_bar.addWidget(self.event_dropdown)
|
||||||
|
self.top_bar.addWidget(QLabel("Image Indexes:"))
|
||||||
|
self.top_bar.addWidget(self.image_index_dropdown)
|
||||||
|
self.top_bar.addWidget(self.submit_button)
|
||||||
|
|
||||||
|
self.scroll = QScrollArea()
|
||||||
|
self.scroll.setWidgetResizable(True)
|
||||||
|
self.scroll_content = QWidget()
|
||||||
|
self.grid_layout = QGridLayout(self.scroll_content)
|
||||||
|
self.scroll.setWidget(self.scroll_content)
|
||||||
|
self.layout.addWidget(self.scroll)
|
||||||
|
|
||||||
|
self.thumb_size = QSize(280, 180)
|
||||||
|
self.showMaximized()
|
||||||
|
|
||||||
|
|
||||||
|
def show_brain_images(self):
|
||||||
|
import src.flares as flares
|
||||||
|
|
||||||
|
selected_event = self.event_dropdown.currentText()
|
||||||
|
if selected_event == "<None Selected>":
|
||||||
|
selected_event = None
|
||||||
|
|
||||||
|
selected_display_names = self._get_checked_items(self.participant_dropdown)
|
||||||
|
selected_file_paths = []
|
||||||
|
for display_name in selected_display_names:
|
||||||
|
for fp, short_label in self.participant_map.items():
|
||||||
|
expected_display = f"{short_label} ({os.path.basename(fp)})"
|
||||||
|
if display_name == expected_display:
|
||||||
|
selected_file_paths.append(fp)
|
||||||
|
break
|
||||||
|
|
||||||
|
selected_indexes = [
|
||||||
|
int(s.split(" ")[0]) for s in self._get_checked_items(self.image_index_dropdown)
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
parameterized_indexes = {
|
||||||
|
0: [
|
||||||
|
{
|
||||||
|
"key": "show_optodes",
|
||||||
|
"label": "Determine what is rendered above the brain. Valid values are 'sensors', 'labels', 'none', 'all'.",
|
||||||
|
"default": "all",
|
||||||
|
"type": str,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "show_brodmann",
|
||||||
|
"label": "Show common brodmann areas on the brain.",
|
||||||
|
"default": "True",
|
||||||
|
"type": bool,
|
||||||
|
}
|
||||||
|
],
|
||||||
|
1: [
|
||||||
|
{
|
||||||
|
"key": "show_optodes",
|
||||||
|
"label": "Determine what is rendered above the brain. Valid values are 'sensors', 'labels', 'none', 'all'.",
|
||||||
|
"default": "all",
|
||||||
|
"type": str,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "t_or_theta",
|
||||||
|
"label": "Specify if t values or theta values should be plotted. Valid values are 't', 'theta'",
|
||||||
|
"default": "theta",
|
||||||
|
"type": str,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "show_text",
|
||||||
|
"label": "Display informative text on the top left corner. THIS DOES NOT WORK AND SHOULD BE LEFT AT FALSE",
|
||||||
|
"default": "False",
|
||||||
|
"type": bool,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "brain_bounds",
|
||||||
|
"label": "Graph Upper/Lower Limit",
|
||||||
|
"default": "1.0",
|
||||||
|
"type": float,
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
# Inject full_text from index_texts
|
||||||
|
for idx, params_list in parameterized_indexes.items():
|
||||||
|
full_text = self.index_texts[idx] if idx < len(self.index_texts) else f"{idx} (No label found)"
|
||||||
|
for param_info in params_list:
|
||||||
|
param_info["full_text"] = full_text
|
||||||
|
|
||||||
|
indexes_needing_params = {idx: parameterized_indexes[idx] for idx in selected_indexes if idx in parameterized_indexes}
|
||||||
|
|
||||||
|
param_values = {}
|
||||||
|
if indexes_needing_params:
|
||||||
|
dialog = ParameterInputDialog(indexes_needing_params, parent=self)
|
||||||
|
if dialog.exec_() == QDialog.Accepted:
|
||||||
|
param_values = dialog.get_values()
|
||||||
|
if param_values is None:
|
||||||
|
return
|
||||||
|
else:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Pass the necessary arguments to each method
|
||||||
|
for file_path in selected_file_paths:
|
||||||
|
haemo_obj = self.haemo_dict.get(file_path)
|
||||||
|
|
||||||
|
if selected_event:
|
||||||
|
participant_events = set(haemo_obj.annotations.description)
|
||||||
|
if selected_event not in participant_events:
|
||||||
|
print(f"Skipping {self.participant_map[file_path]}: Event '{selected_event}' not found.")
|
||||||
|
continue
|
||||||
|
|
||||||
|
if haemo_obj is None:
|
||||||
|
raise Exception("How did we get here?")
|
||||||
|
|
||||||
|
cha = self.cha_dict.get(file_path)
|
||||||
|
|
||||||
|
for idx in selected_indexes:
|
||||||
|
if idx == 0:
|
||||||
|
|
||||||
|
params = param_values.get(idx, {})
|
||||||
|
show_optodes = params.get("show_optodes", None)
|
||||||
|
show_brodmann = params.get("show_brodmann", None)
|
||||||
|
|
||||||
|
if show_optodes is None or show_brodmann is None:
|
||||||
|
print(f"Missing parameters for index {idx}, skipping.")
|
||||||
|
continue
|
||||||
|
|
||||||
|
flares.brain_landmarks_3d(haemo_obj, show_optodes, show_brodmann)
|
||||||
|
|
||||||
|
elif idx == 1:
|
||||||
|
params = param_values.get(idx, {})
|
||||||
|
show_optodes = params.get("show_optodes", None)
|
||||||
|
t_or_theta = params.get("t_or_theta", None)
|
||||||
|
show_text = params.get("show_text", None)
|
||||||
|
brain_bounds = params.get("brain_bounds", None)
|
||||||
|
|
||||||
|
if show_optodes is None or t_or_theta is None or show_text is None or brain_bounds is None:
|
||||||
|
print(f"Missing parameters for index {idx}, skipping.")
|
||||||
|
continue
|
||||||
|
|
||||||
|
flares.brain_3d_visualization(haemo_obj, cha, selected_event, t_or_theta=t_or_theta, show_optodes=show_optodes, show_text=show_text, brain_bounds=brain_bounds)
|
||||||
|
|
||||||
|
else:
|
||||||
|
print(f"No method defined for index {idx}")
|
||||||
@@ -0,0 +1,230 @@
|
|||||||
|
import os
|
||||||
|
|
||||||
|
from PySide6.QtWidgets import QComboBox, QDialog, QFrame, QGridLayout, QHBoxLayout, QListView, QMessageBox, QPushButton, QScrollArea, QSpinBox, QWidget, QVBoxLayout, QLabel, QLineEdit
|
||||||
|
from PySide6.QtCore import QThread, Signal, Qt, QTimer, QEvent, QSize, QPoint, QUrl
|
||||||
|
|
||||||
|
from src.shared.s_flaresbasewidget import FlaresBaseWidget, ParameterInputDialog
|
||||||
|
|
||||||
|
class ParticipantFunctionalConnectivityWidget(FlaresBaseWidget):
|
||||||
|
def __init__(self, haemo_dict, epochs_dict):
|
||||||
|
super().__init__("FunctionalConnectivityWidget")
|
||||||
|
self.setWindowTitle("FLARES Functional Connectivity Viewer [BETA]")
|
||||||
|
self.haemo_dict = haemo_dict
|
||||||
|
self.epochs_dict = epochs_dict
|
||||||
|
|
||||||
|
QMessageBox.warning(self, "Warning - FLARES", f"Functional Connectivity is still in development and the results should currently be taken with a grain of salt. "
|
||||||
|
"By clicking OK, you accept that the images generated may not be factual.")
|
||||||
|
|
||||||
|
# Create mappings: file_path -> participant label and dropdown display text
|
||||||
|
self.participant_map = {} # file_path -> "Participant 1"
|
||||||
|
self.participant_dropdown_items = [] # "Participant 1 (filename)"
|
||||||
|
|
||||||
|
for i, file_path in enumerate(self.haemo_dict.keys(), start=1):
|
||||||
|
short_label = f"Participant {i}"
|
||||||
|
display_label = f"{short_label} ({os.path.basename(file_path)})"
|
||||||
|
self.participant_map[file_path] = short_label
|
||||||
|
self.participant_dropdown_items.append(display_label)
|
||||||
|
|
||||||
|
self.layout = QVBoxLayout(self)
|
||||||
|
self.top_bar = QHBoxLayout()
|
||||||
|
self.layout.addLayout(self.top_bar)
|
||||||
|
|
||||||
|
self.participant_dropdown = self._create_multiselect_dropdown(self.participant_dropdown_items)
|
||||||
|
self.participant_dropdown.currentIndexChanged.connect(self.update_participant_dropdown_label)
|
||||||
|
|
||||||
|
self.event_dropdown = QComboBox()
|
||||||
|
self.event_dropdown.addItem("<None Selected>")
|
||||||
|
|
||||||
|
|
||||||
|
self.index_texts = [
|
||||||
|
"0 (Spectral Connectivity Epochs)",
|
||||||
|
"1 (Envelope Correlation)",
|
||||||
|
"2 (Betas)",
|
||||||
|
"3 (Spectral Connectivity Epochs)",
|
||||||
|
]
|
||||||
|
|
||||||
|
self.image_index_dropdown = self._create_multiselect_dropdown(self.index_texts)
|
||||||
|
self.image_index_dropdown.currentIndexChanged.connect(self.update_image_index_dropdown_label)
|
||||||
|
|
||||||
|
self.submit_button = QPushButton("Submit")
|
||||||
|
self.submit_button.clicked.connect(self.show_brain_images)
|
||||||
|
|
||||||
|
self.top_bar.addWidget(QLabel("Participants:"))
|
||||||
|
self.top_bar.addWidget(self.participant_dropdown)
|
||||||
|
self.top_bar.addWidget(QLabel("Event:"))
|
||||||
|
self.top_bar.addWidget(self.event_dropdown)
|
||||||
|
self.top_bar.addWidget(QLabel("Image Indexes:"))
|
||||||
|
self.top_bar.addWidget(self.image_index_dropdown)
|
||||||
|
self.top_bar.addWidget(self.submit_button)
|
||||||
|
|
||||||
|
self.scroll = QScrollArea()
|
||||||
|
self.scroll.setWidgetResizable(True)
|
||||||
|
self.scroll_content = QWidget()
|
||||||
|
self.grid_layout = QGridLayout(self.scroll_content)
|
||||||
|
self.scroll.setWidget(self.scroll_content)
|
||||||
|
self.layout.addWidget(self.scroll)
|
||||||
|
|
||||||
|
self.thumb_size = QSize(280, 180)
|
||||||
|
self.showMaximized()
|
||||||
|
|
||||||
|
|
||||||
|
def show_brain_images(self):
|
||||||
|
import src.flares as flares
|
||||||
|
|
||||||
|
selected_event = self.event_dropdown.currentText()
|
||||||
|
if selected_event == "<None Selected>":
|
||||||
|
selected_event = None
|
||||||
|
|
||||||
|
selected_display_names = self._get_checked_items(self.participant_dropdown)
|
||||||
|
selected_file_paths = []
|
||||||
|
for display_name in selected_display_names:
|
||||||
|
for fp, short_label in self.participant_map.items():
|
||||||
|
expected_display = f"{short_label} ({os.path.basename(fp)})"
|
||||||
|
if display_name == expected_display:
|
||||||
|
selected_file_paths.append(fp)
|
||||||
|
break
|
||||||
|
|
||||||
|
selected_indexes = [
|
||||||
|
int(s.split(" ")[0]) for s in self._get_checked_items(self.image_index_dropdown)
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
parameterized_indexes = {
|
||||||
|
0: [
|
||||||
|
{
|
||||||
|
"key": "n_lines",
|
||||||
|
"label": "<Description>",
|
||||||
|
"default": "20",
|
||||||
|
"type": int,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "vmin",
|
||||||
|
"label": "<Description>",
|
||||||
|
"default": "0.9",
|
||||||
|
"type": float,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
1: [
|
||||||
|
{
|
||||||
|
"key": "n_lines",
|
||||||
|
"label": "<Description>",
|
||||||
|
"default": "20",
|
||||||
|
"type": int,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "vmin",
|
||||||
|
"label": "<Description>",
|
||||||
|
"default": "0.9",
|
||||||
|
"type": float,
|
||||||
|
},
|
||||||
|
|
||||||
|
],
|
||||||
|
2: [
|
||||||
|
{
|
||||||
|
"key": "n_lines",
|
||||||
|
"label": "<Description>",
|
||||||
|
"default": "20",
|
||||||
|
"type": int,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "vmin",
|
||||||
|
"label": "<Description>",
|
||||||
|
"default": "0.9",
|
||||||
|
"type": float,
|
||||||
|
},
|
||||||
|
|
||||||
|
],
|
||||||
|
3: [
|
||||||
|
{
|
||||||
|
"key": "n_lines",
|
||||||
|
"label": "<Description>",
|
||||||
|
"default": "20",
|
||||||
|
"type": int,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "vmin",
|
||||||
|
"label": "<Description>",
|
||||||
|
"default": "0.9",
|
||||||
|
"type": float,
|
||||||
|
},
|
||||||
|
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
# Inject full_text from index_texts
|
||||||
|
for idx, params_list in parameterized_indexes.items():
|
||||||
|
full_text = self.index_texts[idx] if idx < len(self.index_texts) else f"{idx} (No label found)"
|
||||||
|
for param_info in params_list:
|
||||||
|
param_info["full_text"] = full_text
|
||||||
|
|
||||||
|
indexes_needing_params = {idx: parameterized_indexes[idx] for idx in selected_indexes if idx in parameterized_indexes}
|
||||||
|
|
||||||
|
param_values = {}
|
||||||
|
if indexes_needing_params:
|
||||||
|
dialog = ParameterInputDialog(indexes_needing_params, parent=self)
|
||||||
|
if dialog.exec_() == QDialog.Accepted:
|
||||||
|
param_values = dialog.get_values()
|
||||||
|
if param_values is None:
|
||||||
|
return
|
||||||
|
else:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Pass the necessary arguments to each method
|
||||||
|
for file_path in selected_file_paths:
|
||||||
|
haemo_obj = self.haemo_dict.get(file_path)
|
||||||
|
epochs_obj = self.epochs_dict.get(file_path)
|
||||||
|
|
||||||
|
if selected_event:
|
||||||
|
participant_events = set(haemo_obj.annotations.description)
|
||||||
|
if selected_event not in participant_events:
|
||||||
|
print(f"Skipping {self.participant_map[file_path]}: Event '{selected_event}' not found.")
|
||||||
|
continue
|
||||||
|
|
||||||
|
if haemo_obj is None:
|
||||||
|
raise Exception("How did we get here?")
|
||||||
|
|
||||||
|
|
||||||
|
for idx in selected_indexes:
|
||||||
|
if idx == 0:
|
||||||
|
|
||||||
|
params = param_values.get(idx, {})
|
||||||
|
n_lines = params.get("n_lines", None)
|
||||||
|
vmin = params.get("vmin", None)
|
||||||
|
|
||||||
|
if n_lines is None or vmin is None:
|
||||||
|
print(f"Missing parameters for index {idx}, skipping.")
|
||||||
|
continue
|
||||||
|
flares.functional_connectivity_spectral_epochs(epochs_obj, n_lines, vmin)
|
||||||
|
|
||||||
|
elif idx == 1:
|
||||||
|
params = param_values.get(idx, {})
|
||||||
|
n_lines = params.get("n_lines", None)
|
||||||
|
vmin = params.get("vmin", None)
|
||||||
|
|
||||||
|
if n_lines is None or vmin is None:
|
||||||
|
print(f"Missing parameters for index {idx}, skipping.")
|
||||||
|
continue
|
||||||
|
flares.functional_connectivity_envelope(epochs_obj, n_lines, vmin)
|
||||||
|
|
||||||
|
elif idx == 2:
|
||||||
|
params = param_values.get(idx, {})
|
||||||
|
n_lines = params.get("n_lines", None)
|
||||||
|
vmin = params.get("vmin", None)
|
||||||
|
|
||||||
|
if n_lines is None or vmin is None:
|
||||||
|
print(f"Missing parameters for index {idx}, skipping.")
|
||||||
|
continue
|
||||||
|
flares.functional_connectivity_betas(haemo_obj, n_lines, vmin, selected_event)
|
||||||
|
|
||||||
|
elif idx == 3:
|
||||||
|
params = param_values.get(idx, {})
|
||||||
|
n_lines = params.get("n_lines", None)
|
||||||
|
vmin = params.get("vmin", None)
|
||||||
|
|
||||||
|
if n_lines is None or vmin is None:
|
||||||
|
print(f"Missing parameters for index {idx}, skipping.")
|
||||||
|
continue
|
||||||
|
flares.functional_connectivity_spectral_time(epochs_obj, n_lines, vmin)
|
||||||
|
|
||||||
|
else:
|
||||||
|
print(f"No method defined for index {idx}")
|
||||||
+17
-2680
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,53 @@
|
|||||||
|
import sys
|
||||||
|
import os
|
||||||
|
import platform
|
||||||
|
|
||||||
|
CURRENT_VERSION = "1.5.0"
|
||||||
|
APP_NAME = "flares"
|
||||||
|
API_URL = f"https://git.research.dezeeuw.ca/api/v1/repos/tyler/{APP_NAME}/releases"
|
||||||
|
API_URL_SECONDARY = f"https://git.research2.dezeeuw.ca/api/v1/repos/tyler/{APP_NAME}/releases"
|
||||||
|
PLATFORM_NAME = platform.system().lower()
|
||||||
|
|
||||||
|
PIPELINE_STAGES = [
|
||||||
|
"Preprocessing",
|
||||||
|
"Trimming",
|
||||||
|
"Verify Optode Placement",
|
||||||
|
"Short/Long Channels",
|
||||||
|
"Heart Rate",
|
||||||
|
"Scalp Coupling Index",
|
||||||
|
"Signal to Noise Ratio",
|
||||||
|
"Peak Spectral Power",
|
||||||
|
"Cross Validation",
|
||||||
|
"Median Absolute Deviation",
|
||||||
|
"Power Spectral Density Noise",
|
||||||
|
"Channel Variance",
|
||||||
|
"Bad Channels Handling",
|
||||||
|
"Optical Density",
|
||||||
|
"Temporal Derivative Distribution Repair Filtering",
|
||||||
|
"Wavelet Filtering",
|
||||||
|
"Haemoglobin Concentration",
|
||||||
|
"Enhance Negative Correlation",
|
||||||
|
"Filter",
|
||||||
|
"Extracting Events",
|
||||||
|
"Epoch Calculations",
|
||||||
|
"Design Matrix",
|
||||||
|
"General Linear Model",
|
||||||
|
"Generate GLM Results",
|
||||||
|
"Generate Channel Significance",
|
||||||
|
"Generate Channel, Region of Interest, and Contrast Results",
|
||||||
|
"Compute Contrast Results",
|
||||||
|
"Finishing Up"
|
||||||
|
]
|
||||||
|
|
||||||
|
def resource_path(relative_path):
|
||||||
|
"""
|
||||||
|
Get absolute path to resource regardless of running directly or packaged using PyInstaller
|
||||||
|
"""
|
||||||
|
|
||||||
|
if hasattr(sys, '_MEIPASS'):
|
||||||
|
# PyInstaller bundle path
|
||||||
|
base_path = sys._MEIPASS
|
||||||
|
else:
|
||||||
|
base_path = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
|
||||||
|
return os.path.join(base_path, relative_path)
|
||||||
@@ -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