changes and improvements

This commit is contained in:
2026-01-29 17:23:52 -08:00
parent 7007478c3b
commit f82978e2e8
4 changed files with 371 additions and 149 deletions

387
main.py
View File

@@ -39,10 +39,10 @@ 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, QMenu
QPushButton, QMainWindow, QFileDialog, QLabel, QLineEdit, QFrame, QSizePolicy, QGroupBox, QDialog, QListView, QMenu, QSpinBox, QProgressDialog, QProgressBar
)
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, QImage
from PySide6.QtSvgWidgets import QSvgWidget # needed to show svgs when app is not frozen
@@ -59,71 +59,71 @@ SECTIONS = [
"title": "Preprocessing",
"params": [
{"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"},
{"name": "DOWNSAMPLE_FREQUENCY", "default": 25, "type": int, "depends_on": "DOWNSAMPLE", "help": "Frequency (Hz) to downsample to. If this is set higher than the input data, new data will be interpolated."},
]
},
{
"title": "Trimming",
"params": [
{"name": "TRIM", "default": True, "type": bool, "help": "Trim the file start."},
{"name": "SECONDS_TO_KEEP", "default": 5, "type": float, "help": "Seconds to keep at the beginning of all loaded snirf files before the first annotation/event occurs. Calculation is done seperatly on all loaded snirf files. Setting this to 0 will have the first annotation/event be at time point 0."},
{"name": "TRIM", "default": True, "type": bool, "help": "Should the start of the files be trimmed?"},
{"name": "SECONDS_TO_KEEP", "default": 5, "type": float, "depends_on": "TRIM", "help": "Seconds to keep at the beginning of all loaded snirf files before the first annotation/event occurs. Calculation is done seperatly on all loaded snirf files. Setting this to 0 will have the first annotation/event be at time point 0. Only used if TRIM is set to True."},
]
},
{
"title": "Verify Optode Placement",
"params": [
{"name": "OPTODE_PLACEMENT", "default": True, "type": bool, "help": "Generate an image for each participant outlining their optode placement."},
{"name": "SHOW_OPTODE_NAMES", "default": True, "type": bool, "help": "Should the optode names be written next to their location or not."},
{"name": "OPTODE_PLACEMENT", "default": True, "type": bool, "help": "Should an image be generated for each participant outlining their optode placement on a head?"},
{"name": "SHOW_OPTODE_NAMES", "default": True, "type": bool, "depends_on": "OPTODE_PLACEMENT", "help": "Should the optode names be written next to their location in the image?"},
]
},
{
"title": "Short/Long Channels",
"params": [
{"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."},
{"name": "SHORT_CHANNEL_THRESH", "default": 0.015, "type": float, "help": "The maximum distance the short channel can be in metres."},
{"name": "LONG_CHANNEL_THRESH", "default": 0.045, "type": float, "help": "The maximum distance the long channel can be in metres."},
{"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. For more information about short channels, please visit the Wiki."},
{"name": "SHORT_CHANNEL_THRESH", "default": 0.015, "type": float, "depends_on": "SHORT_CHANNEL", "help": "The maximum distance the short channel can be in metres before it is no longer considered a short channel."},
{"name": "LONG_CHANNEL_THRESH", "default": 0.045, "type": float, "help": "The maximum distance channels can be in metres. Any channel longer than this distance will be discarded."},
]
},
{
"title": "Heart Rate",
"params": [
{"name": "HEART_RATE", "default": True, "type": bool, "help": "Attempt to calculate the participants heart rate."},
{"name": "SECONDS_TO_STRIP_HR", "default": 5, "type": int, "help": "Will remove this many seconds from the start and end of the file. Useful if recording before cap is firmly placed, or participant removes cap while still recording."},
{"name": "MAX_LOW_HR", "default": 40, "type": int, "help": "Any heart rate windows that average below this value will be rounded up to this value."},
{"name": "MAX_HIGH_HR", "default": 200, "type": int, "help": "Any heart rate windows that average above this value will be rounded down to this value."},
{"name": "SMOOTHING_WINDOW_HR", "default": 100, "type": int, "help": "How many individual data points to smooth into a single window."},
{"name": "HEART_RATE_WINDOW", "default": 25, "type": int, "help": "Used for visualization. Shows the range of the calculated heart rate +- this value."},
{"name": "HEART_RATE", "default": True, "type": bool, "help": "Should an attempt be made to calculate participants heart rates?"},
{"name": "SECONDS_TO_STRIP_HR", "default": 5, "type": int, "depends_on": "HEART_RATE", "help": "Will remove this many seconds from the start and end of the file. Useful if recording before cap is firmly placed, or participant removes cap while still recording."},
{"name": "MAX_LOW_HR", "default": 40, "type": int, "depends_on": "HEART_RATE", "help": "Any heart rate windows that average below this value will be rounded up to this value."},
{"name": "MAX_HIGH_HR", "default": 200, "type": int, "depends_on": "HEART_RATE", "help": "Any heart rate windows that average above this value will be rounded down to this value."},
{"name": "SMOOTHING_WINDOW_HR", "default": 100, "type": int, "depends_on": "HEART_RATE", "help": "How many individual data points to be used to create a single data point/window."},
{"name": "HEART_RATE_WINDOW", "default": 25, "type": int, "depends_on": "HEART_RATE", "help": "Only used for visualization. Shows the 'range' of the calculated heart rate, which is just the average +- this value."},
]
},
{
"title": "Scalp Coupling Index",
"params": [
{"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."},
{"name": "SCI_TIME_WINDOW", "default": 3, "type": int, "depends_on": "SCI", "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, "depends_on": "SCI", "help": "SCI threshold on a scale of 0-1. A value of 0 is bad coupling while a value of 1 is perfect coupling. 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 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_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."},
{"name": "SNR_THRESHOLD", "default": 5.0, "type": float, "depends_on": "SNR", "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 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."},
{"name": "PSP_TIME_WINDOW", "default": 3, "type": int, "depends_on": "PSP", "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, "depends_on": "PSP", "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."},
]
},
{
"title": "Bad Channels Handling",
"params": [
{"name": "BAD_CHANNELS_HANDLING", "default": [], "type": list, "options": ["Interpolate", "Remove", "None"], "exclusive": True, "help": "How should we deal with the bad channels that occurred? Note: Some analysis options will only work when this is set to 'Interpolate'."},
{"name": "MAX_DIST", "default": 0.03, "type": float, "help": "The maximum distance to look for neighbours when interpolating. Used only when BAD_CHANNELS_HANDLING is set to 'Interpolate'."},
{"name": "MIN_NEIGHBORS", "default": 2, "type": int, "help": "The minimumn amount of neighbours needed within the MAX_DIST parameter. Used only when BAD_CHANNELS_HANDLING is set to 'Interpolate'."},
{"name": "BAD_CHANNELS_HANDLING", "default": ["Interpolate"], "type": list, "options": ["Interpolate", "Remove", "None"], "exclusive": True, "help": "How should we deal with the bad channels that occurred? Note: Some analysis options will only work when this is set to 'Interpolate'."},
{"name": "MAX_DIST", "default": 0.03, "type": float, "depends_on": "BAD_CHANNELS_HANDLING", "depends_value": "Interpolate", "help": "The maximum distance to look for neighbours when interpolating. Used only when BAD_CHANNELS_HANDLING is set to 'Interpolate'."},
{"name": "MIN_NEIGHBORS", "default": 2, "type": int, "depends_on": "BAD_CHANNELS_HANDLING", "depends_value": "Interpolate", "help": "The minimumn amount of neighbours needed within the MAX_DIST parameter. Used only when BAD_CHANNELS_HANDLING is set to 'Interpolate'."},
]
},
{
@@ -141,10 +141,10 @@ SECTIONS = [
{
"title": "Wavelet filtering",
"params": [
{"name": "WAVELET", "default": True, "type": bool, "help": "Apply Wavelet filtering."},
{"name": "IQR", "default": 1.5, "type": float, "help": "Inter-Quartile Range."},
{"name": "WAVELET_TYPE", "default": "db4", "type": str, "help": "Wavelet type."},
{"name": "WAVELET_LEVEL", "default": 3, "type": int, "help": "Wavelet level."},
{"name": "WAVELET", "default": True, "type": bool, "help": "Apply Wavelet filtering. It is a method to filter involving decomposition, threholding, and reconstruction."},
{"name": "IQR", "default": 1.5, "type": float, "depends_on": "WAVELET", "help": "Scaling factor for the Inter-Quartile Range."},
{"name": "WAVELET_TYPE", "default": "db4", "type": str, "depends_on": "WAVELET", "help": "Wavelet type. Valid values are ['bior1.1', 'bior1.3', 'bior1.5', 'bior2.2', 'bior2.4', 'bior2.6', 'bior2.8', 'bior3.1', 'bior3.3', 'bior3.5', 'bior3.7', 'bior3.9', 'bior4.4', 'bior5.5', 'bior6.8', 'coif1', 'coif2', 'coif3', 'coif4', 'coif5', 'coif6', 'coif7', 'coif8', 'coif9', 'coif10', 'coif11', 'coif12', 'coif13', 'coif14', 'coif15', 'coif16', 'coif17', 'db1', 'db2', 'db3', 'db4', 'db5', 'db6', 'db7', 'db8', 'db9', 'db10', 'db11', 'db12', 'db13', 'db14', 'db15', 'db16', 'db17', 'db18', 'db19', 'db20', 'db21', 'db22', 'db23', 'db24', 'db25', 'db26', 'db27', 'db28', 'db29', 'db30', 'db31', 'db32', 'db33', 'db34', 'db35', 'db36', 'db37', 'db38', 'dmey', 'haar', 'rbio1.1', 'rbio1.3', 'rbio1.5', 'rbio2.2', 'rbio2.4', 'rbio2.6', 'rbio2.8', 'rbio3.1', 'rbio3.3', 'rbio3.5', 'rbio3.7', 'rbio3.9', 'rbio4.4', 'rbio5.5', 'rbio6.8', 'sym2', 'sym3', 'sym4', 'sym5', 'sym6', 'sym7', 'sym8', 'sym9', 'sym10', 'sym11', 'sym12', 'sym13', 'sym14', 'sym15', 'sym16', 'sym17', 'sym18', 'sym19', 'sym20']"},
{"name": "WAVELET_LEVEL", "default": 3, "type": int, "depends_on": "WAVELET", "help": "Wavelet Decomposition level (must be >= 0)."},
]
},
{
@@ -162,11 +162,11 @@ SECTIONS = [
{
"title": "Filtering",
"params": [
{"name": "FILTER", "default": True, "type": bool, "help": "Filter the data."},
{"name": "L_FREQ", "default": 0.005, "type": float, "help": "Any frequencies lower than this value will be removed."},
{"name": "H_FREQ", "default": 0.3, "type": float, "help": "Any frequencies higher than this value will be removed."},
{"name": "L_TRANS_BANDWIDTH", "default": 0.002, "type": float, "help": "How wide the transitional period should be so the data doesn't just drop off on the lower bound."},
{"name": "H_TRANS_BANDWIDTH", "default": 0.002, "type": float, "help": "How wide the transitional period should be so the data doesn't just drop off on the upper bound."},
{"name": "FILTER", "default": True, "type": bool, "help": "Should the data be bandpass filtered?"},
{"name": "L_FREQ", "default": 0.005, "type": float, "depends_on": "FILTER", "help": "Any frequencies lower than this value will be removed."},
{"name": "H_FREQ", "default": 0.3, "type": float, "depends_on": "FILTER", "help": "Any frequencies higher than this value will be removed."},
{"name": "L_TRANS_BANDWIDTH", "default": 0.002, "type": float, "depends_on": "FILTER", "help": "How wide the transitional period should be so the data doesn't just drop off on the lower bound."},
{"name": "H_TRANS_BANDWIDTH", "default": 0.002, "type": float, "depends_on": "FILTER", "help": "How wide the transitional period should be so the data doesn't just drop off on the upper bound."},
]
},
{
@@ -184,11 +184,11 @@ SECTIONS = [
{
"title": "Design Matrix",
"params": [
{"name": "RESAMPLE", "default": True, "type": bool, "help": "The length of your stimulus."},
{"name": "RESAMPLE_FREQ", "default": 1, "type": int, "help": "The length of your stimulus."},
{"name": "RESAMPLE", "default": True, "type": bool, "help": "Should the data be resampled before calculating the design matrix? Downsampling is useful for speeding up calculations without loosing overall data shape."},
{"name": "RESAMPLE_FREQ", "default": 1, "type": int, "help": "The frequency the data should be resampled to."},
{"name": "HRF_MODEL", "default": ["fir"], "type": list, "options": ["fir", "glover", "spm", "spm + derivative", "spm + derivative + dispersion", "glover + derivative", "glover + derivative + dispersion"], "exclusive": True, "help": "Specifies the hemodynamic response function."},
{"name": "STIM_DUR", "default": 0.5, "type": float, "help": "The length of your stimulus."},
{"name": "HRF_MODEL", "default": "fir", "type": str, "help": "Specifies the hemodynamic response function."},
{"name": "DRIFT_MODEL", "default": "cosine", "type": str, "help": "Specifies the desired drift model."},
{"name": "HIGH_PASS", "default": 0.01, "type": float, "help": "High-pass frequency in case of a cosine model (in Hz)."},
{"name": "DRIFT_ORDER", "default": 1, "type": int, "help": "Order of the drift model (in case it is polynomial)"},
@@ -196,15 +196,15 @@ SECTIONS = [
{"name": "MIN_ONSET", "default": -24, "type": int, "help": "Minimal onset relative to frame times (in seconds)"},
{"name": "OVERSAMPLING", "default": 50, "type": int, "help": "Oversampling factor used in temporal convolutions."},
{"name": "REMOVE_EVENTS", "default": "None", "type": list, "help": "Remove events matching the names provided before generating the Design Matrix"},
{"name": "SHORT_CHANNEL_REGRESSION", "default": True, "type": bool, "help": "Whether to use short channel regression and regress out the short channels. Requires SHORT_CHANNELS to be True and at least one short channel to be found."},
{"name": "SHORT_CHANNEL_REGRESSION", "default": True, "type": bool, "depends_on": "SHORT_CHANNEL", "help": "Should short channel regression be used to create the design matrix? This will use the 'signal' from the short channel and regress it out of all other channels."},
]
},
{
"title": "General Linear Model",
"params": [
{"name": "NOISE_MODEL", "default": "ar1", "type": str, "help": "Number of jobs for GLM processing."},
{"name": "BINS", "default": 0, "type": int, "help": "Number of jobs for GLM processing."},
{"name": "N_JOBS", "default": 1, "type": int, "help": "Number of jobs for GLM processing."},
{"name": "NOISE_MODEL", "default": "ar1", "type": str, "help": "The temporal variance model. Defaults to first order auto regressive model 'ar1'. The AR model can be set to any integer value by modifying the value of N. E.g. use ar5 for a fifth order model. If the string auto is provided a model with order 4 times the sample rate will be used."},
{"name": "BINS", "default": 0, "type": int, "help": "Maximum number of discrete bins for the AR coef histogram/clustering. By default the value is 0, which will set the number of bins to the number of channels, effectively estimating the AR model for each channel."},
{"name": "N_JOBS", "default": 1, "type": int, "help": "The number of CPUs to use to do the GLM computation. -1 means 'all CPUs'."},
]
},
{
@@ -218,14 +218,56 @@ SECTIONS = [
"params": [
{"name": "TIME_WINDOW_START", "default": 0, "type": int, "help": "Where to start averaging the fir model bins. Only affects the significance and contrast images."},
{"name": "TIME_WINDOW_END", "default": 15, "type": int, "help": "Where to end averaging the fir model bins. Only affects the significance and contrast images."},
{"name": "MAX_WORKERS", "default": 4, "type": str, "help": "Number of files to be processed at once. Setting this to a small integer value may help on underpowered systems. Remove the value to use an automatic amount."},
{"name": "VERBOSITY", "default": False, "type": bool, "help": "True will log lots of debugging information to the log file. False will only log required data."},
{"name": "MAX_WORKERS", "default": 6, "type": int, "help": "Number of files to be processed at once. Setting this to a small integer value may help on underpowered systems. Remove the value to use an automatic amount."},
{"name": "VERBOSITY", "default": False, "type": bool, "help": "Setting this to True will log lots of debugging information to the log file. Setting this to False will log minimal data."},
]
},
]
from concurrent.futures import ProcessPoolExecutor
from PySide6.QtCore import QObject
from fold import run_fold_process
class FoldWorker(QObject):
progress_sig = Signal(int)
finished_sig = Signal(dict)
error_sig = Signal(str)
def __init__(self, haemo_obj, label, shared_dict):
super().__init__()
self.haemo_obj = haemo_obj
self.label = label
self.shared_dict = shared_dict
def run(self):
try:
with ProcessPoolExecutor(max_workers=1) as executor:
# Submit the function from the external file
future = executor.submit(run_fold_process, self.haemo_obj, self.label, self.shared_dict)
while not future.done():
current_progress = self.shared_dict.get(self.label, 0)
self.progress_sig.emit(current_progress)
time.sleep(0.1)
result = future.result()
# Check if our worker returned an error string instead of a dict
if isinstance(result, str) and result.startswith("ERROR:"):
raise Exception(result)
self.progress_sig.emit(100)
self.finished_sig.emit(result)
except Exception as e:
self.error_sig.emit(str(e))
class TerminalWindow(QWidget):
def __init__(self, parent=None):
super().__init__(parent, Qt.WindowType.Window)
@@ -1447,7 +1489,7 @@ class ParamSection(QWidget):
layout = QVBoxLayout()
self.setLayout(layout)
self.widgets = {}
self.dependencies = []
self.selected_path = None
# Title label
@@ -1489,6 +1531,7 @@ class ParamSection(QWidget):
widget = QComboBox()
widget.addItems(["True", "False"])
widget.setCurrentText(str(param["default"]))
widget.currentTextChanged.connect(self.update_dependencies)
elif param["type"] == int:
widget = QLineEdit()
widget.setValidator(QIntValidator())
@@ -1502,12 +1545,31 @@ class ParamSection(QWidget):
widget = QComboBox()
widget.addItems(param.get("options", []))
widget.setCurrentText(str(param.get("default", "<None Selected>")))
widget.currentTextChanged.connect(self.update_dependencies)
else:
widget = self._create_multiselect_dropdown(None)
elif param["type"] == range:
widget = QSpinBox()
widget.setRange(0, 999) # Set a sensible maximum
# If default is "None" or range(15), handle it gracefully:
default_val = param["default"]
if isinstance(default_val, range):
widget.setValue(default_val.stop)
elif str(default_val).isdigit():
widget.setValue(int(default_val))
else:
widget.setValue(15) # Default fallback
else:
widget = QLineEdit()
widget.setText(str(param["default"]))
if "depends_on" in param:
self.dependencies.append({
"child_name": param["name"],
"parent_name": param["depends_on"],
"depends_value": param.get("depends_value", "True")
})
widget.setToolTip(help_text)
h_layout.addWidget(widget)
@@ -1516,9 +1578,34 @@ class ParamSection(QWidget):
layout.addLayout(h_layout)
self.widgets[param["name"]] = {
"widget": widget,
"type": param["type"]
"type": param["type"],
"h_layout": h_layout
}
self.update_dependencies()
def update_dependencies(self):
"""Disables/Enables widgets based on parent selection values."""
for dep in self.dependencies:
child_info = self.widgets.get(dep["child_name"])
parent_info = self.widgets.get(dep["parent_name"])
if child_info and parent_info:
parent_widget = parent_info["widget"]
# Get current value of parent (works for both bool-combos and list-combos)
current_parent_value = parent_widget.currentText()
# Check if it matches the required value
is_active = (current_parent_value == dep["depends_value"])
# Toggle the entire row (Button, Label, and Input)
h_layout = child_info["h_layout"]
for i in range(h_layout.count()):
item = h_layout.itemAt(i).widget()
if item:
item.setEnabled(is_active)
def _create_multiselect_dropdown(self, items):
combo = FullClickComboBox()
combo.setView(QListView())
@@ -1615,6 +1702,12 @@ class ParamSection(QWidget):
values[name] = [x.strip() for x in widget.lineEdit().text().split(",") if x.strip()]
elif isinstance(widget, QComboBox):
values[name] = widget.currentText()
elif expected_type == range:
if isinstance(widget, QSpinBox):
# Convert the integer N into range(N)
values[name] = range(widget.value())
else:
values[name] = range(15) # Fallback
else:
raw_text = widget.text()
try:
@@ -1768,21 +1861,24 @@ class ParamSection(QWidget):
self.update_dropdown_items("REMOVE_EVENTS", common_annotations)
class FullClickLineEdit(QLineEdit):
def mousePressEvent(self, event):
combo = self.parent()
if isinstance(combo, QComboBox):
combo.showPopup()
super().mousePressEvent(event)
class FullClickComboBox(QComboBox):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.setLineEdit(FullClickLineEdit(self))
def __init__(self, parent=None):
super().__init__(parent)
self.setEditable(True)
self.lineEdit().setReadOnly(True)
self.lineEdit().installEventFilter(self)
def eventFilter(self, obj, event):
if obj == self.lineEdit():
if event.type() == QEvent.MouseButtonPress:
return True
if event.type() == QEvent.MouseButtonRelease:
self.showPopup()
return True
return super().eventFilter(obj, event)
class ParticipantViewerWidget(QWidget):
@@ -2346,7 +2442,7 @@ class ParticipantBrainViewerWidget(QWidget):
haemo_obj = self.haemo_dict.get(file_path)
if haemo_obj is None:
continue
raise Exception("How did we get here?")
cha = self.cha_dict.get(file_path)
@@ -2381,6 +2477,28 @@ class ParticipantBrainViewerWidget(QWidget):
class MultiProgressDialog(QDialog):
def __init__(self, parent=None):
super().__init__(parent)
self.setWindowTitle("fOLD Analysis Progress")
self.setFixedWidth(400)
# Ensure it doesn't block the main thread
self.setWindowModality(Qt.WindowModality.NonModal)
self.layout = QVBoxLayout(self)
self.bars = {}
def add_participant(self, label, total_steps):
label_widget = QLabel(f"Analyzing {label}...")
pbar = QProgressBar()
pbar.setMaximum(total_steps)
self.layout.addWidget(label_widget)
self.layout.addWidget(pbar)
self.bars[label] = pbar
def update_bar(self, label, value):
if label in self.bars:
self.bars[label].setValue(value)
class ParticipantFoldChannelsWidget(QWidget):
def __init__(self, haemo_dict, cha_dict):
@@ -2388,7 +2506,7 @@ class ParticipantFoldChannelsWidget(QWidget):
self.setWindowTitle("FLARES Participant Fold Channels Viewer")
self.haemo_dict = haemo_dict
self.cha_dict = cha_dict
self.active_threads = []
# Create mappings: file_path -> participant label and dropdown display text
self.participant_map = {} # file_path -> "Participant 1"
self.participant_dropdown_items = [] # "Participant 1 (filename)"
@@ -2423,7 +2541,6 @@ class ParticipantFoldChannelsWidget(QWidget):
self.top_bar.addWidget(self.participant_dropdown)
self.top_bar.addWidget(QLabel("Fold Type:"))
self.top_bar.addWidget(self.image_index_dropdown)
self.top_bar.addWidget(QLabel("This will cause the app to hang for ~30s/Participant!"))
self.top_bar.addWidget(self.submit_button)
self.scroll = QScrollArea()
@@ -2544,53 +2661,115 @@ class ParticipantFoldChannelsWidget(QWidget):
self.image_index_dropdown.lineEdit().setText(", ".join(index_labels))
def show_fold_images(self):
import flares
selected_display_names = self._get_checked_items(self.participant_dropdown)
selected_file_paths = []
selected_indexes = [int(s.split(" ")[0]) for s in self._get_checked_items(self.image_index_dropdown)]
if not selected_display_names or 0 not in selected_indexes:
return
# 1. Setup the UI
self.progress_popup = MultiProgressDialog(self)
self.progress_popup.setWindowModality(Qt.WindowModality.NonModal) # Important!
self.active_threads = [] # Keep references alive
# 2. Create the Shared Memory Manager
# This allows the separate processes to "talk" to this GUI thread
self.process_manager = Manager()
self.shared_progress = self.process_manager.dict()
# 3. Launch Workers
for display_name in selected_display_names:
for fp, short_label in self.participant_map.items():
expected_display = f"{short_label} ({os.path.basename(fp)})"
if display_name == expected_display:
selected_file_paths.append(fp)
break
file_path = next((fp for fp, lbl in self.participant_map.items()
if f"{lbl} ({os.path.basename(fp)})" == display_name), None)
if not file_path: continue
# Use .copy() to ensure thread safety during pickling
haemo_obj = self.haemo_dict.get(file_path).copy()
label = self.participant_map[file_path]
# Initialize Shared Dict Entry
self.shared_progress[label] = 0
selected_indexes = [
int(s.split(" ")[0]) for s in self._get_checked_items(self.image_index_dropdown)
]
# Add Bar to Popup
hbo_count = len(haemo_obj.copy().pick('hbo').ch_names) # Just for display logic if needed
self.progress_popup.add_participant(label, 100) # We use 0-100% standard
# Pass the necessary arguments to each method
for file_path in selected_file_paths:
haemo_obj = self.haemo_dict.get(file_path)
# Create Thread & Worker
thread = QThread()
# Pass the shared dict to the worker
worker = FoldWorker(haemo_obj, label, self.shared_progress)
worker.moveToThread(thread)
if haemo_obj is None:
continue
# Connect Signals
thread.started.connect(worker.run)
# Lambda capture to ensure correct label is used for each bar
worker.progress_sig.connect(lambda val, l=label: self.progress_popup.update_bar(l, val))
worker.finished_sig.connect(lambda imgs, l=label: self.on_fold_finished(imgs, l))
worker.error_sig.connect(self.on_fold_error)
# Cleanup Logic
worker.finished_sig.connect(thread.quit)
worker.finished_sig.connect(worker.deleteLater)
thread.finished.connect(thread.deleteLater)
# Store references to prevent garbage collection
self.active_threads.append({'thread': thread, 'worker': worker})
thread.start()
#cha = self.cha_dict.get(file_path)
self.progress_popup.show()
for idx in selected_indexes:
if idx == 0:
try:
flares.fold_channels(haemo_obj)
except:
msg_box = QMessageBox()
msg_box.setIcon(QMessageBox.Icon.Critical)
msg_box.setWindowTitle("Something went wrong!")
message = (
"Unable to locate the fOLD files!<br><br>"
f"Please download the 'Supplementary' folder from <a href='https://github.com/nirx/fOLD-public'>here</a>. "
"Once the folder is downloaded, place it in C:/Users/your username/mne_data/fOLD/fOLD-public-master/Supplementary.<br><br>"
"If you are not using Windows, please go to the FLARES Git page for more information."
)
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()
else:
print(f"No method defined for index {idx}")
def on_fold_finished(self, png_dict, label):
# 1. Close progress popup if all threads are done
# We filter the list to see if any threads are still running
still_running = any(t['thread'].isRunning() for t in self.active_threads)
if not still_running:
self.progress_popup.close()
# Optional: Shutdown manager when absolutely done to free resources
# self.process_manager.shutdown()
# 2. Display Images
if not hasattr(self, 'result_windows'):
self.result_windows = []
offset = len(self.result_windows) * 30 # Cascade offset
for key, png_data in png_dict.items():
popup = QDialog(self)
popup.setWindowTitle(f"{label} - fOLD {key.capitalize()}")
# ... (Your existing layout/image code) ...
# Resize and Position
if key == "main":
popup.resize(1664, 936)
popup.move(100 + offset, 100 + offset)
else:
popup.resize(450, 800)
popup.move(1770 + offset, 100 + offset)
popup.show()
self.result_windows.append(popup)
def on_fold_error(self, error_msg):
if hasattr(self, 'progress_popup'):
self.progress_popup.close()
msg_box = QMessageBox(self)
msg_box.setIcon(QMessageBox.Icon.Critical)
msg_box.setWindowTitle("Something went wrong!")
message = (
"Unable to locate the fOLD files!<br><br>"
f"Please download the 'Supplementary' folder from <a href='https://github.com/nirx/fOLD-public'>here</a>. "
"Once downloaded, place it in C:/Users/your username/mne_data/fOLD/fOLD-public-master/Supplementary."
)
msg_box.setTextFormat(Qt.TextFormat.RichText)
msg_box.setText(message)
msg_box.exec()
class ExportDataAsCSVViewerWidget(QWidget):
def __init__(self, haemo_dict, cha_dict, df_ind, design_matrix, group, contrast_results_dict):
@@ -4252,11 +4431,6 @@ class MainApplication(QMainWindow):
right_content_layout = QVBoxLayout()
self.right_content_widget.setLayout(right_content_layout)
# Option selector dropdown
self.option_selector = QComboBox()
self.option_selector.addItems(["FIR"])
right_content_layout.addWidget(self.option_selector)
# Container for the sections
self.rows_container = QWidget()
self.rows_layout = QVBoxLayout()
@@ -4309,9 +4483,6 @@ class MainApplication(QMainWindow):
self.right_container.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
self.right_scroll_area.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
# Store ParamSection widgets
self.option_selector.currentIndexChanged.connect(self.update_sections)
# Initial build
self.update_sections(0)
@@ -5147,8 +5318,8 @@ class MainApplication(QMainWindow):
self.files_results[file_path] = result_tuple
# Initialize storage
# TODO: Is this check needed?
if not hasattr(self, 'raw_haemo_dict'):
# TODO: Is this check needed? Edit: yes very much so
if getattr(self, 'raw_haemo_dict', None) is None:
self.raw_haemo_dict = {}
self.epochs_dict = {}
self.fig_bytes_dict = {}
@@ -5748,7 +5919,7 @@ def show_critical_error(error_msg):
log_link = f"file:///{log_path2}"
autosave_link = f"file:///{autosave_path}"
window.save_project(True)
window.save_project(True) #TODO: If the window is the one to crash, the file can't get saved. Could be fine as the window is what was storing the data to begin with?
message = (
"FLARES has encountered an unrecoverable error and needs to close.<br><br>"