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