release worthy?

This commit is contained in:
2026-01-31 23:42:49 -08:00
parent dd2ac058af
commit f1dd9bd184
4 changed files with 805 additions and 251 deletions

View File

@@ -1,7 +1,9 @@
# Version 1.2.0
- This is a save-breaking release due to a new save file format. Please update your project files to ensure compatibility. Fixes [Issue 30](https://git.research.dezeeuw.ca/tyler/flares/issues/30)
- Added new parameters to the right side of the screen
- These parameters include SHOW_OPTODE_NAMES, SECONDS_TO_STRIP_HR, MAX_LOW_HR, MAX_HIGH_HR, SMOOTHING_WINDOW_HR, HEART_RATE_WINDOW, BAD_CHANNELS_HANDLING, MAX_DIST, MIN_NEIGHBORS, L_TRANS_BANDWIDTH, H_TRANS_BANDWIDTH, RESAMPLE, RESAMPLE_FREQ, STIM_DUR, HRF_MODEL, HIGH_PASS, DRIFT_ORDER, FIR_DELAYS, MIN_ONSET, OVERSAMPLING, SHORT_CHANNEL_REGRESSION, NOISE_MODEL, BINS, and VERBOSITY.
- Certain parameters now have dependencies on other parameters and will now grey out if they are not used
- All the new parameters have default values matching the underlying values in version 1.1.7
- The order of the parameters have changed to match the order that the code runs when the Process button is clicked
- Moved TIME_WINDOW_START and TIME_WINDOW_END to the 'Other' category
@@ -17,11 +19,18 @@
- Added a new CSV export option to be used by other applications
- Added support for updating optode positions directly from an .xlsx file from a Polhemius system
- Fixed an issue where the dropdowns in the Viewer windows would immediately open and close when using a trackpad
- glover and spm hrf models now function as intended without crashing. Currently, group analysis is still only supported by fir
- Revamped the fold channels viewer to not hang the application and to better process multiple participants at once
- glover and spm hrf models now function as intended without crashing. Currently, group analysis is still only supported by fir. Fixes [Issue 8](https://git.research.dezeeuw.ca/tyler/flares/issues/8)
- Clicking 'Clear' should now properly clear all data. Fixes [Issue 9](https://git.research.dezeeuw.ca/tyler/flares/issues/9)
- Revamped the fold channels viewer to not hang the application and to better process multiple participants at once. Fixes [Issue 34](https://git.research.dezeeuw.ca/tyler/flares/issues/34), [Issue 31](https://git.research.dezeeuw.ca/tyler/flares/issues/31)
- Added a Preferences menu to the navigation bar
- Currently, there is only one preference allowing to bypass the warning of 2D data
- Fixed [Issue 8](https://git.research.dezeeuw.ca/tyler/flares/issues/8), [Issue 9](https://git.research.dezeeuw.ca/tyler/flares/issues/9), [Issue 30](https://git.research.dezeeuw.ca/tyler/flares/issues/30), [Issue 31](https://git.research.dezeeuw.ca/tyler/flares/issues/31), [Issue 34](https://git.research.dezeeuw.ca/tyler/flares/issues/34), [Issue 36](https://git.research.dezeeuw.ca/tyler/flares/issues/36)
- Two preferences have been added allowing to bypass the warning of 2D data detected and save files being from previous, potentially breaking versions
- Fixed a typo when saving a CSV that stated a SNIRF was being saved
- Loading a save file now properly restores AGE, GENDER, and GROUP. Fixes [Issue 40](https://git.research.dezeeuw.ca/tyler/flares/issues/40)
- Saving a project now no longer makes the main window go not responding. Fixes [Issue 43](https://git.research.dezeeuw.ca/tyler/flares/issues/43)
- Memory usage should no longer grow when generating lots of images multiple times. Fixes [Issue 36](https://git.research.dezeeuw.ca/tyler/flares/issues/36)
- Added a new option in the Analysis window for Functional Connectivity
- Functional connectivity is still in development and the results should currently be taken with a grain of salt
- A warning is displayed when entering the Functional Connectivity Viewer disclosing this
# Version 1.1.7

207
fc.py
View File

@@ -1,207 +0,0 @@
import mne
import numpy as np
from mne.preprocessing.nirs import optical_density, beer_lambert_law
from mne_connectivity import spectral_connectivity_epochs
from mne_connectivity.viz import plot_connectivity_circle
raw = mne.io.read_raw_snirf("E:/CVI_V_Adults_Cor/P21_CVI_V_updated.snirf", preload=True)
raw.info["bads"] = [] # mark bad channels here if needed
raw_od = optical_density(raw)
raw_hb = beer_lambert_law(raw_od)
raw_hbo = raw_hb.copy().pick(picks="hbo")
raw_hbo.filter(
l_freq=0.01,
h_freq=0.2,
picks="hbo",
verbose=False
)
events = mne.make_fixed_length_events(
raw_hbo,
duration=30.0
)
epochs = mne.Epochs(
raw_hbo,
events,
tmin=0,
tmax=30.0,
baseline=None,
preload=True,
verbose=False
)
data = epochs.get_data() # (n_epochs, n_channels, n_times)
names = epochs.ch_names
sfreq = epochs.info["sfreq"]
con = spectral_connectivity_epochs(
data,
method=["coh", "plv"],
mode="multitaper",
sfreq=sfreq,
fmin=0.04,
fmax=0.2,
faverage=True,
verbose=True
)
con_coh, con_plv = con
coh = con_coh.get_data(output="dense").squeeze()
plv = con_plv.get_data(output="dense").squeeze()
np.fill_diagonal(coh, 0)
np.fill_diagonal(plv, 0)
plot_connectivity_circle(
coh,
names,
title="fNIRS Functional Connectivity (HbO - Coherence)",
n_lines=40
)
from mne_connectivity import envelope_correlation
env = envelope_correlation(
data,
orthogonalize=False,
absolute=True
)
env_data = env.get_data(output="dense")
env_corr = env_data.mean(axis=0)
env_corr = np.squeeze(env_corr)
np.fill_diagonal(env_corr, 0)
plot_connectivity_circle(
env_corr,
epochs.ch_names,
title="fNIRS HbO Envelope Correlation (Task Connectivity)",
n_lines=40
)
from mne_nirs.statistics import run_glm
from mne_nirs.experimental_design import make_first_level_design_matrix
raw_hb.annotations.description = [
f"Reach_{i}" if d == "Reach" else d
for i, d in enumerate(raw_hb.annotations.description)
]
design_matrix = make_first_level_design_matrix(
raw_hb,
stim_dur=1.0, # We assume a short burst since duration is unknown
hrf_model='fir', # Finite Impulse Response
fir_delays=np.arange(0, 12) # Look at 0-20 seconds after onset
)
# 2. Run the GLM
# This calculates the brain's response for every channel
glm_est = run_glm(raw_hb, design_matrix)
import pandas as pd
# 3. Extract Beta Weights
beta_df = glm_est.to_dataframe()
print("\n--- DEBUG: Dataframe Info ---")
print(f"Total rows in beta_df: {len(beta_df)}")
print(f"Columns available: {list(beta_df.columns)}")
print(f"Unique Chroma values: {beta_df['Chroma'].unique()}")
print(f"First 5 unique Conditions: {beta_df['Condition'].unique()[:5]}")
# FIX: Use .str.contains() because FIR conditions are named like 'Reach[5.0]'
# We filter for HbO AND any condition that starts with 'Reach'
hbo_betas = beta_df[(beta_df['Chroma'] == 'hbo') &
(beta_df['Condition'].str.contains('Reach'))]
hbo_betas = hbo_betas.copy()
hbo_betas[['Trial', 'Delay']] = hbo_betas['Condition'].str.extract(r'(Reach_\d+)_delay_(\d+)')
# 2. Find which DELAY (time point) is best across ALL trials
# We convert 'Delay' to numeric so we can sort them properly later if needed
hbo_betas['Delay'] = pd.to_numeric(hbo_betas['Delay'])
# IMPORTANT: We ignore delay 0 and 1 because they are usually 0.0000 (stimulus onset)
# Brain responses in fNIRS usually peak between delays 4 and 8 (4-8 seconds)
mask = (hbo_betas['Delay'] >= 4) & (hbo_betas['Delay'] <= 8)
hbo_window = hbo_betas[mask]
if hbo_window.empty:
print("Warning: No data found in the 4-8s window. Check your 'fir_delays' range.")
# Fallback to whatever is available if 4-8 is missing
mean_by_delay = hbo_betas.groupby('Delay')['theta'].mean()
else:
mean_by_delay = hbo_window.groupby('Delay')['theta'].mean()
peak_delay_num = mean_by_delay.idxmax()
print(f"\n--- DEBUG: FIR Timing ---")
print(f"Delays analyzed: {list(mean_by_delay.index)}")
print(f"Peak brain response found at delay: {peak_delay_num}")
# 3. Filter the data to ONLY include that peak delay across ALL trials
peak_df = hbo_betas[hbo_betas['Delay'] == peak_delay_num]
mne_order = raw_hbo.ch_names
# 4. Pivot: Rows = Trials (Reach_1, Reach_2...), Columns = Channels
beta_pivot = peak_df.pivot(index='Trial', columns='ch_name', values='theta')
beta_pivot = beta_pivot.reindex(columns=mne_order)
print(f"Pivot table shape: {beta_pivot.shape} (Should be something like 30 trials x 26 channels)")
# 5. Correlation (Now it has a series of data to correlate!)
beta_corr_matrix = beta_pivot.corr().values
np.fill_diagonal(beta_corr_matrix, 0)
# Replace any NaNs with 0 (occurs if a channel has 0 variance)
beta_corr_matrix = np.nan_to_num(beta_corr_matrix)
import matplotlib.pyplot as plt
channel_names = beta_pivot.columns.tolist()
# Create the plot
plot_connectivity_circle(
beta_corr_matrix,
channel_names,
n_lines=40, # Show only the top 40 strongest connections
title=f"FIR Beta Series Connectivity)",
)
# 1. Aggregate the mean response for each delay across all trials and channels
# We want to see the general 'shape' of the Reach response
time_points = np.arange(0, 12) # Matches your fir_delays
average_response = hbo_betas.groupby('Delay')['theta'].mean()
# 2. Plotting
plt.figure(figsize=(10, 6))
for ch in hbo_betas['ch_name'].unique():
ch_data = hbo_betas[hbo_betas['ch_name'] == ch].groupby('Delay')['theta'].mean()
plt.plot(time_points, ch_data, color='gray', alpha=0.3) # Individual channels
# Plot the 'Grand Average' in bold red
plt.plot(time_points, average_response, color='red', linewidth=3, label='Grand Average')
plt.axvline(x=4, color='green', linestyle='--', label='Window Start (4s)')
plt.axvline(x=8, color='green', linestyle='--', label='Window End (8s)')
plt.title("FIR Hemodynamic Response to 'Reach' (HbO)")
plt.xlabel("Seconds after Stimulus")
plt.ylabel("HbO Concentration (Beta Weight)")
plt.legend()
plt.grid(True, alpha=0.3)
plt.show()

256
flares.py
View File

@@ -47,9 +47,9 @@ from nilearn.glm.regression import OLSModel
import statsmodels.formula.api as smf # type: ignore
from statsmodels.stats.multitest import multipletests
from scipy import stats
from scipy.spatial.distance import cdist
from scipy.signal import welch, butter, filtfilt # type: ignore
from scipy.stats import pearsonr, zscore, t
import pywt # type: ignore
import neurokit2 as nk # type: ignore
@@ -91,6 +91,9 @@ from mne_nirs.io.fold import fold_channel_specificity # type: ignore
from mne_nirs.preprocessing import peak_power # type: ignore
from mne_nirs.statistics._glm_level_first import RegressionResults # type: ignore
from mne_connectivity.viz import plot_connectivity_circle
from mne_connectivity import envelope_correlation, spectral_connectivity_epochs, spectral_connectivity_time
# Needs to be set for mne
os.environ["SUBJECTS_DIR"] = str(data_path()) + "/subjects" # type: ignore
@@ -188,9 +191,9 @@ TIME_WINDOW_END: int
MAX_WORKERS: int
VERBOSITY: bool
AGE = 25 # Assume 25 if not set from the GUI. This will result in a reasonable PPF
GENDER = ""
GROUP = "Default"
AGE: int = 25 # Assume 25 if not set from the GUI. This will result in a reasonable PPF
GENDER: str = ""
GROUP: str = "Default"
# These are parameters that are required for the analysis
REQUIRED_KEYS: dict[str, Any] = {
@@ -2818,7 +2821,7 @@ def run_second_level_analysis(df_contrasts, raw, p, bounds):
result = model.fit(Y)
t_val = result.t(0).item()
p_val = 2 * stats.t.sf(np.abs(t_val), df=result.df_model)
p_val = 2 * t.sf(np.abs(t_val), df=result.df_model)
mean_beta = np.mean(Y)
group_results.append({
@@ -3357,6 +3360,7 @@ def process_participant(file_path, progress_callback=None):
logger.info("Step 1 Completed.")
# Step 2: Trimming
# TODO: Clean this into a method
if TRIM:
if hasattr(raw, 'annotations') and len(raw.annotations) > 0:
# Get time of first event
@@ -3637,7 +3641,17 @@ def process_participant(file_path, progress_callback=None):
if progress_callback: progress_callback(25)
logger.info("25")
return raw_haemo, epochs, fig_bytes, cha, contrast_results, df_ind, design_matrix, AGE, GENDER, GROUP, True
# TODO: Tidy up
# Extract the parameters this file was ran with. No need to return age, gender, group?
config = {
k: globals()[k]
for k in __annotations__
if k in globals() and k != "REQUIRED_KEYS"
}
print(config)
return raw_haemo, config, epochs, fig_bytes, cha, contrast_results, df_ind, design_matrix, True
def sanitize_paths_for_pickle(raw_haemo, epochs):
@@ -3648,3 +3662,233 @@ def sanitize_paths_for_pickle(raw_haemo, epochs):
# Fix epochs._raw._filenames
if hasattr(epochs, '_raw') and hasattr(epochs._raw, '_filenames'):
epochs._raw._filenames = [str(p) for p in epochs._raw._filenames]
def functional_connectivity_spectral_epochs(epochs, n_lines, vmin):
# will crash without this load
epochs.load_data()
hbo_epochs = epochs.copy().pick(picks="hbo")
data = hbo_epochs.get_data()
names = hbo_epochs.ch_names
sfreq = hbo_epochs.info["sfreq"]
con = spectral_connectivity_epochs(
data,
method=["coh", "plv"],
mode="multitaper",
sfreq=sfreq,
fmin=0.04,
fmax=0.2,
faverage=True,
verbose=True
)
con_coh, con_plv = con
coh = con_coh.get_data(output="dense").squeeze()
plv = con_plv.get_data(output="dense").squeeze()
np.fill_diagonal(coh, 0)
np.fill_diagonal(plv, 0)
plot_connectivity_circle(
coh,
names,
title="fNIRS Functional Connectivity (HbO - Coherence)",
n_lines=n_lines,
vmin=vmin
)
def functional_connectivity_spectral_time(epochs, n_lines, vmin):
# will crash without this load
epochs.load_data()
hbo_epochs = epochs.copy().pick(picks="hbo")
data = hbo_epochs.get_data()
names = hbo_epochs.ch_names
sfreq = hbo_epochs.info["sfreq"]
freqs = np.linspace(0.04, 0.2, 10)
n_cycles = freqs * 2
con = spectral_connectivity_time(
data,
freqs=freqs,
method=["coh", "plv"],
mode="multitaper",
sfreq=sfreq,
fmin=0.04,
fmax=0.2,
n_cycles=n_cycles,
faverage=True,
verbose=True
)
con_coh, con_plv = con
coh = con_coh.get_data(output="dense").squeeze()
plv = con_plv.get_data(output="dense").squeeze()
np.fill_diagonal(coh, 0)
np.fill_diagonal(plv, 0)
plot_connectivity_circle(
coh,
names,
title="fNIRS Functional Connectivity (HbO - Coherence)",
n_lines=n_lines,
vmin=vmin
)
def functional_connectivity_envelope(epochs, n_lines, vmin):
# will crash without this load
epochs.load_data()
hbo_epochs = epochs.copy().pick(picks="hbo")
data = hbo_epochs.get_data()
env = envelope_correlation(
data,
orthogonalize=False,
absolute=True
)
env_data = env.get_data(output="dense")
env_corr = env_data.mean(axis=0)
env_corr = np.squeeze(env_corr)
np.fill_diagonal(env_corr, 0)
plot_connectivity_circle(
env_corr,
hbo_epochs.ch_names,
title="fNIRS HbO Envelope Correlation (Task Connectivity)",
n_lines=n_lines,
vmin=vmin
)
def functional_connectivity_betas(raw_hbo, n_lines, vmin, event_name=None):
raw_hbo = raw_hbo.copy().pick(picks="hbo")
onsets = raw_hbo.annotations.onset
# CRITICAL: Update the Raw object's annotations so the GLM sees unique events
ann = raw_hbo.annotations
new_desc = []
for i, desc in enumerate(ann.description):
new_desc.append(f"{desc}__trial_{i:03d}")
ann.description = np.array(new_desc)
# shoudl use user defiuned!!!!
design_matrix = make_first_level_design_matrix(
raw=raw_hbo,
hrf_model='fir',
fir_delays=np.arange(0, 12, 1),
drift_model='cosine',
drift_order=1
)
# 3. Run GLM & Extract Betas
glm_results = run_glm(raw_hbo, design_matrix)
betas = np.array(glm_results.theta())
reg_names = list(design_matrix.columns)
n_channels = betas.shape[0]
# ------------------------------------------------------------------
# 5. Find unique trial tags (optionally filtered by event)
# ------------------------------------------------------------------
trial_tags = sorted({
col.split("_delay")[0]
for col in reg_names
if (
("__trial_" in col)
and (event_name is None or col.startswith(event_name + "__"))
)
})
if len(trial_tags) == 0:
raise ValueError(f"No trials found for event_name={event_name}")
# ------------------------------------------------------------------
# 6. Build beta series (average across FIR delays per trial)
# ------------------------------------------------------------------
beta_series = np.zeros((n_channels, len(trial_tags)))
for t, tag in enumerate(trial_tags):
idx = [
i for i, col in enumerate(reg_names)
if col.startswith(f"{tag}_delay")
]
beta_series[:, t] = np.mean(betas[:, idx], axis=1).flatten()
# n_channels, n_trials = betas.shape[0], len(onsets)
# beta_series = np.zeros((n_channels, n_trials))
# for t in range(n_trials):
# trial_indices = [i for i, col in enumerate(reg_names) if col.startswith(f"trial_{t:03d}_delay")]
# if trial_indices:
# beta_series[:, t] = np.mean(betas[:, trial_indices], axis=1).flatten()
# Normalize each channel so they are on the same scale
# Without this, everything is connected to everything. Apparently this is a big issue in fNIRS?
beta_series = zscore(beta_series, axis=1)
global_signal = np.mean(beta_series, axis=0)
beta_series_clean = np.zeros_like(beta_series)
for i in range(n_channels):
slope, _ = np.polyfit(global_signal, beta_series[i, :], 1)
beta_series_clean[i, :] = beta_series[i, :] - (slope * global_signal)
# 4. Correlation & Strict Filtering
corr_matrix = np.zeros((n_channels, n_channels))
p_matrix = np.ones((n_channels, n_channels))
for i in range(n_channels):
for j in range(i + 1, n_channels):
r, p = pearsonr(beta_series_clean[i, :], beta_series_clean[j, :])
corr_matrix[i, j] = corr_matrix[j, i] = r
p_matrix[i, j] = p_matrix[j, i] = p
# 5. High-Bar Thresholding
reject, _ = multipletests(p_matrix[np.triu_indices(n_channels, k=1)], method='fdr_bh', alpha=0.05)[:2]
sig_corr_matrix = np.zeros_like(corr_matrix)
triu = np.triu_indices(n_channels, k=1)
for idx, is_sig in enumerate(reject):
r_val = corr_matrix[triu[0][idx], triu[1][idx]]
# Only keep the absolute strongest connections
if is_sig and abs(r_val) > 0.7:
sig_corr_matrix[triu[0][idx], triu[1][idx]] = r_val
sig_corr_matrix[triu[1][idx], triu[0][idx]] = r_val
# 6. Plot
plot_connectivity_circle(
sig_corr_matrix,
raw_hbo.ch_names,
title="Strictly Filtered Connectivity (TDDR + GSR + Z-Score)",
n_lines=None,
vmin=0.7,
vmax=1.0,
colormap='hot' # Use 'hot' to make positive connections pop
)

566
main.py
View File

@@ -226,6 +226,44 @@ SECTIONS = [
class SaveProjectThread(QThread):
finished_signal = Signal(str)
error_signal = Signal(str)
def __init__(self, filename, project_data):
super().__init__()
self.filename = filename
self.project_data = project_data
def run(self):
try:
import pickle
with open(self.filename, "wb") as f:
pickle.dump(self.project_data, f)
self.finished_signal.emit(self.filename)
except Exception as e:
self.error_signal.emit(str(e))
class SavingOverlay(QDialog):
def __init__(self, parent=None):
super().__init__(parent)
self.setWindowFlags(Qt.WindowType.Dialog | Qt.WindowType.FramelessWindowHint)
self.setModal(True)
self.setWindowModality(Qt.WindowModality.ApplicationModal)
self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground)
layout = QVBoxLayout()
layout.setAlignment(Qt.AlignmentFlag.AlignCenter)
label = QLabel("Saving Project…")
label.setStyleSheet("font-size: 18px; color: white; background-color: rgba(0,0,0,150); padding: 20px; border-radius: 10px;")
layout.addWidget(label)
self.setLayout(layout)
class TerminalWindow(QWidget):
def __init__(self, parent=None):
super().__init__(parent, Qt.WindowType.Window)
@@ -2439,6 +2477,371 @@ class ParticipantBrainViewerWidget(QWidget):
class ParticipantFunctionalConnectivityWidget(QWidget):
def __init__(self, haemo_dict, epochs_dict):
super().__init__()
self.setWindowTitle("FLARES Functional Connectivity Viewer [BETA]")
self.haemo_dict = haemo_dict
self.epochs_dict = epochs_dict
QMessageBox.warning(self, "Warning - FLARES", f"Functional Connectivity is still in development and the results should currently be taken with a grain of salt. "
"By clicking OK, you accept that the images generated may not be factual.")
# Create mappings: file_path -> participant label and dropdown display text
self.participant_map = {} # file_path -> "Participant 1"
self.participant_dropdown_items = [] # "Participant 1 (filename)"
for i, file_path in enumerate(self.haemo_dict.keys(), start=1):
short_label = f"Participant {i}"
display_label = f"{short_label} ({os.path.basename(file_path)})"
self.participant_map[file_path] = short_label
self.participant_dropdown_items.append(display_label)
self.layout = QVBoxLayout(self)
self.top_bar = QHBoxLayout()
self.layout.addLayout(self.top_bar)
self.participant_dropdown = self._create_multiselect_dropdown(self.participant_dropdown_items)
self.participant_dropdown.currentIndexChanged.connect(self.update_participant_dropdown_label)
self.event_dropdown = QComboBox()
self.event_dropdown.addItem("<None Selected>")
self.index_texts = [
"0 (Spectral Connectivity Epochs)",
"1 (Envelope Correlation)",
"2 (Betas)",
"3 (Spectral Connectivity Epochs)",
]
self.image_index_dropdown = self._create_multiselect_dropdown(self.index_texts)
self.image_index_dropdown.currentIndexChanged.connect(self.update_image_index_dropdown_label)
self.submit_button = QPushButton("Submit")
self.submit_button.clicked.connect(self.show_brain_images)
self.top_bar.addWidget(QLabel("Participants:"))
self.top_bar.addWidget(self.participant_dropdown)
self.top_bar.addWidget(QLabel("Event:"))
self.top_bar.addWidget(self.event_dropdown)
self.top_bar.addWidget(QLabel("Image Indexes:"))
self.top_bar.addWidget(self.image_index_dropdown)
self.top_bar.addWidget(self.submit_button)
self.scroll = QScrollArea()
self.scroll.setWidgetResizable(True)
self.scroll_content = QWidget()
self.grid_layout = QGridLayout(self.scroll_content)
self.scroll.setWidget(self.scroll_content)
self.layout.addWidget(self.scroll)
self.thumb_size = QSize(280, 180)
self.showMaximized()
def _create_multiselect_dropdown(self, items):
combo = FullClickComboBox()
combo.setView(QListView())
model = QStandardItemModel()
combo.setModel(model)
combo.setEditable(True)
combo.lineEdit().setReadOnly(True)
combo.lineEdit().setPlaceholderText("Select...")
dummy_item = QStandardItem("<None Selected>")
dummy_item.setFlags(Qt.ItemIsEnabled)
model.appendRow(dummy_item)
toggle_item = QStandardItem("Toggle Select All")
toggle_item.setFlags(Qt.ItemIsUserCheckable | Qt.ItemIsEnabled)
toggle_item.setData(Qt.Unchecked, Qt.CheckStateRole)
model.appendRow(toggle_item)
for item in items:
standard_item = QStandardItem(item)
standard_item.setFlags(Qt.ItemIsUserCheckable | Qt.ItemIsEnabled)
standard_item.setData(Qt.Unchecked, Qt.CheckStateRole)
model.appendRow(standard_item)
combo.setInsertPolicy(QComboBox.NoInsert)
def on_view_clicked(index):
item = model.itemFromIndex(index)
if item.isCheckable():
new_state = Qt.Checked if item.checkState() == Qt.Unchecked else Qt.Unchecked
item.setCheckState(new_state)
combo.view().pressed.connect(on_view_clicked)
self._updating_checkstates = False
def on_item_changed(item):
if self._updating_checkstates:
return
self._updating_checkstates = True
normal_items = [model.item(i) for i in range(2, model.rowCount())] # skip dummy and toggle
if item == toggle_item:
all_checked = all(i.checkState() == Qt.Checked for i in normal_items)
if all_checked:
for i in normal_items:
i.setCheckState(Qt.Unchecked)
toggle_item.setCheckState(Qt.Unchecked)
else:
for i in normal_items:
i.setCheckState(Qt.Checked)
toggle_item.setCheckState(Qt.Checked)
elif item == dummy_item:
pass
else:
# When normal items change, update toggle item
all_checked = all(i.checkState() == Qt.Checked for i in normal_items)
toggle_item.setCheckState(Qt.Checked if all_checked else Qt.Unchecked)
# Update label text immediately after change
if combo == self.participant_dropdown:
self.update_participant_dropdown_label()
elif combo == self.image_index_dropdown:
self.update_image_index_dropdown_label()
self._updating_checkstates = False
model.itemChanged.connect(on_item_changed)
combo.setInsertPolicy(QComboBox.NoInsert)
return combo
def _get_checked_items(self, combo):
checked = []
model = combo.model()
for i in range(model.rowCount()):
item = model.item(i)
# Skip dummy and toggle items:
if item.text() in ("<None Selected>", "Toggle Select All"):
continue
if item.checkState() == Qt.Checked:
checked.append(item.text())
return checked
def update_participant_dropdown_label(self):
selected = self._get_checked_items(self.participant_dropdown)
if not selected:
self.participant_dropdown.lineEdit().setText("<None Selected>")
else:
# Extract just "Participant N" from "Participant N (filename)"
selected_short = [s.split(" ")[0] + " " + s.split(" ")[1] for s in selected]
self.participant_dropdown.lineEdit().setText(", ".join(selected_short))
self._update_event_dropdown()
def update_image_index_dropdown_label(self):
selected = self._get_checked_items(self.image_index_dropdown)
if not selected:
self.image_index_dropdown.lineEdit().setText("<None Selected>")
else:
# Only show the index part
index_labels = [s.split(" ")[0] for s in selected]
self.image_index_dropdown.lineEdit().setText(", ".join(index_labels))
def _update_event_dropdown(self):
selected_display_names = self._get_checked_items(self.participant_dropdown)
selected_file_paths = []
for display_name in selected_display_names:
for fp, short_label in self.participant_map.items():
expected_display = f"{short_label} ({os.path.basename(fp)})"
if display_name == expected_display:
selected_file_paths.append(fp)
break
if not selected_file_paths:
self.event_dropdown.clear()
self.event_dropdown.addItem("<None Selected>")
return
annotation_sets = []
for file_path in selected_file_paths:
raw = self.haemo_dict.get(file_path)
if raw is None or not hasattr(raw, "annotations"):
continue
annotations = set(raw.annotations.description)
annotation_sets.append(annotations)
if not annotation_sets:
self.event_dropdown.clear()
self.event_dropdown.addItem("<None Selected>")
return
shared_annotations = set.intersection(*annotation_sets)
self.event_dropdown.clear()
self.event_dropdown.addItem("<None Selected>")
for ann in sorted(shared_annotations):
self.event_dropdown.addItem(ann)
def show_brain_images(self):
import flares
selected_event = self.event_dropdown.currentText()
if selected_event == "<None Selected>":
selected_event = None
selected_display_names = self._get_checked_items(self.participant_dropdown)
selected_file_paths = []
for display_name in selected_display_names:
for fp, short_label in self.participant_map.items():
expected_display = f"{short_label} ({os.path.basename(fp)})"
if display_name == expected_display:
selected_file_paths.append(fp)
break
selected_indexes = [
int(s.split(" ")[0]) for s in self._get_checked_items(self.image_index_dropdown)
]
parameterized_indexes = {
0: [
{
"key": "n_lines",
"label": "<Description>",
"default": "20",
"type": int,
},
{
"key": "vmin",
"label": "<Description>",
"default": "0.9",
"type": float,
},
],
1: [
{
"key": "n_lines",
"label": "<Description>",
"default": "20",
"type": int,
},
{
"key": "vmin",
"label": "<Description>",
"default": "0.9",
"type": float,
},
],
2: [
{
"key": "n_lines",
"label": "<Description>",
"default": "20",
"type": int,
},
{
"key": "vmin",
"label": "<Description>",
"default": "0.9",
"type": float,
},
],
3: [
{
"key": "n_lines",
"label": "<Description>",
"default": "20",
"type": int,
},
{
"key": "vmin",
"label": "<Description>",
"default": "0.9",
"type": float,
},
],
}
# Inject full_text from index_texts
for idx, params_list in parameterized_indexes.items():
full_text = self.index_texts[idx] if idx < len(self.index_texts) else f"{idx} (No label found)"
for param_info in params_list:
param_info["full_text"] = full_text
indexes_needing_params = {idx: parameterized_indexes[idx] for idx in selected_indexes if idx in parameterized_indexes}
param_values = {}
if indexes_needing_params:
dialog = ParameterInputDialog(indexes_needing_params, parent=self)
if dialog.exec_() == QDialog.Accepted:
param_values = dialog.get_values()
if param_values is None:
return
else:
return
# Pass the necessary arguments to each method
for file_path in selected_file_paths:
haemo_obj = self.haemo_dict.get(file_path)
epochs_obj = self.epochs_dict.get(file_path)
if haemo_obj is None:
raise Exception("How did we get here?")
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:
print(f"Missing parameters for index {idx}, skipping.")
continue
flares.functional_connectivity_spectral_epochs(epochs_obj, n_lines, vmin)
elif idx == 1:
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:
print(f"Missing parameters for index {idx}, skipping.")
continue
flares.functional_connectivity_envelope(epochs_obj, n_lines, vmin)
elif idx == 2:
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:
print(f"Missing parameters for index {idx}, skipping.")
continue
flares.functional_connectivity_betas(haemo_obj, n_lines, vmin, selected_event)
elif idx == 3:
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:
print(f"Missing parameters for index {idx}, skipping.")
continue
flares.functional_connectivity_spectral_time(epochs_obj, n_lines, vmin)
else:
print(f"No method defined for index {idx}")
class MultiProgressDialog(QDialog):
def __init__(self, parent=None):
super().__init__(parent)
@@ -3000,7 +3403,7 @@ class ExportDataAsCSVViewerWidget(QWidget):
# Open save dialog
save_path, _ = QFileDialog.getSaveFileName(
self,
"Save SNIRF File As",
"Save CSV File As",
suggested_name,
"CSV Files (*.csv)"
)
@@ -3017,7 +3420,7 @@ class ExportDataAsCSVViewerWidget(QWidget):
QMessageBox.information(self, "Success", "CSV file has been saved.")
except Exception as e:
QMessageBox.critical(self, "Error", f"Failed to update SNIRF file:\n{e}")
QMessageBox.critical(self, "Error", f"Failed to update CSV file:\n{e}")
elif idx == 1:
@@ -3027,7 +3430,7 @@ class ExportDataAsCSVViewerWidget(QWidget):
# Open save dialog
save_path, _ = QFileDialog.getSaveFileName(
self,
"Save SNIRF File As",
"Save CSV File As",
suggested_name,
"CSV Files (*.csv)"
)
@@ -3071,7 +3474,7 @@ class ExportDataAsCSVViewerWidget(QWidget):
win.show()
except Exception as e:
QMessageBox.critical(self, "Error", f"Failed to update SNIRF file:\n{e}")
QMessageBox.critical(self, "Error", f"Failed to update CSV file:\n{e}")
else:
@@ -4263,10 +4666,15 @@ class GroupBrainViewerWidget(QWidget):
class ViewerLauncherWidget(QWidget):
def __init__(self, haemo_dict, fig_bytes_dict, cha_dict, contrast_results_dict, df_ind, design_matrix, group):
def __init__(self, haemo_dict, config_dict, fig_bytes_dict, cha_dict, contrast_results_dict, df_ind, design_matrix, epochs_dict):
super().__init__()
self.setWindowTitle("Viewer Launcher")
group_dict = {
file_path: config.get("GROUP", "Unknown") # default if GROUP missing
for file_path, config in config_dict.items()
}
layout = QVBoxLayout(self)
btn1 = QPushButton("Open Participant Viewer")
@@ -4278,19 +4686,23 @@ class ViewerLauncherWidget(QWidget):
btn3 = QPushButton("Open Participant Fold Channels Viewer")
btn3.clicked.connect(lambda: self.open_participant_fold_channels_viewer(haemo_dict, cha_dict))
btn7 = QPushButton("Open Functional Connectivity Viewer [BETA]")
btn7.clicked.connect(lambda: self.open_participant_functional_connectivity_viewer(haemo_dict, epochs_dict))
btn4 = QPushButton("Open Inter-Group Viewer")
btn4.clicked.connect(lambda: self.open_group_viewer(haemo_dict, cha_dict, df_ind, design_matrix, contrast_results_dict, group))
btn4.clicked.connect(lambda: self.open_group_viewer(haemo_dict, cha_dict, df_ind, design_matrix, contrast_results_dict, group_dict))
btn5 = QPushButton("Open Cross Group Brain Viewer")
btn5.clicked.connect(lambda: self.open_group_brain_viewer(haemo_dict, df_ind, design_matrix, group, contrast_results_dict))
btn5.clicked.connect(lambda: self.open_group_brain_viewer(haemo_dict, df_ind, design_matrix, group_dict, contrast_results_dict))
btn6 = QPushButton("Open Export Data As CSV Viewer")
btn6.clicked.connect(lambda: self.open_export_data_as_csv_viewer(haemo_dict, cha_dict, df_ind, design_matrix, group, contrast_results_dict))
btn6.clicked.connect(lambda: self.open_export_data_as_csv_viewer(haemo_dict, cha_dict, df_ind, design_matrix, group_dict, contrast_results_dict))
layout.addWidget(btn1)
layout.addWidget(btn2)
layout.addWidget(btn3)
layout.addWidget(btn7)
layout.addWidget(btn4)
layout.addWidget(btn5)
layout.addWidget(btn6)
@@ -4307,6 +4719,10 @@ class ViewerLauncherWidget(QWidget):
self.participant_fold_channels_viewer = ParticipantFoldChannelsWidget(haemo_dict, cha_dict)
self.participant_fold_channels_viewer.show()
def open_participant_functional_connectivity_viewer(self, haemo_dict, epochs_dict):
self.participant_brain_viewer = ParticipantFunctionalConnectivityWidget(haemo_dict, epochs_dict)
self.participant_brain_viewer.show()
def open_group_viewer(self, haemo_dict, cha_dict, df_ind, design_matrix, contrast_results_dict, group):
self.participant_brain_viewer = GroupViewerWidget(haemo_dict, cha_dict, df_ind, design_matrix, contrast_results_dict, group)
self.participant_brain_viewer.show()
@@ -4343,6 +4759,7 @@ class MainApplication(QMainWindow):
self.section_widget = None
self.first_run = True
self.is_2d_bypass = False
self.incompatible_save_bypass = False
self.files_total = 0 # total number of files to process
self.files_done = set() # set of file paths done (success or fail)
@@ -4593,7 +5010,8 @@ class MainApplication(QMainWindow):
preferences_menu = menu_bar.addMenu("Preferences")
preferences_actions = [
("2D Data Bypass", "Ctrl+B", self.is_2d_bypass_func, resource_path("icons/info_24dp_1F1F1F.svg"))
("2D Data Bypass", "", self.is_2d_bypass_func, resource_path("icons/info_24dp_1F1F1F.svg")),
("Incompatible Save Bypass", "", self.incompatable_save_bypass_func, resource_path("icons/info_24dp_1F1F1F.svg"))
]
for name, shortcut, slot, icon in preferences_actions:
preferences_menu.addAction(make_action(name, shortcut, slot, icon=icon, checkable=True, checked=False))
@@ -4643,15 +5061,13 @@ class MainApplication(QMainWindow):
self.statusBar().clearMessage()
self.raw_haemo_dict = None
self.config_dict = None
self.epochs_dict = None
self.fig_bytes_dict = None
self.cha_dict = None
self.contrast_results_dict = None
self.df_ind_dict = None
self.design_matrix_dict = None
self.age_dict = None
self.gender_dict = None
self.group_dict = None
self.valid_dict = None
# Reset any visible UI elements
@@ -4662,7 +5078,7 @@ class MainApplication(QMainWindow):
def open_launcher_window(self):
self.launcher_window = ViewerLauncherWidget(self.raw_haemo_dict, self.fig_bytes_dict, self.cha_dict, self.contrast_results_dict, self.df_ind_dict, self.design_matrix_dict, self.group_dict)
self.launcher_window = ViewerLauncherWidget(self.raw_haemo_dict, self.config_dict, self.fig_bytes_dict, self.cha_dict, self.contrast_results_dict, self.df_ind_dict, self.design_matrix_dict, self.epochs_dict)
self.launcher_window.show()
@@ -4681,6 +5097,9 @@ class MainApplication(QMainWindow):
def is_2d_bypass_func(self, checked):
self.is_2d_bypass = checked
def incompatable_save_bypass_func(self, checked):
self.incompatible_save_bypass = checked
def about_window(self):
if self.about is None or not self.about.isVisible():
self.about = AboutWindow(self)
@@ -4839,19 +5258,19 @@ class MainApplication(QMainWindow):
for bubble in self.bubble_widgets.values()
}
version = CURRENT_VERSION
project_data = {
"version": version,
"file_list": file_list,
"progress_states": progress_states,
"raw_haemo_dict": self.raw_haemo_dict,
"config_dict": self.config_dict,
"epochs_dict": self.epochs_dict,
"fig_bytes_dict": self.fig_bytes_dict,
"cha_dict": self.cha_dict,
"contrast_results_dict": self.contrast_results_dict,
"df_ind_dict": self.df_ind_dict,
"design_matrix_dict": self.design_matrix_dict,
"age_dict": self.age_dict,
"gender_dict": self.gender_dict,
"group_dict": self.group_dict,
"valid_dict": self.valid_dict,
}
@@ -4866,10 +5285,24 @@ class MainApplication(QMainWindow):
project_data = sanitize(project_data)
with open(filename, "wb") as f:
pickle.dump(project_data, f)
self.saving_overlay = SavingOverlay(self)
self.saving_overlay.resize(self.size()) # Cover the main window
self.saving_overlay.show()
QMessageBox.information(self, "Success", f"Project saved to:\n{filename}")
# Start the background save thread
self.save_thread = SaveProjectThread(filename, project_data)
# When finished, close overlay and show success
self.save_thread.finished_signal.connect(lambda f: (
self.saving_overlay.close(),
QMessageBox.information(self, "Success", f"Project saved to:\n{f}")
))
self.save_thread.error_signal.connect(lambda e: (
self.saving_overlay.close(),
QMessageBox.critical(self, "Error", f"Failed to save project:\n{e}")
))
self.save_thread.start()
except Exception as e:
if not onCrash:
@@ -4888,16 +5321,27 @@ class MainApplication(QMainWindow):
with open(filename, "rb") as f:
data = pickle.load(f)
# Check for saves prior to 1.2.0
if "version" not in data:
print(self.incompatible_save_bypass)
if self.incompatible_save_bypass:
QMessageBox.warning(self, "Warning - FLARES", f"This project was saved in an earlier version of FLARES (<=1.1.7) and is potentially not compatible with this version. "
"You are receiving this warning because you have 'Incompatible Save Bypass' turned on. FLARES will now attempt to load the project. It is strongly "
"recommended to recreate the project file.")
else:
QMessageBox.critical(self, "Error - FLARES", f"This project was saved in an earlier version of FLARES (<=1.1.7) and is potentially not compatible with this version. "
"The file can attempt to be loaded if 'Incompatible Save Bypass' is selected in the 'Preferences' menu.")
return
self.raw_haemo_dict = data.get("raw_haemo_dict", {})
self.config_dict = data.get("config_dict", {})
self.epochs_dict = data.get("epochs_dict", {})
self.fig_bytes_dict = data.get("fig_bytes_dict", {})
self.cha_dict = data.get("cha_dict", {})
self.contrast_results_dict = data.get("contrast_results_dict", {})
self.df_ind_dict = data.get("df_ind_dict", {})
self.design_matrix_dict = data.get("design_matrix_dict", {})
self.age_dict = data.get("age_dict", {})
self.gender_dict = data.get("gender_dict", {})
self.group_dict = data.get("group_dict", {})
self.valid_dict = data.get("valid_dict", {})
project_dir = Path(filename).parent
@@ -4914,6 +5358,26 @@ class MainApplication(QMainWindow):
self.show_files_as_bubbles_from_list(file_list, progress_states, filename)
for file_path, config in self.config_dict.items():
# Only store AGE, GENDER, GROUP
self.file_metadata[file_path] = {
key: str(config.get(key, "")) # convert to str for QLineEdit
for key in ["AGE", "GENDER", "GROUP"]
}
if self.config_dict:
first_file = next(iter(self.config_dict.keys()))
self.current_file = first_file
# Update meta fields (AGE/GENDER/GROUP)
for key, field in self.meta_fields.items():
field.setText(self.file_metadata[first_file][key])
self.right_column_widget.show()
# Restore all other constants to the parameter sections
first_config = self.config_dict[first_file]
self.restore_sections_from_config(first_config)
# Re-enable buttons
# self.button1.setVisible(True)
self.button3.setVisible(True)
@@ -4924,6 +5388,54 @@ class MainApplication(QMainWindow):
QMessageBox.critical(self, "Error", f"Failed to load project:\n{e}")
def restore_sections_from_config(self, config):
"""
Fill all ParamSection widgets with values from a participant's config.
"""
for section_widget in self.param_sections:
widgets_dict = getattr(section_widget, 'widgets', None)
if widgets_dict is None:
continue
for name, widget_info in widgets_dict.items():
if name not in config:
continue
value = config[name]
print(f"Restoring {name} = {value}")
widget = widget_info["widget"]
w_type = widget_info.get("type")
# QLineEdit (int, float, str)
if isinstance(widget, QLineEdit):
widget.blockSignals(True)
widget.setText(str(value))
widget.blockSignals(False)
widget.update()
# QComboBox (bool, list)
elif isinstance(widget, QComboBox):
widget.blockSignals(True)
widget.setCurrentText(str(value))
widget.blockSignals(False)
widget.update()
# QSpinBox (range)
elif isinstance(widget, QSpinBox):
widget.blockSignals(True)
try:
widget.setValue(int(value))
except Exception:
pass
widget.blockSignals(False)
widget.update()
# After restoring, make sure dependencies are updated
if hasattr(section_widget, 'update_dependencies'):
section_widget.update_dependencies()
def show_files_as_bubbles(self, folder_paths):
@@ -5366,29 +5878,25 @@ class MainApplication(QMainWindow):
# TODO: Is this check needed? Edit: yes very much so
if getattr(self, 'raw_haemo_dict', None) is None:
self.raw_haemo_dict = {}
self.config_dict = {}
self.epochs_dict = {}
self.fig_bytes_dict = {}
self.cha_dict = {}
self.contrast_results_dict = {}
self.df_ind_dict = {}
self.design_matrix_dict = {}
self.age_dict = {}
self.gender_dict = {}
self.group_dict = {}
self.valid_dict = {}
# Combine all results into the dicts
for file_path, (raw_haemo, epochs, fig_bytes, cha, contrast_results, df_ind, design_matrix, age, gender, group, valid) in results.items():
for file_path, (raw_haemo, config, epochs, fig_bytes, cha, contrast_results, df_ind, design_matrix, valid) in results.items():
self.raw_haemo_dict[file_path] = raw_haemo
self.config_dict[file_path] = config
self.epochs_dict[file_path] = epochs
self.fig_bytes_dict[file_path] = fig_bytes
self.cha_dict[file_path] = cha
self.contrast_results_dict[file_path] = contrast_results
self.df_ind_dict[file_path] = df_ind
self.design_matrix_dict[file_path] = design_matrix
self.age_dict[file_path] = age
self.gender_dict[file_path] = gender
self.group_dict[file_path] = group
self.valid_dict[file_path] = valid
# self.statusbar.showMessage(f"Processing complete! Time elapsed: {elapsed_time:.2f} seconds")