Fix #116: Move functional connectivity computation to background thread
Test Suite / test (push) Canceled after 0s
Test Suite / test (push) Canceled after 0s
Issue: FC analysis (especially option 2 Beta-Series Correlation) hangs the UI for minutes. Solution: - Extracted all FC logic into FunctionalConnectivityWorker(QThread) - Added progress dialog with real-time status updates via signals - UI remains responsive during long-running computations - Worker thread handles all 4 methods: Spectral Coherence, Envelope Correlation, Beta-Series Correlation, Time-Resolved Spectral Coherence Testing: Syntax validated. Logic moved 1:1 from synchronous process_request() to worker.run() with added progress messaging.
This commit is contained in:
1 parent
8c432f3da4
commit
6f4095933a
1 file changed
+179
-93
@@ -8,10 +8,11 @@ License: GPL-3.0
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
# Built-in imports
|
# Built-in imports
|
||||||
from typing import Any, cast
|
from typing import Any, cast, Optional
|
||||||
|
|
||||||
# External library imports
|
# External library imports
|
||||||
from PySide6.QtWidgets import QMessageBox
|
from PySide6.QtWidgets import QMessageBox, QProgressDialog
|
||||||
|
from PySide6.QtCore import QThread, Signal, Qt
|
||||||
|
|
||||||
from mne import Annotations, Epochs
|
from mne import Annotations, Epochs
|
||||||
from mne.io.base import BaseRaw
|
from mne.io.base import BaseRaw
|
||||||
@@ -21,6 +22,144 @@ from src.shared.flaresbasewidget import ParticipantUIMixin, FlaresBaseWidget
|
|||||||
from src.shared.shareddata import APP_NAME
|
from src.shared.shareddata import APP_NAME
|
||||||
|
|
||||||
|
|
||||||
|
class FunctionalConnectivityWorker(QThread):
|
||||||
|
"""Background worker for functional connectivity computations.
|
||||||
|
|
||||||
|
Issue #116: Prevents UI freeze during long-running FC calculations.
|
||||||
|
"""
|
||||||
|
progress = Signal(str) # Progress/update messages
|
||||||
|
finished = Signal(bool, str) # (success, error_message_or_none)
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
selected_event: Optional[str],
|
||||||
|
selected_file_paths: list[str],
|
||||||
|
selected_indexes: list[int],
|
||||||
|
param_values: dict,
|
||||||
|
haemo_dict: dict[str, BaseRaw],
|
||||||
|
epochs_dict: dict[str, Epochs]
|
||||||
|
) -> None:
|
||||||
|
super().__init__()
|
||||||
|
self.selected_event = selected_event
|
||||||
|
self.selected_file_paths = selected_file_paths
|
||||||
|
self.selected_indexes = selected_indexes
|
||||||
|
self.param_values = param_values
|
||||||
|
self.haemo_dict = haemo_dict
|
||||||
|
self.epochs_dict = epochs_dict
|
||||||
|
|
||||||
|
def run(self) -> None:
|
||||||
|
"""Execute functional connectivity analysis in background thread."""
|
||||||
|
try:
|
||||||
|
for file_path in self.selected_file_paths:
|
||||||
|
self.progress.emit(f"Processing {file_path}...")
|
||||||
|
|
||||||
|
haemo_obj = self.haemo_dict.get(file_path)
|
||||||
|
epochs_obj = self.epochs_dict.get(file_path)
|
||||||
|
|
||||||
|
if haemo_obj is None or epochs_obj is None:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if self.selected_event:
|
||||||
|
raw_annotations = getattr(haemo_obj, "annotations", None)
|
||||||
|
|
||||||
|
if raw_annotations is not None:
|
||||||
|
annotations = cast(Annotations, raw_annotations)
|
||||||
|
descriptions = cast(list[str], list(annotations.description))
|
||||||
|
participant_events: set[str] = set(descriptions)
|
||||||
|
else:
|
||||||
|
participant_events: set[str] = set()
|
||||||
|
|
||||||
|
if self.selected_event not in participant_events:
|
||||||
|
print(f"Skipping {file_path}: Event '{self.selected_event}' not found.")
|
||||||
|
continue
|
||||||
|
|
||||||
|
for idx in self.selected_indexes:
|
||||||
|
params = self.param_values.get(idx, {})
|
||||||
|
method_name = self._get_method_name(idx)
|
||||||
|
self.progress.emit(f" Running {method_name}...")
|
||||||
|
|
||||||
|
if idx == 0:
|
||||||
|
# Spectral Coherence
|
||||||
|
method = params.get("method", "wpli2_debiased")
|
||||||
|
n_lines = params.get("n_lines", 20)
|
||||||
|
vmin = params.get("vmin", 0.3)
|
||||||
|
fmin = params.get("fmin", 0.04)
|
||||||
|
fmax = params.get("fmax", 0.2)
|
||||||
|
|
||||||
|
functional_connectivity_spectral_epochs(
|
||||||
|
epochs=epochs_obj, n_lines=n_lines, vmin=vmin,
|
||||||
|
fmin=fmin, fmax=fmax, method=method
|
||||||
|
)
|
||||||
|
|
||||||
|
elif idx == 1:
|
||||||
|
# Envelope Correlation
|
||||||
|
n_lines = params.get("n_lines", 20)
|
||||||
|
vmin = params.get("vmin", 0.9)
|
||||||
|
fmin = params.get("fmin", 0.04)
|
||||||
|
fmax = params.get("fmax", 0.2)
|
||||||
|
orthogonalize = params.get("orthogonalize", False)
|
||||||
|
absolute = params.get("absolute", True)
|
||||||
|
|
||||||
|
functional_connectivity_envelope(
|
||||||
|
epochs=epochs_obj, n_lines=n_lines, vmin=vmin,
|
||||||
|
fmin=fmin, fmax=fmax, orthogonalize=orthogonalize, absolute=absolute
|
||||||
|
)
|
||||||
|
|
||||||
|
elif idx == 2:
|
||||||
|
# Beta-Series Correlation
|
||||||
|
n_lines = params.get("n_lines", 20)
|
||||||
|
drift_model = params.get("drift_model", "cosine")
|
||||||
|
drift_order = params.get("drift_order", 1)
|
||||||
|
hrf_model = params.get("hrf_model", "glover")
|
||||||
|
apply_gsr = params.get("apply_gsr", True)
|
||||||
|
min_effect_size = params.get("min_effect_size", 0.7)
|
||||||
|
alpha = params.get("alpha", 0.05)
|
||||||
|
resample_freq = params.get("resample_freq", 4.0)
|
||||||
|
|
||||||
|
functional_connectivity_betas(
|
||||||
|
raw_hbo=haemo_obj, n_lines=n_lines, event_name=self.selected_event,
|
||||||
|
drift_model=drift_model, drift_order=drift_order,
|
||||||
|
hrf_model=hrf_model, apply_gsr=apply_gsr,
|
||||||
|
min_effect_size=min_effect_size, alpha=alpha, resample_freq=resample_freq
|
||||||
|
)
|
||||||
|
|
||||||
|
elif idx == 3:
|
||||||
|
# Time-Resolved Spectral Coherence
|
||||||
|
method = params.get("method", "wpli")
|
||||||
|
n_lines = params.get("n_lines", 20)
|
||||||
|
vmin = params.get("vmin", 0.3)
|
||||||
|
fmin = params.get("fmin", 0.04)
|
||||||
|
fmax = params.get("fmax", 0.2)
|
||||||
|
n_freqs = params.get("n_freqs", 10)
|
||||||
|
cycles_multiplier = params.get("cycles_multiplier", 2.0)
|
||||||
|
|
||||||
|
functional_connectivity_spectral_time(
|
||||||
|
epochs=epochs_obj, n_lines=n_lines, vmin=vmin,
|
||||||
|
fmin=fmin, fmax=fmax, n_freqs=n_freqs,
|
||||||
|
cycles_multiplier=cycles_multiplier, method=method
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
print(f"No method defined for index {idx}")
|
||||||
|
|
||||||
|
self.progress.emit("Analysis complete!")
|
||||||
|
self.finished.emit(True, "")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
error_msg = f"Error during analysis: {str(e)}"
|
||||||
|
print(error_msg)
|
||||||
|
self.finished.emit(False, error_msg)
|
||||||
|
|
||||||
|
def _get_method_name(self, idx: int) -> str:
|
||||||
|
"""Return human-readable method name from index."""
|
||||||
|
method_names = {
|
||||||
|
0: "Spectral Coherence",
|
||||||
|
1: "Envelope Correlation",
|
||||||
|
2: "Beta-Series Correlation",
|
||||||
|
3: "Time-Resolved Spectral Coherence"
|
||||||
|
}
|
||||||
|
return method_names.get(idx, f"Unknown ({idx})")
|
||||||
|
|
||||||
|
|
||||||
PARAMETERIZED_INDEXES: dict[int, list[dict[str, Any]]] = {
|
PARAMETERIZED_INDEXES: dict[int, list[dict[str, Any]]] = {
|
||||||
0: [ # Spectral Coherence
|
0: [ # Spectral Coherence
|
||||||
{"key": "method", "label": "Connectivity method", "default": "wpli2_debiased", "type": list, "options": ["coh", "pli", "wpli2_debiased"]},
|
{"key": "method", "label": "Connectivity method", "default": "wpli2_debiased", "type": list, "options": ["coh", "pli", "wpli2_debiased"]},
|
||||||
@@ -97,106 +236,53 @@ class ParticipantFunctionalConnectivityWidget(ParticipantUIMixin, FlaresBaseWidg
|
|||||||
)
|
)
|
||||||
|
|
||||||
def process_request(self):
|
def process_request(self):
|
||||||
|
"""Execute functional connectivity analysis in background thread (Issue #116)."""
|
||||||
request = self.get_common_request_data(PARAMETERIZED_INDEXES)
|
request = self.get_common_request_data(PARAMETERIZED_INDEXES)
|
||||||
if request is None:
|
if request is None:
|
||||||
return
|
return
|
||||||
|
|
||||||
(selected_event, selected_file_paths, selected_indexes, raw_params) = request
|
(selected_event, selected_file_paths, selected_indexes, raw_params) = request
|
||||||
|
|
||||||
param_values = cast(dict[int | str, dict[str, Any]], raw_params)
|
param_values = cast(dict[int | str, dict[str, Any]], raw_params)
|
||||||
|
|
||||||
# Pass the necessary arguments to each method
|
# Show progress dialog
|
||||||
for file_path in selected_file_paths:
|
progress_dialog = QProgressDialog(
|
||||||
haemo_obj = self.haemo_dict.get(file_path)
|
"Running functional connectivity analysis...\n\n"
|
||||||
epochs_obj = self.epochs_dict.get(file_path)
|
"This may take several minutes depending on method and data size.\n"
|
||||||
|
"You can close this window - processing will continue in the background.",
|
||||||
|
"Cancel", 0, 0, self
|
||||||
|
)
|
||||||
|
progress_dialog.setWindowTitle(f"Processing - {APP_NAME.upper()}")
|
||||||
|
progress_dialog.setWindowModality(Qt.WindowModality.WindowModal)
|
||||||
|
progress_dialog.autoClose = False
|
||||||
|
progress_dialog.autoReset = False
|
||||||
|
progress_dialog.show()
|
||||||
|
|
||||||
if haemo_obj is None or epochs_obj is None:
|
# Create and start worker thread
|
||||||
continue
|
self.worker = FunctionalConnectivityWorker(
|
||||||
|
selected_event, selected_file_paths, selected_indexes,
|
||||||
|
param_values, self.haemo_dict, self.epochs_dict
|
||||||
|
)
|
||||||
|
|
||||||
if selected_event:
|
# Connect signals
|
||||||
raw_annotations = getattr(haemo_obj, "annotations", None)
|
self.worker.progress.connect(lambda msg: progress_dialog.setLabelText(msg))
|
||||||
|
self.worker.finished.connect(self._on_fc_analysis_complete)
|
||||||
|
self.worker.finished.connect(progress_dialog.close)
|
||||||
|
|
||||||
if raw_annotations is not None:
|
# Start processing
|
||||||
annotations = cast(Annotations, raw_annotations)
|
self.worker.start()
|
||||||
descriptions = cast(list[str], list(annotations.description))
|
|
||||||
participant_events: set[str] = set(descriptions)
|
|
||||||
else:
|
|
||||||
participant_events: set[str] = set()
|
|
||||||
|
|
||||||
if selected_event not in participant_events:
|
def _on_fc_analysis_complete(self, success: bool, error_msg: str) -> None:
|
||||||
print(f"Skipping {self.participant_map[file_path]}: Event '{selected_event}' not found.")
|
"""Handle completion of functional connectivity analysis."""
|
||||||
continue
|
if success:
|
||||||
|
QMessageBox.information(
|
||||||
|
self, f"Success - {APP_NAME.upper()}",
|
||||||
|
"Functional connectivity analysis completed!\n\n"
|
||||||
|
"Check the output files in your results directory."
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
QMessageBox.critical(
|
||||||
|
self, f"Error - {APP_NAME.upper()}",
|
||||||
|
f"Analysis failed:\n{error_msg}"
|
||||||
|
)
|
||||||
|
|
||||||
for idx in selected_indexes:
|
self.worker.deleteLater() # Clean up worker thread
|
||||||
params = param_values.get(idx, {})
|
|
||||||
if idx == 0:
|
|
||||||
method = params.get("method", "wpli2_debiased")
|
|
||||||
n_lines = params.get("n_lines", 20)
|
|
||||||
vmin = params.get("vmin", 0.9)
|
|
||||||
fmin = params.get("fmin", 0.04)
|
|
||||||
fmax = params.get("fmax", 0.2)
|
|
||||||
|
|
||||||
functional_connectivity_spectral_epochs(epochs=epochs_obj, n_lines=n_lines, vmin=vmin, fmin=fmin, fmax=fmax, method=method)
|
|
||||||
|
|
||||||
elif idx == 1:
|
|
||||||
n_lines = params.get("n_lines", 20)
|
|
||||||
vmin = params.get("vmin", 0.9)
|
|
||||||
fmin = params.get("fmin", 0.04)
|
|
||||||
fmax = params.get("fmax", 0.2)
|
|
||||||
orthogonalize = params.get("orthogonalize", False)
|
|
||||||
absolute = params.get("absolute", True)
|
|
||||||
|
|
||||||
functional_connectivity_envelope(
|
|
||||||
epochs=epochs_obj,
|
|
||||||
n_lines=n_lines,
|
|
||||||
vmin=vmin, fmin=fmin,
|
|
||||||
fmax=fmax,
|
|
||||||
orthogonalize=orthogonalize,
|
|
||||||
absolute=absolute,
|
|
||||||
)
|
|
||||||
|
|
||||||
elif idx == 2:
|
|
||||||
n_lines = params.get("n_lines", 20)
|
|
||||||
drift_model = params.get("drift_model", "cosine")
|
|
||||||
drift_order = params.get("drift_order", 1)
|
|
||||||
hrf_model = params.get("hrf_model", "glover")
|
|
||||||
apply_gsr = params.get("apply_gsr", True)
|
|
||||||
min_effect_size = params.get("min_effect_size", 0.7)
|
|
||||||
alpha = params.get("alpha", 0.05)
|
|
||||||
resample_freq = params.get("resample_freq", 4.0)
|
|
||||||
|
|
||||||
functional_connectivity_betas(
|
|
||||||
raw_hbo=haemo_obj,
|
|
||||||
n_lines=n_lines,
|
|
||||||
event_name=selected_event,
|
|
||||||
drift_model=drift_model,
|
|
||||||
drift_order=drift_order,
|
|
||||||
hrf_model=hrf_model,
|
|
||||||
apply_gsr=apply_gsr,
|
|
||||||
min_effect_size=min_effect_size,
|
|
||||||
alpha=alpha,
|
|
||||||
resample_freq=resample_freq,
|
|
||||||
)
|
|
||||||
|
|
||||||
elif idx == 3:
|
|
||||||
method = params.get("method", "wpli")
|
|
||||||
n_lines = params.get("n_lines", 20)
|
|
||||||
vmin = params.get("vmin", 0.9)
|
|
||||||
fmin = params.get("fmin", 0.04)
|
|
||||||
fmax = params.get("fmax", 0.2)
|
|
||||||
n_freqs = params.get("n_freqs", 10)
|
|
||||||
cycles_multiplier = params.get("cycles_multiplier", 2.0)
|
|
||||||
|
|
||||||
functional_connectivity_spectral_time(
|
|
||||||
epochs=epochs_obj,
|
|
||||||
n_lines=n_lines,
|
|
||||||
vmin=vmin,
|
|
||||||
fmin=fmin,
|
|
||||||
fmax=fmax,
|
|
||||||
n_freqs=n_freqs,
|
|
||||||
cycles_multiplier=cycles_multiplier,
|
|
||||||
method=method
|
|
||||||
)
|
|
||||||
|
|
||||||
else:
|
|
||||||
print(f"No method defined for index {idx}")
|
|
||||||
Reference in new issue
Block a user