partial rewrite into multiple files

This commit is contained in:
2026-06-28 08:57:38 -07:00
parent 766bf75dd7
commit 617daa0e9b
16 changed files with 3175 additions and 2680 deletions
@@ -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}")
+155
View File
@@ -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()}")
+193
View File
@@ -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}")
View File
+17 -2680
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+53
View File
@@ -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)
View File
+30
View File
@@ -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)
+59
View File
@@ -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}"
+846
View File
@@ -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}")
+281
View File
@@ -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)
+33
View File
@@ -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)
+70
View File
@@ -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()