3 Commits

Author SHA1 Message Date
3d0fbd5c5e fix to boris events 2025-10-03 16:58:49 -07:00
3f38f5a978 updates for boris support 2025-09-26 14:01:32 -07:00
0607ced61e fixes 2025-09-12 16:22:12 -07:00
3 changed files with 719 additions and 214 deletions

View File

@@ -1,6 +1,22 @@
# Version 1.1.1
- Fixed the number of rectangles in the progress bar to 19
- Fixed a crash when attempting to load a brain image on Windows
- Removed hardcoded event annotations. Fixes [Issue 16](https://git.research.dezeeuw.ca/tyler/flares/issues/16)
# Version 1.1.0 # Version 1.1.0
- Changelog details coming soon - Revamped the Analysis window
- 4 Options of Participant, Participant Brain, Inter-Group, and Cross Group Brain are available.
- Customization is present to query different participants, images, events, brains, etc.
- Removed preprocessing options and reorganized their order to correlate with the actual order.
- Most preprocessing options removed will be coming back soon
- Added a group option when clicking on a participant's file
- If no group is specified, the participant will be added to the "Default" group
- Added option to update the optode positions in a snirf file from the Options menu (F6)
- Fixed [Issue 3](https://git.research.dezeeuw.ca/tyler/flares/issues/3), [Issue 4](https://git.research.dezeeuw.ca/tyler/flares/issues/4), [Issue 17](https://git.research.dezeeuw.ca/tyler/flares/issues/17), [Issue 21](https://git.research.dezeeuw.ca/tyler/flares/issues/21), [Issue 22](https://git.research.dezeeuw.ca/tyler/flares/issues/22)
# Version 1.0.1 # Version 1.0.1

467
flares.py
View File

@@ -48,6 +48,11 @@ from statsmodels.stats.multitest import multipletests
from scipy import stats from scipy import stats
from scipy.spatial.distance import cdist from scipy.spatial.distance import cdist
# Backen visualization needed to be defined for pyinstaller
import pyvistaqt # type: ignore
# import vtkmodules.util.data_model
# import vtkmodules.util.execution_model
# External library imports for mne # External library imports for mne
from mne import ( from mne import (
EvokedArray, SourceEstimate, Info, Epochs, Label, EvokedArray, SourceEstimate, Info, Epochs, Label,
@@ -125,6 +130,8 @@ TDDR: bool
ENHANCE_NEGATIVE_CORRELATION: bool ENHANCE_NEGATIVE_CORRELATION: bool
SHORT_CHANNEL: bool
VERBOSITY = True VERBOSITY = True
# FIXME: Shouldn't need each ordering - just order it before checking # FIXME: Shouldn't need each ordering - just order it before checking
@@ -171,6 +178,7 @@ REQUIRED_KEYS: dict[str, Any] = {
"PSP_TIME_WINDOW": int, "PSP_TIME_WINDOW": int,
"PSP_THRESHOLD": float, "PSP_THRESHOLD": float,
"SHORT_CHANNEL": bool,
# "REJECT_PAIRS": bool, # "REJECT_PAIRS": bool,
# "FORCE_DROP_ANNOTATIONS": list, # "FORCE_DROP_ANNOTATIONS": list,
# "FILTER_LOW_PASS": float, # "FILTER_LOW_PASS": float,
@@ -1100,7 +1108,7 @@ def epochs_calculations(raw_haemo, events, event_dict):
evokeds3 = [] evokeds3 = []
colors = [] colors = []
conditions = list(epochs.event_id.keys()) conditions = list(epochs.event_id.keys())
cmap = plt.cm.get_cmap("tab10", len(conditions)) cmap = plt.get_cmap("tab10", len(conditions))
for idx, cond in enumerate(conditions): for idx, cond in enumerate(conditions):
evoked = epochs[cond].average(picks="hbo") evoked = epochs[cond].average(picks="hbo")
@@ -1120,16 +1128,20 @@ def epochs_calculations(raw_haemo, events, event_dict):
fig.legend(lines, conditions, loc="lower right") fig.legend(lines, conditions, loc="lower right")
fig_epochs.append(("evoked_topo", help)) # Store with a unique name fig_epochs.append(("evoked_topo", help)) # Store with a unique name
# Evoked response for specific condition ("Reach") unique_annotations = set(raw_haemo.annotations.description)
evoked_stim1 = epochs['Reach'].average()
fig_evoked_hbo = evoked_stim1.copy().pick(picks='hbo').plot(time_unit='s', show=False) for cond in unique_annotations:
fig_evoked_hbr = evoked_stim1.copy().pick(picks='hbr').plot(time_unit='s', show=False)
fig_epochs.append(("fig_evoked_hbo", fig_evoked_hbo)) # Store with a unique name
fig_epochs.append(("fig_evoked_hbr", fig_evoked_hbr)) # Store with a unique name
print("Evoked HbO peak amplitude:", evoked_stim1.copy().pick(picks='hbo').data.max()) # Evoked response for specific condition ("Activity")
evoked_stim1 = epochs[cond].average()
fig_evoked_hbo = evoked_stim1.copy().pick(picks='hbo').plot(time_unit='s', show=False)
fig_evoked_hbr = evoked_stim1.copy().pick(picks='hbr').plot(time_unit='s', show=False)
fig_epochs.append((f"fig_evoked_hbo_{cond}", fig_evoked_hbo)) # Store with a unique name
fig_epochs.append((f"fig_evoked_hbr_{cond}", fig_evoked_hbr)) # Store with a unique name
print("Evoked HbO peak amplitude:", evoked_stim1.copy().pick(picks='hbo').data.max())
evokeds = {} evokeds = {}
for condition in epochs2.event_id: for condition in epochs2.event_id:
@@ -1200,26 +1212,36 @@ def epochs_calculations(raw_haemo, events, event_dict):
def make_design_matrix(raw_haemo, short_chans): def make_design_matrix(raw_haemo, short_chans):
raw_haemo.resample(1, npad="auto") raw_haemo.resample(1, npad="auto")
short_chans.resample(1)
raw_haemo._data = raw_haemo._data * 1e6 raw_haemo._data = raw_haemo._data * 1e6
# 2) Create design matrix # 2) Create design matrix
design_matrix = make_first_level_design_matrix( if SHORT_CHANNEL:
raw=raw_haemo, short_chans.resample(1)
hrf_model='fir', design_matrix = make_first_level_design_matrix(
stim_dur=0.5, raw=raw_haemo,
fir_delays=range(15), hrf_model='fir',
drift_model='cosine', stim_dur=0.5,
high_pass=0.01, fir_delays=range(15),
oversampling=1, drift_model='cosine',
min_onset=-125, high_pass=0.01,
add_regs=short_chans.get_data().T, oversampling=1,
add_reg_names=short_chans.ch_names min_onset=-125,
) add_regs=short_chans.get_data().T,
add_reg_names=short_chans.ch_names
)
else:
design_matrix = make_first_level_design_matrix(
raw=raw_haemo,
hrf_model='fir',
stim_dur=0.5,
fir_delays=range(15),
drift_model='cosine',
high_pass=0.01,
oversampling=1,
min_onset=-125,
)
print(design_matrix.head()) print(design_matrix.head())
print(design_matrix.columns) print(design_matrix.columns)
@@ -1232,10 +1254,6 @@ def make_design_matrix(raw_haemo, short_chans):
def generate_montage_locations(): def generate_montage_locations():
"""Get standard MNI montage locations in dataframe. """Get standard MNI montage locations in dataframe.
@@ -1600,153 +1618,158 @@ def fold_channels(raw: BaseRaw) -> None:
def individual_significance(raw_haemo, glm_est): def individual_significance(raw_haemo, glm_est):
fig_individual_significances = [] # List to store figures
# TODO: BAD! # TODO: BAD!
cha = glm_est.to_dataframe() cha = glm_est.to_dataframe()
ch_summary = cha.query("Condition.str.startswith('Reach_delay_') and Chroma == 'hbo'", engine='python') unique_annotations = set(raw_haemo.annotations.description)
print(ch_summary.head()) for cond in unique_annotations:
channel_averages = ch_summary.groupby('ch_name')['theta'].mean().reset_index() ch_summary = cha.query(f"Condition.str.startswith('{cond}_delay_') and Chroma == 'hbo'", engine='python')
print(channel_averages.head())
print(ch_summary.head())
channel_averages = ch_summary.groupby('ch_name')['theta'].mean().reset_index()
print(channel_averages.head())
reach_ch_summary = ch_summary.query( activity_ch_summary = ch_summary.query(
"Chroma == 'hbo' and Condition.str.startswith('Reach_delay_')", engine='python' f"Chroma == 'hbo' and Condition.str.startswith('{cond}_delay_')", engine='python'
) )
# Function to correct p-values per channel # Function to correct p-values per channel
def fdr_correct_per_channel(df): def fdr_correct_per_channel(df):
df = df.copy() df = df.copy()
df['pval_fdr'] = multipletests(df['p_value'], method='fdr_bh')[1] df['pval_fdr'] = multipletests(df['p_value'], method='fdr_bh')[1]
return df return df
# Apply FDR correction grouped by channel # Apply FDR correction grouped by channel
corrected = reach_ch_summary.groupby("ch_name", group_keys=False).apply(fdr_correct_per_channel) corrected = activity_ch_summary.groupby("ch_name", group_keys=False).apply(fdr_correct_per_channel)
# Determine which channels are significant across any delay # Determine which channels are significant across any delay
sig_channels = ( sig_channels = (
corrected.groupby('ch_name') corrected.groupby('ch_name')
.apply(lambda df: (df['pval_fdr'] < 0.05).any()) .apply(lambda df: (df['pval_fdr'] < 0.05).any())
.reset_index(name='significant') .reset_index(name='significant')
) )
# Merge with mean theta (optional for plotting) # Merge with mean theta (optional for plotting)
mean_theta = reach_ch_summary.groupby('ch_name')['theta'].mean().reset_index() mean_theta = activity_ch_summary.groupby('ch_name')['theta'].mean().reset_index()
sig_channels = sig_channels.merge(mean_theta, on='ch_name') sig_channels = sig_channels.merge(mean_theta, on='ch_name')
print(sig_channels) print(sig_channels)
# For example, take the minimum corrected p-value per channel # For example, take the minimum corrected p-value per channel
summary_pvals = corrected.groupby('ch_name')['pval_fdr'].min().reset_index() summary_pvals = corrected.groupby('ch_name')['pval_fdr'].min().reset_index()
print(summary_pvals) print(summary_pvals)
def parse_ch_name(ch_name): def parse_ch_name(ch_name):
# Extract numbers after S and D in names like 'S10_D5 hbo' # Extract numbers after S and D in names like 'S10_D5 hbo'
match = re.match(r'S(\d+)_D(\d+)', ch_name) match = re.match(r'S(\d+)_D(\d+)', ch_name)
if match: if match:
return int(match.group(1)), int(match.group(2)) return int(match.group(1)), int(match.group(2))
else: else:
return None, None return None, None
min_pvals = corrected.groupby('ch_name')['pval_fdr'].min().reset_index() min_pvals = corrected.groupby('ch_name')['pval_fdr'].min().reset_index()
# Merge the real p-values into sig_channels / avg_df # Merge the real p-values into sig_channels / avg_df
avg_df = sig_channels.merge(min_pvals, on='ch_name') avg_df = sig_channels.merge(min_pvals, on='ch_name')
# Rename columns for consistency # Rename columns for consistency
avg_df = avg_df.rename(columns={'theta': 't_or_theta', 'pval_fdr': 'p_value'}) avg_df = avg_df.rename(columns={'theta': 't_or_theta', 'pval_fdr': 'p_value'})
# Add Source and Detector columns again # Add Source and Detector columns again
avg_df['Source'], avg_df['Detector'] = zip(*avg_df['ch_name'].map(parse_ch_name)) avg_df['Source'], avg_df['Detector'] = zip(*avg_df['ch_name'].map(parse_ch_name))
# Keep relevant columns # Keep relevant columns
avg_df = avg_df[['Source', 'Detector', 't_or_theta', 'p_value']].dropna() avg_df = avg_df[['Source', 'Detector', 't_or_theta', 'p_value']].dropna()
ABS_SIGNIFICANCE_THETA_VALUE = 1 ABS_SIGNIFICANCE_THETA_VALUE = 1
ABS_SIGNIFICANCE_T_VALUE = 1 ABS_SIGNIFICANCE_T_VALUE = 1
P_THRESHOLD = 0.05 P_THRESHOLD = 0.05
SOURCE_DETECTOR_SEPARATOR = "_" SOURCE_DETECTOR_SEPARATOR = "_"
Reach = "Reach"
t_or_theta = 'theta'
for _, row in avg_df.iterrows(): # type: ignore
print(f"Source {row['Source']} <-> Detector {row['Detector']}: "
f"Avg {t_or_theta}-value = {row['t_or_theta']:.3f}, Avg p-value = {row['p_value']:.3f}")
t_or_theta = 'theta' # Extract the cource and detector positions from raw
for _, row in avg_df.iterrows(): # type: ignore src_pos: dict[int, tuple[float, float]] = {}
print(f"Source {row['Source']} <-> Detector {row['Detector']}: " det_pos: dict[int, tuple[float, float]] = {}
f"Avg {t_or_theta}-value = {row['t_or_theta']:.3f}, Avg p-value = {row['p_value']:.3f}") for ch in getattr(raw_haemo, "info")["chs"]:
ch_name = ch['ch_name']
if not ch_name or not ch['loc'].any():
continue
parts = ch_name.split()[0]
src_str, det_str = parts.split(SOURCE_DETECTOR_SEPARATOR)
src_num = int(src_str[1:])
det_num = int(det_str[1:])
src_pos[src_num] = ch['loc'][3:5]
det_pos[det_num] = ch['loc'][6:8]
# Extract the cource and detector positions from raw # Set up the plot
src_pos: dict[int, tuple[float, float]] = {} fig, ax = plt.subplots(figsize=(8, 6)) # type: ignore
det_pos: dict[int, tuple[float, float]] = {}
for ch in getattr(raw_haemo, "info")["chs"]:
ch_name = ch['ch_name']
if not ch_name or not ch['loc'].any():
continue
parts = ch_name.split()[0]
src_str, det_str = parts.split(SOURCE_DETECTOR_SEPARATOR)
src_num = int(src_str[1:])
det_num = int(det_str[1:])
src_pos[src_num] = ch['loc'][3:5]
det_pos[det_num] = ch['loc'][6:8]
# Set up the plot # Plot the sources
fig, ax = plt.subplots(figsize=(8, 6)) # type: ignore for pos in src_pos.values():
ax.scatter(pos[0], pos[1], s=120, c='k', marker='o', edgecolors='white', linewidths=1, zorder=3) # type: ignore
# Plot the sources # Plot the detectors
for pos in src_pos.values(): for pos in det_pos.values():
ax.scatter(pos[0], pos[1], s=120, c='k', marker='o', edgecolors='white', linewidths=1, zorder=3) # type: ignore ax.scatter(pos[0], pos[1], s=120, c='k', marker='s', edgecolors='white', linewidths=1, zorder=3) # type: ignore
# Plot the detectors # Ensure that the colors stay within the boundaries even if they are over or under the max/min values
for pos in det_pos.values(): if t_or_theta == 't':
ax.scatter(pos[0], pos[1], s=120, c='k', marker='s', edgecolors='white', linewidths=1, zorder=3) # type: ignore norm = mcolors.Normalize(vmin=-ABS_SIGNIFICANCE_T_VALUE, vmax=ABS_SIGNIFICANCE_T_VALUE)
elif t_or_theta == 'theta':
norm = mcolors.Normalize(vmin=-ABS_SIGNIFICANCE_THETA_VALUE, vmax=ABS_SIGNIFICANCE_THETA_VALUE)
# Ensure that the colors stay within the boundaries even if they are over or under the max/min values cmap: mcolors.Colormap = plt.get_cmap('seismic')
if t_or_theta == 't':
norm = mcolors.Normalize(vmin=-ABS_SIGNIFICANCE_T_VALUE, vmax=ABS_SIGNIFICANCE_T_VALUE)
elif t_or_theta == 'theta':
norm = mcolors.Normalize(vmin=-ABS_SIGNIFICANCE_THETA_VALUE, vmax=ABS_SIGNIFICANCE_THETA_VALUE)
cmap: mcolors.Colormap = plt.get_cmap('seismic') # Plot connections with avg t-values
for row in avg_df.itertuples():
src: int = cast(int, row.Source) # type: ignore
det: int = cast(int, row.Detector) # type: ignore
tval: float = cast(float, row.t_or_theta) # type: ignore
pval: float = cast(float, row.p_value) # type: ignore
# Plot connections with avg t-values if src in src_pos and det in det_pos:
for row in avg_df.itertuples(): x = [src_pos[src][0], det_pos[det][0]]
src: int = cast(int, row.Source) # type: ignore y = [src_pos[src][1], det_pos[det][1]]
det: int = cast(int, row.Detector) # type: ignore style = '-' if pval <= P_THRESHOLD else '--'
tval: float = cast(float, row.t_or_theta) # type: ignore ax.plot(x, y, linestyle=style, color=cmap(norm(tval)), linewidth=4, alpha=0.9, zorder=2) # type: ignore
pval: float = cast(float, row.p_value) # type: ignore
# Format the Colorbar
sm = plt.cm.ScalarMappable(cmap=cmap, norm=norm)
sm.set_array([])
cbar = plt.colorbar(sm, ax=ax, shrink=0.85) # type: ignore
cbar.set_label(f'Average {cond} {t_or_theta} value (hbo)', fontsize=11) # type: ignore
# Formatting the subplots
ax.set_aspect('equal')
ax.set_title(f"Average {t_or_theta} values for {cond} (HbO)", fontsize=14) # type: ignore
ax.set_xlabel('X position (m)', fontsize=11) # type: ignore
ax.set_ylabel('Y position (m)', fontsize=11) # type: ignore
ax.grid(True, alpha=0.3) # type: ignore
# Set axis limits to be 1cm more than the optode positions
all_x = [pos[0] for pos in src_pos.values()] + [pos[0] for pos in det_pos.values()]
all_y = [pos[1] for pos in src_pos.values()] + [pos[1] for pos in det_pos.values()]
ax.set_xlim(min(all_x)-0.01, max(all_x)+0.01)
ax.set_ylim(min(all_y)-0.01, max(all_y)+0.01)
fig.tight_layout()
fig_individual_significances.append((f"Condition {cond}", fig))
if src in src_pos and det in det_pos: return fig_individual_significances
x = [src_pos[src][0], det_pos[det][0]]
y = [src_pos[src][1], det_pos[det][1]]
style = '-' if pval <= P_THRESHOLD else '--'
ax.plot(x, y, linestyle=style, color=cmap(norm(tval)), linewidth=4, alpha=0.9, zorder=2) # type: ignore
# Format the Colorbar
sm = plt.cm.ScalarMappable(cmap=cmap, norm=norm)
sm.set_array([])
cbar = plt.colorbar(sm, ax=ax, shrink=0.85) # type: ignore
cbar.set_label(f'Average {Reach} {t_or_theta} value (hbo)', fontsize=11) # type: ignore
# Formatting the subplots
ax.set_aspect('equal')
ax.set_title(f"Average {t_or_theta} values for {Reach} (HbO)", fontsize=14) # type: ignore
ax.set_xlabel('X position (m)', fontsize=11) # type: ignore
ax.set_ylabel('Y position (m)', fontsize=11) # type: ignore
ax.grid(True, alpha=0.3) # type: ignore
# Set axis limits to be 1cm more than the optode positions
all_x = [pos[0] for pos in src_pos.values()] + [pos[0] for pos in det_pos.values()]
all_y = [pos[1] for pos in src_pos.values()] + [pos[1] for pos in det_pos.values()]
ax.set_xlim(min(all_x)-0.01, max(all_x)+0.01)
ax.set_ylim(min(all_y)-0.01, max(all_y)+0.01)
fig.tight_layout()
return fig
# TODO: Hardcoded # TODO: Hardcoded
def group_significance( def group_significance(
@@ -1761,7 +1784,7 @@ def group_significance(
Args: Args:
raw_haemo: Raw haemoglobin MNE object (used for optode positions) raw_haemo: Raw haemoglobin MNE object (used for optode positions)
all_cha: DataFrame with columns including 'ID', 'Condition', 'p_value', 'theta', 'df', 'ch_name', 'Chroma' all_cha: DataFrame with columns including 'ID', 'Condition', 'p_value', 'theta', 'df', 'ch_name', 'Chroma'
condition: condition prefix, e.g., 'Reach' condition: condition prefix, e.g., 'Activity'
correction: p-value correction method ('fdr_bh' or 'bonferroni') correction: p-value correction method ('fdr_bh' or 'bonferroni')
Returns: Returns:
@@ -1919,7 +1942,12 @@ def group_significance(
def plot_glm_results(file_path, raw_haemo, glm_est, design_matrix): def plot_glm_results(file_path, raw_haemo, glm_est, design_matrix):
fig_glms = [] # List to store figures
dm = design_matrix.copy() dm = design_matrix.copy()
logger.info(design_matrix.shape)
logger.info(design_matrix.columns)
logger.info(design_matrix.head())
rois = dict(AllChannels=range(len(raw_haemo.ch_names))) rois = dict(AllChannels=range(len(raw_haemo.ch_names)))
conditions = design_matrix.columns conditions = design_matrix.columns
@@ -1928,72 +1956,83 @@ def plot_glm_results(file_path, raw_haemo, glm_est, design_matrix):
df_individual["ID"] = file_path df_individual["ID"] = file_path
# df_individual["theta"] = [t * 1.0e6 for t in df_individual["theta"]] # df_individual["theta"] = [t * 1.0e6 for t in df_individual["theta"]]
condition_of_interest="Reach" first_onset_for_cond = {}
for onset, desc in zip(raw_haemo.annotations.onset, raw_haemo.annotations.description):
if desc not in first_onset_for_cond:
first_onset_for_cond[desc] = onset
# Filter for the condition of interest and FIR delays # Get unique condition names from annotations (descriptions)
df_individual["isCondition"] = [condition_of_interest in n for n in df_individual["Condition"]] unique_annotations = set(raw_haemo.annotations.description)
df_individual["isDelay"] = ["delay" in n for n in df_individual["Condition"]]
df_individual = df_individual.query("isDelay and isCondition") for cond in unique_annotations:
logger.info(cond)
# Remove other conditions from design matrix df_individual_filtered = df_individual.copy()
dm_condition_cols = [col for col in dm.columns if condition_of_interest in col]
dm_cond = dm[dm_condition_cols] # Filter for the condition of interest and FIR delays
df_individual_filtered["isCondition"] = [cond in n for n in df_individual_filtered["Condition"]]
df_individual_filtered["isDelay"] = ["delay" in n for n in df_individual_filtered["Condition"]]
df_individual_filtered = df_individual_filtered.query("isDelay and isCondition")
# Add a numeric delay column # Remove other conditions from design matrix
def extract_delay_number(condition_str): dm_condition_cols = [col for col in dm.columns if cond in col]
# Extracts the number at the end of a string like 'Reach_delay_5' dm_cond = dm[dm_condition_cols]
return int(condition_str.split("_")[-1])
# Add a numeric delay column
def extract_delay_number(condition_str):
# Extracts the number at the end of a string like 'Activity_delay_5'
return int(condition_str.split("_")[-1])
df_individual["DelayNum"] = df_individual["Condition"].apply(extract_delay_number) df_individual_filtered["DelayNum"] = df_individual_filtered["Condition"].apply(extract_delay_number)
# Now separate and sort using numeric delay # Now separate and sort using numeric delay
df_hbo = df_individual[df_individual["Chroma"] == "hbo"].sort_values("DelayNum") df_hbo = df_individual_filtered[df_individual_filtered["Chroma"] == "hbo"].sort_values("DelayNum")
df_hbr = df_individual[df_individual["Chroma"] == "hbr"].sort_values("DelayNum") df_hbr = df_individual_filtered[df_individual_filtered["Chroma"] == "hbr"].sort_values("DelayNum")
vals_hbo = df_hbo["theta"].values vals_hbo = df_hbo["theta"].values
vals_hbr = df_hbr["theta"].values vals_hbr = df_hbr["theta"].values
# Create the plot # Create the plot
fig, axes = plt.subplots(nrows=1, ncols=3, figsize=(19, 10)) fig, axes = plt.subplots(nrows=1, ncols=3, figsize=(19, 10))
# Scale design matrix components using numpy arrays instead of pandas operations # Scale design matrix components using numpy arrays instead of pandas operations
dm_cond_values = dm_cond.values dm_cond_values = dm_cond.values
dm_cond_scaled_hbo = dm_cond_values * vals_hbo.reshape(1, -1) dm_cond_scaled_hbo = dm_cond_values * vals_hbo.reshape(1, -1)
dm_cond_scaled_hbr = dm_cond_values * vals_hbr.reshape(1, -1) dm_cond_scaled_hbr = dm_cond_values * vals_hbr.reshape(1, -1)
# Create time axis relative to stimulus onset # Create time axis relative to stimulus onset
time = dm_cond.index - np.ceil(raw_haemo.annotations.onset[1]) time = dm_cond.index - np.ceil(first_onset_for_cond.get(cond, 0))
# Plot # Plot
axes[0].plot(time, dm_cond_values) axes[0].plot(time, dm_cond_values)
axes[1].plot(time, dm_cond_scaled_hbo) axes[1].plot(time, dm_cond_scaled_hbo)
axes[2].plot(time, np.sum(dm_cond_scaled_hbo, axis=1), 'r') axes[2].plot(time, np.sum(dm_cond_scaled_hbo, axis=1), 'r')
axes[2].plot(time, np.sum(dm_cond_scaled_hbr, axis=1), 'b') axes[2].plot(time, np.sum(dm_cond_scaled_hbr, axis=1), 'b')
# Format plots # Format plots
for ax in range(3): for ax in range(3):
axes[ax].set_xlim(-5, 25) axes[ax].set_xlim(-5, 25)
axes[ax].set_xlabel("Time (s)") axes[ax].set_xlabel("Time (s)")
axes[0].set_ylim(-0.2, 1.2) axes[0].set_ylim(-0.2, 1.2)
axes[1].set_ylim(-0.5, 1) axes[1].set_ylim(-0.5, 1)
axes[2].set_ylim(-0.5, 1) axes[2].set_ylim(-0.5, 1)
axes[0].set_title(f"FIR Model (Unscaled)") axes[0].set_title(f"FIR Model (Unscaled)")
axes[1].set_title(f"FIR Components (Scaled by {condition_of_interest} GLM Estimates)") axes[1].set_title(f"FIR Components (Scaled by {cond} GLM Estimates)")
axes[2].set_title(f"Evoked Response ({condition_of_interest})") axes[2].set_title(f"Evoked Response ({cond})")
axes[0].set_ylabel("FIR Model") axes[0].set_ylabel("FIR Model")
axes[1].set_ylabel("Oxyhaemoglobin (ΔμMol)") axes[1].set_ylabel("Oxyhaemoglobin (ΔμMol)")
axes[2].set_ylabel("Haemoglobin (ΔμMol)") axes[2].set_ylabel("Haemoglobin (ΔμMol)")
axes[2].legend(["Oxyhaemoglobin", "Deoxyhaemoglobin"]) axes[2].legend(["Oxyhaemoglobin", "Deoxyhaemoglobin"])
print(f"Number of FIR bins: {len(vals_hbo)}") print(f"Number of FIR bins: {len(vals_hbo)}")
print(f"Mean theta (HbO): {np.mean(vals_hbo):.4f}") print(f"Mean theta (HbO): {np.mean(vals_hbo):.4f}")
print(f"Sum of theta (HbO): {np.sum(vals_hbo):.4f}") print(f"Sum of theta (HbO): {np.sum(vals_hbo):.4f}")
print(f"Mean theta (HbR): {np.mean(vals_hbr):.4f}") print(f"Mean theta (HbR): {np.mean(vals_hbr):.4f}")
print(f"Sum of theta (HbR): {np.sum(vals_hbr):.4f}") print(f"Sum of theta (HbR): {np.sum(vals_hbr):.4f}")
return fig fig_glms.append((f"Condition {cond}", fig))
return fig_glms
def plot_3d_evoked_array( def plot_3d_evoked_array(
@@ -2871,9 +2910,12 @@ def process_participant(file_path, progress_callback=None):
logger.info("11") logger.info("11")
# Step 11: Get short / long channels # Step 11: Get short / long channels
short_chans = get_short_channels(raw_haemo, max_dist=0.015) if SHORT_CHANNEL:
fig_short_chans = short_chans.plot(duration=raw_haemo.times[-1], n_channels=raw_haemo.info['nchan'], title="Short Channels Only", show=False) short_chans = get_short_channels(raw_haemo, max_dist=0.015)
fig_individual["short"] = fig_short_chans fig_short_chans = short_chans.plot(duration=raw_haemo.times[-1], n_channels=raw_haemo.info['nchan'], title="Short Channels Only", show=False)
fig_individual["short"] = fig_short_chans
else:
short_chans = None
raw_haemo = get_long_channels(raw_haemo) raw_haemo = get_long_channels(raw_haemo)
if progress_callback: progress_callback(12) if progress_callback: progress_callback(12)
logger.info("12") logger.info("12")
@@ -2916,13 +2958,15 @@ def process_participant(file_path, progress_callback=None):
# Step 16: Plot GLM results # Step 16: Plot GLM results
fig_glm_result = plot_glm_results(file_path, raw_haemo, glm_est, design_matrix) fig_glm_result = plot_glm_results(file_path, raw_haemo, glm_est, design_matrix)
fig_individual["GLM"] = fig_glm_result for name, fig in fig_glm_result:
fig_individual[f"GLM {name}"] = fig
if progress_callback: progress_callback(17) if progress_callback: progress_callback(17)
logger.info("17") logger.info("17")
# Step 17: Plot channel significance # Step 17: Plot channel significance
fig_significance = individual_significance(raw_haemo, glm_est) fig_significance = individual_significance(raw_haemo, glm_est)
fig_individual["Significance"] = fig_significance for name, fig in fig_significance:
fig_individual[f"Significance {name}"] = fig
if progress_callback: progress_callback(18) if progress_callback: progress_callback(18)
logger.info("18") logger.info("18")
@@ -2975,6 +3019,9 @@ def process_participant(file_path, progress_callback=None):
contrast_dict[condition] = contrast_vector contrast_dict[condition] = contrast_vector
if progress_callback: progress_callback(19)
logger.info("19")
# Compute contrast results # Compute contrast results
contrast_results = {} contrast_results = {}
@@ -2988,7 +3035,7 @@ def process_participant(file_path, progress_callback=None):
fig_bytes = convert_fig_dict_to_png_bytes(fig_individual) fig_bytes = convert_fig_dict_to_png_bytes(fig_individual)
if progress_callback: progress_callback(20)
logger.info("20")
return raw_haemo, epochs, fig_bytes, cha, contrast_results, df_ind, design_matrix, AGE, GENDER, GROUP, True return raw_haemo, epochs, fig_bytes, cha, contrast_results, df_ind, design_matrix, AGE, GENDER, GROUP, True
# Not 3000 lines yay!

448
main.py
View File

@@ -10,6 +10,7 @@ License: GPL-3.0
import os import os
import re import re
import sys import sys
import json
import time import time
import shlex import shlex
import pickle import pickle
@@ -33,6 +34,7 @@ from mne.io import read_raw_snirf
from mne.preprocessing.nirs import source_detector_distances from mne.preprocessing.nirs import source_detector_distances
from mne_nirs.io import write_raw_snirf from mne_nirs.io import write_raw_snirf
from mne.channels import make_dig_montage from mne.channels import make_dig_montage
from mne import Annotations
from PySide6.QtWidgets import ( from PySide6.QtWidgets import (
QApplication, QWidget, QMessageBox, QVBoxLayout, QHBoxLayout, QTextEdit, QScrollArea, QComboBox, QGridLayout, QApplication, QWidget, QMessageBox, QVBoxLayout, QHBoxLayout, QTextEdit, QScrollArea, QComboBox, QGridLayout,
@@ -120,6 +122,12 @@ SECTIONS = [
#{"name": "FILTER", "default": True, "type": bool, "help": "Calculate Peak Spectral Power."}, #{"name": "FILTER", "default": True, "type": bool, "help": "Calculate Peak Spectral Power."},
] ]
}, },
{
"title": "Short Channels",
"params": [
{"name": "SHORT_CHANNEL", "default": True, "type": bool, "help": "Does the data have a short channel?"},
]
},
{ {
"title": "Extracting Events", "title": "Extracting Events",
"params": [ "params": [
@@ -242,6 +250,9 @@ class UpdateCheckThread(QThread):
error_occurred = Signal(str) error_occurred = Signal(str)
def run(self): def run(self):
if not getattr(sys, 'frozen', False):
self.error_occurred.emit("Application is not frozen (Development mode).")
return
try: try:
latest_version, download_url = self.get_latest_release_for_platform() latest_version, download_url = self.get_latest_release_for_platform()
if not latest_version: if not latest_version:
@@ -615,6 +626,430 @@ class UpdateOptodesWindow(QWidget):
class UpdateEventsWindow(QWidget):
def __init__(self, parent=None):
super().__init__(parent, Qt.WindowType.Window)
self.setWindowTitle("Update event markers")
self.resize(760, 200)
self.label_file_a = QLabel("SNIRF file:")
self.line_edit_file_a = QLineEdit()
self.line_edit_file_a.setReadOnly(True)
self.btn_browse_a = QPushButton("Browse .snirf")
self.btn_browse_a.clicked.connect(self.browse_file_a)
self.label_file_b = QLabel("BORIS file:")
self.line_edit_file_b = QLineEdit()
self.line_edit_file_b.setReadOnly(True)
self.btn_browse_b = QPushButton("Browse .boris")
self.btn_browse_b.clicked.connect(self.browse_file_b)
self.label_suffix = QLabel("Filename in BORIS project file:")
self.combo_suffix = QComboBox()
self.combo_suffix.setEditable(False)
self.combo_suffix.currentIndexChanged.connect(self.on_observation_selected)
self.label_events = QLabel("Events in selected observation:")
self.combo_events = QComboBox()
self.combo_events.setEnabled(False)
self.label_snirf_events = QLabel("Events in SNIRF file:")
self.combo_snirf_events = QComboBox()
self.combo_snirf_events.setEnabled(False)
self.btn_clear = QPushButton("Clear")
self.btn_go = QPushButton("Go")
self.btn_clear.clicked.connect(self.clear_files)
self.btn_go.clicked.connect(self.go_action)
# ---
layout = QVBoxLayout()
self.description = QLabel()
self.description.setTextFormat(Qt.TextFormat.RichText)
self.description.setTextInteractionFlags(Qt.TextInteractionFlag.TextBrowserInteraction)
self.description.setOpenExternalLinks(False) # Handle the click internally
self.description.setText("Some software when creating snirf files will insert a template of optode positions as the correct position of the optodes for the participant.<br>"
"This is rarely correct as each head differs slightly in shape or size, and a lot of calculations require the optodes to be in the correct location.<br>"
"Using a .txt file, we can update the positions in the snirf file to match those of a digitization system such as one from Polhemus or elsewhere.<br>"
"The .txt file should have the fiducials, detectors, and sources clearly labeled, followed by the x, y, and z coordinates seperated by a space.<br>"
"An example format of what a digitization text file should look like can be found <a href='custom_link'>by clicking here</a>.")
layout.addWidget(self.description)
help_text_a = "Select the SNIRF (.snirf) file to update with new event markers."
file_a_layout = QHBoxLayout()
# Help button on the left
help_btn_a = QPushButton("?")
help_btn_a.setFixedWidth(25)
help_btn_a.setToolTip(help_text_a)
help_btn_a.clicked.connect(lambda _, text=help_text_a: self.show_help_popup(text))
file_a_layout.addWidget(help_btn_a)
# Container for label + line_edit + browse button with tooltip
file_a_container = QWidget()
file_a_container_layout = QHBoxLayout()
file_a_container_layout.setContentsMargins(0, 0, 0, 0)
file_a_container_layout.addWidget(self.label_file_a)
file_a_container_layout.addWidget(self.line_edit_file_a)
file_a_container_layout.addWidget(self.btn_browse_a)
file_a_container.setLayout(file_a_container_layout)
file_a_container.setToolTip(help_text_a)
file_a_layout.addWidget(file_a_container)
layout.addLayout(file_a_layout)
help_text_b = "Provide a .boris project file that contains events for this participant."
file_b_layout = QHBoxLayout()
help_btn_b = QPushButton("?")
help_btn_b.setFixedWidth(25)
help_btn_b.setToolTip(help_text_b)
help_btn_b.clicked.connect(lambda _, text=help_text_b: self.show_help_popup(text))
file_b_layout.addWidget(help_btn_b)
file_b_container = QWidget()
file_b_container_layout = QHBoxLayout()
file_b_container_layout.setContentsMargins(0, 0, 0, 0)
file_b_container_layout.addWidget(self.label_file_b)
file_b_container_layout.addWidget(self.line_edit_file_b)
file_b_container_layout.addWidget(self.btn_browse_b)
file_b_container.setLayout(file_b_container_layout)
file_b_container.setToolTip(help_text_b)
file_b_layout.addWidget(file_b_container)
layout.addLayout(file_b_layout)
help_text_suffix = "This participant from the .boris project file matches the .snirf file."
suffix_layout = QHBoxLayout()
help_btn_suffix = QPushButton("?")
help_btn_suffix.setFixedWidth(25)
help_btn_suffix.setToolTip(help_text_suffix)
help_btn_suffix.clicked.connect(lambda _, text=help_text_suffix: self.show_help_popup(text))
suffix_layout.addWidget(help_btn_suffix)
suffix_container = QWidget()
suffix_container_layout = QHBoxLayout()
suffix_container_layout.setContentsMargins(0, 0, 0, 0)
suffix_container_layout.addWidget(self.label_suffix)
suffix_container_layout.addWidget(self.combo_suffix)
suffix_container.setLayout(suffix_container_layout)
suffix_container.setToolTip(help_text_suffix)
suffix_layout.addWidget(suffix_container)
layout.addLayout(suffix_layout)
help_text_suffix = "The events extracted from the BORIS project file for the selected observation."
suffix2_layout = QHBoxLayout()
help_btn_suffix = QPushButton("?")
help_btn_suffix.setFixedWidth(25)
help_btn_suffix.setToolTip(help_text_suffix)
help_btn_suffix.clicked.connect(lambda _, text=help_text_suffix: self.show_help_popup(text))
suffix2_layout.addWidget(help_btn_suffix)
suffix2_container = QWidget()
suffix2_container_layout = QHBoxLayout()
suffix2_container_layout.setContentsMargins(0, 0, 0, 0)
suffix2_container_layout.addWidget(self.label_events)
suffix2_container_layout.addWidget(self.combo_events)
suffix2_container.setLayout(suffix2_container_layout)
suffix2_container.setToolTip(help_text_suffix)
suffix2_layout.addWidget(suffix2_container)
layout.addLayout(suffix2_layout)
snirf_events_layout = QHBoxLayout()
help_text_snirf_events = "The event markers extracted from the SNIRF file."
help_btn_snirf_events = QPushButton("?")
help_btn_snirf_events.setFixedWidth(25)
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))
snirf_events_layout.addWidget(help_btn_snirf_events)
snirf_events_container = QWidget()
snirf_events_container_layout = QHBoxLayout()
snirf_events_container_layout.setContentsMargins(0, 0, 0, 0)
snirf_events_container_layout.addWidget(self.label_snirf_events)
snirf_events_container_layout.addWidget(self.combo_snirf_events)
snirf_events_container.setLayout(snirf_events_container_layout)
snirf_events_container.setToolTip(help_text_snirf_events)
snirf_events_layout.addWidget(snirf_events_container)
layout.addLayout(snirf_events_layout)
buttons_layout = QHBoxLayout()
buttons_layout.addStretch()
buttons_layout.addWidget(self.btn_clear)
buttons_layout.addWidget(self.btn_go)
layout.addLayout(buttons_layout)
self.setLayout(layout)
def show_help_popup(self, text):
msg = QMessageBox(self)
msg.setWindowTitle("Parameter Info")
msg.setText(text)
msg.exec()
def browse_file_a(self):
file_path, _ = QFileDialog.getOpenFileName(self, "Select SNIRF File", "", "SNIRF Files (*.snirf)")
if file_path:
self.line_edit_file_a.setText(file_path)
try:
raw = read_raw_snirf(file_path, preload=False)
annotations = raw.annotations
# Build individual event entries
event_entries = []
for onset, description in zip(annotations.onset, annotations.description):
event_str = f"{description} @ {onset:.3f}s"
event_entries.append(event_str)
if not event_entries:
QMessageBox.information(self, "No Events", "No events found in SNIRF file.")
self.combo_snirf_events.clear()
self.combo_snirf_events.setEnabled(False)
return
self.combo_snirf_events.clear()
self.combo_snirf_events.addItems(event_entries)
self.combo_snirf_events.setEnabled(True)
except Exception as e:
QMessageBox.warning(self, "Error", f"Could not read SNIRF file with MNE:\n{str(e)}")
self.combo_snirf_events.clear()
self.combo_snirf_events.setEnabled(False)
def browse_file_b(self):
file_path, _ = QFileDialog.getOpenFileName(self, "Select BORIS File", "", "BORIS project Files (*.boris)")
if file_path:
self.line_edit_file_b.setText(file_path)
try:
with open(file_path, 'r', encoding='utf-8') as f:
data = json.load(f)
self.boris_data = data
observation_keys = self.extract_boris_observation_keys(data)
self.combo_suffix.clear()
self.combo_suffix.addItems(observation_keys)
except (json.JSONDecodeError, FileNotFoundError, KeyError) as e:
QMessageBox.warning(self, "Error", f"Failed to parse BORIS file:\n{e}")
def extract_boris_observation_keys(self, data):
if "observations" not in data:
raise KeyError("Missing 'observations' key in BORIS file.")
observations = data["observations"]
if not isinstance(observations, dict):
raise TypeError("'observations' must be a dictionary.")
return list(observations.keys())
def on_observation_selected(self):
selected_obs = self.combo_suffix.currentText()
if not selected_obs or not hasattr(self, 'boris_data'):
self.combo_events.clear()
self.combo_events.setEnabled(False)
return
try:
events = self.boris_data["observations"][selected_obs]["events"]
except (KeyError, TypeError):
self.combo_events.clear()
self.combo_events.setEnabled(False)
return
event_entries = []
for event in events:
if isinstance(event, list) and len(event) >= 3:
timestamp = event[0]
label = event[2]
display = f"{label} @ {timestamp:.3f}"
event_entries.append(display)
self.combo_events.clear()
self.combo_events.addItems(event_entries)
self.combo_events.setEnabled(bool(event_entries))
def clear_files(self):
self.line_edit_file_a.clear()
self.line_edit_file_b.clear()
def go_action(self):
file_a = self.line_edit_file_a.text()
file_b = self.line_edit_file_b.text()
suffix = "flare"
if not hasattr(self, "boris_data") or self.combo_events.count() == 0 or self.combo_snirf_events.count() == 0:
QMessageBox.warning(self, "Missing data", "Please make sure a BORIS and SNIRF event are selected.")
return
# Extract BORIS anchor
try:
boris_label, boris_time_str = self.combo_events.currentText().split(" @ ")
boris_anchor_time = float(boris_time_str.replace("s", "").strip())
except Exception as e:
QMessageBox.critical(self, "BORIS Event Error", f"Could not parse BORIS anchor event:\n{e}")
return
# Extract SNIRF anchor
try:
snirf_label, snirf_time_str = self.combo_snirf_events.currentText().split(" @ ")
snirf_anchor_time = float(snirf_time_str.replace("s", "").strip())
except Exception as e:
QMessageBox.critical(self, "SNIRF Event Error", f"Could not parse SNIRF anchor event:\n{e}")
return
time_shift = snirf_anchor_time - boris_anchor_time
selected_obs = self.combo_suffix.currentText()
if not selected_obs or selected_obs not in self.boris_data["observations"]:
QMessageBox.warning(self, "Invalid selection", "Selected observation not found in BORIS file.")
return
boris_events = self.boris_data["observations"][selected_obs].get("events", [])
if not boris_events:
QMessageBox.warning(self, "No BORIS events", "No events found in selected BORIS observation.")
return
snirf_path = self.line_edit_file_a.text()
if not snirf_path:
QMessageBox.warning(self, "No SNIRF file", "Please select a SNIRF file.")
return
base_name = os.path.splitext(os.path.basename(file_a))[0]
suggested_name = f"{base_name}_{suffix}.snirf"
# Open save dialog
save_path, _ = QFileDialog.getSaveFileName(
self,
"Save SNIRF File As",
suggested_name,
"SNIRF Files (*.snirf)"
)
if not save_path:
print("Save cancelled.")
return
if not save_path.lower().endswith(".snirf"):
save_path += ".snirf"
try:
raw = read_raw_snirf(snirf_path, preload=True)
# Build new Annotations from shifted BORIS events
onsets = []
durations = []
descriptions = []
label_counts = {}
used_times = set()
sfreq = raw.info['sfreq'] # sampling frequency in Hz
min_shift = 1.0 / sfreq
max_attempts = 10
for event in boris_events:
if not isinstance(event, list) or len(event) < 3:
continue
orig_time = event[0]
desc = event[2]
# Count occurrences per event label
count = label_counts.get(desc, 0)
label_counts[desc] = count + 1
# Only use 1st, 3rd, 5th... (odd occurrences)
if (count % 2) == 0:
shifted_time = orig_time + time_shift
# Ensure unique timestamp by checking and adjusting slightly
adjusted_time = shifted_time
# Try to find a unique timestamp
attempts = 0
while round(adjusted_time, 6) in used_times and attempts < max_attempts:
adjusted_time += min_shift
attempts += 1
if attempts == max_attempts:
print(f"Warning: Could not find unique timestamp for event '{desc}' at original time {orig_time:.3f}s. Skipping.")
continue # Skip problematic event
adjusted_time = round(adjusted_time, 6)
used_times.add(adjusted_time)
print(f"Applying event: {desc} @ {adjusted_time:.3f}s (original: {orig_time:.3f}s)")
onsets.append(adjusted_time)
durations.append(0.0)
descriptions.append(desc)
new_annotations = Annotations(onset=onsets, duration=durations, description=descriptions)
# Replace annotations in raw object
raw.set_annotations(new_annotations)
# Write a new SNIRF file
write_raw_snirf(raw, suggested_name)
QMessageBox.information(self, "Success", "SNIRF file updated with aligned BORIS events.")
except Exception as e:
QMessageBox.critical(self, "Error", f"Failed to update SNIRF file:\n{e}")
def update_optode_positions(self, file_a, file_b, save_path):
fiducials = {}
ch_positions = {}
# Read the lines from the optode file
with open(file_b, 'r') as f:
for line in f:
if line.strip():
# Split by the semicolon and convert to meters
ch_name, coords_str = line.split(":")
coords = np.array(list(map(float, coords_str.strip().split()))) * 0.001
# The key we have is a fiducial
if ch_name.lower() in ['lpa', 'nz', 'rpa']:
fiducials[ch_name.lower()] = coords
# The key we have is a source or detector
else:
ch_positions[ch_name.upper()] = coords
# Create montage with updated coords in head space
initial_montage = make_dig_montage(ch_pos=ch_positions, nasion=fiducials.get('nz'), lpa=fiducials.get('lpa'), rpa=fiducials.get('rpa'), coord_frame='head') # type: ignore
# Read the SNIRF file, set the montage, and write it back
raw = read_raw_snirf(file_a, preload=True)
raw.set_montage(initial_montage)
write_raw_snirf(raw, save_path)
class ProgressBubble(QWidget): class ProgressBubble(QWidget):
""" """
A clickable widget displaying a progress bar made of colored rectangles and a label. A clickable widget displaying a progress bar made of colored rectangles and a label.
@@ -646,9 +1081,9 @@ class ProgressBubble(QWidget):
self.progress_layout = QHBoxLayout() self.progress_layout = QHBoxLayout()
self.rects = [] self.rects = []
for _ in range(12): for _ in range(19):
rect = QFrame() rect = QFrame()
rect.setFixedSize(10, 20) rect.setFixedSize(10, 18)
rect.setStyleSheet("background-color: white; border: 1px solid gray;") rect.setStyleSheet("background-color: white; border: 1px solid gray;")
self.progress_layout.addWidget(rect) self.progress_layout.addWidget(rect)
self.rects.append(rect) self.rects.append(rect)
@@ -2627,6 +3062,7 @@ class MainApplication(QMainWindow):
self.about = None self.about = None
self.help = None self.help = None
self.optodes = None self.optodes = None
self.events = None
self.bubble_widgets = {} self.bubble_widgets = {}
self.param_sections = [] self.param_sections = []
self.folder_paths = [] self.folder_paths = []
@@ -2859,12 +3295,13 @@ class MainApplication(QMainWindow):
("User Guide", "F1", self.user_guide, resource_path("icons/help_24dp_1F1F1F.svg")), ("User Guide", "F1", self.user_guide, resource_path("icons/help_24dp_1F1F1F.svg")),
("Check for Updates", "F5", self.manual_check_for_updates, resource_path("icons/update_24dp_1F1F1F.svg")), ("Check for Updates", "F5", self.manual_check_for_updates, resource_path("icons/update_24dp_1F1F1F.svg")),
("Update optodes in snirf file...", "F6", self.update_optode_positions, resource_path("icons/update_24dp_1F1F1F.svg")), ("Update optodes in snirf file...", "F6", self.update_optode_positions, resource_path("icons/update_24dp_1F1F1F.svg")),
("Update events in snirf file...", "F7", self.update_event_markers, resource_path("icons/update_24dp_1F1F1F.svg")),
("About", "F12", self.about_window, resource_path("icons/info_24dp_1F1F1F.svg")) ("About", "F12", self.about_window, resource_path("icons/info_24dp_1F1F1F.svg"))
] ]
for i, (name, shortcut, slot, icon) in enumerate(options_actions): for i, (name, shortcut, slot, icon) in enumerate(options_actions):
options_menu.addAction(make_action(name, shortcut, slot, icon=icon)) options_menu.addAction(make_action(name, shortcut, slot, icon=icon))
if i == 1 or i == 2: # after the first 2 actions (0,1) if i == 1 or i == 3: # after the first 2 actions (0,1)
options_menu.addSeparator() options_menu.addSeparator()
self.statusbar = self.statusBar() self.statusbar = self.statusBar()
@@ -2955,6 +3392,11 @@ class MainApplication(QMainWindow):
self.optodes.show() self.optodes.show()
def update_event_markers(self):
if self.events is None or not self.events.isVisible():
self.events = UpdateEventsWindow(self)
self.events.show()
def open_file_dialog(self): def open_file_dialog(self):
file_path, _ = QFileDialog.getOpenFileName( file_path, _ = QFileDialog.getOpenFileName(
self, "Open File", "", "All Files (*);;Text Files (*.txt)" self, "Open File", "", "All Files (*);;Text Files (*.txt)"