8 Commits
v1.1.4 ... main

Author SHA1 Message Date
473c945563 fix for desktop windows 2025-11-30 15:42:56 -08:00
64ed6d2e87 more parameters 2025-11-03 16:56:05 -08:00
1aa2402d09 updates to hr 2025-10-31 21:21:10 -07:00
45c6176dba quick bug fixes 2025-10-21 18:05:30 -07:00
a4bbdb90c8 update to changelog for build 1.1.5 2025-10-20 16:08:34 -07:00
953ea90c67 fix to bandpass filter 2025-10-20 16:07:18 -07:00
20b255321b improvements 2025-10-20 09:33:50 -07:00
b5afcec37d fixes to cross platform saves 2025-10-15 17:09:51 -07:00
5 changed files with 1202 additions and 238 deletions

View File

@@ -1,3 +1,43 @@
# Version 1.1.7
- Fixed a bug where having both a L_FREQ and H_FREQ would cause only the L_FREQ to be used
- Changed the default H_FREQ from 0.7 to 0.3
- Added a PSD graph, along with 2 heart rate images to the individual participant viewer
- The PSD graph is used to help calculate the heart rate, whereas the other 2 are currently just for show
- SCI is now done using a .6hz window around the calculated heart rate compared to a window around an average heart rate
- Fixed an issue with some epochs figures not showing under the participant analysis
- Removed SECONDS_TO_STRIP from the preprocessing options
- Added new parameters to the right side of the screen
- These parameters include TRIM, SECONDS_TO_KEEP, OPTODE_PLACEMENT, HEART_RATE, WAVELET, IQR, WAVELET_TYPE, WAVELET_LEVEL, ENHANCE_NEGATIVE_CORRELATION, SHORT_CHANNEL_THRESH, LONG_CHANNEL_THRESH, and DRIFT_MODEL
- Changed number of rectangles in the progress bar to 25 to account for the new options
# Version 1.1.6
- Fixed Process button from appearing when no files are selected
- Fixed a bug that would cause an instant child process crash on Windows
- Added L_FREQ and H_FREQ parameters for more user control over low and high pass filtering
# Version 1.1.5
- Fixed Windows saves not being able to be opened by a Mac (hopefully the other way too!)
- Added the option to right click loaded snirf files to reveal them in a file browser or delete them if they are no longer desired
- Changed the way folders are opened to store the files seperately rather than the folder as a whole to allow for the removal of files
- Fixed issues with dropdowns and bubbles not populating correctly when opening a single file and temporarily removed the option to open multiple folders
- Improved crash handling and the message that is displayed to the user if the application crashes
- Progress bar will now colour the stage that fails as red if a file fails during processing
- A warning message will be displayed when a file fails to process with information on what went wrong. This message does not halt the rest of the processing of the other files
- Fixed the number of rectangles in the progress bar to 20 (was incorrect in v1.1.1)
- Added validation to ensure loaded files do not have 2 dimensional data when clicking process to prevent inaccurate results from being generated
- Added more metadata information to the top left information panel
- Changed the Status Bar message when processing is complete to state how many were successful and how many were not
- Added a clickable link below the selected file's metadata explaining the independent parameters and why they are useful
- Updated some tooltips to provide better, more accurate information
- Added details about the processing steps and their order into the user guide
- Changed the default bandpass filtering parameters
# Version 1.1.4
- Fixed some display text to now display the correct information

781
flares.py

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="#1f1f1f"><path d="M160-160q-33 0-56.5-23.5T80-240v-480q0-33 23.5-56.5T160-800h240l80 80h320q33 0 56.5 23.5T880-640v242q-18-14-38-23t-42-19v-200H447l-80-80H160v480h120v80H160ZM640-40q-91 0-168-48T360-220q35-84 112-132t168-48q91 0 168 48t112 132q-35 84-112 132T640-40Zm0-80q57 0 107.5-26t82.5-74q-32-48-82.5-74T640-320q-57 0-107.5 26T450-220q32 48 82.5 74T640-120Zm0-40q-25 0-42.5-17.5T580-220q0-25 17.5-42.5T640-280q25 0 42.5 17.5T700-220q0 25-17.5 42.5T640-160Zm-480-80v-480 277-37 240Z"/></svg>

After

Width:  |  Height:  |  Size: 593 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="#1f1f1f"><path d="M200-440v-80h560v80H200Z"/></svg>

After

Width:  |  Height:  |  Size: 149 B

601
main.py
View File

@@ -19,7 +19,7 @@ import zipfile
import platform
import traceback
import subprocess
from pathlib import Path
from pathlib import Path, PurePosixPath
from datetime import datetime
from multiprocessing import Process, current_process, freeze_support, Manager
@@ -34,13 +34,14 @@ from mne.io import read_raw_snirf
from mne.preprocessing.nirs import source_detector_distances
from mne_nirs.io import write_raw_snirf
from mne.channels import make_dig_montage
from mne_nirs.channels import get_short_channels # type: ignore
from mne import Annotations
from PySide6.QtWidgets import (
QApplication, QWidget, QMessageBox, QVBoxLayout, QHBoxLayout, QTextEdit, QScrollArea, QComboBox, QGridLayout,
QPushButton, QMainWindow, QFileDialog, QLabel, QLineEdit, QFrame, QSizePolicy, QGroupBox, QDialog, QListView,
QPushButton, QMainWindow, QFileDialog, QLabel, QLineEdit, QFrame, QSizePolicy, QGroupBox, QDialog, QListView, QMenu
)
from PySide6.QtCore import QThread, Signal, Qt, QTimer, QEvent, QSize
from PySide6.QtCore import QThread, Signal, Qt, QTimer, QEvent, QSize, QPoint
from PySide6.QtGui import QAction, QKeySequence, QIcon, QIntValidator, QDoubleValidator, QPixmap, QStandardItemModel, QStandardItem
from PySide6.QtSvgWidgets import QSvgWidget # needed to show svgs when app is not frozen
@@ -57,34 +58,53 @@ SECTIONS = [
{
"title": "Preprocessing",
"params": [
{"name": "SECONDS_TO_STRIP", "default": 0, "type": int, "help": "Seconds to remove from beginning of file. Setting this to 0 will remove nothing from the file."},
{"name": "DOWNSAMPLE", "default": True, "type": bool, "help": "Downsample snirf files."},
{"name": "DOWNSAMPLE_FREQUENCY", "default": 25, "type": int, "help": "Frequency (Hz) to downsample to. If this is set higher than the input data, new data will be interpolated."},
# {"name": "SECONDS_TO_STRIP", "default": 0, "type": int, "help": "Seconds to remove from beginning of all loaded snirf files. Setting this to 0 will remove nothing from the files."},
{"name": "DOWNSAMPLE", "default": True, "type": bool, "help": "Should the snirf files be downsampled? If this is set to True, DOWNSAMPLE_FREQUENCY will be used as the target frequency to downsample to."},
{"name": "DOWNSAMPLE_FREQUENCY", "default": 25, "type": int, "help": "Frequency (Hz) to downsample to. If this is set higher than the input data, new data will be interpolated. Only used if DOWNSAMPLE is set to True"},
]
},
{
"title": "Trimming",
"params": [
{"name": "TRIM", "default": True, "type": bool, "help": "Trim the file start."},
{"name": "SECONDS_TO_KEEP", "default": 5, "type": float, "help": "Seconds to keep at the beginning of all loaded snirf files before the first annotation/event occurs. Calculation is done seperatly on all loaded snirf files. Setting this to 0 will have the first annotation/event be at time point 0."},
]
},
{
"title": "Verify Optode Placement",
"params": [
{"name": "OPTODE_PLACEMENT", "default": True, "type": bool, "help": "Generate an image for each participant outlining their optode placement."},
]
},
{
"title": "Heart Rate",
"params": [
{"name": "HEART_RATE", "default": True, "type": bool, "help": "Attempt to calculate the participants heart rate."},
]
},
{
"title": "Scalp Coupling Index",
"params": [
{"name": "SCI", "default": True, "type": bool, "help": "Calculate Scalp Coupling Index."},
{"name": "SCI_TIME_WINDOW", "default": 3, "type": int, "help": "SCI time window."},
{"name": "SCI_THRESHOLD", "default": 0.6, "type": float, "help": "SCI threshold (0-1)."},
{"name": "SCI", "default": True, "type": bool, "help": "Calculate and mark channels bad based on their Scalp Coupling Index. This metric calculates the quality of the connection between the optode and the scalp."},
{"name": "SCI_TIME_WINDOW", "default": 3, "type": int, "help": "Independent SCI calculations will be perfomed in a time window for the duration of the value provided, until the end of the file is reached."},
{"name": "SCI_THRESHOLD", "default": 0.6, "type": float, "help": "SCI threshold on a scale of 0-1. A value of 0 is bad coupling while a value of 1 is perfect coupling. If SCI is True, any channels lower than this value will be marked as bad."},
]
},
{
"title": "Signal to Noise Ratio",
"params": [
{"name": "SNR", "default": True, "type": bool, "help": "Calculate Signal to Noise Ratio."},
{"name": "SNR", "default": True, "type": bool, "help": "Calculate and mark channels bad based on their Signal to Noise Ratio. This metric calculates how much of the observed signal was noise versus how much of it was a useful signal."},
# {"name": "SNR_TIME_WINDOW", "default": -1, "type": int, "help": "SNR time window."},
{"name": "SNR_THRESHOLD", "default": 5.0, "type": float, "help": "SNR threshold (dB)."},
{"name": "SNR_THRESHOLD", "default": 5.0, "type": float, "help": "SNR threshold (dB). A typical scale would be 0-25, but it is possible for values to be both above and below this range. Higher values correspond to a better signal. If SNR is True, any channels lower than this value will be marked as bad."},
]
},
{
"title": "Peak Spectral Power",
"params": [
{"name": "PSP", "default": True, "type": bool, "help": "Calculate Peak Spectral Power."},
{"name": "PSP_TIME_WINDOW", "default": 3, "type": int, "help": "PSP time window."},
{"name": "PSP_THRESHOLD", "default": 0.1, "type": float, "help": "PSP threshold."},
{"name": "PSP", "default": True, "type": bool, "help": "Calculate and mark channels bad based on their Peak Spectral Power. This metric calculates the amplitude or strength of a frequency component that is most prominent in a particular frequency range or spectrum."},
{"name": "PSP_TIME_WINDOW", "default": 3, "type": int, "help": "Independent PSP calculations will be perfomed in a time window for the duration of the value provided, until the end of the file is reached."},
{"name": "PSP_THRESHOLD", "default": 0.1, "type": float, "help": "PSP threshold. A typical scale would be 0-0.5, but it is possible for values to be above this range. Higher values correspond to a better signal. If PSP is True, any channels lower than this value will be marked as bad."},
]
},
{
@@ -104,7 +124,16 @@ SECTIONS = [
{
"title": "Temporal Derivative Distribution Repair filtering",
"params": [
{"name": "TDDR", "default": True, "type": bool, "help": "Apply TDDR filtering."},
{"name": "TDDR", "default": True, "type": bool, "help": "Apply Temporal Derivitave Distribution Repair filtering - a method that removes baseline shift and spike artifacts from the data."},
]
},
{
"title": "Wavelet filtering",
"params": [
{"name": "WAVELET", "default": True, "type": bool, "help": "Apply Wavelet filtering."},
{"name": "IQR", "default": 1.5, "type": float, "help": "Inter-Quartile Range."},
{"name": "WAVELET_TYPE", "default": "db4", "type": str, "help": "Wavelet type."},
{"name": "WAVELET_LEVEL", "default": 3, "type": int, "help": "Wavelet level."},
]
},
{
@@ -116,19 +145,23 @@ SECTIONS = [
{
"title": "Enhance Negative Correlation",
"params": [
#{"name": "ENHANCE_NEGATIVE_CORRELATION", "default": False, "type": bool, "help": "Calculate Peak Spectral Power."},
{"name": "ENHANCE_NEGATIVE_CORRELATION", "default": False, "type": bool, "help": "Apply Enhance Negative Correlation."},
]
},
{
"title": "Filtering",
"params": [
#{"name": "FILTER", "default": True, "type": bool, "help": "Calculate Peak Spectral Power."},
{"name": "FILTER", "default": True, "type": bool, "help": "Filter the data."},
{"name": "L_FREQ", "default": 0.005, "type": float, "help": "Any frequencies lower than this value will be removed."},
{"name": "H_FREQ", "default": 0.3, "type": float, "help": "Any frequencies higher than this value will be removed."},
]
},
{
"title": "Short Channels",
"title": "Short/Long Channels",
"params": [
{"name": "SHORT_CHANNEL", "default": True, "type": bool, "help": "Does the data have a short channel?"},
{"name": "SHORT_CHANNEL", "default": True, "type": bool, "help": "This should be set to True if the data has a short channel present in the data."},
{"name": "SHORT_CHANNEL_THRESH", "default": 0.015, "type": float, "help": "The maximum distance the short channel can be in metres."},
{"name": "LONG_CHANNEL_THRESH", "default": 0.045, "type": float, "help": "The maximum distance the long channel can be in metres."},
]
},
{
@@ -147,7 +180,7 @@ SECTIONS = [
"title": "Design Matrix",
"params": [
{"name": "REMOVE_EVENTS", "default": "None", "type": list, "help": "Remove events matching the names provided before generating the Design Matrix"},
# {"name": "DRIFT_MODEL", "default": "cosine", "type": str, "help": "Drift model for GLM."},
{"name": "DRIFT_MODEL", "default": "cosine", "type": str, "help": "Drift model for GLM."},
# {"name": "DURATION_BETWEEN_ACTIVITIES", "default": 35, "type": int, "help": "Time between activities (s)."},
# {"name": "SHORT_CHANNEL_REGRESSION", "default": True, "type": bool, "help": "Use short channel regression."},
]
@@ -169,7 +202,7 @@ SECTIONS = [
{
"title": "Other",
"params": [
{"name": "MAX_WORKERS", "default": 4, "type": int, "help": "Number of files to process at once."},
{"name": "MAX_WORKERS", "default": 4, "type": int, "help": "Number of files to be processed at once. Lowering this value may help on underpowered systems."},
]
},
]
@@ -445,13 +478,34 @@ class UserGuideWindow(QWidget):
def __init__(self, parent=None):
super().__init__(parent, Qt.WindowType.Window)
self.setWindowTitle("User Guide for FLARES")
self.setWindowTitle("User Guide - FLARES")
self.resize(250, 100)
layout = QVBoxLayout()
label = QLabel("No user guide available yet!", self)
label = QLabel("Progress Bar Stages:", self)
label2 = QLabel("Stage 1: Load the snirf file\n"
"Stage 2: Check the optode positions\n"
"Stage 3: Scalp Coupling Index\n"
"Stage 4: Signal to Noise Ratio\n"
"Stage 5: Peak Spectral Power\n"
"Stage 6: Identify bad channels\n"
"Stage 7: Interpolate bad channels\n"
"Stage 8: Optical Density\n"
"Stage 9: Temporal Derivative Distribution Repair\n"
"Stage 10: Beer Lambert Law\n"
"Stage 11: Heart Rate Filtering\n"
"Stage 12: Get Short/Long Channels\n"
"Stage 13: Calculate Events from Annotations\n"
"Stage 14: Epoch Calculations\n"
"Stage 15: Design Matrix\n"
"Stage 16: General Linear Model\n"
"Stage 17: Generate Plots from the GLM\n"
"Stage 18: Individual Significance\n"
"Stage 19: Channel, Region of Interest, and Contrast Results\n"
"Stage 20: Image Conversion\n", self)
layout.addWidget(label)
layout.addWidget(label2)
self.setLayout(layout)
@@ -582,7 +636,7 @@ class UpdateOptodesWindow(QWidget):
def show_help_popup(self, text):
msg = QMessageBox(self)
msg.setWindowTitle("Parameter Info")
msg.setWindowTitle("Parameter Info - FLARES")
msg.setText(text)
msg.exec()
@@ -863,7 +917,7 @@ class UpdateEventsWindow(QWidget):
def show_help_popup(self, text):
msg = QMessageBox(self)
msg.setWindowTitle("Parameter Info")
msg.setWindowTitle("Parameter Info - FLARES")
msg.setText(text)
msg.exec()
@@ -1154,6 +1208,7 @@ class ProgressBubble(QWidget):
"""
clicked = Signal(object)
rightClicked = Signal(object, QPoint)
def __init__(self, display_name, file_path):
super().__init__()
@@ -1174,7 +1229,7 @@ class ProgressBubble(QWidget):
self.progress_layout = QHBoxLayout()
self.rects = []
for _ in range(19):
for _ in range(25):
rect = QFrame()
rect.setFixedSize(10, 18)
rect.setStyleSheet("background-color: white; border: 1px solid gray;")
@@ -1212,7 +1267,10 @@ class ProgressBubble(QWidget):
rect.setStyleSheet("background-color: red; border: 1px solid gray;")
def mousePressEvent(self, event):
self.clicked.emit(self)
if event.button() == Qt.MouseButton.LeftButton:
self.clicked.emit(self)
elif event.button() == Qt.MouseButton.RightButton:
self.rightClicked.emit(self, event.globalPosition().toPoint())
super().mousePressEvent(event)
def setSuffixText(self, suffix):
@@ -1394,7 +1452,7 @@ class ParamSection(QWidget):
def show_help_popup(self, text):
msg = QMessageBox(self)
msg.setWindowTitle("Parameter Info")
msg.setWindowTitle("Parameter Info - FLARES")
msg.setText(text)
msg.exec()
@@ -1529,52 +1587,40 @@ class ParamSection(QWidget):
# You can customize how you display selected items here:
widget.lineEdit().setText(", ".join(selected))
def update_annotation_dropdown_from_folder(self, folder_path):
"""
Reads all EEG files in the given folder, extracts annotations using MNE,
and updates the dropdown for the `target_param` with the set of common annotations.
Args:
folder_param (str): The name of the parameter holding the folder path.
target_param (str): The name of the multi-select dropdown to update.
"""
# folder_path_widget = self.widgets.get(folder_param, {}).get("widget")
# if not folder_path_widget:
# print(f"[ParamSection] Folder path param '{folder_param}' not found.")
# return
# folder_path = folder_path_widget.text().strip()
# if not os.path.isdir(folder_path):
# print(f"[ParamSection] '{folder_path}' is not a valid directory.")
# return
def update_annotation_dropdown_from_loaded_files(self, bubble_widgets, button1):
annotation_sets = []
for filename in os.listdir(folder_path):
full_path = os.path.join(folder_path, filename)
print(f"[ParamSection] Number of loaded bubbles: {len(bubble_widgets)}")
for bubble in bubble_widgets.values():
file_path = bubble.file_path
print(f"[ParamSection] Trying file: {file_path}")
try:
raw = read_raw_snirf(full_path, preload=False, verbose="ERROR")
raw = read_raw_snirf(file_path, preload=False, verbose="ERROR")
annotations = raw.annotations
if annotations is not None:
print(f"[ParamSection] Found annotations with descriptions: {annotations.description}")
labels = set(annotations.description)
annotation_sets.append(labels)
except Exception as e:
print(f"[ParamSection] Skipping file '{filename}' due to error: {e}")
else:
print(f"[ParamSection] No annotations found in file: {file_path}")
except Exception:
raise
if not annotation_sets:
print(f"[ParamSection] No annotations found in folder '{folder_path}'")
print("[ParamSection] No annotations found in loaded files")
self.update_dropdown_items("REMOVE_EVENTS", [])
button1.setVisible(False)
return
# Get common annotations
common_annotations = set.intersection(*annotation_sets) if len(annotation_sets) > 1 else annotation_sets[0]
common_annotations = sorted(list(common_annotations)) # for consistent order
common_annotations = sorted(list(common_annotations))
print(f"[ParamSection] Common annotations: {common_annotations}")
# Update the dropdown
self.update_dropdown_items("REMOVE_EVENTS", common_annotations)
class FullClickLineEdit(QLineEdit):
def mousePressEvent(self, event):
combo = self.parent()
@@ -3870,6 +3916,11 @@ class MainApplication(QMainWindow):
self.section_widget = None
self.first_run = True
self.files_total = 0 # total number of files to process
self.files_done = set() # set of file paths done (success or fail)
self.files_failed = set() # set of failed file paths
self.files_results = {} # dict for successful results (if needed)
self.init_ui()
self.create_menu_bar()
@@ -3938,6 +3989,19 @@ class MainApplication(QMainWindow):
right_column_layout.addWidget(label)
right_column_layout.addWidget(field)
label_desc = QLabel('<a href="#">Why are these useful?</a>')
label_desc.setTextInteractionFlags(Qt.TextInteractionFlag.TextBrowserInteraction)
label_desc.setOpenExternalLinks(False)
def show_info_popup():
QMessageBox.information(None, "Parameter Info - FLARES",
"Age: Used to calculate the DPF factor.\nGender: Not currently used. "
"Will be able to sort into groups by gender in the near future.\nGroup: Allows contrast "
"images to be created comparing one group to another once the processing has completed.")
label_desc.linkActivated.connect(show_info_popup)
right_column_layout.addWidget(label_desc)
right_column_layout.addStretch() # Push fields to top
self.right_column_widget.hide()
@@ -4062,7 +4126,7 @@ class MainApplication(QMainWindow):
file_actions = [
("Open File...", "Ctrl+O", self.open_file_dialog, resource_path("icons/file_open_24dp_1F1F1F.svg")),
("Open Folder...", "Ctrl+Alt+O", self.open_folder_dialog, resource_path("icons/folder_24dp_1F1F1F.svg")),
("Open Folders...", "Ctrl+Shift+O", self.open_multiple_folders_dialog, resource_path("icons/folder_copy_24dp_1F1F1F.svg")),
# ("Open Folders...", "Ctrl+Shift+O", self.open_folder_dialog, resource_path("icons/folder_copy_24dp_1F1F1F.svg")),
("Load Project...", "Ctrl+L", self.load_project, resource_path("icons/article_24dp_1F1F1F.svg")),
("Save Project...", "Ctrl+S", self.save_project, resource_path("icons/save_24dp_1F1F1F.svg")),
("Save Project As...", "Ctrl+Shift+S", self.save_project, resource_path("icons/save_as_24dp_1F1F1F.svg")),
@@ -4070,7 +4134,7 @@ class MainApplication(QMainWindow):
for i, (name, shortcut, slot, icon) in enumerate(file_actions):
file_menu.addAction(make_action(name, shortcut, slot, icon=icon))
if i == 2: # after the first 3 actions (0,1,2)
if i == 1: # after the first 3 actions (0,1,2)
file_menu.addSeparator()
file_menu.addSeparator()
@@ -4216,26 +4280,59 @@ class MainApplication(QMainWindow):
def open_file_dialog(self):
file_path, _ = QFileDialog.getOpenFileName(
self, "Open File", "", "All Files (*);;Text Files (*.txt)"
self, "Open File", "", "SNIRF Files (*.snirf);;All Files (*)"
)
if file_path:
self.selected_path = file_path # store the file path
self.show_files_as_bubbles(file_path)
# Create and display the bubble directly for this file
display_name = os.path.basename(file_path)
bubble = ProgressBubble(display_name, file_path)
bubble.clicked.connect(self.on_bubble_clicked)
bubble.rightClicked.connect(self.on_bubble_right_clicked)
if not hasattr(self, 'bubble_widgets'):
self.bubble_widgets = {}
if not hasattr(self, 'selected_paths'):
self.selected_paths = []
file_path = os.path.normpath(file_path)
self.bubble_widgets[file_path] = bubble
self.selected_paths.append(file_path)
self.bubble_layout.addWidget(bubble)
for section_widget in self.param_sections:
if hasattr(section_widget, 'update_annotation_dropdown_from_loaded_files'):
if "REMOVE_EVENTS" in section_widget.widgets:
section_widget.update_annotation_dropdown_from_loaded_files(self.bubble_widgets, self.button1)
break
else:
print("[MainWindow] Could not find ParamSection with 'REMOVE_EVENTS' widget")
self.statusBar().showMessage(f"{file_path} loaded.")
self.button1.setVisible(True)
def open_folder_dialog(self):
folder_path = QFileDialog.getExistingDirectory(
self, "Select Folder", ""
)
folder_path = QFileDialog.getExistingDirectory(self, "Select Folder", "")
if folder_path:
self.selected_path = folder_path # store the folder path
snirf_files = [str(f) for f in Path(folder_path).glob("*.snirf")]
if not hasattr(self, 'selected_paths'):
self.selected_paths = []
for file_path in snirf_files:
if file_path not in self.selected_paths:
self.selected_paths.append(file_path)
self.show_files_as_bubbles(folder_path)
for section_widget in self.param_sections:
if "REMOVE_EVENTS" in section_widget.widgets:
section_widget.update_annotation_dropdown_from_folder(folder_path)
break
if hasattr(section_widget, 'update_annotation_dropdown_from_loaded_files'):
if "REMOVE_EVENTS" in section_widget.widgets:
section_widget.update_annotation_dropdown_from_loaded_files(self.bubble_widgets, self.button1)
break
else:
print("[MainWindow] Could not find ParamSection with 'REMOVE_EVENTS' widget")
@@ -4244,16 +4341,29 @@ class MainApplication(QMainWindow):
def open_multiple_folders_dialog(self):
while True:
folder = QFileDialog.getExistingDirectory(self, "Select Folder")
if not folder:
folder_path = QFileDialog.getExistingDirectory(self, "Select Folder")
if not folder_path:
break
snirf_files = [str(f) for f in Path(folder_path).glob("*.snirf")]
if not hasattr(self, 'selected_paths'):
self.selected_paths = []
if folder not in self.selected_paths:
self.selected_paths.append(folder)
self.show_files_as_bubbles(self.selected_paths)
for file_path in snirf_files:
if file_path not in self.selected_paths:
self.selected_paths.append(file_path)
self.show_files_as_bubbles(folder_path)
for section_widget in self.param_sections:
if hasattr(section_widget, 'update_annotation_dropdown_from_loaded_files'):
if "REMOVE_EVENTS" in section_widget.widgets:
section_widget.update_annotation_dropdown_from_loaded_files(self.bubble_widgets, self.button1)
break
else:
print("[MainWindow] Could not find ParamSection with 'REMOVE_EVENTS' widget")
# Ask if the user wants to add another
more = QMessageBox.question(
@@ -4267,21 +4377,42 @@ class MainApplication(QMainWindow):
self.button1.setVisible(True)
def save_project(self):
filename, _ = QFileDialog.getSaveFileName(
self, "Save Project", "", "FLARE Project (*.flare)"
)
if not filename:
return
def save_project(self, onCrash=False):
if not onCrash:
filename, _ = QFileDialog.getSaveFileName(
self, "Save Project", "", "FLARE Project (*.flare)"
)
if not filename:
return
else:
if PLATFORM_NAME == "darwin":
filename = os.path.join(os.path.dirname(sys.executable), "../../../flares_autosave.flare")
else:
filename = os.path.join(os.getcwd(), "flares_autosave.flare")
try:
project_data = {
"file_list": [bubble.file_path for bubble in self.bubble_widgets.values()],
"progress_states": {
bubble.file_path: bubble.current_step for bubble in self.bubble_widgets.values()
},
# Ensure the filename has the proper extension
if not filename.endswith(".flare"):
filename += ".flare"
project_path = Path(filename).resolve()
project_dir = project_path.parent
file_list = [
str(PurePosixPath(Path(bubble.file_path).resolve().relative_to(project_dir)))
for bubble in self.bubble_widgets.values()
]
progress_states = {
str(PurePosixPath(Path(bubble.file_path).resolve().relative_to(project_dir))): bubble.current_step
for bubble in self.bubble_widgets.values()
}
project_data = {
"file_list": file_list,
"progress_states": progress_states,
"raw_haemo_dict": self.raw_haemo_dict,
"epochs_dict": self.epochs_dict,
"fig_bytes_dict": self.fig_bytes_dict,
@@ -4295,13 +4426,25 @@ class MainApplication(QMainWindow):
"valid_dict": self.valid_dict,
}
def sanitize(obj):
if isinstance(obj, Path):
return str(PurePosixPath(obj))
elif isinstance(obj, dict):
return {sanitize(k): sanitize(v) for k, v in obj.items()}
elif isinstance(obj, list):
return [sanitize(i) for i in obj]
return obj
project_data = sanitize(project_data)
with open(filename, "wb") as f:
pickle.dump(project_data, f)
QMessageBox.information(self, "Success", f"Project saved to:\n{filename}")
except Exception as e:
QMessageBox.critical(self, "Error", f"Failed to save project:\n{e}")
if not onCrash:
QMessageBox.critical(self, "Error", f"Failed to save project:\n{e}")
@@ -4328,8 +4471,19 @@ class MainApplication(QMainWindow):
self.group_dict = data.get("group_dict", {})
self.valid_dict = data.get("valid_dict", {})
# Restore bubbles and progress
self.show_files_as_bubbles_from_list(data["file_list"], data.get("progress_states", {}), filename)
project_dir = Path(filename).parent
# Convert saved relative paths to absolute paths
file_list = [str((project_dir / Path(rel_path)).resolve()) for rel_path in data["file_list"]]
# Also resolve progress_states with updated paths
raw_progress = data.get("progress_states", {})
progress_states = {
str((project_dir / Path(rel_path)).resolve()): step
for rel_path, step in raw_progress.items()
}
self.show_files_as_bubbles_from_list(file_list, progress_states, filename)
# Re-enable buttons
# self.button1.setVisible(True)
@@ -4348,11 +4502,11 @@ class MainApplication(QMainWindow):
folder_paths = [folder_paths]
# Clear previous bubbles
while self.bubble_layout.count():
item = self.bubble_layout.takeAt(0)
widget = item.widget()
if widget:
widget.deleteLater()
# while self.bubble_layout.count():
# item = self.bubble_layout.takeAt(0)
# widget = item.widget()
# if widget:
# widget.deleteLater()
temp_bubble = ProgressBubble("Test Bubble", "") # A dummy bubble for measurement
temp_bubble.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy. Preferred)
@@ -4364,21 +4518,26 @@ class MainApplication(QMainWindow):
cols = max(1, available_width // bubble_width) # Ensure at least 1 column
index = 0
if not hasattr(self, 'selected_paths'):
self.selected_paths = []
for folder_path in folder_paths:
if not os.path.isdir(folder_path):
continue
files = os.listdir(folder_path)
files = [f for f in files if os.path.isfile(os.path.join(folder_path, f))]
snirf_files = [str(f) for f in Path(folder_path).glob("*.snirf")]
for filename in files:
full_path = os.path.join(folder_path, filename)
display_name = f"{os.path.basename(folder_path)} / {filename}"
for full_path in snirf_files:
display_name = f"{os.path.basename(folder_path)} / {os.path.basename(full_path)}"
bubble = ProgressBubble(display_name, full_path)
bubble.clicked.connect(self.on_bubble_clicked)
self.bubble_widgets[filename] = bubble
bubble.rightClicked.connect(self.on_bubble_right_clicked)
self.bubble_widgets[full_path] = bubble
if full_path not in self.selected_paths:
self.selected_paths.append(full_path)
row = index // cols
col = index % cols
@@ -4418,6 +4577,7 @@ class MainApplication(QMainWindow):
# Create bubble with full path
bubble = ProgressBubble(display_name, file_path)
bubble.clicked.connect(self.on_bubble_clicked)
bubble.rightClicked.connect(self.on_bubble_right_clicked)
self.bubble_widgets[file_path] = bubble
step = progress_states.get(file_path, 0)
@@ -4506,6 +4666,77 @@ class MainApplication(QMainWindow):
field.blockSignals(False)
def on_bubble_right_clicked(self, bubble, global_pos):
menu = QMenu(self)
action1 = menu.addAction(QIcon(resource_path("icons/folder_eye_24dp_1F1F1F.svg")), "Reveal")
action2 = menu.addAction(QIcon(resource_path("icons/remove_24dp_1F1F1F.svg")), "Remove")
action = menu.exec(global_pos)
if action == action1:
path = bubble.file_path
if os.path.exists(path):
if PLATFORM_NAME == "windows":
subprocess.run(["explorer", "/select,", os.path.normpath(path)])
elif PLATFORM_NAME == "darwin": # macOS
subprocess.run(["open", "-R", path])
else: # Linux
folder = os.path.dirname(path)
subprocess.run(["xdg-open", folder])
else:
print("File not found:", path)
elif action == action2:
if self.button3.isVisible():
reply = QMessageBox.warning(
self,
"Confirm Remove",
"Are you sure you want to remove this file? This will remove the analysis option and the processing will have to be performed again.",
QMessageBox.StandardButton.Ok | QMessageBox.StandardButton.Cancel
)
if reply != QMessageBox.StandardButton.Ok:
return
else:
self.button3.setVisible(False)
self.top_left_widget.clear()
self.right_column_widget.hide()
parent_layout = bubble.parent().layout()
if parent_layout is not None:
parent_layout.removeWidget(bubble)
key_to_delete = None
for path, b in self.bubble_widgets.items():
if b is bubble:
key_to_delete = path
break
if key_to_delete:
del self.bubble_widgets[key_to_delete]
# Remove from selected_paths
if hasattr(self, 'selected_paths'):
try:
self.selected_paths.remove(bubble.file_path)
except ValueError:
pass
# Remove from selected_path (if used)
if hasattr(self, 'selected_path') and self.selected_path == bubble.file_path:
self.selected_path = None
for section_widget in self.param_sections:
if hasattr(section_widget, 'update_annotation_dropdown_from_loaded_files'):
if "REMOVE_EVENTS" in section_widget.widgets:
section_widget.update_annotation_dropdown_from_loaded_files(self.bubble_widgets, self.button1)
break
bubble.setParent(None)
bubble.deleteLater()
if getattr(self, 'last_clicked_bubble', None) is bubble:
self.last_clicked_bubble = None
def eventFilter(self, watched, event):
if event.type() == QEvent.Type.MouseButtonPress:
widget = self.childAt(event.pos())
@@ -4590,7 +4821,8 @@ class MainApplication(QMainWindow):
if not self.first_run:
for bubble in self.bubble_widgets.values():
bubble.mark_cancelled()
pass
# bubble.mark_cancelled()
self.first_run = False
# Collect all selected snirf files in a flat list
@@ -4617,6 +4849,40 @@ class MainApplication(QMainWindow):
if not snirf_files:
raise ValueError("No .snirf files found in selection")
# validate
for i in snirf_files:
x_coords = set()
y_coords = set()
z_coords = set()
raw = read_raw_snirf(i)
dig = raw.info.get('dig', None)
if dig is not None:
for point in dig:
if point['kind'] == 3:
coord = point['r']
x_coords.add(coord[0])
y_coords.add(coord[1])
z_coords.add(coord[2])
print(f"Coord: {coord}")
is_2d = (
all(abs(x) < 1e-6 for x in x_coords) or
all(abs(y) < 1e-6 for y in y_coords) or
all(abs(z) < 1e-6 for z in z_coords)
)
if is_2d:
QMessageBox.critical(None, "Error - 2D Data Detected - FLARES", f"Error: 2 dimensional data was found in {i}. "
"It is not possible to process this file. Please update the coordinates "
"using the 'Update optodes in snirf file...' option from the Options menu or by pressing 'F6'.")
self.button1.clicked.disconnect(self.cancel_task)
self.button1.setText("Process")
self.button1.clicked.connect(self.on_run_task)
return
self.files_total = len(snirf_files)
self.files_done = set()
self.files_failed = set()
self.files_results = {}
all_params = {}
for section_widget in self.param_sections:
@@ -4659,8 +4925,13 @@ class MainApplication(QMainWindow):
if isinstance(msg, dict):
if msg.get("success"):
results = msg["result"] # from flares.py
for file_path, result_tuple in results.items():
self.files_done.add(file_path)
self.files_results[file_path] = result_tuple
# Initialize storage
# TODO: Is this check needed?
if not hasattr(self, 'raw_haemo_dict'):
@@ -4691,24 +4962,82 @@ class MainApplication(QMainWindow):
self.valid_dict[file_path] = valid
# self.statusbar.showMessage(f"Processing complete! Time elapsed: {elapsed_time:.2f} seconds")
self.statusbar.showMessage(f"Processing complete!")
# self.statusbar.showMessage(f"Processing complete!")
self.button3.setVisible(True)
else:
elif msg.get("success") is False:
error_msg = msg.get("error", "Unknown error")
print("Error during processing:", error_msg)
self.statusbar.showMessage(f"Processing failed! {error_msg}")
traceback_str = msg.get("traceback", "")
self.show_error_popup("Processing failed!", error_msg, traceback_str)
self.files_done = set(self.files_results.keys())
self.statusbar.showMessage(f"Processing failed!")
self.result_timer.stop()
self.cleanup_after_process()
return
self.result_timer.stop()
elif msg.get("type") == "error":
# Error forwarded from a single file (e.g. from a worker)
file_path = msg.get("file", "Unknown file")
error_msg = msg.get("error", "Unknown error")
traceback_str = msg.get("traceback", "")
self.files_done.add(file_path)
self.files_failed.add(file_path)
self.show_error_popup(f"{file_path}", error_msg, traceback_str)
self.statusbar.showMessage(f"Error processing {file_path}")
if file_path in self.bubble_widgets:
self.bubble_widgets[file_path].mark_cancelled()
self.cleanup_after_process()
return
elif isinstance(msg, tuple) and msg[0] == 'progress':
_, file_path, step_index = msg
file_name = os.path.basename(file_path) # extract file name
self.progress_update_signal.emit(file_name, step_index)
self.progress_update_signal.emit(file_path, step_index)
if len(self.files_done) == self.files_total:
self.result_timer.stop()
self.cleanup_after_process()
success_count = len(self.files_results)
fail_count = len(self.files_failed)
summary_msg = f"Processing complete: {success_count} succeeded, {fail_count} failed."
self.statusbar.showMessage(summary_msg)
if success_count > 0:
self.button3.setVisible(True)
self.button1.clicked.disconnect(self.cancel_task)
self.button1.setText("Process")
self.button1.clicked.connect(self.on_run_task)
def show_error_popup(self, title, error_message, traceback_str=""):
msgbox = QMessageBox(self)
msgbox.setIcon(QMessageBox.Warning)
msgbox.setWindowTitle("Warning - FLARES")
message = (
f"FLARES has encountered an error processing the file {title}.<br><br>"
"This error was likely due to incorrect parameters on the right side of the screen and not an error with your data. "
"Processing of the remaining files continues in the background and this participant will be ignored in the analysis. "
"If you think the parameters on the right side are correct for your data, raise an issue <a href='https://git.research.dezeeuw.ca/tyler/flares/issues'>here</a>.<br><br>"
f"Error message: {error_message}"
)
msgbox.setTextFormat(Qt.TextFormat.RichText)
msgbox.setText(message)
msgbox.setTextInteractionFlags(Qt.TextInteractionFlag.TextBrowserInteraction)
# Add traceback to detailed text
if traceback_str:
msgbox.setDetailedText(traceback_str)
msgbox.setStandardButtons(QMessageBox.Ok)
msgbox.exec_()
def cleanup_after_process(self):
@@ -4738,8 +5067,9 @@ class MainApplication(QMainWindow):
self.manager.shutdown()
def update_file_progress(self, filename, step_index):
bubble = self.bubble_widgets.get(filename)
def update_file_progress(self, file_path, step_index):
key = os.path.normpath(file_path)
bubble = self.bubble_widgets.get(key)
if bubble:
bubble.update_progress(step_index)
@@ -4895,6 +5225,14 @@ class MainApplication(QMainWindow):
# Measurement date
snirf_info['Measurement Date'] = str(raw.info.get('meas_date'))
try:
short_chans = get_short_channels(raw, max_dist=0.015)
snirf_info['Short Channels'] = f"Likely - {short_chans.ch_names}"
if len(short_chans.ch_names) > 6:
snirf_info['Short Channels'] += "\n There are a lot of short channels. Optode distances are likely incorrect!"
except:
snirf_info['Short Channels'] = "Unlikely"
# Source-detector distances
distances = source_detector_distances(raw.info)
distance_info = []
@@ -5169,11 +5507,50 @@ def exception_hook(exc_type, exc_value, exc_traceback):
if app is None:
app = QApplication(sys.argv)
QMessageBox.critical(None, "Unexpected Error", f"An unhandled exception occurred:\n\n{error_msg}")
show_critical_error(error_msg)
# Exit the app after user acknowledges
sys.exit(1)
def show_critical_error(error_msg):
msg_box = QMessageBox()
msg_box.setIcon(QMessageBox.Icon.Critical)
msg_box.setWindowTitle("Something went wrong!")
if PLATFORM_NAME == "darwin":
log_path = os.path.join(os.path.dirname(sys.executable), "../../../flares.log")
log_path2 = os.path.join(os.path.dirname(sys.executable), "../../../flares_error.log")
save_path = os.path.join(os.path.dirname(sys.executable), "../../../flares_autosave.flare")
else:
log_path = os.path.join(os.getcwd(), "flares.log")
log_path2 = os.path.join(os.getcwd(), "flares_error.log")
save_path = os.path.join(os.getcwd(), "flares_autosave.flare")
shutil.copy(log_path, log_path2)
log_path2 = Path(log_path2).absolute().as_posix()
autosave_path = Path(save_path).absolute().as_posix()
log_link = f"file:///{log_path2}"
autosave_link = f"file:///{autosave_path}"
window.save_project(True)
message = (
"FLARES has encountered an unrecoverable error and needs to close.<br><br>"
f"We are sorry for the inconvenience. An autosave was attempted to be saved to <a href='{autosave_link}'>{autosave_path}</a>, but it may not have been saved. "
"If the file was saved, it still may not be intact, openable, or contain the correct data. Use the autosave at your discretion.<br><br>"
"This unrecoverable error was likely due to an error with FLARES and not your data.<br>"
f"Please raise an issue <a href='https://git.research.dezeeuw.ca/tyler/flares/issues'>here</a> and attach the error file located at <a href='{log_link}'>{log_path2}</a><br><br>"
f"<pre>{error_msg}</pre>"
)
msg_box.setTextFormat(Qt.TextFormat.RichText)
msg_box.setText(message)
msg_box.setTextInteractionFlags(Qt.TextInteractionFlag.TextBrowserInteraction)
msg_box.setStandardButtons(QMessageBox.StandardButton.Ok)
msg_box.exec()
if __name__ == "__main__":
# Redirect exceptions to the popup window