functional connectivity, pylance, and other improvements

This commit is contained in:
2026-08-22 23:38:13 -07:00
parent 19bd3f1279
commit e37275a1bb
14 changed files with 1455 additions and 841 deletions
+21 -7
View File
@@ -1,14 +1,28 @@
# Version 1.6.1 # Version 1.6.1
- Fixed an issue where file associations appeared to work but would not load the project on macOS - Renamed all instances of "Inter" to properly read as "Intra" and changed "Cross" to now read as "Inter"
- Fixed an issue where file associations would refuse to associate on macOS - Changed RESAMPLE to only apply where it is required to avoid having Functional Connectivity analysis methods running on data that has been resampled
- Added parameters that appear when attempting to generate results from the Participant and Intra-Group Functional Connectivity viewers and removed the non-functional placeholder parameters
- Modified the Participant and Intra-Group Functional Connectivity analysis options to better perform their expected tasks. This remains as a BETA feature
- Removed the existing Intra-Group Functional Connectivity option and replaced it with two new ones: Beta-Series Correlation and Spectral Coherence (epochs)
- Updated the names of the methods provided for the Participant Functional Coneectivity Viewer to better match the actions they perform
- Updated the warnings for the Functional Connectivity Viewers to better represent the challenges these analysis options now face
- Added basic unit testing to hopefully prevent any accidental processing changes from occurring in the future
- Added description text to the Inter-Group and Intra-Group Brain and Image Viewers, as well as the Functional Connectivity windows to explain what output can be expected
- Removed image index 1 (Significance) from the Intra-Group Brain and Image Viewer as it is now provided more in depth with the Stats viewers
- Modified the timeout when waiting for the application to close while performing updates down to a reasonable number
- Modified the heart rate calculation to not take only one channel in the data to use, but rather an average of channels. This still prefers short channels if they are present
- Fixed an issue that could prevent log file generation while the application was in the middle of an update
- Fixed an issue where a rare crash could occur while the application was in the middle of an update
- Fixed an issue that could have passed multiple conditions when generating an Intra-Group Stats image
- Fixed an issue that could pass NaN values when attempting to collapse channels
- Fixed an issue that was causing the OLS model to always be used for brain images with multiple participants, and not the MixedLM model
- Fixed an issue that could cause the Wavelet filtering step to crash
- Fixed an issue where file associations appeared to work as intended but would not load the project on macOS and only open the application
- Fixed an issue where file associations would refuse to associate on macOS once they have attempted to be associated
- Fixed an issue where certain parameters would not enable or disable depending on other parameters when they should've - Fixed an issue where certain parameters would not enable or disable depending on other parameters when they should've
- Fixed an issue where not all widgets would close when attempting to close the application causing the application to crash - Fixed an issue where not all widgets would close when attempting to close the application causing the application to crash
- Renamed all instances of "Inter" to properly read as "Intra" and changed "Cross" to now read as "Inter" - Fixed an issue where events were not created correctly after the data had been resampled by the design matrix
- Revamped the Participant Functional Connectivity Viewer to contain descriptions of the methods like the Stats Viewers
- Modified the Participant Functional Connectivity Analysis options to better perform their tasks. This remains as a BETA feature
- Changed RESAMPLE to only apply where it is required to avoid having Functional Connectivity Analysis methods running on data that has been resampled
- Added basic unit testing to hopefully prevent any accidental processing changes from occurring in the future
# Version 1.6.0 # Version 1.6.0
+884 -419
View File
File diff suppressed because it is too large Load Diff
+45 -26
View File
@@ -1,6 +1,7 @@
""" """
Filename: flares_updater.py Filename: flares_updater.py
Description: FLARES updater executable Description: FLARES updater executable
Note: Compliant with pylance strict type checking
Author: Tyler de Zeeuw Author: Tyler de Zeeuw
License: GPL-3.0 License: GPL-3.0
@@ -15,8 +16,11 @@ import psutil
import shutil import shutil
import platform import platform
import subprocess import subprocess
from typing import Union
from pathlib import Path
from datetime import datetime from datetime import datetime
PLATFORM_NAME = platform.system().lower() PLATFORM_NAME = platform.system().lower()
APP_NAME = "flares" APP_NAME = "flares"
@@ -27,13 +31,14 @@ else:
LOG_FILE = _log_path LOG_FILE = _log_path
def log(msg):
def log(msg: str) -> None:
with open(LOG_FILE, "a", encoding="utf-8") as f: with open(LOG_FILE, "a", encoding="utf-8") as f:
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S") timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
f.write(f"{timestamp} - {msg}\n") f.write(f"{timestamp} - {msg}\n")
def kill_all_processes_by_executable(exe_path): def kill_all_processes_by_executable(exe_path: Union[str, Path]) -> bool:
terminated_any = False terminated_any = False
exe_path = os.path.realpath(exe_path) exe_path = os.path.realpath(exe_path)
@@ -65,7 +70,7 @@ def kill_all_processes_by_executable(exe_path):
return terminated_any return terminated_any
def _terminate_process(proc): def _terminate_process(proc: psutil.Process) -> None:
try: try:
proc.terminate() proc.terminate()
proc.wait(timeout=10) proc.wait(timeout=10)
@@ -77,7 +82,7 @@ def _terminate_process(proc):
log(f"Process {proc.pid} killed.") log(f"Process {proc.pid} killed.")
def wait_for_unlock(path, timeout=100): def wait_for_unlock(path: Union[str, Path], timeout: Union[int, float] = 100) -> None:
start_time = time.time() start_time = time.time()
while time.time() - start_time < timeout: while time.time() - start_time < timeout:
try: try:
@@ -93,7 +98,7 @@ def wait_for_unlock(path, timeout=100):
log(f"Failed to delete after wait: {path}") log(f"Failed to delete after wait: {path}")
def delete_path(path): def delete_path(path: Union[str, Path]) -> None:
if os.path.exists(path): if os.path.exists(path):
try: try:
if os.path.isdir(path): if os.path.isdir(path):
@@ -106,7 +111,7 @@ def delete_path(path):
log(f"Error deleting {path}: {e}") log(f"Error deleting {path}: {e}")
def copy_update_files(src_folder, dest_folder, updater_name): def copy_update_files(src_folder: Union[str, Path], dest_folder: Union[str, Path], updater_name: str) -> None:
for item in os.listdir(src_folder): for item in os.listdir(src_folder):
if item.lower() == updater_name.lower(): if item.lower() == updater_name.lower():
log(f"Skipping updater executable: {item}") log(f"Skipping updater executable: {item}")
@@ -125,7 +130,7 @@ def copy_update_files(src_folder, dest_folder, updater_name):
log(f"Error copying {s} -> {d}: {e}") log(f"Error copying {s} -> {d}: {e}")
def copy_update_files_darwin(src_folder, dest_folder, updater_name): def copy_update_files_darwin(src_folder: Union[str, Path], dest_folder: Union[str, Path], updater_name: str) -> None:
updater_name = updater_name + ".app" updater_name = updater_name + ".app"
@@ -147,19 +152,33 @@ def copy_update_files_darwin(src_folder, dest_folder, updater_name):
log(f"Error copying {s} -> {d}: {e}") log(f"Error copying {s} -> {d}: {e}")
def remove_quarantine(app_path): def remove_quarantine(app_path: Union[str, Path]) -> bool:
"""Removes the macOS quarantine extended attribute from an application bundle using osascript.
Returns True on success, False on error or cancellation.
"""
clean_path: str = str(app_path)
escaped_path: str = shlex.quote(clean_path)
script = f''' script = f'''
do shell script "xattr -d -r com.apple.quarantine {shlex.quote(app_path)}" with administrator privileges with prompt "{APP_NAME} needs privileges to finish the update. (1/2)" do shell script "xattr -d -r com.apple.quarantine {escaped_path}" with administrator privileges with prompt "{APP_NAME} needs privileges to finish the update. (1/2)"
''' '''
try: try:
subprocess.run(['osascript', '-e', script], check=True) subprocess.run(["osascript", "-e", script], check=True)
print("✅ Quarantine attribute removed.") print("✅ Quarantine attribute removed.")
return True
except subprocess.CalledProcessError as e: except subprocess.CalledProcessError as e:
print("❌ Failed to remove quarantine attribute.") print("❌ Failed to remove quarantine attribute.")
print(e) print(e)
return False
def main(): def main():
main_exe: str = ""
app_dir: Path = Path()
bundle_dir: Path = Path()
parent_bundle_dir: Path = Path()
try: try:
log(f"[Updater] sys.argv: {sys.argv}") log(f"[Updater] sys.argv: {sys.argv}")
@@ -171,10 +190,10 @@ def main():
main_exe = sys.argv[2] main_exe = sys.argv[2]
# Interesting naming convention # Interesting naming convention
parent_dir = os.path.dirname(os.path.abspath(main_exe)) main_exe_path = Path(main_exe).resolve()
pparent_dir = os.path.dirname(parent_dir) app_dir = main_exe_path.parent
ppparent_dir = os.path.dirname(pparent_dir) bundle_dir = main_exe_path.parents[2]
pppparent_dir = os.path.dirname(ppparent_dir) parent_bundle_dir = main_exe_path.parents[3]
updater_name = os.path.basename(sys.argv[0]) updater_name = os.path.basename(sys.argv[0])
@@ -183,13 +202,13 @@ def main():
log(f"Main EXE: {main_exe}") log(f"Main EXE: {main_exe}")
log(f"Updater EXE: {updater_name}") log(f"Updater EXE: {updater_name}")
if PLATFORM_NAME == 'darwin': if PLATFORM_NAME == 'darwin':
log(f"Main App Folder: {ppparent_dir}") log(f"Main App Folder: {bundle_dir}")
# Kill all instances of main app # Kill all instances of main app
kill_all_processes_by_executable(main_exe) kill_all_processes_by_executable(main_exe)
# Wait until main_exe process is fully gone (polling) # Wait until main_exe process is fully gone (polling)
for _ in range(20): # wait max 10 seconds for _ in range(10): # wait max 10 seconds
running = False running = False
for proc in psutil.process_iter(['exe', 'cmdline']): for proc in psutil.process_iter(['exe', 'cmdline']):
try: try:
@@ -215,17 +234,17 @@ def main():
# Delete old version files # Delete old version files
if PLATFORM_NAME == 'darwin': if PLATFORM_NAME == 'darwin':
log(f'Attempting to delete {ppparent_dir}') log(f'Attempting to delete {bundle_dir}')
delete_path(ppparent_dir) delete_path(str(bundle_dir))
update_folder = os.path.join(sys.argv[1], f"{APP_NAME}-darwin") update_folder = os.path.join(sys.argv[1], f"{APP_NAME}-darwin")
copy_update_files_darwin(update_folder, pppparent_dir, updater_name) copy_update_files_darwin(update_folder, str(parent_bundle_dir), updater_name)
else: else:
delete_path(main_exe) delete_path(main_exe)
wait_for_unlock(os.path.join(parent_dir, "_internal")) wait_for_unlock(os.path.join(str(app_dir), "_internal"))
# Copy new files excluding the updater itself # Copy new files excluding the updater itself
copy_update_files(update_folder, parent_dir, updater_name) copy_update_files(update_folder, str(app_dir), updater_name)
except Exception as e: except Exception as e:
log(f"Something went wrong: {e}") log(f"Something went wrong: {e}")
@@ -237,13 +256,13 @@ def main():
log("Added executable bit") log("Added executable bit")
if PLATFORM_NAME == 'darwin': if PLATFORM_NAME == 'darwin':
os.chmod(ppparent_dir, 0o755) os.chmod(str(bundle_dir), 0o755)
log("Added executable bit") log("Added executable bit")
remove_quarantine(ppparent_dir) remove_quarantine(str(bundle_dir))
log(f"Removed the quarantine flag on {ppparent_dir}") log(f"Removed the quarantine flag on {bundle_dir}")
subprocess.Popen(['open', ppparent_dir, "--args", "--finish-update"]) subprocess.Popen(['open', str(bundle_dir), "--args", "--finish-update"])
else: else:
subprocess.Popen([main_exe, "--finish-update"], cwd=parent_dir) subprocess.Popen([main_exe, "--finish-update"], cwd=str(app_dir))
log("Relaunched main app.") log("Relaunched main app.")
except Exception as e: except Exception as e:
+3 -7
View File
@@ -1,11 +1,7 @@
src\analysis\participantfoldchannels.py 379 src\analysis\participantfoldchannels.py 157
src\shared\flaresbasewidget.py 1001+ src\shared\flaresbasewidget.py 1001+
src\window\updateevents.py 193 src\window\updateevents.py 151
src\window\updateoptodes.py 59
src\viewerlauncher.py 71
flares_updater.py 83
flares.py 1001+ flares.py 1001+
main_unit_tests.py 153 main_unit_tests.py 153
main.py 709 main.py 709
project_manager.py 407 project_manager.py 407
updater.py 243
+8 -1
View File
@@ -58,6 +58,13 @@ PARAMETERIZED_INDEXES: dict[int, list[dict[str, Any]]] = {
} }
DESCRIPTION = """\n1. Group Contrast 2D/3D (plot_2d_3d_contrasts_between_groups)
\nCompares two participant groups' contrast results (e.g. condition-vs-baseline effects) channel-by-channel, fitting a mixed-effects model with group, channel, and chromophore as factors. Produces BOTH directions of the contrast (Group A minus Group B, and Group B minus Group A) as separate plots, so the sign convention is explicit either way you read it.
\nis_3d controls the display: True renders a 3D weighted brain map per contrast direction (same rendering as intra method 1, but showing the between-group difference rather than a single group's estimate); False renders a 2D topographic map instead, which is faster and sometimes easier to read at a glance for a whole-head pattern.
\nA channel is only included if BOTH groups have at least min_participants_per_group (default 2) contributing participants for that channel - channels present in only one group, or with too few participants in either group to estimate within-group variance, are dropped before fitting. If this drops too many channels, check that both groups have enough participants with usable data for the selected event/channels.
\nAs with other mixed-effects models in this app, small participant counts can produce convergence warnings; when that happens, the model falls back to pooled OLS, which does not account for the repeated-measures structure of the data and may understate uncertainty - treat results run this way with extra caution.
"""
class InterGroupBrainImageWidget(InterGroupUIMixin, FlaresBaseWidget): class InterGroupBrainImageWidget(InterGroupUIMixin, FlaresBaseWidget):
def __init__( def __init__(
@@ -77,7 +84,7 @@ class InterGroupBrainImageWidget(InterGroupUIMixin, FlaresBaseWidget):
self.contrast_results_dict = contrast_results_dict self.contrast_results_dict = contrast_results_dict
self.group_dict = group_dict self.group_dict = group_dict
self.setup_inter_group_ui(["0 (Contrast Image)"]) self.setup_inter_group_ui(["0 (Group Contrast 2D/3D)"], placeholder_text=DESCRIPTION)
def process_request(self): def process_request(self):
+10 -44
View File
@@ -39,20 +39,6 @@ PARAMETERIZED_INDEXES: dict[int, list[dict[str, Any]]] = {
} }
], ],
1: [ 1: [
{
"key": "p_value",
"label": "Significance threshold P-value (e.g. 0.05)",
"default": "0.05",
"type": float,
},
{
"key": "graph_bounds",
"label": "Graph Upper/Lower Limit",
"default": "3.0",
"type": float,
}
],
2: [
{ {
"key": "show_optodes", "key": "show_optodes",
"label": "Determine what is rendered above the brain. Valid values are 'sensors', 'labels', 'none', 'all'.", "label": "Determine what is rendered above the brain. Valid values are 'sensors', 'labels', 'none', 'all'.",
@@ -81,6 +67,15 @@ PARAMETERIZED_INDEXES: dict[int, list[dict[str, Any]]] = {
} }
DESCRIPTION = """0. FIR Model Results (plot_fir_model_results)
\nCURRENTLY NON-FUNCTIONAL. This method requires per-FIR-delay Condition rows (e.g. "Tapping_delay_3") to plot the shape of the evoked response over time. The dataframe it receives (df_ind_dict) has already had delay information collapsed away upstream in generate_roi_results, regardless of HRF model setting - so this will always fail with an empty-data error. Needs an uncollapsed, per-delay ROI dataframe threaded through separately before it can work again.
\n1. Brain Activity Visualization (brain_3d_visualization)
\nRenders a single group's (or single participant's) channel-level GLM estimates (t or theta values) as a 3D weighted brain map. Fits a mixed-effects model across participants (falling back to OLS for a single participant) to get one estimate per channel, then displays it on a template brain surface with optional optode/sensor overlay.
\nUses collapsed (non-FIR-delay) condition data - shows the overall magnitude of the response per channel, not its time course. Geometry for multi-participant views is averaged across participants' actual optode positions where available; channels or optodes missing valid 3D coordinates for every participant are silently excluded from the map.
"""
class IntraGroupBrainImageWidget(IntraGroupUIMixin, FlaresBaseWidget): class IntraGroupBrainImageWidget(IntraGroupUIMixin, FlaresBaseWidget):
def __init__( def __init__(
self, self,
@@ -101,7 +96,7 @@ class IntraGroupBrainImageWidget(IntraGroupUIMixin, FlaresBaseWidget):
self.contrast_results_dict = contrast_results_dict self.contrast_results_dict = contrast_results_dict
self.group_dict = group_dict self.group_dict = group_dict
self.setup_intra_group_ui(["0 (GLM Results)", "1 (Significance)", "2 (Brain Activity Visualization)",]) self.setup_intra_group_ui(["0 (GLM Results)", "1 (Brain Activity Visualization)"], placeholder_text=DESCRIPTION)
def process_request(self): def process_request(self):
@@ -162,35 +157,9 @@ class IntraGroupBrainImageWidget(IntraGroupUIMixin, FlaresBaseWidget):
print(f"Missing parameters for index {idx}, skipping.") print(f"Missing parameters for index {idx}, skipping.")
continue continue
plot_fir_model_results(df_group, p_haemo, p_design_matrix, selected_event, lower_bound, upper_bound) plot_fir_model_results(df_group, p_haemo, p_design_matrix, selected_event, lower_bound, upper_bound)
elif idx == 1: elif idx == 1:
params = param_values.get(idx, {})
p_val = params.get("p_value", None)
graph_bounds = params.get("graph_bounds", None)
if p_val is None or graph_bounds is None:
print(f"Missing parameters for index {idx}, skipping.")
continue
all_contrasts: list[DataFrame] = []
for fp in selected_file_paths:
condition_dfs = self.contrast_results_dict.get(fp, {})
if selected_event in condition_dfs:
df = condition_dfs[selected_event].copy()
df["ID"] = fp
all_contrasts.append(df)
if not all_contrasts:
print("No contrast data found for selected participants and event.")
return
# TODO: look at intergroupstats and figure out what to do
_ = pd.concat(all_contrasts, ignore_index=True)
#flares.run_second_level_analysis(df_contrasts, p_haemo, p_val, graph_bounds)
elif idx == 2:
params = param_values.get(idx, {}) params = param_values.get(idx, {})
show_optodes = params.get("show_optodes", None) show_optodes = params.get("show_optodes", None)
t_or_theta = params.get("t_or_theta", None) t_or_theta = params.get("t_or_theta", None)
@@ -213,8 +182,5 @@ class IntraGroupBrainImageWidget(IntraGroupUIMixin, FlaresBaseWidget):
brain_3d_visualization(processed_raw, all_cha, selected_event, t_or_theta=t_or_theta, show_optodes=show_optodes, show_text=show_text, brain_bounds=brain_bounds) brain_3d_visualization(processed_raw, all_cha, selected_event, t_or_theta=t_or_theta, show_optodes=show_optodes, show_text=show_text, brain_bounds=brain_bounds)
elif idx == 3:
pass
else: else:
print(f"No method defined for index {idx}") print(f"No method defined for index {idx}")
@@ -14,70 +14,122 @@ from typing import Any, cast
# External library imports # External library imports
from PySide6.QtWidgets import QMessageBox from PySide6.QtWidgets import QMessageBox
from mne import Epochs
from mne.io.base import BaseRaw from mne.io.base import BaseRaw
from flares import run_group_functional_connectivity from flares import run_group_functional_connectivity_betas, run_group_functional_connectivity_epochs
from src.shared.flaresbasewidget import IntraGroupUIMixin, FlaresBaseWidget from src.shared.flaresbasewidget import IntraGroupUIMixin, FlaresBaseWidget
from src.shared.shareddata import APP_NAME from src.shared.shareddata import APP_NAME
PARAMETERIZED_INDEXES: dict[int, list[dict[str, Any]]] = { PARAMETERIZED_INDEXES: dict[int, list[dict[str, Any]]] = {
0: [ 0: [ # Beta-Series Correlation
{ {"key": "n_lines", "label": "Number of strongest connections to draw", "default": "20", "type": int},
"key": "n_lines", {"key": "vmin", "label": "Minimum |r| to display (group average)", "default": "0.5", "type": float},
"label": "<Description>", {"key": "drift_model", "label": "Drift model", "default": "cosine", "type": list, "options": ["cosine", "polynomial"]},
"default": "20", {"key": "drift_order", "label": "Drift order", "default": "1", "type": int},
"type": int, {"key": "hrf_model", "label": "HRF model", "default": "glover", "type": list, "options": ["glover", "spm", "fir"]},
}, {"key": "apply_gsr", "label": "Apply Global Signal Regression", "default": "True", "type": bool},
{ {"key": "resample_freq", "label": "Resample rate before GLM fit (Hz) - lower is much faster", "default": "4.0", "type": float},
"key": "vmin", {"key": "alpha", "label": "FDR significance threshold (group-level)", "default": "0.05", "type": float},
"label": "<Description>", {"key": "min_participants", "label": "Minimum participants required to run the group test", "default": "3", "type": int},
"default": "0.9", ],
"type": float, 1: [ # Spectral Coherence
}, {"key": "method", "label": "Connectivity method", "default": "wpli2_debiased", "type": list, "options": ["coh", "pli", "wpli2_debiased"]},
{"key": "n_lines", "label": "Number of strongest connections to draw", "default": "20", "type": int},
{"key": "vmin", "label": "Minimum |r| to display (group average)", "default": "0.5", "type": float},
{"key": "fmin", "label": "Lower frequency bound (Hz)", "default": "0.04", "type": float},
{"key": "fmax", "label": "Upper frequency bound (Hz)", "default": "0.2", "type": float},
{"key": "alpha", "label": "FDR significance threshold (group-level)", "default": "0.05", "type": float},
{"key": "min_participants", "label": "Minimum participants required to run the group test", "default": "3", "type": int},
], ],
} }
DESCRIPTION = """0. Beta-Series Correlation (run_group_functional_connectivity_betas)
\nFor each selected participant, resamples to resample_freq (default 4 Hz - well above what's needed to resolve trial-level GLM amplitudes, but far cheaper than running the fit at full acquisition rate) and computes trial-level GLM betas per channel, correlating them within-subject WITHOUT thresholding at the individual level. Those raw per-subject correlation matrices are Fisher-Z transformed and combined across the group using a one-sample t-test (against zero) per channel pair, then FDR-corrected (q < alpha) across all pairs. A significant connection means the group, on average, shows consistent trial-evoked co-activation between two channels - not that every individual participant showed it.
\nRequires at least min_participants (default 3, more is stronger) participants with usable data - each needs enough trials of the selected event to compute their own beta series. Participants with channel sets that don't overlap with the rest of the group are excluded from the shared channel set before analysis.
\nWith a small number of participants and many channel pairs, FDR correction is often the limiting factor even when there's a real underlying effect - check the p-value histogram and top-pairs report generated alongside the main plot: a cluster of small (but not FDR-significant) p-values well below what's expected by chance suggests a real but underpowered effect, worth revisiting with more participants, rather than a true null result.
\n1. Spectral Coherence (run_group_functional_connectivity_epochs)
\nFor each selected participant, computes spectral connectivity between HbO channels using the selected method: coherence ('coh'), Phase Lag Index ('pli'), or debiased weighted PLI squared ('wpli2_debiased', default). PLI/wPLI-family methods discount zero-lag contributions to connectivity, making them substantially more robust to shared systemic/vascular signal (which tends to hit multiple channels near-simultaneously) than plain coherence - recommended over 'coh' unless you have a specific reason to want raw coherence. Raw per-subject matrices are combined across the group the same way as the Beta-Series method: Fisher-Z, one-sample t-test per channel pair, FDR correction.
\nfmin must satisfy at least 5 full oscillation cycles within your epoch length (epoch_duration x fmin >= 5) for a reliable estimate - if it doesn't, the analysis will refuse to run with an error stating the minimum viable fmin for your epoch length, rather than silently producing an unreliable result. Shorter epochs require a higher fmin, which moves you out of the classic 0.04-0.2 Hz "low-frequency oscillation" band used in longer resting-state recordings - this is a real trade-off in what the analysis measures, not just a technical constraint.
\nSame minimum-participant, channel-alignment, and underpowered-vs-null-result caveats apply as the Beta-Series method above.
"""
class IntraGroupFunctionalConnectivityWidget(IntraGroupUIMixin, FlaresBaseWidget): class IntraGroupFunctionalConnectivityWidget(IntraGroupUIMixin, FlaresBaseWidget):
def __init__( def __init__(
self, self,
haemo_dict: dict[str | Path, BaseRaw], haemo_dict: dict[str | Path, BaseRaw],
epochs_dict: dict[str, Epochs],
group_dict: dict[str, str], group_dict: dict[str, str],
config_dict: dict[str, dict[str, Any]]
) -> None: ) -> None:
super().__init__("IntraGroupFunctionalConnectivity") super().__init__("IntraGroupFunctionalConnectivity")
self.setWindowTitle(f"Intra-Group Functional Connectivity Viewer [BETA] - {APP_NAME.upper()}") self.setWindowTitle(f"Intra-Group Functional Connectivity Viewer [BETA] - {APP_NAME.upper()}")
self.haemo_dict = haemo_dict self.haemo_dict = haemo_dict
#self.group_dict = group_dict self.epochs_dict = epochs_dict
self.config_dict = config_dict self.group_dict = group_dict
QMessageBox.warning(self, f"Warning - {APP_NAME.upper()}", f"Functional Connectivity is still in development and the results should currently be taken with a grain of salt. " QMessageBox.warning(self, f"Warning - {APP_NAME.upper()}", f"Functional Connectivity is still in beta. While the results are now almost finalized, the processing is slow and it WILL hang the application for HOURS.")
"By clicking OK, you accept that the images generated may not be factual.")
self.setup_intra_group_ui(["0 (Betas)",]) self.setup_intra_group_ui(["0 (Beta-Series Correlation)", "1 (Spectral Coherence)"], placeholder_text=DESCRIPTION)
def process_request(self): def process_request(self):
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)
for idx in selected_indexes:
if idx == 0:
params = param_values.get(idx, {})
n_lines = params.get("n_lines", None)
vmin = params.get("vmin", None)
if n_lines is None or vmin is None: for idx in selected_indexes:
print(f"Missing parameters for index {idx}, skipping.") params = param_values.get(idx, {})
continue
run_group_functional_connectivity(self.haemo_dict, self.config_dict, selected_file_paths, selected_event, 50, 0.5) if idx == 0:
n_lines = params.get("n_lines", 20)
vmin = params.get("vmin", 0.5)
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)
resample_freq = params.get("resample_freq", 4.0)
alpha = params.get("alpha", 0.05)
min_participants = params.get("min_participants", 3)
run_group_functional_connectivity_betas(
self.haemo_dict, selected_file_paths, selected_event, n_lines, vmin,
drift_model=drift_model,
drift_order=drift_order,
hrf_model=hrf_model,
apply_gsr=apply_gsr,
resample_freq=resample_freq,
alpha=alpha,
min_participants=min_participants,
)
elif idx == 1:
method = params.get("method", "wpli2_debiased")
n_lines = params.get("n_lines", 20)
vmin = params.get("vmin", 0.5)
fmin = params.get("fmin", 0.04)
fmax = params.get("fmax", 0.2)
alpha = params.get("alpha", 0.05)
min_participants = params.get("min_participants", 3)
run_group_functional_connectivity_epochs(
self.epochs_dict,
selected_file_paths,
event_name=selected_event,
n_lines=n_lines,
vmin=vmin,
fmin=fmin,
method=method,
fmax=fmax,
alpha=alpha,
min_participants=min_participants,
)
else: else:
print(f"No method defined for index {idx}") print(f"No method defined for index {idx}")
+2 -6
View File
@@ -268,6 +268,7 @@ class IntraGroupStatsWidget(IntraGroupUIMixin, FlaresBaseWidget):
run_roi_second_level_analysis( run_roi_second_level_analysis(
df_roi_all=df_filtered, df_roi_all=df_filtered,
condition=selected_event,
df_cha_all=all_cha_filtered, df_cha_all=all_cha_filtered,
raw_haemo=p_haemo, raw_haemo=p_haemo,
p_threshold=p_threshold, p_threshold=p_threshold,
@@ -382,14 +383,9 @@ class IntraGroupStatsWidget(IntraGroupUIMixin, FlaresBaseWidget):
"(check regions.json channel names against this montage).") "(check regions.json channel names against this montage).")
continue continue
# TODO: Come back to this
# df_cha_all intentionally omitted (None): the topography
# section of run_roi_second_level_analysis expects
# single-condition Condition values in df_cha_all, which
# doesn't semantically match a contrast name - skip it here
# rather than pass mismatched data.
run_roi_second_level_analysis( run_roi_second_level_analysis(
df_roi_all=roi_theta, df_roi_all=roi_theta,
condition=contrast_name,
df_cha_all=None, df_cha_all=None,
raw_haemo=p_haemo, raw_haemo=p_haemo,
p_threshold=p_threshold, p_threshold=p_threshold,
+82 -92
View File
@@ -6,11 +6,16 @@ Author: Tyler de Zeeuw
License: GPL-3.0 License: GPL-3.0
""" """
# Built-in Imports
import os import os
from pathlib import Path
import time import time
import traceback import traceback
from multiprocessing import Process, current_process, Manager from multiprocessing import Process, current_process, Manager
from typing import Any, Dict, List, Optional, Tuple, Union
# External library imports
from matplotlib.backend_bases import Event
import numpy as np import numpy as np
import matplotlib.pyplot as plt import matplotlib.pyplot as plt
@@ -18,25 +23,27 @@ import matplotlib.image as mpimg
from matplotlib.figure import Figure from matplotlib.figure import Figure
from matplotlib.backends.backend_qtagg import FigureCanvasQTAgg as FigureCanvas from matplotlib.backends.backend_qtagg import FigureCanvasQTAgg as FigureCanvas
from PySide6.QtWidgets import QFrame, QGridLayout, QHBoxLayout, QLabel, QProgressBar, QPushButton, QScrollArea, QSizePolicy, QWidget, QDialog, QVBoxLayout from PySide6.QtWidgets import QFrame, QGridLayout, QHBoxLayout, QLabel, QLayout, QProgressBar, QPushButton, QScrollArea, QSizePolicy, QWidget, QDialog, QVBoxLayout
from PySide6.QtCore import QThread, Qt, QSize, QTimer from PySide6.QtCore import QThread, Qt, QSize, QTimer, QObject, Signal
from PySide6.QtGui import QPixmap, QImage from PySide6.QtGui import QCloseEvent, QMouseEvent, QPixmap, QImage
from pandas import DataFrame
from mne.io.base import BaseRaw
from src.shared.flaresbasewidget import FlaresBaseWidget from src.shared.flaresbasewidget import FlaresBaseWidget
from src.shared.shareddata import APP_NAME, resource_path from src.shared.shareddata import APP_NAME, resource_path
class MultiProgressDialog(QDialog): class MultiProgressDialog(QDialog):
def __init__(self, parent=None): def __init__(self, parent: Optional[QWidget] = None) -> None:
super().__init__(parent) super().__init__(parent)
self.setWindowTitle("fOLD Analysis Progress") self.setWindowTitle("fOLD Analysis Progress")
self.setFixedWidth(400) self.setFixedWidth(400)
self.setWindowModality(Qt.WindowModality.NonModal) self.setWindowModality(Qt.WindowModality.NonModal)
self.layout = QVBoxLayout(self) self.main_layout = QVBoxLayout(self)
self.bars = {} self.bars: Dict[str, QProgressBar] = {}
self.allow_closing = False self.allow_closing = False
def add_participant(self, label, total_steps): def add_participant(self, label: Any, total_steps: Union[int, float, str]) -> None:
clean_key = str(label).strip() clean_key = str(label).strip()
label_widget = QLabel(f"Analyzing {clean_key}...") label_widget = QLabel(f"Analyzing {clean_key}...")
pbar = QProgressBar() pbar = QProgressBar()
@@ -44,16 +51,17 @@ class MultiProgressDialog(QDialog):
pbar.setMaximum(int(total_steps)) # Ensure this is a strict integer pbar.setMaximum(int(total_steps)) # Ensure this is a strict integer
pbar.setValue(0) pbar.setValue(0)
self.layout.addWidget(label_widget) self.main_layout.addWidget(label_widget)
self.layout.addWidget(pbar) self.main_layout.addWidget(pbar)
self.bars[label] = pbar self.bars[clean_key] = pbar
def update_bar(self, label, value): def update_bar(self, label: Any, value: Union[int, float, str]) -> None:
if label in self.bars: clean_key = str(label).strip()
if clean_key in self.bars:
# Force integers to prevent QProgressBar from breaking or flickering # Force integers to prevent QProgressBar from breaking or flickering
self.bars[label].setValue(int(value)) self.bars[clean_key].setValue(int(value))
def closeEvent(self, event): def closeEvent(self, event: QCloseEvent) -> None:
if self.allow_closing: if self.allow_closing:
event.accept() event.accept()
else: else:
@@ -64,8 +72,13 @@ class MultiProgressDialog(QDialog):
self.close() self.close()
def single_participant_worker(
file_path: str,
raw_data: Any,
result_queue: Any,
progress_queue: Any,
) -> None:
def single_participant_worker(file_path, raw_data, result_queue, progress_queue):
""" Runs inside its own dedicated process """ """ Runs inside its own dedicated process """
p_name = os.path.basename(file_path) p_name = os.path.basename(file_path)
try: try:
@@ -81,8 +94,7 @@ def single_participant_worker(file_path, raw_data, result_queue, progress_queue)
progress_queue.put(f"ERROR: {p_name} - {str(e)}") progress_queue.put(f"ERROR: {p_name} - {str(e)}")
def get_landmark_color_map() -> Dict[str, Tuple[float, float, float, float]]:
def get_landmark_color_map():
"""Generates the unified 40-color map for fOLD landmarks.""" """Generates the unified 40-color map for fOLD landmarks."""
landmarks = [ landmarks = [
"1 - Primary Somatosensory Cortex", "2 - Primary Somatosensory Cortex", "1 - Primary Somatosensory Cortex", "2 - Primary Somatosensory Cortex",
@@ -116,7 +128,15 @@ class StaticChannelCanvas(FigureCanvas):
"""The Pop-up Window Canvas. """The Pop-up Window Canvas.
Renders the interactive pie chart on the left, and a matching PNG image on the right. Renders the interactive pie chart on the left, and a matching PNG image on the right.
""" """
def __init__(self, channel_name, data_list, color_map, image_path=None, parent=None): def __init__(
self,
channel_name: str,
data_list: List[Dict[str, Any]],
color_map: Dict[str, Union[str, Tuple[float, float, float, float]]],
image_path: Optional[str] = None,
parent: Optional[QWidget] = None,
) -> None:
self.fig = Figure(figsize=(11.0, 5.5)) self.fig = Figure(figsize=(11.0, 5.5))
self.ax = self.fig.subplots(1, 2) self.ax = self.fig.subplots(1, 2)
@@ -194,7 +214,7 @@ class StaticChannelCanvas(FigureCanvas):
self.mpl_connect('motion_notify_event', self._on_hover) self.mpl_connect('motion_notify_event', self._on_hover)
def _on_hover(self, event): def _on_hover(self, event: Event) -> None:
try: try:
# FIX: Only track mouse events when hovering over the LEFT axis frame containing the pie chart # FIX: Only track mouse events when hovering over the LEFT axis frame containing the pie chart
if event.inaxes != self.ax[0]: if event.inaxes != self.ax[0]:
@@ -231,10 +251,10 @@ class StaticChannelCanvas(FigureCanvas):
self.draw_idle() self.draw_idle()
except Exception as err: except Exception as err:
print("[ERROR] Internal failure inside _on_hover loop:") print(f"[ERROR] Internal failure inside _on_hover loop: {err}")
traceback.print_exc() traceback.print_exc()
def _explode_wedge(self, index_to_expand): def _explode_wedge(self, index_to_expand: int) -> None:
changed = False changed = False
for idx, wedge in enumerate(self.wedges): for idx, wedge in enumerate(self.wedges):
if idx == index_to_expand: if idx == index_to_expand:
@@ -252,7 +272,7 @@ class StaticChannelCanvas(FigureCanvas):
if changed: if changed:
self.draw_idle() self.draw_idle()
def _reset_wedges(self): def _reset_wedges(self) -> None:
changed = False changed = False
for wedge in self.wedges: for wedge in self.wedges:
if wedge.center != (0.0, 0.0): if wedge.center != (0.0, 0.0):
@@ -274,7 +294,7 @@ class StandaloneLegendDialog(QWidget):
layout.setContentsMargins(10, 10, 10, 10) layout.setContentsMargins(10, 10, 10, 10)
# Reuse your exact card creation method to render inside the popup window # Reuse your exact card creation method to render inside the popup window
legend_card = canvas_engine.create_legend_card(title_prefix, self) legend_card = canvas_engine.create_legend_card(title_prefix)
layout.addWidget(legend_card) layout.addWidget(legend_card)
@@ -381,7 +401,7 @@ class InteractiveParticipantGridCanvas(FigureCanvas):
self.mpl_connect('button_press_event', self._on_canvas_click) self.mpl_connect('button_press_event', self._on_canvas_click)
def create_matrix_card(self, title_prefix, layout_to_attach_to): def create_matrix_card(self, title_prefix: str, layout_to_attach_to: QLayout) -> QFrame:
"""Wraps the channel matrix layout inside a responsive, matching hover-stylized card frame.""" """Wraps the channel matrix layout inside a responsive, matching hover-stylized card frame."""
# 1. Create matching styled container card frame # 1. Create matching styled container card frame
card_frame = QFrame() card_frame = QFrame()
@@ -422,7 +442,7 @@ class InteractiveParticipantGridCanvas(FigureCanvas):
layout_to_attach_to.addWidget(card_frame) layout_to_attach_to.addWidget(card_frame)
return card_frame return card_frame
def _on_canvas_click(self, event): def _on_canvas_click(self, event: Any) -> None:
# CASE 1: Whitespace Clicked -> Open full 25-matrix in fullscreen window # CASE 1: Whitespace Clicked -> Open full 25-matrix in fullscreen window
if event.inaxes is None: if event.inaxes is None:
self._open_fullscreen_grid() self._open_fullscreen_grid()
@@ -473,7 +493,8 @@ class InteractiveParticipantGridCanvas(FigureCanvas):
self._fullscreen_refs = [w for w in self._fullscreen_refs if w.isVisible()] self._fullscreen_refs = [w for w in self._fullscreen_refs if w.isVisible()]
self._fullscreen_refs.append(fullscreen_window) self._fullscreen_refs.append(fullscreen_window)
def _calculate_total_brodmann_profile(self, channels_data):
def _calculate_total_brodmann_profile(self, channels_data: Dict[str, Any]):
"""Sums and normalizes the specificity profile across all channels.""" """Sums and normalizes the specificity profile across all channels."""
totals = {} totals = {}
num_channels = len(channels_data) num_channels = len(channels_data)
@@ -553,7 +574,7 @@ class InteractiveParticipantGridCanvas(FigureCanvas):
self._open_popups.append(popup) self._open_popups.append(popup)
def create_total_summary_card(self, title_prefix, layout_to_attach_to): def create_total_summary_card(self, title_prefix: str, layout_to_attach_to: QLayout) -> QFrame:
"""Generates a highly compact, clickable embedded card on the main window showing aggregated data.""" """Generates a highly compact, clickable embedded card on the main window showing aggregated data."""
# 1. Calculate the normalized profile data payload using the instance's own data # 1. Calculate the normalized profile data payload using the instance's own data
summary_data = self._calculate_total_brodmann_profile(self.channels_data) summary_data = self._calculate_total_brodmann_profile(self.channels_data)
@@ -609,7 +630,7 @@ class InteractiveParticipantGridCanvas(FigureCanvas):
card_layout.addWidget(summary_canvas) card_layout.addWidget(summary_canvas)
card_layout.addStretch(0) card_layout.addStretch(0)
def handle_card_click(event): def handle_card_click(event: QMouseEvent) -> None:
# Only trigger expansion if it's a primary left-click action # Only trigger expansion if it's a primary left-click action
if event.button() == Qt.MouseButton.LeftButton: if event.button() == Qt.MouseButton.LeftButton:
self._open_expanded_summary_window(title_prefix, summary_data) self._open_expanded_summary_window(title_prefix, summary_data)
@@ -626,7 +647,7 @@ class InteractiveParticipantGridCanvas(FigureCanvas):
def create_legend_card(self, title_prefix, layout_to_attach_to): def create_legend_card(self, title_prefix: str) -> QFrame:
card = QFrame() card = QFrame()
card.setStyleSheet("QFrame { background-color: #ffffff; border-radius: 8px; border: 1px solid #e9ecef; }") card.setStyleSheet("QFrame { background-color: #ffffff; border-radius: 8px; border: 1px solid #e9ecef; }")
@@ -686,7 +707,7 @@ class InteractiveParticipantGridCanvas(FigureCanvas):
return card return card
def _open_expanded_summary_window(self, title_prefix, summary_data): def _open_expanded_summary_window(self, title_prefix: str, summary_data: List[Any]) -> None:
"""Pops open a beautifully scaled, independent large window when the card is clicked.""" """Pops open a beautifully scaled, independent large window when the card is clicked."""
popup = QWidget(None) popup = QWidget(None)
popup.setWindowTitle(f"Grand Total Profile Details - {title_prefix}") popup.setWindowTitle(f"Grand Total Profile Details - {title_prefix}")
@@ -719,16 +740,18 @@ class InteractiveParticipantGridCanvas(FigureCanvas):
self._summary_popups.append(popup) self._summary_popups.append(popup)
from PySide6.QtCore import QObject, Signal
from multiprocessing import Manager, Process
class ProcessOrchestrator(QObject): class ProcessOrchestrator(QObject):
# Fires when Manager + Processes are completely ready # Fires when Manager + Processes are completely ready
# Emits: (manager_instance, result_queue, progress_queue, active_processes_list) # Emits: (manager_instance, result_queue, progress_queue, active_processes_list)
setup_finished = Signal(object, object, object, list) setup_finished = Signal(object, object, object, list)
setup_failed = Signal(str) setup_failed = Signal(str)
def __init__(self, selected_files, haemo_dict, worker_func): def __init__(self,
selected_files,
haemo_dict: dict[str | Path, BaseRaw],
worker_func
):
super().__init__() super().__init__()
self.selected_files = selected_files self.selected_files = selected_files
self.haemo_dict = haemo_dict self.haemo_dict = haemo_dict
@@ -758,7 +781,12 @@ class ProcessOrchestrator(QObject):
class ParticipantFoldChannelsWidget(FlaresBaseWidget): class ParticipantFoldChannelsWidget(FlaresBaseWidget):
def __init__(self, haemo_dict, cha_dict): def __init__(
self,
haemo_dict: dict[str | Path, BaseRaw],
cha_dict: dict[str, DataFrame]
) -> None:
super().__init__("ParticipantFoldChannels") super().__init__("ParticipantFoldChannels")
self.setWindowTitle(f"Participant Fold Channels Viewer - {APP_NAME.upper()}") self.setWindowTitle(f"Participant Fold Channels Viewer - {APP_NAME.upper()}")
self.haemo_dict = haemo_dict self.haemo_dict = haemo_dict
@@ -773,9 +801,9 @@ class ParticipantFoldChannelsWidget(FlaresBaseWidget):
self.participant_map[file_path] = short_label self.participant_map[file_path] = short_label
self.participant_dropdown_items.append(display_label) self.participant_dropdown_items.append(display_label)
self.layout = QVBoxLayout(self) self.main_layout = QVBoxLayout(self)
self.top_bar = QHBoxLayout() self.top_bar = QHBoxLayout()
self.layout.addLayout(self.top_bar) self.main_layout.addLayout(self.top_bar)
self.participant_dropdown = self._create_multiselect_dropdown(self.participant_dropdown_items) self.participant_dropdown = self._create_multiselect_dropdown(self.participant_dropdown_items)
self.participant_dropdown.currentIndexChanged.connect(self.update_participant_dropdown_label) self.participant_dropdown.currentIndexChanged.connect(self.update_participant_dropdown_label)
@@ -829,7 +857,7 @@ class ParticipantFoldChannelsWidget(FlaresBaseWidget):
self.scroll_area.setWidget(self.scroll_content_widget) self.scroll_area.setWidget(self.scroll_content_widget)
# Add the self.scroll_area widget to your root layout view frame panel # Add the self.scroll_area widget to your root layout view frame panel
self.layout.addWidget(self.scroll_area) self.main_layout.addWidget(self.scroll_area)
self.thumb_size = QSize(280, 180) self.thumb_size = QSize(280, 180)
self.showMaximized() self.showMaximized()
@@ -889,7 +917,14 @@ class ParticipantFoldChannelsWidget(FlaresBaseWidget):
self.orchestrator_thread.start() self.orchestrator_thread.start()
print(f"After 4: {datetime.now()}") print(f"After 4: {datetime.now()}")
def on_orchestration_success(self, manager, result_queue, progress_queue, active_processes): def on_orchestration_success(
self,
manager: Any,
result_queue: Any,
progress_queue: Any,
active_processes: List[Any]
) -> None:
""" Executed on the Main GUI Thread once background process setup finishes """ """ Executed on the Main GUI Thread once background process setup finishes """
self.manager = manager self.manager = manager
self.result_queue = result_queue self.result_queue = result_queue
@@ -902,15 +937,15 @@ class ParticipantFoldChannelsWidget(FlaresBaseWidget):
self.result_timer.timeout.connect(self.check_parallel_results) self.result_timer.timeout.connect(self.check_parallel_results)
self.result_timer.start() self.result_timer.start()
def on_orchestration_failed(self, error_msg):
def on_orchestration_failed(self, error_msg: str) -> None:
""" Fallback handler if Windows permissions or pickling fails in background """ """ Fallback handler if Windows permissions or pickling fails in background """
if hasattr(self, 'multi_progress'): if hasattr(self, 'multi_progress'):
self.multi_progress.close() self.multi_progress.close()
print(f"[CRITICAL FAILURE] Background Orchestration Failed:\n{error_msg}") print(f"[CRITICAL FAILURE] Background Orchestration Failed:\n{error_msg}")
def check_parallel_results(self) -> None:
def check_parallel_results(self):
# Check for progress/completion signals # Check for progress/completion signals
while not self.progress_queue.empty(): while not self.progress_queue.empty():
@@ -991,8 +1026,7 @@ class ParticipantFoldChannelsWidget(FlaresBaseWidget):
legend_title = "Grand Total Brodmann Mapping Profile" legend_title = "Grand Total Brodmann Mapping Profile"
legend_card = global_canvas.create_legend_card( legend_card = global_canvas.create_legend_card(
title_prefix=legend_title, title_prefix=legend_title
layout_to_attach_to=self.scroll_content_widget.layout()
) )
def handle_legend_click(event): def handle_legend_click(event):
@@ -1006,52 +1040,8 @@ class ParticipantFoldChannelsWidget(FlaresBaseWidget):
col = count % 3 col = count % 3
self.grid_layout.addWidget(legend_card, row, col) self.grid_layout.addWidget(legend_card, row, col)
def add_images_to_grid(self, result_dict: Dict[str, Dict[str, Any]]) -> None:
# def add_images_to_grid(self, result_dict):
# """
# result_dict format: { file_path: {"main": bytes, "legend": bytes} }
# """
# for file_path, images in result_dict.items():
# if self.grid_layout.count() == 0 and "legend" in images:
# self._add_legend_to_grid(images["legend"])
# # Create a container for this participant's results
# container = QFrame()
# container.setFrameShape(QFrame.StyledPanel)
# vbox = QVBoxLayout(container)
# participant_label = self.participant_map.get(file_path, os.path.basename(file_path))
# title = QLabel(f"<b>{participant_label}</b>")
# title.setAlignment(Qt.AlignCenter)
# vbox.addWidget(title)
# # We primarily want to show the 'main' plot in the grid
# if "main" in images:
# pixmap = self._bytes_to_pixmap(images["main"])
# img_label = QLabel()
# # Scale it to fit the thumbnail size defined in __init__
# img_label.setPixmap(pixmap.scaled(
# self.thumb_size,
# Qt.KeepAspectRatio,
# Qt.SmoothTransformation
# ))
# img_label.setAlignment(Qt.AlignCenter)
# # Optional: Click to open full size
# img_label.mousePressEvent = lambda e, p=pixmap, t=participant_label: self._open_full_size(p, t)
# vbox.addWidget(img_label)
# # Determine grid position (row-major order)
# count = self.grid_layout.count()
# row = count // 3 # 3 columns wide
# col = count % 3
# self.grid_layout.addWidget(container, row, col)
def add_images_to_grid(self, result_dict):
color_map = get_landmark_color_map() color_map = get_landmark_color_map()
for file_path, channels_data in result_dict.items(): for file_path, channels_data in result_dict.items():
@@ -1091,12 +1081,12 @@ class ParticipantFoldChannelsWidget(FlaresBaseWidget):
self.grid_layout.addWidget(summary_card, row, col) self.grid_layout.addWidget(summary_card, row, col)
def _bytes_to_pixmap(self, png_bytes): def _bytes_to_pixmap(self, png_bytes: bytes) -> QPixmap:
"""Converts raw bytes from the multiprocess queue to a QPixmap.""" """Converts raw bytes from the multiprocess queue to a QPixmap."""
image = QImage.fromData(png_bytes) image = QImage.fromData(png_bytes)
return QPixmap.fromImage(image) return QPixmap.fromImage(image)
def _open_full_size(self, pixmap, title): def _open_full_size(self, pixmap: QPixmap, title: str) -> None:
"""Simple popup to view the image at a readable scale.""" """Simple popup to view the image at a readable scale."""
view = QDialog(self) view = QDialog(self)
view.setWindowTitle(f"Full View - {title}") view.setWindowTitle(f"Full View - {title}")
@@ -1106,7 +1096,7 @@ class ParticipantFoldChannelsWidget(FlaresBaseWidget):
layout.addWidget(label) layout.addWidget(label)
view.show() view.show()
def _add_legend_to_grid(self, legend_bytes): def _add_legend_to_grid(self, legend_bytes: bytes) -> None:
"""Helper to put the legend in the first slot.""" """Helper to put the legend in the first slot."""
container = QFrame() container = QFrame()
container.setStyleSheet("background-color: #f9f9f9; border: 1px solid #ccc;") container.setStyleSheet("background-color: #f9f9f9; border: 1px solid #ccc;")
+106 -101
View File
@@ -23,83 +23,58 @@ from src.shared.shareddata import APP_NAME
PARAMETERIZED_INDEXES: dict[int, list[dict[str, Any]]] = { PARAMETERIZED_INDEXES: dict[int, list[dict[str, Any]]] = {
0: [ 0: [ # Spectral Coherence
{ {"key": "method", "label": "Connectivity method", "default": "wpli2_debiased", "type": list, "options": ["coh", "pli", "wpli2_debiased"]},
"key": "n_lines", {"key": "n_lines", "label": "Number of strongest connections to draw", "default": "20", "type": int},
"label": "<Description>", {"key": "vmin", "label": "Minimum coherence value to display", "default": "0.3", "type": float},
"default": "20", {"key": "fmin", "label": "Lower frequency bound (Hz)", "default": "0.04", "type": float},
"type": int, {"key": "fmax", "label": "Upper frequency bound (Hz)", "default": "0.2", "type": float},
},
{
"key": "vmin",
"label": "<Description>",
"default": "0.9",
"type": float,
},
], ],
1: [ 1: [ # Envelope Correlation
{ {"key": "n_lines", "label": "Number of strongest connections to draw", "default": "20", "type": int},
"key": "n_lines", {"key": "vmin", "label": "Minimum correlation value to display", "default": "0.9", "type": float},
"label": "<Description>", {"key": "fmin", "label": "Band-pass lower frequency (Hz)", "default": "0.04", "type": float},
"default": "20", {"key": "fmax", "label": "Band-pass upper frequency (Hz)", "default": "0.2", "type": float},
"type": int, {"key": "orthogonalize", "label": "Orthogonalize (reduce signal leakage between channels)", "default": "False", "type": bool},
}, {"key": "absolute", "label": "Use absolute value (discard anti-correlation sign)", "default": "True", "type": bool},
{
"key": "vmin",
"label": "<Description>",
"default": "0.9",
"type": float,
},
], ],
2: [ 2: [ # Beta-Series Correlation
{ {"key": "n_lines", "label": "Number of strongest connections to draw", "default": "20", "type": int},
"key": "n_lines", {"key": "drift_model", "label": "Drift model", "default": "cosine", "type": list, "options": ["cosine", "polynomial"]},
"label": "<Description>", {"key": "drift_order", "label": "Drift order", "default": "1", "type": int},
"default": "20", {"key": "hrf_model", "label": "HRF model", "default": "glover", "type": list, "options": ["glover", "spm", "fir"]},
"type": int, {"key": "apply_gsr", "label": "Apply Global Signal Regression", "default": "True", "type": bool},
}, {"key": "resample_freq", "label": "Resample rate before GLM fit (Hz) - lower is much faster", "default": "4.0", "type": float},
{ {"key": "min_effect_size", "label": "Minimum |r| to display", "default": "0.7", "type": float},
"key": "vmin", {"key": "alpha", "label": "FDR significance threshold", "default": "0.05", "type": float},
"label": "<Description>",
"default": "0.9",
"type": float,
},
], ],
3: [ 3: [ # Time-Resolved Spectral Coherence
{ {"key": "method", "label": "Connectivity method", "default": "wpli", "type": list, "options": ["coh", "pli", "wpli"]},
"key": "n_lines", {"key": "n_lines", "label": "Number of strongest connections to draw", "default": "20", "type": int},
"label": "<Description>", {"key": "vmin", "label": "Minimum coherence value to display", "default": "0.3", "type": float},
"default": "20", {"key": "fmin", "label": "Lower frequency bound (Hz)", "default": "0.04", "type": float},
"type": int, {"key": "fmax", "label": "Upper frequency bound (Hz)", "default": "0.2", "type": float},
}, {"key": "n_freqs", "label": "Number of frequency bins", "default": "10", "type": int},
{ {"key": "cycles_multiplier", "label": "Cycles per frequency (window length control)", "default": "2.0", "type": float},
"key": "vmin",
"label": "<Description>",
"default": "0.9",
"type": float,
},
], ],
} }
DESCRIPTION = """0. Spectral Coherence (functional_connectivity_spectral_epochs) DESCRIPTION = """0. Spectral Coherence (functional_connectivity_spectral_epochs)
\nTests for frequency-domain phase synchronization between channel pairs in a specific oscillatory band (0.04-0.2 Hz) across epoched data using multitaper spectral estimation. A significant connection means two brain regions share consistent, synchronized oscillatory phase dynamics across epochs, reflecting steady-state functional coupling. It does not tell you when during the epoch the interaction occurred (as time is integrated out), nor does it guarantee the interaction is neural, as shared systemic vascular oscillations or motion artifacts can drive spurious high coherence across distant sensors. \nTests for connectivity between channel pairs using the selected method: coherence ('coh'), Phase Lag Index ('pli'), or debiased weighted PLI squared ('wpli2_debiased', default). PLI/wPLI-family methods discount zero-lag contributions, making them substantially more robust to shared systemic/vascular signal (which tends to hit multiple channels near-simultaneously) than plain coherence - recommended over 'coh' unless you have a specific reason to want raw coherence.
\nIf connectivity appears lower or sparser than expected, common causes include: non-stationarity within the epoch (phase relationships that shift rapidly over time cancel out when averaged across the whole window); trial-to-trial timing jitter; or applying overly stringent edge thresholding or FDR correction across all unique channel pairs. \nfmin must satisfy at least 5 full oscillation cycles within your epoch length (epoch_duration x fmin >= 5) for a reliable estimate - if it doesn't, this will refuse to run with an error stating the minimum viable fmin for your epoch length, rather than silently producing an unreliable result. Note that different methods have very different typical value ranges (coherence commonly 0.3-1.0; wPLI/wPLI2-debiased often much lower, sometimes 0.1-0.4) - vmin needs to be recalibrated when switching methods, or real connections may not render.
\n1. Envelope Correlation (functional_connectivity_envelope) \n1. Envelope Correlation (functional_connectivity_envelope)
\nExtracts the Hilbert amplitude envelope from bandpass-filtered signals (0.04-0.2 Hz) to measure slow amplitude power correlations across time within epoched data. A significant result indicates that the overall energy profiles or activation magnitudes of two regions co-vary over time, independent of sub-second phase locking. It says nothing about fast phase interactions or exact event-locked timing, and its power is heavily degraded if epoch lengths are too short (under ~10-15s) to capture multiple complete cycles of low-frequency hemodynamic fluctuations. \nExtracts the Hilbert amplitude envelope from bandpass-filtered signals to measure slow amplitude power correlations across time within epoched data. A significant result indicates that the overall energy profiles or activation magnitudes of two regions co-vary over time, independent of sub-second phase locking. Its power is heavily degraded if epoch lengths are too short to capture multiple complete cycles at fmin - same cycle-count requirement as the Spectral Coherence method above, though this method does not currently enforce it automatically.
\nIf this method underperforms compared to phase-based coherence, the most likely explanation is that your trial window is too brief for robust envelope extraction, or that the functional coupling between regions is purely phase-locked rather than power-coupled. Additionally, uncorrected global motion or systemic arterial pressure shifts can globally inflate envelope correlations across the whole head. \nUncorrected global motion or systemic arterial pressure shifts can globally inflate envelope correlations across the whole head - consider this alongside orthogonalize/absolute when interpreting results.
\n2. Time-Resolved Spectral Coherence (functional_connectivity_spectral_time) \n2. Beta-Series Correlation (functional_connectivity_betas)
\nUses continuous Morlet wavelet time-frequency decomposition across multiple frequencies (0.04-0.2 Hz) to track how spectral coherence between channel pairs dynamically evolves over the duration of a trial. A significant result pinpoints the exact temporal window within a trial where functional coupling emerges or dissolves (e.g., during stimulus encoding vs. motor execution). It demands precise, jitter-free stimulus onset triggers and carries high computational complexity; it is also susceptible to wavelet edge artifacts at the start and end of epoch windows. \nFits a GLM to estimate trial-by-trial activation magnitudes (betas), optionally applies Global Signal Regression (GSR) to strip head-wide systemic noise, and correlates those beta series across trials with FDR (q < alpha) and effect-size thresholding. A significant connection means that when Region A responds more strongly on a given trial, Region B also responds more strongly. Not subject to the epoch-length/frequency-resolution constraint that affects the spectral methods above, since no spectral estimation is involved.
\nIf expected temporal connectivity changes fail to emerge, check whether trial-to-trial onset latency variability across subjects is smearing the time-resolved average, or if the chosen wavelet cycle parameter (n_cycles) is oversmoothing short-lived, transient phase-coupling events. \nRequires at least 4 (ideally 15+) repeated trials of the selected event. hrf_model='fir' is far more computationally expensive than 'glover'/'spm' (a separate regressor column per FIR delay per trial) - if this method is slow to the point of appearing frozen, check hrf_model is not set to 'fir' before assuming something is broken.
\n3. Beta-Series Correlation (functional_connectivity_betas) \n3. Time-Resolved Spectral Coherence (functional_connectivity_spectral_time)
\nFits a General Linear Model (GLM) using a flexible Finite Impulse Response (FIR) basis set to estimate trial-by-trial activation magnitudes (betas), optionally applies Global Signal Regression (GSR) to strip head-wide systemic noise, and correlates those beta series across events with FDR (q < alpha) and effect-size thresholding. A significant connection means that when Region A responds more strongly on a given trial, Region B also responds more strongly, isolating true task-evoked co-activation from background resting-state noise. It requires at least 4 (ideally 15+) repeated trials per condition to establish degrees of freedom for the correlation t-test, and relies heavily on a correctly specified trial annotation structure. \nSame connectivity methods as Spectral Coherence above ('coh'/'pli'/'wpli' - note: 'wpli2_debiased' is NOT available for this method, unlike the epochs-based one), but tracks how connectivity evolves over multiple frequency bins across the trial duration rather than a single averaged value. Same fmin/epoch-length cycle-count requirement as method 0 applies and is enforced the same way.
\nIf this test returns no significant edges, the primary culprit is typically insufficient trial count (leading to severely underpowered degrees of freedom), poorly separated trials that induce severe multicollinearity in the FIR design matrix, or applying GSR when the underlying neural effect itself is diffuse, causing true network correlations to be over-regressed. \nMore computationally expensive than method 0 due to the additional frequency/time resolution - if timing matters, prefer method 0 unless the time-resolved view is specifically needed.
""" """
@@ -115,11 +90,12 @@ class ParticipantFunctionalConnectivityWidget(ParticipantUIMixin, FlaresBaseWidg
self.haemo_dict = haemo_dict self.haemo_dict = haemo_dict
self.epochs_dict = epochs_dict self.epochs_dict = epochs_dict
QMessageBox.warning(self, f"Warning - {APP_NAME.upper()}", f"Functional Connectivity is still in development and the results should currently be taken with a grain of salt. " QMessageBox.warning(self, f"Warning - {APP_NAME.upper()}", f"Functional Connectivity is still in beta. While the results are now almost finalized, the processing is slow and it WILL hang the application for minutes.")
"By clicking OK, you accept that the images generated may not be factual.")
self.setup_participant_ui(["0 (Spectral Connectivity Epochs)", "1 (Envelope Correlation)", "2 (Betas)", "3 (Spectral Connectivity Epochs)"], placeholder_text=DESCRIPTION)
self.setup_participant_ui(
["0 (Spectral Coherence)", "1 (Envelope Correlation)", "2 (Beta-Series Correlation)", "3 (Time-Resolved Spectral Coherence)"],
placeholder_text=DESCRIPTION
)
def process_request(self): def process_request(self):
request = self.get_common_request_data(PARAMETERIZED_INDEXES) request = self.get_common_request_data(PARAMETERIZED_INDEXES)
@@ -134,7 +110,7 @@ class ParticipantFunctionalConnectivityWidget(ParticipantUIMixin, FlaresBaseWidg
for file_path in selected_file_paths: for file_path in selected_file_paths:
haemo_obj = self.haemo_dict.get(file_path) haemo_obj = self.haemo_dict.get(file_path)
epochs_obj = self.epochs_dict.get(file_path) epochs_obj = self.epochs_dict.get(file_path)
if haemo_obj is None or epochs_obj is None: if haemo_obj is None or epochs_obj is None:
continue continue
@@ -153,46 +129,75 @@ class ParticipantFunctionalConnectivityWidget(ParticipantUIMixin, FlaresBaseWidg
continue continue
for idx in selected_indexes: for idx in selected_indexes:
params = param_values.get(idx, {})
if idx == 0: if idx == 0:
method = params.get("method", "wpli2_debiased")
params = param_values.get(idx, {}) n_lines = params.get("n_lines", 20)
n_lines = params.get("n_lines", None) vmin = params.get("vmin", 0.9)
vmin = params.get("vmin", None) fmin = params.get("fmin", 0.04)
fmax = params.get("fmax", 0.2)
if n_lines is None or vmin is None:
print(f"Missing parameters for index {idx}, skipping.") functional_connectivity_spectral_epochs(epochs=epochs_obj, n_lines=n_lines, vmin=vmin, fmin=fmin, fmax=fmax, method=method)
continue
functional_connectivity_spectral_epochs(epochs_obj, n_lines, vmin)
elif idx == 1: elif idx == 1:
params = param_values.get(idx, {}) n_lines = params.get("n_lines", 20)
n_lines = params.get("n_lines", None) vmin = params.get("vmin", 0.9)
vmin = params.get("vmin", None) 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,
)
if n_lines is None or vmin is None:
print(f"Missing parameters for index {idx}, skipping.")
continue
functional_connectivity_envelope(epochs_obj, n_lines, vmin)
elif idx == 2: elif idx == 2:
params = param_values.get(idx, {}) n_lines = params.get("n_lines", 20)
n_lines = params.get("n_lines", None) drift_model = params.get("drift_model", "cosine")
vmin = params.get("vmin", None) 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)
if n_lines is None or vmin is None: functional_connectivity_betas(
print(f"Missing parameters for index {idx}, skipping.") raw_hbo=haemo_obj,
continue n_lines=n_lines,
functional_connectivity_betas(haemo_obj, n_lines, vmin, selected_event) 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: elif idx == 3:
params = param_values.get(idx, {}) method = params.get("method", "wpli")
n_lines = params.get("n_lines", None) n_lines = params.get("n_lines", 20)
vmin = params.get("vmin", None) 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)
if n_lines is None or vmin is None: functional_connectivity_spectral_time(
print(f"Missing parameters for index {idx}, skipping.") epochs=epochs_obj,
continue n_lines=n_lines,
functional_connectivity_spectral_time(epochs_obj, n_lines, vmin) vmin=vmin,
fmin=fmin,
fmax=fmax,
n_freqs=n_freqs,
cycles_multiplier=cycles_multiplier,
method=method
)
else: else:
print(f"No method defined for index {idx}") print(f"No method defined for index {idx}")
+26 -23
View File
@@ -6,19 +6,22 @@ Author: Tyler de Zeeuw
License: GPL-3.0 License: GPL-3.0
""" """
# Built-in imports
import os import os
import json import json
from enum import Enum, auto from enum import Enum, auto
from datetime import datetime from datetime import datetime
from typing import Optional
# External library imports
import numpy as np import numpy as np
from PySide6.QtWidgets import QWidget, QVBoxLayout, QLabel, QLineEdit, QPushButton, QComboBox, QHBoxLayout, QMessageBox, QFileDialog from PySide6.QtWidgets import QWidget, QVBoxLayout, QLabel, QLineEdit, QPushButton, QComboBox, QHBoxLayout, QMessageBox, QFileDialog
from PySide6.QtCore import Qt from PySide6.QtCore import Qt
from mne import Annotations from mne import Annotations
from mne.io import read_raw_snirf from mne.io import read_raw_snirf #type: ignore
from mne_nirs.io import write_raw_snirf from mne_nirs.io import write_raw_snirf #type: ignore
from src.shared.shareddata import APP_NAME from src.shared.shareddata import APP_NAME
@@ -29,7 +32,7 @@ class EventUpdateMode(Enum):
class UpdateEventsWindow(QWidget): class UpdateEventsWindow(QWidget):
def __init__(self, parent=None, mode=EventUpdateMode.WRITE_SNIRF, caller=None): def __init__(self, parent: Optional[QWidget]=None, mode=EventUpdateMode.WRITE_SNIRF, caller=None):
super().__init__(parent, Qt.WindowType.Window) super().__init__(parent, Qt.WindowType.Window)
self.mode = mode self.mode = mode
@@ -91,7 +94,7 @@ class UpdateEventsWindow(QWidget):
help_btn_a = QPushButton("?") help_btn_a = QPushButton("?")
help_btn_a.setFixedWidth(25) help_btn_a.setFixedWidth(25)
help_btn_a.setToolTip(help_text_a) help_btn_a.setToolTip(help_text_a)
help_btn_a.clicked.connect(lambda _, text=help_text_a: self.show_help_popup(text)) help_btn_a.clicked.connect(lambda: self.show_help_popup(help_text_a))
file_a_layout.addWidget(help_btn_a) file_a_layout.addWidget(help_btn_a)
# Container for label + line_edit + browse button with tooltip # Container for label + line_edit + browse button with tooltip
@@ -114,7 +117,7 @@ class UpdateEventsWindow(QWidget):
help_btn_b = QPushButton("?") help_btn_b = QPushButton("?")
help_btn_b.setFixedWidth(25) help_btn_b.setFixedWidth(25)
help_btn_b.setToolTip(help_text_b) help_btn_b.setToolTip(help_text_b)
help_btn_b.clicked.connect(lambda _, text=help_text_b: self.show_help_popup(text)) help_btn_b.clicked.connect(lambda: self.show_help_popup(help_text_b))
file_b_layout.addWidget(help_btn_b) file_b_layout.addWidget(help_btn_b)
file_b_container = QWidget() file_b_container = QWidget()
@@ -136,7 +139,7 @@ class UpdateEventsWindow(QWidget):
help_btn_suffix = QPushButton("?") help_btn_suffix = QPushButton("?")
help_btn_suffix.setFixedWidth(25) help_btn_suffix.setFixedWidth(25)
help_btn_suffix.setToolTip(help_text_suffix) help_btn_suffix.setToolTip(help_text_suffix)
help_btn_suffix.clicked.connect(lambda _, text=help_text_suffix: self.show_help_popup(text)) help_btn_suffix.clicked.connect(lambda: self.show_help_popup(help_text_suffix))
suffix_layout.addWidget(help_btn_suffix) suffix_layout.addWidget(help_btn_suffix)
suffix_container = QWidget() suffix_container = QWidget()
@@ -157,7 +160,7 @@ class UpdateEventsWindow(QWidget):
help_btn_suffix = QPushButton("?") help_btn_suffix = QPushButton("?")
help_btn_suffix.setFixedWidth(25) help_btn_suffix.setFixedWidth(25)
help_btn_suffix.setToolTip(help_text_suffix) help_btn_suffix.setToolTip(help_text_suffix)
help_btn_suffix.clicked.connect(lambda _, text=help_text_suffix: self.show_help_popup(text)) help_btn_suffix.clicked.connect(lambda: self.show_help_popup(help_text_suffix))
suffix2_layout.addWidget(help_btn_suffix) suffix2_layout.addWidget(help_btn_suffix)
suffix2_container = QWidget() suffix2_container = QWidget()
@@ -177,7 +180,7 @@ class UpdateEventsWindow(QWidget):
help_btn_snirf_events = QPushButton("?") help_btn_snirf_events = QPushButton("?")
help_btn_snirf_events.setFixedWidth(25) help_btn_snirf_events.setFixedWidth(25)
help_btn_snirf_events.setToolTip(help_text_snirf_events) help_btn_snirf_events.setToolTip(help_text_snirf_events)
help_btn_snirf_events.clicked.connect(lambda _, text=help_text_snirf_events: self.show_help_popup(text)) help_btn_snirf_events.clicked.connect(lambda: self.show_help_popup(help_text_snirf_events))
snirf_events_layout.addWidget(help_btn_snirf_events) snirf_events_layout.addWidget(help_btn_snirf_events)
snirf_events_container = QWidget() snirf_events_container = QWidget()
@@ -199,13 +202,13 @@ class UpdateEventsWindow(QWidget):
self.setLayout(layout) self.setLayout(layout)
def show_help_popup(self, text): def show_help_popup(self, text: str) -> None:
msg = QMessageBox(self) msg = QMessageBox(self)
msg.setWindowTitle(f"Parameter Info - {APP_NAME.upper()}") msg.setWindowTitle(f"Parameter Info - {APP_NAME.upper()}")
msg.setText(text) msg.setText(text)
msg.exec() msg.exec()
def browse_file_a(self): def browse_file_a(self) -> None:
file_path, _ = QFileDialog.getOpenFileName(self, "Select SNIRF File", "", "SNIRF Files (*.snirf)") file_path, _ = QFileDialog.getOpenFileName(self, "Select SNIRF File", "", "SNIRF Files (*.snirf)")
if file_path: if file_path:
self.line_edit_file_a.setText(file_path) self.line_edit_file_a.setText(file_path)
@@ -235,7 +238,7 @@ class UpdateEventsWindow(QWidget):
self.combo_snirf_events.clear() self.combo_snirf_events.clear()
self.combo_snirf_events.setEnabled(False) self.combo_snirf_events.setEnabled(False)
def browse_file_b(self): def browse_file_b(self) -> None:
file_path, _ = QFileDialog.getOpenFileName(self, "Select BORIS File", "", "BORIS project Files (*.boris)") file_path, _ = QFileDialog.getOpenFileName(self, "Select BORIS File", "", "BORIS project Files (*.boris)")
if file_path: if file_path:
self.line_edit_file_b.setText(file_path) self.line_edit_file_b.setText(file_path)
@@ -288,11 +291,11 @@ class UpdateEventsWindow(QWidget):
self.combo_events.addItems(event_entries) self.combo_events.addItems(event_entries)
self.combo_events.setEnabled(bool(event_entries)) self.combo_events.setEnabled(bool(event_entries))
def clear_files(self): def clear_files(self) -> None:
self.line_edit_file_a.clear() self.line_edit_file_a.clear()
self.line_edit_file_b.clear() self.line_edit_file_b.clear()
def go_action(self): def go_action(self) -> None:
file_a = self.line_edit_file_a.text() file_a = self.line_edit_file_a.text()
suffix = "flare" suffix = "flare"
@@ -540,7 +543,7 @@ class UpdateEventsWindow(QWidget):
class UpdateEventsBlazesWindow(QWidget): class UpdateEventsBlazesWindow(QWidget):
def __init__(self, parent=None, mode=EventUpdateMode.WRITE_SNIRF, caller=None): def __init__(self, parent: Optional[QWidget]=None, mode=EventUpdateMode.WRITE_SNIRF, caller=None):
super().__init__(parent, Qt.WindowType.Window) super().__init__(parent, Qt.WindowType.Window)
self.mode = mode self.mode = mode
@@ -595,7 +598,7 @@ class UpdateEventsBlazesWindow(QWidget):
help_btn_a = QPushButton("?") help_btn_a = QPushButton("?")
help_btn_a.setFixedWidth(25) help_btn_a.setFixedWidth(25)
help_btn_a.setToolTip(help_text_a) help_btn_a.setToolTip(help_text_a)
help_btn_a.clicked.connect(lambda _, text=help_text_a: self.show_help_popup(text)) help_btn_a.clicked.connect(lambda: self.show_help_popup(help_text_a))
file_a_layout.addWidget(help_btn_a) file_a_layout.addWidget(help_btn_a)
# Container for label + line_edit + browse button with tooltip # Container for label + line_edit + browse button with tooltip
@@ -618,7 +621,7 @@ class UpdateEventsBlazesWindow(QWidget):
help_btn_b = QPushButton("?") help_btn_b = QPushButton("?")
help_btn_b.setFixedWidth(25) help_btn_b.setFixedWidth(25)
help_btn_b.setToolTip(help_text_b) help_btn_b.setToolTip(help_text_b)
help_btn_b.clicked.connect(lambda _, text=help_text_b: self.show_help_popup(text)) help_btn_b.clicked.connect(lambda: self.show_help_popup(help_text_b))
file_b_layout.addWidget(help_btn_b) file_b_layout.addWidget(help_btn_b)
file_b_container = QWidget() file_b_container = QWidget()
@@ -640,7 +643,7 @@ class UpdateEventsBlazesWindow(QWidget):
help_btn_suffix = QPushButton("?") help_btn_suffix = QPushButton("?")
help_btn_suffix.setFixedWidth(25) help_btn_suffix.setFixedWidth(25)
help_btn_suffix.setToolTip(help_text_suffix) help_btn_suffix.setToolTip(help_text_suffix)
help_btn_suffix.clicked.connect(lambda _, text=help_text_suffix: self.show_help_popup(text)) help_btn_suffix.clicked.connect(lambda: self.show_help_popup(help_text_suffix))
suffix2_layout.addWidget(help_btn_suffix) suffix2_layout.addWidget(help_btn_suffix)
suffix2_container = QWidget() suffix2_container = QWidget()
@@ -660,7 +663,7 @@ class UpdateEventsBlazesWindow(QWidget):
help_btn_snirf_events = QPushButton("?") help_btn_snirf_events = QPushButton("?")
help_btn_snirf_events.setFixedWidth(25) help_btn_snirf_events.setFixedWidth(25)
help_btn_snirf_events.setToolTip(help_text_snirf_events) help_btn_snirf_events.setToolTip(help_text_snirf_events)
help_btn_snirf_events.clicked.connect(lambda _, text=help_text_snirf_events: self.show_help_popup(text)) help_btn_snirf_events.clicked.connect(lambda: self.show_help_popup(help_text_snirf_events))
snirf_events_layout.addWidget(help_btn_snirf_events) snirf_events_layout.addWidget(help_btn_snirf_events)
snirf_events_container = QWidget() snirf_events_container = QWidget()
@@ -683,13 +686,13 @@ class UpdateEventsBlazesWindow(QWidget):
self.setLayout(layout) self.setLayout(layout)
def show_help_popup(self, text): def show_help_popup(self, text: str) -> None:
msg = QMessageBox(self) msg = QMessageBox(self)
msg.setWindowTitle(f"Parameter Info - {APP_NAME.upper()}") msg.setWindowTitle(f"Parameter Info - {APP_NAME.upper()}")
msg.setText(text) msg.setText(text)
msg.exec() msg.exec()
def browse_file_a(self): def browse_file_a(self) -> None:
file_path, _ = QFileDialog.getOpenFileName(self, "Select SNIRF File", "", "SNIRF Files (*.snirf)") file_path, _ = QFileDialog.getOpenFileName(self, "Select SNIRF File", "", "SNIRF Files (*.snirf)")
if file_path: if file_path:
self.line_edit_file_a.setText(file_path) self.line_edit_file_a.setText(file_path)
@@ -719,7 +722,7 @@ class UpdateEventsBlazesWindow(QWidget):
self.combo_snirf_events.clear() self.combo_snirf_events.clear()
self.combo_snirf_events.setEnabled(False) self.combo_snirf_events.setEnabled(False)
def browse_file_b(self): def browse_file_b(self) -> None:
file_path, _ = QFileDialog.getOpenFileName(self, "Select JSON Timeline File", "", "JSON Files (*.json)") file_path, _ = QFileDialog.getOpenFileName(self, "Select JSON Timeline File", "", "JSON Files (*.json)")
if file_path: if file_path:
self.line_edit_file_b.setText(file_path) self.line_edit_file_b.setText(file_path)
@@ -762,12 +765,12 @@ class UpdateEventsBlazesWindow(QWidget):
return event_strings return event_strings
def clear_files(self): def clear_files(self) -> None:
self.line_edit_file_a.clear() self.line_edit_file_a.clear()
self.line_edit_file_b.clear() self.line_edit_file_b.clear()
def go_action(self): def go_action(self) -> None:
file_a = self.line_edit_file_a.text() file_a = self.line_edit_file_a.text()
file_b = self.line_edit_file_b.text() file_b = self.line_edit_file_b.text()
suffix = APP_NAME suffix = APP_NAME
+33 -18
View File
@@ -1,16 +1,21 @@
""" """
Filename: updateoptodes.py Filename: updateoptodes.py
Description: Methods to update optode locations for FLARES Description: Methods to update optode locations for FLARES
Note: Compliant with pylance strict type checking
Author: Tyler de Zeeuw Author: Tyler de Zeeuw
License: GPL-3.0 License: GPL-3.0
""" """
# Built-in imports
import os import os
from pathlib import Path from pathlib import Path
from typing import Dict, Optional, Union
# External library imports
import pandas as pd import pandas as pd
import numpy as np import numpy as np
import numpy.typing as npt
from PySide6.QtWidgets import QWidget, QVBoxLayout, QLabel, QHBoxLayout, QMessageBox, QLineEdit, QPushButton, QFileDialog from PySide6.QtWidgets import QWidget, QVBoxLayout, QLabel, QHBoxLayout, QMessageBox, QLineEdit, QPushButton, QFileDialog
from PySide6.QtCore import Qt from PySide6.QtCore import Qt
@@ -24,7 +29,7 @@ from src.shared.shareddata import APP_NAME
class UpdateOptodesWindow(QWidget): class UpdateOptodesWindow(QWidget):
def __init__(self, parent=None): def __init__(self, parent: Optional[QWidget] = None) -> None:
super().__init__(parent, Qt.WindowType.Window) super().__init__(parent, Qt.WindowType.Window)
self.setWindowTitle(f"Update optode positions - {APP_NAME.upper()}") self.setWindowTitle(f"Update optode positions - {APP_NAME.upper()}")
self.resize(760, 200) self.resize(760, 200)
@@ -50,7 +55,6 @@ class UpdateOptodesWindow(QWidget):
self.btn_clear.clicked.connect(self.clear_files) self.btn_clear.clicked.connect(self.clear_files)
self.btn_go.clicked.connect(self.go_action) self.btn_go.clicked.connect(self.go_action)
# ---
layout = QVBoxLayout() layout = QVBoxLayout()
self.description = QLabel() self.description = QLabel()
self.description.setTextFormat(Qt.TextFormat.RichText) self.description.setTextFormat(Qt.TextFormat.RichText)
@@ -75,7 +79,7 @@ class UpdateOptodesWindow(QWidget):
help_btn_a = QPushButton("?") help_btn_a = QPushButton("?")
help_btn_a.setFixedWidth(25) help_btn_a.setFixedWidth(25)
help_btn_a.setToolTip(help_text_a) help_btn_a.setToolTip(help_text_a)
help_btn_a.clicked.connect(lambda _, text=help_text_a: self.show_help_popup(text)) help_btn_a.clicked.connect(lambda: self.show_help_popup(help_text_a))
file_a_layout.addWidget(help_btn_a) file_a_layout.addWidget(help_btn_a)
# Container for label + line_edit + browse button with tooltip # Container for label + line_edit + browse button with tooltip
@@ -98,7 +102,7 @@ class UpdateOptodesWindow(QWidget):
help_btn_b = QPushButton("?") help_btn_b = QPushButton("?")
help_btn_b.setFixedWidth(25) help_btn_b.setFixedWidth(25)
help_btn_b.setToolTip(help_text_b) help_btn_b.setToolTip(help_text_b)
help_btn_b.clicked.connect(lambda _, text=help_text_b: self.show_help_popup(text)) help_btn_b.clicked.connect(lambda: self.show_help_popup(help_text_b))
file_b_layout.addWidget(help_btn_b) file_b_layout.addWidget(help_btn_b)
file_b_container = QWidget() file_b_container = QWidget()
@@ -121,7 +125,7 @@ class UpdateOptodesWindow(QWidget):
help_btn_suffix = QPushButton("?") help_btn_suffix = QPushButton("?")
help_btn_suffix.setFixedWidth(25) help_btn_suffix.setFixedWidth(25)
help_btn_suffix.setToolTip(help_text_suffix) help_btn_suffix.setToolTip(help_text_suffix)
help_btn_suffix.clicked.connect(lambda _, text=help_text_suffix: self.show_help_popup(text)) help_btn_suffix.clicked.connect(lambda: self.show_help_popup(help_text_suffix))
suffix_layout.addWidget(help_btn_suffix) suffix_layout.addWidget(help_btn_suffix)
suffix_container = QWidget() suffix_container = QWidget()
@@ -143,13 +147,13 @@ class UpdateOptodesWindow(QWidget):
self.setLayout(layout) self.setLayout(layout)
def show_help_popup(self, text): def show_help_popup(self, text: str) -> None:
msg = QMessageBox(self) msg = QMessageBox(self)
msg.setWindowTitle(f"Parameter Info - {APP_NAME.upper()}") msg.setWindowTitle(f"Parameter Info - {APP_NAME.upper()}")
msg.setText(text) msg.setText(text)
msg.exec() msg.exec()
def handle_link_click(self, link): def handle_link_click(self, link: str) -> None:
if link == "custom_link": if link == "custom_link":
msg = QMessageBox(self) msg = QMessageBox(self)
msg.setWindowTitle("Example Digitization File") msg.setWindowTitle("Example Digitization File")
@@ -166,21 +170,21 @@ class UpdateOptodesWindow(QWidget):
msg.setText(text) msg.setText(text)
msg.exec() msg.exec()
def browse_file_a(self): def browse_file_a(self) -> None:
file_path, _ = QFileDialog.getOpenFileName(self, "Select SNIRF File", "", "SNIRF Files (*.snirf)") file_path, _ = QFileDialog.getOpenFileName(self, "Select SNIRF File", "", "SNIRF Files (*.snirf)")
if file_path: if file_path:
self.line_edit_file_a.setText(file_path) self.line_edit_file_a.setText(file_path)
def browse_file_b(self): def browse_file_b(self) -> None:
file_path, _ = QFileDialog.getOpenFileName(self, "Select File", "", "Supported Files (*.txt *.xlsx)") file_path, _ = QFileDialog.getOpenFileName(self, "Select File", "", "Supported Files (*.txt *.xlsx)")
if file_path: if file_path:
self.line_edit_file_b.setText(file_path) self.line_edit_file_b.setText(file_path)
def clear_files(self): def clear_files(self) -> None:
self.line_edit_file_a.clear() self.line_edit_file_a.clear()
self.line_edit_file_b.clear() self.line_edit_file_b.clear()
def go_action(self): def go_action(self) -> None:
file_a = self.line_edit_file_a.text() file_a = self.line_edit_file_a.text()
file_b = self.line_edit_file_b.text() file_b = self.line_edit_file_b.text()
suffix = self.line_edit_suffix.text().strip() or "flare" suffix = self.line_edit_suffix.text().strip() or "flare"
@@ -220,7 +224,12 @@ class UpdateOptodesWindow(QWidget):
QMessageBox.information(self, "File Saved", f"File was saved to:\n{save_path}") QMessageBox.information(self, "File Saved", f"File was saved to:\n{save_path}")
def update_optode_positions(self, file_a, file_b, save_path): def update_optode_positions(
self,
file_a: Union[str, Path],
file_b: Union[str, Path],
save_path: Union[str, Path]
) -> None:
fiducials = {} fiducials = {}
ch_positions = {} ch_positions = {}
@@ -247,16 +256,22 @@ class UpdateOptodesWindow(QWidget):
elif extension == '.xlsx': elif extension == '.xlsx':
# TODO: Bad! Why assume sheet1 has the contents? # TODO: Bad! Why assume sheet1 has the contents?
df = pd.read_excel(file_b, sheet_name='Sheet1') df = pd.read_excel(file_b, sheet_name='Sheet1') # type: ignore
def _get_block_data(df, block_id, row_mapping, scale=0.001): def _get_block_data(
target_df: pd.DataFrame,
block_id: int,
row_mapping: Union[Dict[int, str], str],
scale: float = 0.001
) -> Dict[str, npt.NDArray[np.float64]]:
"""Isolates a block, cleans numeric data, and returns a scaled dictionary.""" """Isolates a block, cleans numeric data, and returns a scaled dictionary."""
# 1. Isolate and clean # 1. Isolate and clean
block = df[df['block_id'] == block_id].iloc[:, [1, 2, 3]].copy() block = target_df[target_df['block_id'] == block_id].iloc[:, [1, 2, 3]].copy()
block = block.apply(pd.to_numeric, errors='coerce') block = block.apply(pd.to_numeric, errors='coerce')
# 2. Extract into dictionary based on mapping # 2. Extract into dictionary based on mapping
result = {} result: Dict[str, npt.NDArray[np.float64]] = {}
# If row_mapping is a dict (like {0: 'nz'}), use it directly # If row_mapping is a dict (like {0: 'nz'}), use it directly
if isinstance(row_mapping, dict): if isinstance(row_mapping, dict):
@@ -265,7 +280,7 @@ class UpdateOptodesWindow(QWidget):
result[key] = block.iloc[row_idx].to_numpy(dtype=float) * scale result[key] = block.iloc[row_idx].to_numpy(dtype=float) * scale
# If row_mapping is a string prefix (like 'D' or 'S'), auto-generate keys # If row_mapping is a string prefix (like 'D' or 'S'), auto-generate keys
elif isinstance(row_mapping, str): else:
for i in range(len(block)): for i in range(len(block)):
result[f"{row_mapping}{i+1}"] = block.iloc[i].to_numpy(dtype=float) * scale result[f"{row_mapping}{i+1}"] = block.iloc[i].to_numpy(dtype=float) * scale
@@ -292,5 +307,5 @@ class UpdateOptodesWindow(QWidget):
# Read the SNIRF file, set the montage, and write it back # Read the SNIRF file, set the montage, and write it back
raw = read_raw_snirf(file_a, preload=True) raw = read_raw_snirf(file_a, preload=True)
raw.set_montage(initial_montage) raw.set_montage(initial_montage) # type: ignore
write_raw_snirf(raw, save_path) write_raw_snirf(raw, save_path)
+53 -8
View File
@@ -1,15 +1,25 @@
""" """
Filename: viewerlauncher.py Filename: viewerlauncher.py
Description: Viewer launcher window Description: Viewer launcher window
Note: Compliant with pylance strict type checking
Author: Tyler de Zeeuw Author: Tyler de Zeeuw
License: GPL-3.0 License: GPL-3.0
""" """
# Built-in imports
from pathlib import Path
from typing import Any, Callable, Type
# External library imports # External library imports
from pandas import DataFrame
from PySide6.QtWidgets import QPushButton, QWidget, QVBoxLayout from PySide6.QtWidgets import QPushButton, QWidget, QVBoxLayout
from PySide6.QtCore import QTimer from PySide6.QtCore import QTimer
from mne import Epochs
from mne.io.base import BaseRaw
from src.analysis.exporttocsv import ExportToCSVWidget from src.analysis.exporttocsv import ExportToCSVWidget
from src.analysis.intragroupbrainimage import IntraGroupBrainImageWidget from src.analysis.intragroupbrainimage import IntraGroupBrainImageWidget
from src.analysis.intergroupbrainimage import InterGroupBrainImageWidget from src.analysis.intergroupbrainimage import InterGroupBrainImageWidget
@@ -24,18 +34,31 @@ from src.shared.shareddata import APP_NAME
class ViewerLauncherWidget(QWidget): class ViewerLauncherWidget(QWidget):
def __init__(self, haemo_dict, epochs_dict, cha_dict, df_ind_dict, design_matrix_dict, config_dict, fig_bytes_dict, contrast_results_dict, roi_channel_map_dict, folding_bypass): def __init__(
self,
haemo_dict: dict[str | Path, BaseRaw],
epochs_dict: dict[str, Epochs],
cha_dict: dict[str, DataFrame],
df_ind_dict: dict[str, DataFrame],
design_matrix_dict: dict[str, DataFrame],
config_dict: dict[str, dict[str, Any]],
fig_bytes_dict: dict[str, dict[str, bytes]],
contrast_results_dict: dict[str, dict[str, Any]],
roi_channel_map_dict: dict[str, dict[str, str]],
folding_bypass: bool,
) -> None:
super().__init__() super().__init__()
self.setWindowTitle(f"Viewer Launcher - {APP_NAME.upper()}") self.setWindowTitle(f"Viewer Launcher - {APP_NAME.upper()}")
group_dict = {f: c.get("GROUP", "Unknown") for f, c in config_dict.items()} group_dict = {f: c.get("GROUP", "Unknown") for f, c in config_dict.items()}
btn_data = [ btn_data: list[tuple[str, Type[QWidget], list[Any], bool]] = [
("Participant Image Viewer", ParticipantImageViewerWidget, [haemo_dict, fig_bytes_dict], True), ("Participant Image Viewer", ParticipantImageViewerWidget, [haemo_dict, fig_bytes_dict], True),
("Participant Brain Viewer", ParticipantBrainViewerWidget, [haemo_dict, cha_dict], True), ("Participant Brain Viewer", ParticipantBrainViewerWidget, [haemo_dict, cha_dict], True),
("Participant Fold Channels Viewer", ParticipantFoldChannelsWidget, [haemo_dict, cha_dict], False), ("Participant Fold Channels Viewer", ParticipantFoldChannelsWidget, [haemo_dict, cha_dict], False),
("Participant Functional Connectivity Viewer [BETA]", ParticipantFunctionalConnectivityWidget, [haemo_dict, epochs_dict], True), ("Participant Functional Connectivity Viewer [BETA]", ParticipantFunctionalConnectivityWidget, [haemo_dict, epochs_dict], True),
("Intra-Group Functional Connectivity Viewer [BETA]", IntraGroupFunctionalConnectivityWidget, [haemo_dict, group_dict, config_dict], True), ("Intra-Group Functional Connectivity Viewer [BETA]", IntraGroupFunctionalConnectivityWidget, [haemo_dict, epochs_dict, group_dict], True),
("Intra-Group Stats Viewer", IntraGroupStatsWidget, [haemo_dict, cha_dict, df_ind_dict, design_matrix_dict, contrast_results_dict, roi_channel_map_dict, group_dict], True), ("Intra-Group Stats Viewer", IntraGroupStatsWidget, [haemo_dict, cha_dict, df_ind_dict, design_matrix_dict, contrast_results_dict, roi_channel_map_dict, group_dict], True),
("Inter-Group Stats Viewer", InterGroupStatsWidget, [haemo_dict, cha_dict, df_ind_dict, design_matrix_dict, contrast_results_dict, roi_channel_map_dict, group_dict], True), ("Inter-Group Stats Viewer", InterGroupStatsWidget, [haemo_dict, cha_dict, df_ind_dict, design_matrix_dict, contrast_results_dict, roi_channel_map_dict, group_dict], True),
("Intra-Group Brain and Image Viewer", IntraGroupBrainImageWidget, [haemo_dict, cha_dict, df_ind_dict, design_matrix_dict, contrast_results_dict, group_dict], True), ("Intra-Group Brain and Image Viewer", IntraGroupBrainImageWidget, [haemo_dict, cha_dict, df_ind_dict, design_matrix_dict, contrast_results_dict, group_dict], True),
@@ -47,21 +70,43 @@ class ViewerLauncherWidget(QWidget):
for label, widget_class, args, requires_bypass in btn_data: for label, widget_class, args, requires_bypass in btn_data:
btn = QPushButton(f"Open {label}") btn = QPushButton(f"Open {label}")
# Connect directly to the generic opener # Connect directly to the generic opener
btn.clicked.connect(lambda _, c=widget_class, b=btn, a=args: self._open_viewer(c, b, *a)) btn.clicked.connect(self._make_viewer_callback(widget_class, btn, args))
btn.setEnabled(not (requires_bypass and folding_bypass)) btn.setEnabled(not (requires_bypass and folding_bypass))
layout.addWidget(btn) layout.addWidget(btn)
def _open_viewer(self, widget_class, btn, *args): def _make_viewer_callback(
self,
widget_class: Type[QWidget],
btn: QPushButton,
args: list[Any],
) -> Callable[[bool], None]:
def callback(_checked: bool = False) -> None:
self._open_viewer(widget_class, btn, *args)
return callback
def _open_viewer(
self,
widget_class: Type[QWidget],
btn: QPushButton,
*args: Any
) -> None:
# Instantiate and show dynamically # Instantiate and show dynamically
self.active_viewer = widget_class(*args) self.active_viewer = widget_class(*args)
self.active_viewer.show() self.active_viewer.show()
self._trigger_success(btn) self._trigger_success(btn)
def _launch(self, func, btn, *args): def _launch(
self,
func: Callable[..., Any],
btn: QPushButton,
*args: Any
) -> None:
func(*args) func(*args)
self._trigger_success(btn) self._trigger_success(btn)
def _trigger_success(self, button): def _trigger_success(self, button: QPushButton) -> None:
"""Temporarily adds a green checkmark to the button text.""" """Temporarily adds a green checkmark to the button text."""
original_text = button.text() original_text = button.text()
button.setText(f"{original_text} ✔") button.setText(f"{original_text} ✔")
@@ -70,6 +115,6 @@ class ViewerLauncherWidget(QWidget):
# Revert after 1 second # Revert after 1 second
QTimer.singleShot(1000, lambda: self._revert_button(button, original_text)) QTimer.singleShot(1000, lambda: self._revert_button(button, original_text))
def _revert_button(self, button, original_text): def _revert_button(self, button: QPushButton, original_text: str) -> None:
button.setText(original_text) button.setText(original_text)
button.setStyleSheet("") button.setStyleSheet("")
+97 -56
View File
@@ -1,6 +1,7 @@
""" """
Filename: updater.py Filename: updater.py
Description: Generic updater file Description: Generic updater file
Note: Compliant with pylance strict type checking
Author: Tyler de Zeeuw Author: Tyler de Zeeuw
License: GPL-3.0 License: GPL-3.0
@@ -17,13 +18,14 @@ import zipfile
import traceback import traceback
import subprocess import subprocess
import configparser import configparser
from typing import List
# External library imports # External library imports
import psutil import psutil
import requests import requests
from PySide6.QtWidgets import QMessageBox
from PySide6.QtCore import QThread, Signal, QObject from PySide6.QtCore import QThread, Signal, QObject
from PySide6.QtWidgets import QMainWindow, QMessageBox
class UpdateDownloadThread(QThread): class UpdateDownloadThread(QThread):
@@ -38,7 +40,14 @@ class UpdateDownloadThread(QThread):
update_ready = Signal(str, str) update_ready = Signal(str, str)
error_occurred = Signal(str) error_occurred = Signal(str)
def __init__(self, download_url, latest_version, platform_name, app_name): def __init__(
self,
download_url: str,
latest_version: str,
platform_name: str,
app_name: str,
) -> None:
super().__init__() super().__init__()
self.download_url = download_url self.download_url = download_url
self.latest_version = latest_version self.latest_version = latest_version
@@ -54,6 +63,7 @@ class UpdateDownloadThread(QThread):
os.makedirs(tmp_dir, exist_ok=True) os.makedirs(tmp_dir, exist_ok=True)
local_path = os.path.join(tmp_dir, local_filename) local_path = os.path.join(tmp_dir, local_filename)
else: else:
tmp_dir = os.getcwd()
local_path = os.path.join(os.getcwd(), local_filename) local_path = os.path.join(os.getcwd(), local_filename)
# Download the file # Download the file
@@ -92,7 +102,6 @@ class UpdateDownloadThread(QThread):
self.error_occurred.emit(str(e)) self.error_occurred.emit(str(e))
class UpdateCheckThread(QThread): class UpdateCheckThread(QThread):
""" """
Thread that checks for updates by querying the API and emits a signal based on the result. Thread that checks for updates by querying the API and emits a signal based on the result.
@@ -107,7 +116,15 @@ class UpdateCheckThread(QThread):
no_update_available = Signal() no_update_available = Signal()
error_occurred = Signal(str) error_occurred = Signal(str)
def __init__(self, api_url, api_url_sec, current_version, platform_name, app_name): def __init__(
self,
api_url: str,
api_url_sec: str,
current_version: str,
platform_name: str,
app_name: str,
) -> None:
super().__init__() super().__init__()
self.api_url = api_url self.api_url = api_url
self.api_url_sec = api_url_sec self.api_url_sec = api_url_sec
@@ -137,8 +154,9 @@ class UpdateCheckThread(QThread):
except Exception as e: except Exception as e:
self.error_occurred.emit(f"Update check failed: {e}") self.error_occurred.emit(f"Update check failed: {e}")
def version_compare(self, v1, v2): def version_compare(self, v1: str, v2: str) -> int:
def normalize(v): return [int(x) for x in v.split(".")] def normalize(v: str) -> List[int]:
return [int(x) for x in v.split(".")]
return (normalize(v1) > normalize(v2)) - (normalize(v1) < normalize(v2)) return (normalize(v1) > normalize(v2)) - (normalize(v1) < normalize(v2))
def get_latest_release_for_platform(self): def get_latest_release_for_platform(self):
@@ -165,7 +183,7 @@ class UpdateCheckThread(QThread):
return tag, asset["browser_download_url"] return tag, asset["browser_download_url"]
return tag, None return tag, None
except (requests.RequestException, ValueError) as e: except (requests.RequestException, ValueError, KeyError):
continue continue
return None, None return None, None
@@ -182,15 +200,23 @@ class LocalPendingUpdateCheckThread(QThread):
pending_update_found = Signal(str, str) pending_update_found = Signal(str, str)
no_pending_update = Signal() no_pending_update = Signal()
def __init__(self, current_version, platform_suffix, platform_name, app_name): def __init__(
self,
current_version: str,
platform_suffix: str,
platform_name: str,
app_name: str,
) -> None:
super().__init__() super().__init__()
self.current_version = current_version self.current_version = current_version
self.platform_suffix = platform_suffix self.platform_suffix = platform_suffix
self.platform_name = platform_name self.platform_name = platform_name
self.app_name = app_name self.app_name = app_name
def version_compare(self, v1, v2): def version_compare(self, v1: str, v2: str) -> int:
def normalize(v): return [int(x) for x in v.split(".")] def normalize(v: str) -> List[int]:
return [int(x) for x in v.split(".")]
return (normalize(v1) > normalize(v2)) - (normalize(v1) < normalize(v2)) return (normalize(v1) > normalize(v2)) - (normalize(v1) < normalize(v2))
def run(self): def run(self):
@@ -220,18 +246,25 @@ class LocalPendingUpdateCheckThread(QThread):
self.no_pending_update.emit() self.no_pending_update.emit()
class UpdateManager(QObject): class UpdateManager(QObject):
""" """
Orchestrates the update process. Orchestrates the update process.
Main apps should instantiate this and call check_for_updates(). Main apps should instantiate this and call check_for_updates().
""" """
def __init__(self, main_window, api_url, api_url_sec, current_version, platform_name, platform_suffix, app_name): def __init__(
super().__init__() self,
self.parent = main_window main_window: QMainWindow,
api_url: str,
api_url_sec: str,
current_version: str,
platform_name: str,
platform_suffix: str,
app_name: str,
) -> None:
super().__init__(main_window)
self.main_window: QMainWindow = main_window
self.api_url = api_url self.api_url = api_url
self.api_url_sec = api_url_sec self.api_url_sec = api_url_sec
self.current_version = current_version self.current_version = current_version
@@ -243,59 +276,64 @@ class UpdateManager(QObject):
self.pending_update_path = None self.pending_update_path = None
def manual_check_for_updates(self): def manual_check_for_updates(self) -> None:
self.local_check_thread = LocalPendingUpdateCheckThread(self.current_version, self.platform_suffix, self.platform_name, self.app_name) self.local_check_thread = LocalPendingUpdateCheckThread(self.current_version, self.platform_suffix, self.platform_name, self.app_name)
self.local_check_thread.pending_update_found.connect(self.on_pending_update_found) self.local_check_thread.pending_update_found.connect(self.on_pending_update_found)
self.local_check_thread.no_pending_update.connect(self.on_no_pending_update) self.local_check_thread.no_pending_update.connect(self.on_no_pending_update)
self.local_check_thread.start() self.local_check_thread.start()
def on_pending_update_found(self, version, folder_path): def on_pending_update_found(self, version: str, folder_path: str) -> None:
self.parent.statusBar().showMessage(f"Pending update found: version {version}") self.main_window.statusBar().showMessage(f"Pending update found: version {version}")
self.pending_update_version = version self.pending_update_version = version
self.pending_update_path = folder_path self.pending_update_path = folder_path
self.show_pending_update_popup() self.show_pending_update_popup()
def on_no_pending_update(self): def on_no_pending_update(self) -> None:
# No pending update found locally, start server check directly # No pending update found locally, start server check directly
self.parent.statusBar().showMessage("No pending local update found. Checking server...") self.main_window.statusBar().showMessage("No pending local update found. Checking server...")
self.start_update_check_thread() self.start_update_check_thread()
def show_pending_update_popup(self): def show_pending_update_popup(self) -> None:
msg_box = QMessageBox(self.parent) msg_box = QMessageBox(self.main_window)
msg_box.setWindowTitle("Pending Update Found") msg_box.setWindowTitle("Pending Update Found")
msg_box.setText(f"A previously downloaded update for {self.app_name.upper()} (version {self.pending_update_version}) is available at:\n{self.pending_update_path}\nWould you like to install it now?") msg_box.setText(f"A previously downloaded update for {self.app_name.upper()} (version {self.pending_update_version}) is available at:\n{self.pending_update_path}\nWould you like to install it now?")
install_now_button = msg_box.addButton("Install Now", QMessageBox.ButtonRole.AcceptRole) install_now_button = msg_box.addButton("Install Now", QMessageBox.ButtonRole.AcceptRole)
install_later_button = msg_box.addButton("Install Later", QMessageBox.ButtonRole.RejectRole) msg_box.addButton("Install Later", QMessageBox.ButtonRole.RejectRole)
msg_box.exec() msg_box.exec()
if msg_box.clickedButton() == install_now_button: if msg_box.clickedButton() == install_now_button and self.pending_update_path:
self.install_update(self.pending_update_path) self.install_update(self.pending_update_path)
else: else:
self.parent.statusBar().showMessage("Pending update available. Install later.") if self.main_window.statusBar():
self.main_window.statusBar().showMessage("Pending update available. Install later.")
# After user dismisses, still check the server for new updates # After user dismisses, still check the server for new updates
self.start_update_check_thread() self.start_update_check_thread()
def start_update_check_thread(self): def start_update_check_thread(self) -> None:
self.check_thread = UpdateCheckThread(self.api_url, self.api_url_sec, self.current_version, self.platform_name, self.app_name) self.check_thread = UpdateCheckThread(self.api_url, self.api_url_sec, self.current_version, self.platform_name, self.app_name)
self.check_thread.download_requested.connect(self.on_server_update_requested) self.check_thread.download_requested.connect(self.on_server_update_requested)
self.check_thread.no_update_available.connect(self.on_server_no_update) self.check_thread.no_update_available.connect(self.on_server_no_update)
self.check_thread.error_occurred.connect(self.on_error) self.check_thread.error_occurred.connect(self.on_error)
self.check_thread.start() self.check_thread.start()
def on_server_no_update(self): def on_server_no_update(self) -> None:
self.parent.statusBar().showMessage("No new updates found on server.", 5000) if self.main_window.statusBar():
self.main_window.statusBar().showMessage("No new updates found on server.", 5000)
def on_server_update_requested(self, download_url, latest_version): def on_server_update_requested(self, download_url: str, latest_version: str) -> None:
if self.pending_update_version: pending_path = self.pending_update_path
cmp = self.version_compare(latest_version, self.pending_update_version) pending_version = self.pending_update_version
if pending_version and pending_path:
cmp = self.version_compare(latest_version, pending_version)
if cmp > 0: if cmp > 0:
# Server version is newer than pending update # Server version is newer than pending update
self.parent.statusBar().showMessage(f"Newer version {latest_version} available on server. Removing old pending update...") self.main_window.statusBar().showMessage(f"Newer version {latest_version} available on server. Removing old pending update...")
try: try:
shutil.rmtree(self.pending_update_path) shutil.rmtree(pending_path)
self.parent.statusBar().showMessage(f"Deleted old update folder: {self.pending_update_path}") self.main_window.statusBar().showMessage(f"Deleted old update folder: {pending_path}")
except Exception as e: except Exception as e:
self.parent.statusBar().showMessage(f"Failed to delete old update folder: {e}") self.main_window.statusBar().showMessage(f"Failed to delete old update folder: {e}")
# Clear pending update info so new download proceeds # Clear pending update info so new download proceeds
self.pending_update_version = None self.pending_update_version = None
@@ -305,39 +343,41 @@ class UpdateManager(QObject):
self.download_update(download_url, latest_version) self.download_update(download_url, latest_version)
elif cmp == 0: elif cmp == 0:
# Versions equal, no download needed # Versions equal, no download needed
self.parent.statusBar().showMessage(f"Pending update version {self.pending_update_version} is already latest. No download needed.") self.main_window.statusBar().showMessage(f"Pending update version {self.pending_update_version} is already latest. No download needed.")
else: else:
# Server version older than pending? Unlikely but just keep pending update # Server version older than pending? Unlikely but just keep pending update
self.parent.statusBar().showMessage(f"Pending update version {self.pending_update_version} is newer than server version. No action.") self.main_window.statusBar().showMessage(f"Pending update version {self.pending_update_version} is newer than server version. No action.")
else: else:
# No pending update, just download # No pending update, just download
self.download_update(download_url, latest_version) self.download_update(download_url, latest_version)
def download_update(self, download_url, latest_version): def download_update(self, download_url: str, latest_version: str) -> None:
self.parent.statusBar().showMessage("Downloading update...") if self.main_window.statusBar():
self.main_window.statusBar().showMessage("Downloading update...")
self.download_thread = UpdateDownloadThread(download_url, latest_version, self.platform_name, self.app_name) self.download_thread = UpdateDownloadThread(download_url, latest_version, self.platform_name, self.app_name)
self.download_thread.update_ready.connect(self.on_update_ready) self.download_thread.update_ready.connect(self.on_update_ready)
self.download_thread.error_occurred.connect(self.on_error) self.download_thread.error_occurred.connect(self.on_error)
self.download_thread.start() self.download_thread.start()
def on_update_ready(self, latest_version, extract_folder): def on_update_ready(self, latest_version: str, extract_folder: str) -> None:
self.parent.statusBar().showMessage("Update downloaded and extracted.") if self.main_window.statusBar():
self.main_window.statusBar().showMessage("Update downloaded and extracted.")
msg_box = QMessageBox(self.parent) msg_box = QMessageBox(self.main_window)
msg_box.setWindowTitle("Update Ready") msg_box.setWindowTitle("Update Ready")
msg_box.setText(f"Version {latest_version} has been downloaded and extracted to:\n{extract_folder}\nWould you like to install it now?") msg_box.setText(f"Version {latest_version} has been downloaded and extracted to:\n{extract_folder}\nWould you like to install it now?")
install_now_button = msg_box.addButton("Install Now", QMessageBox.ButtonRole.AcceptRole) install_now_button = msg_box.addButton("Install Now", QMessageBox.ButtonRole.AcceptRole)
install_later_button = msg_box.addButton("Install Later", QMessageBox.ButtonRole.RejectRole) msg_box.addButton("Install Later", QMessageBox.ButtonRole.RejectRole)
msg_box.exec() msg_box.exec()
if msg_box.clickedButton() == install_now_button: if msg_box.clickedButton() == install_now_button:
self.install_update(extract_folder) self.install_update(extract_folder)
else: else:
self.parent.statusBar().showMessage("Update ready. Install later.") self.main_window.statusBar().showMessage("Update ready. Install later.")
def install_update(self, extract_folder): def install_update(self, extract_folder: str) -> None:
# Path to updater executable # Path to updater executable
if self.platform_name == 'windows': if self.platform_name == 'windows':
@@ -354,7 +394,7 @@ class UpdateManager(QObject):
updater_path = os.getcwd() updater_path = os.getcwd()
if not os.path.exists(updater_path): if not os.path.exists(updater_path):
QMessageBox.critical(self.parent, "Error", f"Updater not found at:\n{updater_path}. The absolute path was {os.path.abspath(updater_path)}") QMessageBox.critical(self.main_window, "Error", f"Updater not found at:\n{updater_path}. The absolute path was {os.path.abspath(updater_path)}")
return return
# Launch updater with extracted folder path as argument # Launch updater with extracted folder path as argument
@@ -373,18 +413,19 @@ class UpdateManager(QObject):
sys.exit(0) sys.exit(0)
except Exception as e: except Exception as e:
QMessageBox.critical(self.parent, "Error", f"[Updater Launch Failed]\n{str(e)}\n{traceback.format_exc()}") QMessageBox.critical(self.main_window, "Error", f"[Updater Launch Failed]\n{str(e)}\n{traceback.format_exc()}")
def on_error(self, message): def on_error(self, message: str) -> None:
# print(f"Error: {message}") if self.main_window.statusBar():
self.parent.statusBar().showMessage(f"Error occurred during update process. {message}") self.main_window.statusBar().showMessage(f"Error occurred during update process. {message}")
def version_compare(self, v1, v2): def version_compare(self, v1: str, v2: str) -> int:
def normalize(v): return [int(x) for x in v.split(".")] def normalize(v: str) -> List[int]:
return [int(x) for x in v.split(".")]
return (normalize(v1) > normalize(v2)) - (normalize(v1) < normalize(v2)) return (normalize(v1) > normalize(v2)) - (normalize(v1) < normalize(v2))
def wait_for_process_to_exit(process_name, timeout=10): def wait_for_process_to_exit(process_name: str, timeout: int = 10) -> bool:
""" """
Waits for a process with the specified name to exit within a timeout period. Waits for a process with the specified name to exit within a timeout period.
@@ -416,7 +457,7 @@ def wait_for_process_to_exit(process_name, timeout=10):
return False return False
def finish_update_if_needed(platform_name, app_name, cfg_path, finish_update): def finish_update_if_needed(platform_name: str, app_name: str, cfg_path: str, finish_update: bool) -> None:
""" """
Completes a pending application update if '--finish-update' is present in the command-line arguments. Completes a pending application update if '--finish-update' is present in the command-line arguments.
""" """
@@ -534,7 +575,7 @@ def finish_update_if_needed(platform_name, app_name, cfg_path, finish_update):
sys.argv.remove("--finish-update") sys.argv.remove("--finish-update")
def remove_quarantine(app_path, app_name): def remove_quarantine(app_path: str, app_name: str) -> None:
""" """
Removes the macOS quarantine attribute from the specified application path. Removes the macOS quarantine attribute from the specified application path.
""" """