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

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