diff --git a/changelog.md b/changelog.md
index 32b2ea3..11b3d4e 100644
--- a/changelog.md
+++ b/changelog.md
@@ -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
diff --git a/flares.py b/flares.py
index 21e4733..6e302ed 100644
--- a/flares.py
+++ b/flares.py
@@ -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}")
diff --git a/icons/folder_eye_24dp_1F1F1F.svg b/icons/folder_eye_24dp_1F1F1F.svg
new file mode 100644
index 0000000..aa5ccc6
--- /dev/null
+++ b/icons/folder_eye_24dp_1F1F1F.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/icons/remove_24dp_1F1F1F.svg b/icons/remove_24dp_1F1F1F.svg
new file mode 100644
index 0000000..ed87e30
--- /dev/null
+++ b/icons/remove_24dp_1F1F1F.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/main.py b/main.py
index 2b09069..ce60d82 100644
--- a/main.py
+++ b/main.py
@@ -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('Why are these useful?')
+ 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}.
"
+ "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 here.
"
+ 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.
"
+ f"We are sorry for the inconvenience. An autosave was attempted to be saved to {autosave_path}, 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.
"
+ "This unrecoverable error was likely due to an error with FLARES and not your data.
"
+ f"Please raise an issue here and attach the error file located at {log_path2}
"
+ f"
{error_msg}"
+ )
+
+ 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