5 Commits

Author SHA1 Message Date
45c6176dba quick bug fixes 2025-10-21 18:05:30 -07:00
a4bbdb90c8 update to changelog for build 1.1.5 2025-10-20 16:08:34 -07:00
953ea90c67 fix to bandpass filter 2025-10-20 16:07:18 -07:00
20b255321b improvements 2025-10-20 09:33:50 -07:00
b5afcec37d fixes to cross platform saves 2025-10-15 17:09:51 -07:00
5 changed files with 580 additions and 159 deletions

View File

@@ -1,3 +1,29 @@
# Version 1.1.6
- Fixed Process button from appearing when no files are selected
- Fix for instand child process crash on Windows
- Added L_FREQ and H_FREQ parameters for more user control over low and high pass filtering
# Version 1.1.5
- Fixed Windows saves not being able to be opened by a Mac (hopefully the other way too!)
- Added the option to right click loaded snirf files to reveal them in a file browser or delete them if they are no longer desired
- Changed the way folders are opened to store the files seperately rather than the folder as a whole to allow for the removal of files
- Fixed issues with dropdowns and bubbles not populating correctly when opening a single file and temporarily removed the option to open multiple folders
- Improved crash handling and the message that is displayed to the user if the application crashes
- Progress bar will now colour the stage that fails as red if a file fails during processing
- A warning message will be displayed when a file fails to process with information on what went wrong. This message does not halt the rest of the processing of the other files
- Fixed the number of rectangles in the progress bar to 20 (was incorrect in v1.1.1)
- Added validation to ensure loaded files do not have 2 dimensional data when clicking process to prevent inaccurate results from being generated
- Added more metadata information to the top left information panel
- Changed the Status Bar message when processing is complete to state how many were successful and how many were not
- Added a clickable link below the selected file's metadata explaining the independent parameters and why they are useful
- Updated some tooltips to provide better, more accurate information
- Added details about the processing steps and their order into the user guide
- Changed the default bandpass filtering parameters
# Version 1.1.4 # Version 1.1.4
- Fixed some display text to now display the correct information - Fixed some display text to now display the correct information

137
flares.py
View File

@@ -50,8 +50,8 @@ from scipy.spatial.distance import cdist
# Backen visualization needed to be defined for pyinstaller # Backen visualization needed to be defined for pyinstaller
import pyvistaqt # type: ignore import pyvistaqt # type: ignore
# import vtkmodules.util.data_model import vtkmodules.util.data_model
# import vtkmodules.util.execution_model import vtkmodules.util.execution_model
# External library imports for mne # External library imports for mne
from mne import ( from mne import (
@@ -130,6 +130,9 @@ TDDR: bool
ENHANCE_NEGATIVE_CORRELATION: bool ENHANCE_NEGATIVE_CORRELATION: bool
L_FREQ: float
H_FREQ: float
SHORT_CHANNEL: bool SHORT_CHANNEL: bool
REMOVE_EVENTS: list REMOVE_EVENTS: list
@@ -186,7 +189,9 @@ REQUIRED_KEYS: dict[str, Any] = {
"SHORT_CHANNEL": bool, "SHORT_CHANNEL": bool,
"REMOVE_EVENTS": list, "REMOVE_EVENTS": list,
"TIME_WINDOW_START": int, "TIME_WINDOW_START": int,
"TIME_WINDOW_END": int "TIME_WINDOW_END": int,
"L_FREQ": float,
"H_FREQ": float,
# "REJECT_PAIRS": bool, # "REJECT_PAIRS": bool,
# "FORCE_DROP_ANNOTATIONS": list, # "FORCE_DROP_ANNOTATIONS": list,
# "FILTER_LOW_PASS": float, # "FILTER_LOW_PASS": float,
@@ -268,40 +273,42 @@ def set_metadata(file_path, metadata: dict[str, Any]) -> None:
val = file_metadata.get(key, None) val = file_metadata.get(key, None)
if val not in (None, '', [], {}, ()): # check for "empty" values if val not in (None, '', [], {}, ()): # check for "empty" values
globals()[key] = val 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: def gui_entry(config: dict[str, Any], gui_queue: Queue, progress_queue: Queue) -> None:
try: def forward_progress():
# Start a thread to forward progress messages back to GUI while True:
def forward_progress(): try:
while True: msg = progress_queue.get(timeout=1)
try: if msg == "__done__":
msg = progress_queue.get(timeout=1) break
if msg == "__done__": gui_queue.put(msg)
break except Empty:
gui_queue.put(msg) continue
except: except Exception as e:
continue 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 = threading.Thread(target=forward_progress, daemon=True)
t.start() t.start()
try:
file_paths = config['SNIRF_FILES'] file_paths = config['SNIRF_FILES']
file_params = config['PARAMS'] file_params = config['PARAMS']
file_metadata = config['METADATA'] file_metadata = config['METADATA']
max_workers = file_params.get("MAX_WORKERS", int(os.cpu_count()/4)) max_workers = file_params.get("MAX_WORKERS", int(os.cpu_count()/4))
# Run the actual processing, with progress_queue passed down results = process_multiple_participants(
print("actual call") 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}) gui_queue.put({"success": True, "result": results})
except Exception as e: except Exception as e:
gui_queue.put({ gui_queue.put({
"success": False, "success": False,
@@ -309,6 +316,14 @@ def gui_entry(config: dict[str, Any], gui_queue: Queue, progress_queue: Queue) -
"traceback": traceback.format_exc() "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): def process_participant_worker(args):
@@ -343,9 +358,16 @@ def process_multiple_participants(file_paths, file_params, file_metadata, progre
try: try:
file_path, result, error = future.result() file_path, result, error = future.result()
if error: if error:
print(f"Error processing {file_path}: {error[0]}") error_message, error_traceback = error
print(error[1]) if progress_queue:
progress_queue.put({
"type": "error",
"file": file_path,
"error": error_message,
"traceback": error_traceback
})
continue continue
results_by_file[file_path] = result results_by_file[file_path] = result
except Exception as e: except Exception as e:
print(f"Unexpected error processing {file_path}: {e}") print(f"Unexpected error processing {file_path}: {e}")
@@ -1052,8 +1074,17 @@ def filter_the_data(raw_haemo):
fig_filter = raw_haemo.compute_psd(fmax=2).plot( fig_filter = raw_haemo.compute_psd(fmax=2).plot(
average=True, xscale="log", color="r", show=False, amplitude=False average=True, xscale="log", color="r", show=False, amplitude=False
) )
raw_haemo = raw_haemo.filter(l_freq=None, h_freq=0.4, h_trans_bandwidth=0.2) if L_FREQ == 0 and H_FREQ != 0:
raw_haemo = raw_haemo.filter(l_freq=None, h_freq=H_FREQ, h_trans_bandwidth=0.02)
elif L_FREQ != 0 and H_FREQ == 0:
raw_haemo = raw_haemo.filter(l_freq=L_FREQ, h_freq=None, l_trans_bandwidth=0.002)
elif L_FREQ != 0 and H_FREQ == 0:
raw_haemo = raw_haemo.filter(l_freq=L_FREQ, h_freq=H_FREQ, l_trans_bandwidth=0.002, h_trans_bandwidth=0.02)
#raw_haemo = raw_haemo.filter(l_freq=None, h_freq=0.4, h_trans_bandwidth=0.2)
#raw_haemo = raw_haemo.filter(l_freq=None, h_freq=0.7, h_trans_bandwidth=0.2)
#raw_haemo = raw_haemo.filter(0.005, 0.7, h_trans_bandwidth=0.02, l_trans_bandwidth=0.002)
raw_haemo.compute_psd(fmax=2).plot( raw_haemo.compute_psd(fmax=2).plot(
average=True, xscale="log", axes=fig_filter.axes, color="g", amplitude=False, show=False average=True, xscale="log", axes=fig_filter.axes, color="g", amplitude=False, show=False
@@ -1499,7 +1530,6 @@ def fold_channels(raw: BaseRaw) -> None:
# Format the output to make it slightly easier to read # Format the output to make it slightly easier to read
if True: if True:
num_channels = len(hbo_channel_names) num_channels = len(hbo_channel_names)
rows, cols = 4, 7 # 6 rows and 4 columns of pie charts rows, cols = 4, 7 # 6 rows and 4 columns of pie charts
fig, axes = plt.subplots(rows, cols, figsize=(16, 10), constrained_layout=True) fig, axes = plt.subplots(rows, cols, figsize=(16, 10), constrained_layout=True)
@@ -2259,21 +2289,25 @@ def brain_landmarks_3d(raw_haemo: BaseRaw, show_optodes: Literal['sensors', 'lab
if show_brodmann:# Add Brodmann labels if show_brodmann:# Add Brodmann labels
labels = cast(list[Label], read_labels_from_annot("fsaverage", "PALS_B12_Brodmann", "rh", verbose=False)) # type: ignore labels = cast(list[Label], read_labels_from_annot("fsaverage", "PALS_B12_Brodmann", "lh", verbose=False)) # type: ignore
label_colors = { label_colors = {
"Brodmann.39-rh": "blue", "Brodmann.1-lh": "red",
"Brodmann.40-rh": "green", "Brodmann.2-lh": "red",
"Brodmann.6-rh": "pink", "Brodmann.3-lh": "red",
"Brodmann.7-rh": "orange", "Brodmann.4-lh": "orange",
"Brodmann.17-rh": "red", "Brodmann.5-lh": "green",
"Brodmann.1-rh": "yellow", "Brodmann.6-lh": "yellow",
"Brodmann.2-rh": "yellow", "Brodmann.7-lh": "green",
"Brodmann.3-rh": "yellow", "Brodmann.17-lh": "blue",
"Brodmann.18-rh": "red", "Brodmann.18-lh": "blue",
"Brodmann.19-rh": "red", "Brodmann.19-lh": "blue",
"Brodmann.4-rh": "purple", "Brodmann.39-lh": "purple",
"Brodmann.8-rh": "white" "Brodmann.40-lh": "pink",
"Brodmann.42-lh": "white",
"Brodmann.44-lh": "white",
"Brodmann.48-lh": "white",
} }
for label in labels: for label in labels:
@@ -2810,7 +2844,7 @@ def calculate_dpf(file_path):
# order is hbo / hbr # order is hbo / hbr
with h5py.File(file_path, 'r') as f: with h5py.File(file_path, 'r') as f:
wavelengths = f['/nirs/probe/wavelengths'][:] wavelengths = f['/nirs/probe/wavelengths'][:]
logger.info("Wavelengths (nm):", wavelengths) logger.info(f"Wavelengths (nm): {wavelengths}")
wavelengths = sorted(wavelengths, reverse=True) wavelengths = sorted(wavelengths, reverse=True)
age = float(AGE) age = float(AGE)
logger.info(f"Their age was {AGE}") logger.info(f"Their age was {AGE}")
@@ -2926,7 +2960,7 @@ def process_participant(file_path, progress_callback=None):
# Step 11: Get short / long channels # Step 11: Get short / long channels
if SHORT_CHANNEL: if SHORT_CHANNEL:
short_chans = get_short_channels(raw_haemo, max_dist=0.015) short_chans = get_short_channels(raw_haemo, max_dist=0.02)
fig_short_chans = short_chans.plot(duration=raw_haemo.times[-1], n_channels=raw_haemo.info['nchan'], title="Short Channels Only", show=False) fig_short_chans = short_chans.plot(duration=raw_haemo.times[-1], n_channels=raw_haemo.info['nchan'], title="Short Channels Only", show=False)
fig_individual["short"] = fig_short_chans fig_individual["short"] = fig_short_chans
else: else:
@@ -3069,5 +3103,16 @@ def process_participant(file_path, progress_callback=None):
if progress_callback: progress_callback(20) if progress_callback: progress_callback(20)
logger.info("20") logger.info("20")
sanitize_paths_for_pickle(raw_haemo, epochs)
return raw_haemo, epochs, fig_bytes, cha, contrast_results, df_ind, design_matrix, AGE, GENDER, GROUP, True return raw_haemo, epochs, fig_bytes, cha, contrast_results, df_ind, design_matrix, AGE, GENDER, GROUP, True
def sanitize_paths_for_pickle(raw_haemo, epochs):
# Fix raw_haemo._filenames
if hasattr(raw_haemo, '_filenames'):
raw_haemo._filenames = [str(p) for p in raw_haemo._filenames]
# Fix epochs._raw._filenames
if hasattr(epochs, '_raw') and hasattr(epochs._raw, '_filenames'):
epochs._raw._filenames = [str(p) for p in epochs._raw._filenames]

View File

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

After

Width:  |  Height:  |  Size: 593 B

View File

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

After

Width:  |  Height:  |  Size: 149 B

574
main.py
View File

@@ -19,7 +19,7 @@ import zipfile
import platform import platform
import traceback import traceback
import subprocess import subprocess
from pathlib import Path from pathlib import Path, PurePosixPath
from datetime import datetime from datetime import datetime
from multiprocessing import Process, current_process, freeze_support, Manager from multiprocessing import Process, current_process, freeze_support, Manager
@@ -34,13 +34,14 @@ from mne.io import read_raw_snirf
from mne.preprocessing.nirs import source_detector_distances from mne.preprocessing.nirs import source_detector_distances
from mne_nirs.io import write_raw_snirf from mne_nirs.io import write_raw_snirf
from mne.channels import make_dig_montage from mne.channels import make_dig_montage
from mne_nirs.channels import get_short_channels # type: ignore
from mne import Annotations from mne import Annotations
from PySide6.QtWidgets import ( from PySide6.QtWidgets import (
QApplication, QWidget, QMessageBox, QVBoxLayout, QHBoxLayout, QTextEdit, QScrollArea, QComboBox, QGridLayout, 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.QtGui import QAction, QKeySequence, QIcon, QIntValidator, QDoubleValidator, QPixmap, QStandardItemModel, QStandardItem
from PySide6.QtSvgWidgets import QSvgWidget # needed to show svgs when app is not frozen from PySide6.QtSvgWidgets import QSvgWidget # needed to show svgs when app is not frozen
@@ -57,34 +58,34 @@ SECTIONS = [
{ {
"title": "Preprocessing", "title": "Preprocessing",
"params": [ "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": "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": "Downsample snirf 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."}, {"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", "title": "Scalp Coupling Index",
"params": [ "params": [
{"name": "SCI", "default": True, "type": bool, "help": "Calculate Scalp Coupling Index."}, {"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": "SCI time window."}, {"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 (0-1)."}, {"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", "title": "Signal to Noise Ratio",
"params": [ "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_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", "title": "Peak Spectral Power",
"params": [ "params": [
{"name": "PSP", "default": True, "type": bool, "help": "Calculate Peak Spectral Power."}, {"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": "PSP time window."}, {"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."}, {"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", "title": "Temporal Derivative Distribution Repair filtering",
"params": [ "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."},
] ]
}, },
{ {
@@ -122,13 +123,16 @@ SECTIONS = [
{ {
"title": "Filtering", "title": "Filtering",
"params": [ "params": [
{"name": "L_FREQ", "default": 0.005, "type": float, "help": "Any frequencies lower than this value will be removed."},
{"name": "H_FREQ", "default": 0.7, "type": float, "help": "Any frequencies higher than this value will be removed."},
#{"name": "FILTER", "default": True, "type": bool, "help": "Calculate Peak Spectral Power."}, #{"name": "FILTER", "default": True, "type": bool, "help": "Calculate Peak Spectral Power."},
] ]
}, },
{ {
"title": "Short Channels", "title": "Short Channels",
"params": [ "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 +173,7 @@ SECTIONS = [
{ {
"title": "Other", "title": "Other",
"params": [ "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 +449,34 @@ class UserGuideWindow(QWidget):
def __init__(self, parent=None): def __init__(self, parent=None):
super().__init__(parent, Qt.WindowType.Window) super().__init__(parent, Qt.WindowType.Window)
self.setWindowTitle("User Guide for FLARES") self.setWindowTitle("User Guide - FLARES")
self.resize(250, 100) self.resize(250, 100)
layout = QVBoxLayout() 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(label)
layout.addWidget(label2)
self.setLayout(layout) self.setLayout(layout)
@@ -582,7 +607,7 @@ class UpdateOptodesWindow(QWidget):
def show_help_popup(self, text): def show_help_popup(self, text):
msg = QMessageBox(self) msg = QMessageBox(self)
msg.setWindowTitle("Parameter Info") msg.setWindowTitle("Parameter Info - FLARES")
msg.setText(text) msg.setText(text)
msg.exec() msg.exec()
@@ -863,7 +888,7 @@ class UpdateEventsWindow(QWidget):
def show_help_popup(self, text): def show_help_popup(self, text):
msg = QMessageBox(self) msg = QMessageBox(self)
msg.setWindowTitle("Parameter Info") msg.setWindowTitle("Parameter Info - FLARES")
msg.setText(text) msg.setText(text)
msg.exec() msg.exec()
@@ -1154,7 +1179,8 @@ class ProgressBubble(QWidget):
""" """
clicked = Signal(object) clicked = Signal(object)
rightClicked = Signal(object, QPoint)
def __init__(self, display_name, file_path): def __init__(self, display_name, file_path):
super().__init__() super().__init__()
@@ -1174,7 +1200,7 @@ class ProgressBubble(QWidget):
self.progress_layout = QHBoxLayout() self.progress_layout = QHBoxLayout()
self.rects = [] self.rects = []
for _ in range(19): for _ in range(20):
rect = QFrame() rect = QFrame()
rect.setFixedSize(10, 18) rect.setFixedSize(10, 18)
rect.setStyleSheet("background-color: white; border: 1px solid gray;") rect.setStyleSheet("background-color: white; border: 1px solid gray;")
@@ -1212,7 +1238,10 @@ class ProgressBubble(QWidget):
rect.setStyleSheet("background-color: red; border: 1px solid gray;") rect.setStyleSheet("background-color: red; border: 1px solid gray;")
def mousePressEvent(self, event): 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) super().mousePressEvent(event)
def setSuffixText(self, suffix): def setSuffixText(self, suffix):
@@ -1394,7 +1423,7 @@ class ParamSection(QWidget):
def show_help_popup(self, text): def show_help_popup(self, text):
msg = QMessageBox(self) msg = QMessageBox(self)
msg.setWindowTitle("Parameter Info") msg.setWindowTitle("Parameter Info - FLARES")
msg.setText(text) msg.setText(text)
msg.exec() msg.exec()
@@ -1529,52 +1558,40 @@ class ParamSection(QWidget):
# You can customize how you display selected items here: # You can customize how you display selected items here:
widget.lineEdit().setText(", ".join(selected)) widget.lineEdit().setText(", ".join(selected))
def update_annotation_dropdown_from_folder(self, folder_path): def update_annotation_dropdown_from_loaded_files(self, bubble_widgets, button1):
"""
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
annotation_sets = [] 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: try:
raw = read_raw_snirf(full_path, preload=False, verbose="ERROR") raw = read_raw_snirf(file_path, preload=False, verbose="ERROR")
annotations = raw.annotations annotations = raw.annotations
if annotations is not None: if annotations is not None:
print(f"[ParamSection] Found annotations with descriptions: {annotations.description}")
labels = set(annotations.description) labels = set(annotations.description)
annotation_sets.append(labels) annotation_sets.append(labels)
except Exception as e: else:
print(f"[ParamSection] Skipping file '{filename}' due to error: {e}") print(f"[ParamSection] No annotations found in file: {file_path}")
except Exception:
raise
if not annotation_sets: 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 return
# Get common annotations
common_annotations = set.intersection(*annotation_sets) if len(annotation_sets) > 1 else annotation_sets[0] 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}") print(f"[ParamSection] Common annotations: {common_annotations}")
# Update the dropdown
self.update_dropdown_items("REMOVE_EVENTS", common_annotations) self.update_dropdown_items("REMOVE_EVENTS", common_annotations)
class FullClickLineEdit(QLineEdit): class FullClickLineEdit(QLineEdit):
def mousePressEvent(self, event): def mousePressEvent(self, event):
combo = self.parent() combo = self.parent()
@@ -3869,6 +3886,11 @@ class MainApplication(QMainWindow):
self.folder_paths = [] self.folder_paths = []
self.section_widget = None self.section_widget = None
self.first_run = True 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.init_ui()
self.create_menu_bar() self.create_menu_bar()
@@ -3938,6 +3960,19 @@ class MainApplication(QMainWindow):
right_column_layout.addWidget(label) right_column_layout.addWidget(label)
right_column_layout.addWidget(field) 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 right_column_layout.addStretch() # Push fields to top
self.right_column_widget.hide() self.right_column_widget.hide()
@@ -4062,7 +4097,7 @@ class MainApplication(QMainWindow):
file_actions = [ file_actions = [
("Open File...", "Ctrl+O", self.open_file_dialog, resource_path("icons/file_open_24dp_1F1F1F.svg")), ("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 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")), ("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...", "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")), ("Save Project As...", "Ctrl+Shift+S", self.save_project, resource_path("icons/save_as_24dp_1F1F1F.svg")),
@@ -4070,7 +4105,7 @@ class MainApplication(QMainWindow):
for i, (name, shortcut, slot, icon) in enumerate(file_actions): for i, (name, shortcut, slot, icon) in enumerate(file_actions):
file_menu.addAction(make_action(name, shortcut, slot, icon=icon)) 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()
file_menu.addSeparator() file_menu.addSeparator()
@@ -4216,26 +4251,59 @@ class MainApplication(QMainWindow):
def open_file_dialog(self): def open_file_dialog(self):
file_path, _ = QFileDialog.getOpenFileName( file_path, _ = QFileDialog.getOpenFileName(
self, "Open File", "", "All Files (*);;Text Files (*.txt)" self, "Open File", "", "SNIRF Files (*.snirf);;All Files (*)"
) )
if file_path: if file_path:
self.selected_path = file_path # store the file path # Create and display the bubble directly for this file
self.show_files_as_bubbles(file_path) 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) self.button1.setVisible(True)
def open_folder_dialog(self): def open_folder_dialog(self):
folder_path = QFileDialog.getExistingDirectory( folder_path = QFileDialog.getExistingDirectory(self, "Select Folder", "")
self, "Select Folder", ""
)
if folder_path: 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) self.show_files_as_bubbles(folder_path)
for section_widget in self.param_sections: for section_widget in self.param_sections:
if "REMOVE_EVENTS" in section_widget.widgets: if hasattr(section_widget, 'update_annotation_dropdown_from_loaded_files'):
section_widget.update_annotation_dropdown_from_folder(folder_path) if "REMOVE_EVENTS" in section_widget.widgets:
break section_widget.update_annotation_dropdown_from_loaded_files(self.bubble_widgets, self.button1)
break
else: else:
print("[MainWindow] Could not find ParamSection with 'REMOVE_EVENTS' widget") print("[MainWindow] Could not find ParamSection with 'REMOVE_EVENTS' widget")
@@ -4244,16 +4312,29 @@ class MainApplication(QMainWindow):
def open_multiple_folders_dialog(self): def open_multiple_folders_dialog(self):
while True: while True:
folder = QFileDialog.getExistingDirectory(self, "Select Folder") folder_path = QFileDialog.getExistingDirectory(self, "Select Folder")
if not folder: if not folder_path:
break break
snirf_files = [str(f) for f in Path(folder_path).glob("*.snirf")]
if not hasattr(self, 'selected_paths'): if not hasattr(self, 'selected_paths'):
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 # Ask if the user wants to add another
more = QMessageBox.question( more = QMessageBox.question(
@@ -4267,21 +4348,42 @@ class MainApplication(QMainWindow):
self.button1.setVisible(True) self.button1.setVisible(True)
def save_project(self):
filename, _ = QFileDialog.getSaveFileName( def save_project(self, onCrash=False):
self, "Save Project", "", "FLARE Project (*.flare)"
)
if not filename:
return
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: try:
project_data = { # Ensure the filename has the proper extension
"file_list": [bubble.file_path for bubble in self.bubble_widgets.values()], if not filename.endswith(".flare"):
"progress_states": { filename += ".flare"
bubble.file_path: bubble.current_step for bubble in self.bubble_widgets.values()
},
project_path = Path(filename).resolve()
project_dir = project_path.parent
file_list = [
str(PurePosixPath(Path(bubble.file_path).resolve().relative_to(project_dir)))
for bubble in self.bubble_widgets.values()
]
progress_states = {
str(PurePosixPath(Path(bubble.file_path).resolve().relative_to(project_dir))): bubble.current_step
for bubble in self.bubble_widgets.values()
}
project_data = {
"file_list": file_list,
"progress_states": progress_states,
"raw_haemo_dict": self.raw_haemo_dict, "raw_haemo_dict": self.raw_haemo_dict,
"epochs_dict": self.epochs_dict, "epochs_dict": self.epochs_dict,
"fig_bytes_dict": self.fig_bytes_dict, "fig_bytes_dict": self.fig_bytes_dict,
@@ -4294,6 +4396,17 @@ class MainApplication(QMainWindow):
"group_dict": self.group_dict, "group_dict": self.group_dict,
"valid_dict": self.valid_dict, "valid_dict": self.valid_dict,
} }
def sanitize(obj):
if isinstance(obj, Path):
return str(PurePosixPath(obj))
elif isinstance(obj, dict):
return {sanitize(k): sanitize(v) for k, v in obj.items()}
elif isinstance(obj, list):
return [sanitize(i) for i in obj]
return obj
project_data = sanitize(project_data)
with open(filename, "wb") as f: with open(filename, "wb") as f:
pickle.dump(project_data, f) pickle.dump(project_data, f)
@@ -4301,7 +4414,8 @@ class MainApplication(QMainWindow):
QMessageBox.information(self, "Success", f"Project saved to:\n{filename}") QMessageBox.information(self, "Success", f"Project saved to:\n{filename}")
except Exception as e: except Exception as e:
QMessageBox.critical(self, "Error", f"Failed to save project:\n{e}") if not onCrash:
QMessageBox.critical(self, "Error", f"Failed to save project:\n{e}")
@@ -4328,9 +4442,20 @@ class MainApplication(QMainWindow):
self.group_dict = data.get("group_dict", {}) self.group_dict = data.get("group_dict", {})
self.valid_dict = data.get("valid_dict", {}) self.valid_dict = data.get("valid_dict", {})
# Restore bubbles and progress project_dir = Path(filename).parent
self.show_files_as_bubbles_from_list(data["file_list"], data.get("progress_states", {}), filename)
# Convert saved relative paths to absolute paths
file_list = [str((project_dir / Path(rel_path)).resolve()) for rel_path in data["file_list"]]
# Also resolve progress_states with updated paths
raw_progress = data.get("progress_states", {})
progress_states = {
str((project_dir / Path(rel_path)).resolve()): step
for rel_path, step in raw_progress.items()
}
self.show_files_as_bubbles_from_list(file_list, progress_states, filename)
# Re-enable buttons # Re-enable buttons
# self.button1.setVisible(True) # self.button1.setVisible(True)
self.button3.setVisible(True) self.button3.setVisible(True)
@@ -4348,11 +4473,11 @@ class MainApplication(QMainWindow):
folder_paths = [folder_paths] folder_paths = [folder_paths]
# Clear previous bubbles # Clear previous bubbles
while self.bubble_layout.count(): # while self.bubble_layout.count():
item = self.bubble_layout.takeAt(0) # item = self.bubble_layout.takeAt(0)
widget = item.widget() # widget = item.widget()
if widget: # if widget:
widget.deleteLater() # widget.deleteLater()
temp_bubble = ProgressBubble("Test Bubble", "") # A dummy bubble for measurement temp_bubble = ProgressBubble("Test Bubble", "") # A dummy bubble for measurement
temp_bubble.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy. Preferred) temp_bubble.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy. Preferred)
@@ -4364,21 +4489,26 @@ class MainApplication(QMainWindow):
cols = max(1, available_width // bubble_width) # Ensure at least 1 column cols = max(1, available_width // bubble_width) # Ensure at least 1 column
index = 0 index = 0
if not hasattr(self, 'selected_paths'):
self.selected_paths = []
for folder_path in folder_paths: for folder_path in folder_paths:
if not os.path.isdir(folder_path): if not os.path.isdir(folder_path):
continue continue
files = os.listdir(folder_path) snirf_files = [str(f) for f in Path(folder_path).glob("*.snirf")]
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}"
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 = ProgressBubble(display_name, full_path)
bubble.clicked.connect(self.on_bubble_clicked) 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 row = index // cols
col = index % cols col = index % cols
@@ -4418,6 +4548,7 @@ class MainApplication(QMainWindow):
# Create bubble with full path # Create bubble with full path
bubble = ProgressBubble(display_name, file_path) bubble = ProgressBubble(display_name, file_path)
bubble.clicked.connect(self.on_bubble_clicked) bubble.clicked.connect(self.on_bubble_clicked)
bubble.rightClicked.connect(self.on_bubble_right_clicked)
self.bubble_widgets[file_path] = bubble self.bubble_widgets[file_path] = bubble
step = progress_states.get(file_path, 0) step = progress_states.get(file_path, 0)
@@ -4506,6 +4637,77 @@ class MainApplication(QMainWindow):
field.blockSignals(False) 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): def eventFilter(self, watched, event):
if event.type() == QEvent.Type.MouseButtonPress: if event.type() == QEvent.Type.MouseButtonPress:
widget = self.childAt(event.pos()) widget = self.childAt(event.pos())
@@ -4587,10 +4789,11 @@ class MainApplication(QMainWindow):
self.button1.clicked.disconnect(self.on_run_task) self.button1.clicked.disconnect(self.on_run_task)
self.button1.setText("Cancel") self.button1.setText("Cancel")
self.button1.clicked.connect(self.cancel_task) self.button1.clicked.connect(self.cancel_task)
if not self.first_run: if not self.first_run:
for bubble in self.bubble_widgets.values(): for bubble in self.bubble_widgets.values():
bubble.mark_cancelled() pass
# bubble.mark_cancelled()
self.first_run = False self.first_run = False
# Collect all selected snirf files in a flat list # Collect all selected snirf files in a flat list
@@ -4617,7 +4820,41 @@ class MainApplication(QMainWindow):
if not snirf_files: if not snirf_files:
raise ValueError("No .snirf files found in selection") 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 = {} all_params = {}
for section_widget in self.param_sections: for section_widget in self.param_sections:
section_params = section_widget.get_param_values() section_params = section_widget.get_param_values()
@@ -4659,7 +4896,12 @@ class MainApplication(QMainWindow):
if isinstance(msg, dict): if isinstance(msg, dict):
if msg.get("success"): if msg.get("success"):
results = msg["result"] # from flares.py 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 # Initialize storage
# TODO: Is this check needed? # TODO: Is this check needed?
@@ -4691,26 +4933,84 @@ class MainApplication(QMainWindow):
self.valid_dict[file_path] = valid self.valid_dict[file_path] = valid
# self.statusbar.showMessage(f"Processing complete! Time elapsed: {elapsed_time:.2f} seconds") # 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) self.button3.setVisible(True)
else: elif msg.get("success") is False:
error_msg = msg.get("error", "Unknown error") error_msg = msg.get("error", "Unknown error")
print("Error during processing:", error_msg) traceback_str = msg.get("traceback", "")
self.statusbar.showMessage(f"Processing failed! {error_msg}") 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': elif isinstance(msg, tuple) and msg[0] == 'progress':
_, file_path, step_index = msg _, file_path, step_index = msg
file_name = os.path.basename(file_path) # extract file name self.progress_update_signal.emit(file_path, step_index)
self.progress_update_signal.emit(file_name, 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): def cleanup_after_process(self):
if hasattr(self, 'result_process'): if hasattr(self, 'result_process'):
@@ -4738,8 +5038,9 @@ class MainApplication(QMainWindow):
self.manager.shutdown() self.manager.shutdown()
def update_file_progress(self, filename, step_index): def update_file_progress(self, file_path, step_index):
bubble = self.bubble_widgets.get(filename) key = os.path.normpath(file_path)
bubble = self.bubble_widgets.get(key)
if bubble: if bubble:
bubble.update_progress(step_index) bubble.update_progress(step_index)
@@ -4895,6 +5196,14 @@ class MainApplication(QMainWindow):
# Measurement date # Measurement date
snirf_info['Measurement Date'] = str(raw.info.get('meas_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 # Source-detector distances
distances = source_detector_distances(raw.info) distances = source_detector_distances(raw.info)
distance_info = [] distance_info = []
@@ -5169,11 +5478,50 @@ def exception_hook(exc_type, exc_value, exc_traceback):
if app is None: if app is None:
app = QApplication(sys.argv) 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 # Exit the app after user acknowledges
sys.exit(1) 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__": if __name__ == "__main__":
# Redirect exceptions to the popup window # Redirect exceptions to the popup window