improvements

This commit is contained in:
2025-10-20 09:33:50 -07:00
parent b5afcec37d
commit 20b255321b
5 changed files with 476 additions and 130 deletions

View File

@@ -1,3 +1,21 @@
# Next Release
- 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
# Version 1.1.4
- Fixed some display text to now display the correct information

View File

@@ -268,40 +268,42 @@ def set_metadata(file_path, metadata: dict[str, Any]) -> None:
val = file_metadata.get(key, None)
if val not in (None, '', [], {}, ()): # check for "empty" values
globals()[key] = val
from queue import Empty # This works with multiprocessing.Manager().Queue()
def gui_entry(config: dict[str, Any], gui_queue: Queue, progress_queue: Queue) -> None:
try:
# Start a thread to forward progress messages back to GUI
def forward_progress():
while True:
try:
msg = progress_queue.get(timeout=1)
if msg == "__done__":
break
gui_queue.put(msg)
except:
continue
def forward_progress():
while True:
try:
msg = progress_queue.get(timeout=1)
if msg == "__done__":
break
gui_queue.put(msg)
except Empty:
continue
except Exception as e:
gui_queue.put({
"type": "error",
"error": f"Forwarding thread crashed: {e}",
"traceback": traceback.format_exc()
})
break
t = threading.Thread(target=forward_progress, daemon=True)
t.start()
t = threading.Thread(target=forward_progress, daemon=True)
t.start()
try:
file_paths = config['SNIRF_FILES']
file_params = config['PARAMS']
file_metadata = config['METADATA']
max_workers = file_params.get("MAX_WORKERS", int(os.cpu_count()/4))
# Run the actual processing, with progress_queue passed down
print("actual call")
results = process_multiple_participants(file_paths, file_params, file_metadata, progress_queue, max_workers)
results = process_multiple_participants(
file_paths, file_params, file_metadata, progress_queue, max_workers
)
# Signal end of progress
progress_queue.put("__done__")
t.join()
gui_queue.put({"success": True, "result": results})
except Exception as e:
gui_queue.put({
"success": False,
@@ -309,6 +311,14 @@ def gui_entry(config: dict[str, Any], gui_queue: Queue, progress_queue: Queue) -
"traceback": traceback.format_exc()
})
finally:
# Always send done to the thread and avoid hanging
try:
progress_queue.put("__done__")
except:
pass
t.join(timeout=5) # prevent permanent hang
def process_participant_worker(args):
@@ -343,9 +353,16 @@ def process_multiple_participants(file_paths, file_params, file_metadata, progre
try:
file_path, result, error = future.result()
if error:
print(f"Error processing {file_path}: {error[0]}")
print(error[1])
error_message, error_traceback = error
if progress_queue:
progress_queue.put({
"type": "error",
"file": file_path,
"error": error_message,
"traceback": error_traceback
})
continue
results_by_file[file_path] = result
except Exception as e:
print(f"Unexpected error processing {file_path}: {e}")

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

521
main.py
View File

@@ -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,34 @@ 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": "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 +105,7 @@ 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."},
]
},
{
@@ -128,7 +129,7 @@ SECTIONS = [
{
"title": "Short 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."},
]
},
{
@@ -169,7 +170,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 +446,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 +604,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 +885,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,7 +1176,8 @@ class ProgressBubble(QWidget):
"""
clicked = Signal(object)
rightClicked = Signal(object, QPoint)
def __init__(self, display_name, file_path):
super().__init__()
@@ -1174,7 +1197,7 @@ class ProgressBubble(QWidget):
self.progress_layout = QHBoxLayout()
self.rects = []
for _ in range(19):
for _ in range(20):
rect = QFrame()
rect.setFixedSize(10, 18)
rect.setStyleSheet("background-color: white; border: 1px solid gray;")
@@ -1212,7 +1235,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 +1420,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 +1555,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()
@@ -3869,6 +3883,11 @@ class MainApplication(QMainWindow):
self.folder_paths = []
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 +3957,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()
@@ -4012,7 +4044,7 @@ class MainApplication(QMainWindow):
self.button2.setMinimumSize(100, 40)
self.button3.setMinimumSize(100, 40)
self.button1.setVisible(False)
# self.button1.setVisible(False)
self.button3.setVisible(False)
self.button1.clicked.connect(self.on_run_task)
@@ -4062,7 +4094,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 +4102,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 +4248,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 +4309,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)
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(self.selected_paths)
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,14 +4345,21 @@ 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:
# Ensure the filename has the proper extension
if not filename.endswith(".flare"):
@@ -4326,7 +4411,8 @@ class MainApplication(QMainWindow):
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}")
@@ -4384,11 +4470,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)
@@ -4400,21 +4486,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))]
for filename in files:
full_path = os.path.join(folder_path, filename)
display_name = f"{os.path.basename(folder_path)} / {filename}"
snirf_files = [str(f) for f in Path(folder_path).glob("*.snirf")]
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
@@ -4454,6 +4545,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)
@@ -4542,6 +4634,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())
@@ -4623,10 +4786,11 @@ class MainApplication(QMainWindow):
self.button1.clicked.disconnect(self.on_run_task)
self.button1.setText("Cancel")
self.button1.clicked.connect(self.cancel_task)
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
@@ -4653,7 +4817,41 @@ 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:
section_params = section_widget.get_param_values()
@@ -4695,7 +4893,12 @@ 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?
@@ -4727,26 +4930,84 @@ 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):
if hasattr(self, 'result_process'):
@@ -4774,8 +5035,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)
@@ -4931,6 +5193,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 = []
@@ -5205,11 +5475,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