unhardcoding and general cleanup

This commit is contained in:
2026-07-17 00:07:56 -07:00
parent 2ff7cda93a
commit 0680718398
7 changed files with 1614 additions and 555 deletions
+1112 -411
View File
File diff suppressed because it is too large Load Diff
+17 -13
View File
@@ -104,9 +104,9 @@ SECTIONS = [
{ {
"title": "Short/Long Channels", "title": "Short/Long Channels",
"params": [ "params": [
{"name": "SHORT_CHANNEL", "default": True, "type": bool, "help": "This should be set to True if the data has a short channel present in the data. For more information about short channels, please visit the Wiki."}, {"name": "SHORT_CHANNELS", "default": True, "type": bool, "help": "This should be set to True if the data has a short channel present in the data. For more information about short channels, please visit the Wiki."},
{"name": "SHORT_CHANNEL_THRESH", "default": 0.015, "type": float, "depends_on": "SHORT_CHANNEL", "help": "The maximum distance the short channel can be in metres before it is no longer considered a short channel."}, {"name": "SHORT_CHANNELS_THRESHOLD", "default": 0.015, "type": float, "depends_on": "SHORT_CHANNEL", "help": "The maximum distance the short channel can be in metres before it is no longer considered a short channel."},
{"name": "LONG_CHANNEL_THRESH", "default": 0.045, "type": float, "help": "The maximum distance channels can be in metres. Any channel longer than this distance will be discarded."}, {"name": "LONG_CHANNELS_THRESHOLD", "default": 0.045, "type": float, "help": "The maximum distance channels can be in metres. Any channel longer than this distance will be discarded."},
] ]
}, },
{ {
@@ -144,16 +144,16 @@ SECTIONS = [
] ]
}, },
{ {
"title": "Cross Validation", "title": "Coefficient of Variation",
"params": [ "params": [
{"name": "CV", "default": True, "type": bool, "help": "Identifies bad channels using the Coefficient of Variation."}, {"name": "COEFF_VAR", "default": True, "type": bool, "help": "Identifies bad channels using the Coefficient of Variation."},
{"name": "CV_THRESHOLD", "default": 20, "type": int, "depends_on": "CV", "help": "Noise threshold (%)."}, {"name": "COEFF_VAR_THRESHOLD", "default": 20, "type": int, "depends_on": "COEFF_VAR", "help": "Noise threshold (%)."},
] ]
}, },
{ {
"title": "Median Absolute Deviation", "title": "Median Absolute Deviation",
"params": [ "params": [
{"name": "MAD", "default": True, "type": bool, "help": "Identifies bad channels using Mean Absolute Deviation."}, {"name": "MAD", "default": True, "type": bool, "help": "Identifies bad channels using Median Absolute Deviation."},
{"name": "MAD_THRESHOLD", "default": 4, "type": int, "depends_on": "MAD", "help": "Amount of deviations before the channel is flagged bad."}, {"name": "MAD_THRESHOLD", "default": 4, "type": int, "depends_on": "MAD", "help": "Amount of deviations before the channel is flagged bad."},
] ]
}, },
@@ -166,10 +166,10 @@ SECTIONS = [
] ]
}, },
{ {
"title": "Channel Variance", "title": "Sensor Dropout",
"params": [ "params": [
{"name": "CHANNEL_VAR", "default": True, "type": bool, "help": "Identifies bad channels based on comparing the variance of the first 25% of the data to the last 25%."}, {"name": "SENSOR_DROPOUT", "default": True, "type": bool, "help": "Identifies bad channels based on comparing the variance of the first 25% of the data to the last 25%."},
{"name": "CHANNEL_THRESH", "default": 0.05, "type": float, "depends_on": "CHANNEL_VAR", "help": "If the end variance is less than this % of the start variance, the channel will be marked as bad."}, {"name": "SENSOR_DROPOUT_VARIANCE_THRESHOLD", "default": 0.05, "type": float, "depends_on": "SENSOR_DROPOUT", "help": "If the end variance is less than this % of the start variance, the channel will be marked as bad."},
] ]
}, },
{ {
@@ -206,6 +206,7 @@ SECTIONS = [
"title": "Haemoglobin Concentration", "title": "Haemoglobin Concentration",
"params": [ "params": [
# NOTE: Intentionally empty # NOTE: Intentionally empty
# TODO: Manual override of PPF?
] ]
}, },
{ {
@@ -504,6 +505,7 @@ class MainApplication(QMainWindow):
self.missing_events_bypass = False self.missing_events_bypass = False
self.analysis_clearing_bypass = False self.analysis_clearing_bypass = False
self.folding_bypass = False self.folding_bypass = False
self.json_location = r"C:\Users\tyler\Desktop\research\flares\regions.json"
# Initialization to ensure that saving can occur # Initialization to ensure that saving can occur
@@ -1063,7 +1065,8 @@ class MainApplication(QMainWindow):
data_map["config_dict"], data_map["config_dict"],
data_map["fig_bytes_dict"], data_map["fig_bytes_dict"],
data_map["contrast_results_dict"], data_map["contrast_results_dict"],
self.folding_bypass self.folding_bypass,
self.json_location
] ]
self.launcher_window = ViewerLauncherWidget(*args) self.launcher_window = ViewerLauncherWidget(*args)
@@ -2500,8 +2503,9 @@ def show_critical_error(error_msg):
message = ( message = (
f"{APP_NAME.upper()} has encountered an unrecoverable error and needs to close.<br><br>" f"{APP_NAME.upper()} has encountered an unrecoverable error and needs to close.<br><br>"
f"We are sorry for the inconvenience. An autosave was attempted to be saved to <a href='{autosave_link}'>{autosave_path}</a>, but it may not have been saved. " f"We are sorry for the inconvenience. An autosave was attempted to be saved to <a href='{autosave_link}'>{autosave_path}</a>, but it may not have been saved. "
"If the file was saved, it still may not be intact, openable, or contain the correct data. Use the autosave at your discretion.<br><br>" "If the file was saved, it still may not be intact, openable, or contain the correct data. Use the autosave at your own discretion.<br><br>"
f"This unrecoverable error was likely due to an error with {APP_NAME.upper()} and not your data.<br>" f"This unrecoverable error was due to an error with {APP_NAME.upper()} and not your data.<br>"
f"If this crash occured inside a [BETA] branch, it is likely to eventually be fixed.<br>"
f"Please raise an issue <a href='https://git.research.dezeeuw.ca/tyler/{APP_NAME}/issues'>here</a> and attach the error file located at <a href='{log_link}'>{log_path2}</a><br><br>" f"Please raise an issue <a href='https://git.research.dezeeuw.ca/tyler/{APP_NAME}/issues'>here</a> and attach the error file located at <a href='{log_link}'>{log_path2}</a><br><br>"
f"<pre>{error_msg}</pre>" f"<pre>{error_msg}</pre>"
) )
+242 -32
View File
@@ -9,7 +9,7 @@ License: GPL-3.0
# External library imports # External library imports
import pandas as pd import pandas as pd
from flares import run_cross_group_second_level_analysis from flares import run_cross_group_contrast_analysis, run_cross_group_laterality_analysis, run_cross_group_second_level_analysis
from src.shared.flaresbasewidget import CrossGroupUIMixin, FlaresBaseWidget from src.shared.flaresbasewidget import CrossGroupUIMixin, FlaresBaseWidget
from src.shared.shareddata import APP_NAME from src.shared.shareddata import APP_NAME
@@ -17,39 +17,135 @@ from src.shared.shareddata import APP_NAME
PARAMETERIZED_INDEXES = { PARAMETERIZED_INDEXES = {
0: [ 0: [
{ {
"key": "show_optodes", "key": "p_threshold",
"label": "Determine what is rendered above the brain. Valid values are 'sensors', 'labels', 'none', 'all'.", "label": "Significance threshold P-value (e.g. 0.05)",
"default": "all", "default": "0.05",
"type": str,
},
{
"key": "t_or_theta",
"label": "Specify if t values or theta values should be plotted. Valid values are 't', 'theta'",
"default": "theta",
"type": str,
},
{
"key": "show_text",
"label": "Display informative text on the top left corner about the contrast.",
"default": "True",
"type": bool,
},
{
"key": "brain_bounds",
"label": "Graph Upper/Lower Limit",
"default": "1.0",
"type": float, "type": float,
}, },
{ {
"key": "is_3d", "key": "min_subjects",
"label": "Should we display the results in a 3D interactive window?", "label": "Minimum number of participants to process",
"default": "True", "default": "3",
"type": int,
},
{
"key": "correction_method",
"label": "Correction method to utilize. Valid values are 'fdr_bh', 'None'",
"default": "fdr_bh",
"type": str,
},
{
"key": "target_chroma",
"label": "Which chroma to target. Valid values are 'hbo', 'hbr'",
"default": "hbo",
"type": str,
},
{
"key": "roi_config",
"label": "Location of the ROI config file",
"default": r"C:\Users\tyler\Desktop\research\flares\regions.json",
"type": str,
},
{
"key": "threshold_topo",
"label": "threshold_topo: TBD",
"default": False,
"type": bool, "type": bool,
} }
], ],
1: [
{
"key": "p_threshold",
"label": "Significance threshold P-value (e.g. 0.05)",
"default": "0.05",
"type": float,
},
{
"key": "min_subjects",
"label": "Minimum number of participants to process",
"default": "3",
"type": int,
},
{
"key": "correction_method",
"label": "Correction method to utilize. Valid values are 'fdr_bh', 'None'",
"default": "None",
"type": str,
},
{
"key": "target_chroma",
"label": "Which chroma to target. Valid values are 'hbo', 'hbr'",
"default": "hbo",
"type": str,
},
{
"key": "roi_a",
"label": "ROI A (e.g. contralateral region name from regions.json)",
"default": "",
"type": str,
},
{
"key": "roi_b",
"label": "ROI B (e.g. ipsilateral region name from regions.json)",
"default": "",
"type": str,
}
],
2: [
{
"key": "p_value",
"label": "Significance threshold P-value (e.g. 0.05)",
"default": "0.05",
"type": float,
},
{
"key": "min_subjects",
"label": "Minimum number of participants to process",
"default": "3",
"type": int,
},
{
"key": "correction_method",
"label": "Correction method to utilize. Valid values are 'fdr_bh', 'None'",
"default": "fdr_bh",
"type": str,
},
{
"key": "target_chroma",
"label": "Which chroma to target. Valid values are 'hbo', 'hbr'",
"default": "hbo",
"type": str,
},
{
"key": "roi_config",
"label": "Location of the ROI config file",
"default": r"C:\Users\tyler\Desktop\research\flares\regions.json",
"type": str,
},
{
"key": "contrast_name",
"label": "Name of the contrast to use",
"default": "",
"type": str,
},
],
} }
DESCRIPTION = """0. Raw ROI Comparison (run_cross_group_second_level_analysis)
\nCompares one ROI's raw response magnitude between two independent groups (e.g. control vs. target) for a given condition, using Welch's t-test. A significant result means the two populations differ in this ROI's response magnitude for this condition. It does not tell you whether that difference is a real, localized, task-specific effect or a generic between-population difference - different overall vascular reactivity, arousal, or skull/scalp optical properties can produce the exact same statistical signature, and two independently recruited groups (especially patients vs. healthy controls) are considerably more likely to differ this way than two subsets of one study population.
\nIf you expected a group difference and didn't find one, the most common cause is within-group heterogeneity swallowing a real between-group difference - a "target" population (e.g. a clinical group) is often more variable than a tightly-screened control group, and that added within-group variance directly weakens a between-group t-test even if the group means truly differ. Small per-group sample sizes compound this. It's also possible the true difference between your groups isn't in raw magnitude at all, but in spatial specificity or task-differentiation - which is exactly why the laterality and contrast-comparison methods exist alongside this one; a null result here doesn't rule those out.
\n\n1. Laterality Comparison (run_cross_group_laterality_analysis)
\nComputes each subject's own contralateral-minus-ipsilateral laterality index first, then compares those indices between the two groups with Welch's t-test. A significant result means the degree of spatial specificity/lateralization differs between the two populations - a claim about lateralization itself, harder to explain away as a generic population confound since person-level differences in overall reactivity largely cancel before the group comparison happens. It says nothing about overall response magnitude between groups (a group could have identical laterality but very different raw amplitude), and it only uses subjects who have both the contra and ipsi ROI valid, so it can lose subjects the raw-ROI comparison would have kept.
\nNon-significance here has two likely sources, and it's worth distinguishing them. First, the same covariance issue from the within-group paired test applies across a whole group: if contra/ipsi responses aren't well-correlated within subjects, the laterality index itself is noisier than either ROI alone, and that added noise now has to clear a between-group test on top of it - a double power cost at small N. Second, and more informative if true: the groups may genuinely have similar lateralization but differ in overall magnitude instead, in which case this test correctly returns null while method 4 (raw comparison) should be the one to look at.
\n\n2. Contrast Comparison (run_cross_group_contrast_analysis)
\nCompares a jointly-fit task contrast (e.g. Task A minus Task B, estimated together within each subject's GLM), aggregated to ROI level, between two independent groups. A significant result means one group differentiates between the two tasks more or less than the other does, at this specific ROI - with systemic noise cancelled at the model-fitting stage, the same benefit that makes the within-group version of this method the strongest of that trio. As with the within-group version, it does not by itself say where a difference is localized unless you compare sign/pattern across multiple ROIs - opposite-signed group differences across regions point to something spatially specific, same-signed differences everywhere point to a diffuse/non-specific group difference (e.g. one group simply has stronger contrast responses across the whole head).
\nIf this comes back non-significant despite an expected group difference, check first whether the underlying single-subject contrast estimates are noisy for either group - small per-group N means the joint contrast's precision depends on the same limited subject count as everything else, and a noisy input propagates all the way through the ROI aggregation. It's also possible for a real, localized sub-regional effect to get washed out by ROI averaging itself: if only part of an ROI's channels actually show the group difference while others don't, the inverse-variance-weighted average can dilute it toward null - in that case, a finer-grained ROI definition (splitting the region further) may recover the effect that a coarser ROI averaged away. Finally, FDR correction across every ROI tested reduces power exactly as it does everywhere else in this framework - a real but modest effect can fail to survive correction even when the raw p-value would have looked convincing on its own.
\n\n
\nWhy channels needed to be aggregated into ROIs: Testing every channel independently means paying a steep multiple-comparisons tax - with dozens of channels, FDR/Bonferroni correction demands very large effect sizes to call anything significant, and at small subject counts (n=5) essentially nothing survives even when a real, consistent effect exists. Collapsing channels into a handful of anatomically meaningful ROIs cuts the number of independent tests from a minimum of ~40 down to 2-8, which lets a genuinely present effect actually clear correction. It also matches the scientific question better: you have a hypothesis about regions (contralateral motor cortex, prefrontal cortex), not about individual source-detector pairs, so testing at the ROI level is testing the thing you actually believe in, using inverse-variance weighting so noisier channels contribute less to the region's combined estimate rather than diluting it equally.
\nWhy some analyses needed contrasts instead of raw values: A single condition's GLM beta is only ever measured relative to the model's implicit intercept, and that intercept absorbs whatever's happening for the rest of the recording - including systemic physiology (blood pressure, arousal, general vascular reactivity) that rises during almost any active task, not just the one you care about. Testing a raw "vs. zero" value can't tell a real, localized neural response apart from that shared full-head noise. A contrast - either a within-subject spatial subtraction (ROI A minus ROI B) or a jointly-fit task contrast (Condition A minus Condition B, estimated together in one GLM) cancels out whatever's common to both halves of the subtraction, leaving something closer to the actual differential signal.
\nWhy a minimum subject count is enforced: Every one of these tests is a t-test, and a t-test's ability to detect a real effect (its power) depends heavily on degrees of freedom - at n=5 (df=4), even a fairly large true effect can produce a middling p-value, and at n=2 (df=1) the test is barely meaningful at all regardless of the underlying data. The min_subjects floor exists to stop a channel or ROI from being silently tested (and potentially reported as significant or non-significant) on a sample too small for the resulting p-value to mean anything reliable - it's better to explicitly skip and flag an underpowered channel than to quietly produce a number that looks statistically legitimate but isn't backed by enough independent observations to trust."""
class CrossGroupStatsWidget(CrossGroupUIMixin, FlaresBaseWidget): class CrossGroupStatsWidget(CrossGroupUIMixin, FlaresBaseWidget):
def __init__(self, haemo_dict, cha_dict, df_ind_dict, design_matrix_dict, contrast_results_dict, group_dict): def __init__(self, haemo_dict, cha_dict, df_ind_dict, design_matrix_dict, contrast_results_dict, group_dict):
@@ -62,7 +158,7 @@ class CrossGroupStatsWidget(CrossGroupUIMixin, 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_cross_group_ui(["0 (Compute Statistics)"]) self.setup_cross_group_ui(["0 (Raw ROI Comparison)", "1 (Laterality Comparison)", "2 (Contrast Comparison)",], placeholder_text=DESCRIPTION)
def process_request(self): def process_request(self):
@@ -94,6 +190,14 @@ class CrossGroupStatsWidget(CrossGroupUIMixin, FlaresBaseWidget):
# Visualizations # Visualizations
for idx in selected_indexes: for idx in selected_indexes:
if idx == 0: if idx == 0:
params = param_values.get(idx, {})
p_threshold = params.get("p_threshold", 0.05)
min_subjects = params.get("min_subjects", 3)
correction_method = params.get("correction_method", "fdr_bh")
target_chroma = params.get("target_chroma", "hbo")
roi_config = params.get("roi_config", r"C:\Users\tyler\Desktop\research\flares\regions.json")
threshold_topo = params.get("threshold_topo", False)
run_cross_group_second_level_analysis( run_cross_group_second_level_analysis(
df_roi_all=df_ind_combined, # Individual stats dataframe df_roi_all=df_ind_combined, # Individual stats dataframe
file_paths_a=file_paths_a, file_paths_a=file_paths_a,
@@ -102,13 +206,119 @@ class CrossGroupStatsWidget(CrossGroupUIMixin, FlaresBaseWidget):
group_b_name=self.group_b_dropdown.currentText(), group_b_name=self.group_b_dropdown.currentText(),
df_cha_all=cha_combined, df_cha_all=cha_combined,
raw_haemo=p_haemo, raw_haemo=p_haemo,
p_threshold=0.05, p_threshold=p_threshold,
min_subjects=3, min_subjects=min_subjects,
correction_method='fdr_bh', correction_method=correction_method,
target_chroma='hbo', target_chroma=target_chroma,
selected_event=selected_event, selected_event=selected_event,
roi_config=r"C:\Users\tyler\Desktop\research\flares\regions.json", roi_config=roi_config,
threshold_topo=False # Shows the raw difference map (Unthresholded) threshold_topo=threshold_topo # Shows the raw difference map (Unthresholded)
) )
elif idx == 1:
if not selected_event:
print("Laterality comparison requires a specific event/condition "
"to be selected first.")
continue
params = param_values.get(idx, {})
p_threshold = params.get("p_threshold", 0.05)
min_subjects = params.get("min_subjects", 3)
correction_method = params.get("correction_method", "None")
target_chroma = params.get("target_chroma", "hbo")
roi_a = params.get("roi_a", "").strip()
roi_b = params.get("roi_b", "").strip()
if not roi_a or not roi_b:
print("Both a contralateral and ipsilateral ROI name must be specified.")
continue
if correction_method == "None":
correction_method = None
# Build each group's dataframe directly from the dict using
# the file-path lists as keys - no ID cleaning/matching needed.
def _build_group_df(file_paths, dict_source):
valid_dfs = [
dict_source[fp] for fp in file_paths
if fp in dict_source and isinstance(dict_source[fp], pd.DataFrame)
and not dict_source[fp].empty
]
return pd.concat(valid_dfs, ignore_index=True) if valid_dfs else pd.DataFrame()
df_roi_a = _build_group_df(file_paths_a, self.df_ind_dict)
df_roi_b = _build_group_df(file_paths_b, self.df_ind_dict)
if df_roi_a.empty or df_roi_b.empty:
print("No ROI data (df_ind) found for one or both groups.")
continue
run_cross_group_laterality_analysis(
df_roi_all_a=df_roi_a,
df_roi_all_b=df_roi_b,
roi_pairs=(roi_a, roi_b),
condition=selected_event,
group_a_name=self.group_a_dropdown.currentText(),
group_b_name=self.group_b_dropdown.currentText(),
target_chroma=target_chroma,
min_subjects=min_subjects,
p_threshold=p_threshold,
correction_method=correction_method,
roi_contra_label=roi_a,
roi_ipsi_label=roi_b,
)
elif idx == 2:
params = param_values.get(idx, {})
p_threshold = params.get("p_threshold", 0.05)
min_subjects = params.get("min_subjects", 3)
correction_method = params.get("correction_method", "fdr_bh")
target_chroma = params.get("target_chroma", "hbo")
roi_config = params.get("roi_config", r"C:\Users\tyler\Desktop\research\flares\regions.json")
contrast_name = params.get("contrast_name", "")
if not contrast_name:
print("A contrast name must be specified (e.g. '2.0_vs_3.0').")
continue
# Build each group's channel-level contrast dataframe
# directly from contrast_results_dict, keyed by file path -
# same dict-key approach as the laterality patch, avoids
# any ID-string matching.
def _build_group_contrast_df(file_paths, contrast_dict, name):
all_rows = []
for fp in file_paths:
condition_dfs = contrast_dict.get(fp)
if condition_dfs is None:
print(f" [MISSING] '{fp}' not found in contrast_results.")
continue
if name in condition_dfs:
df = condition_dfs[name].copy()
df["ID"] = fp
df["contrast_name"] = name
all_rows.append(df)
else:
print(f" [MISSING CONTRAST] '{name}' not available for '{fp}'.")
return pd.concat(all_rows, ignore_index=True) if all_rows else pd.DataFrame()
df_contrasts_a = _build_group_contrast_df(file_paths_a, self.contrast_results_dict, contrast_name)
df_contrasts_b = _build_group_contrast_df(file_paths_b, self.contrast_results_dict, contrast_name)
if df_contrasts_a.empty or df_contrasts_b.empty:
print("No contrast data found for one or both groups.")
continue
run_cross_group_contrast_analysis(
df_contrasts_a=df_contrasts_a,
df_contrasts_b=df_contrasts_b,
contrast_name=contrast_name,
roi_json_path=roi_config,
group_a_name=self.group_a_dropdown.currentText(),
group_b_name=self.group_b_dropdown.currentText(),
target_chroma=target_chroma,
min_subjects=min_subjects,
p_threshold=p_threshold,
correction_method=correction_method,
)
else: else:
print("no") print("no")
+145 -97
View File
@@ -17,63 +17,111 @@ from src.shared.shareddata import APP_NAME
PARAMETERIZED_INDEXES = { PARAMETERIZED_INDEXES = {
0: [ 0: [
{ {
"key": "info", "key": "p_threshold",
"label": "Tests whether one ROI's response during one condition reliably differs from zero across subjects.\nIf significant, you can claim: This region's signal during this condition is consistently non-zero across your sample - not just noise.\nIt does NOT say: Whether that response is localized/specific to this region, or whether it reflects real neural activity versus systemic physiology (blood pressure, arousal) shared across the whole head during any active task.",
"default": "Okay.",
"type": str,
},
{
"key": "p_value",
"label": "Significance threshold P-value (e.g. 0.05)", "label": "Significance threshold P-value (e.g. 0.05)",
"default": "0.05", "default": "0.05",
"type": float, "type": float,
}, },
{
"key": "min_subjects",
"label": "Minimum number of participants to process",
"default": "5",
"type": int,
},
{
"key": "correction_method",
"label": "Correction method to utilize. Valid values are 'fdr_bh', 'None'",
"default": "fdr_bh",
"type": str,
},
{
"key": "target_chroma",
"label": "Which chroma to target. Valid values are 'hbo', 'hbr'",
"default": "hbo",
"type": str,
},
{ {
"key": "graph_bounds", "key": "graph_bounds",
"label": "Graph Y-Limit (Optional, e.g. 1e-5)", "label": "Graph Upper/Lower Limit",
"default": "0.0", # Set to 0.0 to auto-scale "default": "0.0",
"type": float, "type": float,
} }
], ],
1: [ 1: [
{ {
"key": "info", "key": "p_threshold",
"label": "For one condition, subtracts each subject's ROI_A response from their ROI_B response, then tests whether that per-subject difference is reliably non-zero.\nIf significant, you can claim: The two regions respond differently from each other during this specific condition - a real spatial contrast, since shared systemic noise partially cancels in the subtraction.\nIt does NOT say: Anything about whether the condition itself produced meaningful activity at all (only a relative difference between two places); and its power depends on the two ROIs' noise being correlated across subjects, which isn't guaranteed.", "label": "Significance threshold P-value (e.g. 0.05)",
"default": "Okay.", "default": "0.05",
"type": float,
},
{
"key": "min_subjects",
"label": "Minimum number of participants to process",
"default": "5",
"type": int,
},
{
"key": "correction_method",
"label": "Correction method to utilize. Valid values are 'fdr_bh', 'None'",
"default": "None",
"type": str,
},
{
"key": "target_chroma",
"label": "Which chroma to target. Valid values are 'hbo', 'hbr'",
"default": "hbo",
"type": str, "type": str,
}, },
{ {
"key": "roi_a", "key": "roi_a",
"label": "ROI A (e.g. contralateral region name from regions.json)", "label": "ROI A (e.g. contralateral region name from regions.json)",
"default": "", "default": [],
"type": str, "type": list,
}, },
{ {
"key": "roi_b", "key": "roi_b",
"label": "ROI B (e.g. ipsilateral region name from regions.json)", "label": "ROI B (e.g. ipsilateral region name from regions.json)",
"default": "", "default": [],
"type": str, "type": list,
}, }
{
"key": "p_value",
"label": "Significance threshold P-value (e.g. 0.05)",
"default": "0.05",
"type": float,
},
], ],
2: [ 2: [
{
"key": "info",
"label": "Uses a contrast fit jointly within each subject's GLM (Condition A minus Condition B, estimated together), then aggregates that per-channel contrast to ROI level and tests it against zero across subjects.\nIf significant, you can claim: The two conditions produce reliably different responses at this ROI, with systemic noise largely cancelled at the model-fitting stage itself - the most statistically efficient of the three.\nIt does NOT say: Which region the difference is localized to, unless you compare the sign/pattern across multiple ROIs",
"default": "Okay.",
"type": str,
},
{ {
"key": "p_value", "key": "p_value",
"label": "Significance threshold P-value (e.g. 0.05)", "label": "Significance threshold P-value (e.g. 0.05)",
"default": "0.05", "default": "0.05",
"type": float, "type": float,
}, },
{
"key": "min_subjects",
"label": "Minimum number of participants to process",
"default": "5",
"type": int,
},
{
"key": "correction_method",
"label": "Correction method to utilize. Valid values are 'fdr_bh', 'None'",
"default": "fdr_bh",
"type": str,
},
{
"key": "target_chroma",
"label": "Which chroma to target. Valid values are 'hbo', 'hbr'",
"default": "hbo",
"type": str,
},
{
"key": "contrast_name",
"label": "Name of the contrast to use",
"default": [],
"type": list,
},
{
"key": "weighted",
"label": "Use inverse-variance weighting to minimize noisy channels",
"default": True,
"type": bool,
},
{ {
"key": "graph_bounds", "key": "graph_bounds",
"label": "Graph Upper/Lower Limit", "label": "Graph Upper/Lower Limit",
@@ -84,9 +132,23 @@ PARAMETERIZED_INDEXES = {
} }
DESCRIPTION = """0. ROI vs. Zero (run_roi_second_level_analysis)
\nTests whether one ROI's response during one condition reliably differs from zero across subjects - a one-sample t-test on each subject's ROI-averaged theta. A significant result means the region's signal during this condition is consistently non-zero across your sample, not just noise. It does not tell you whether the response is localized/specific to this region, or whether it reflects real neural activity versus systemic physiology (blood pressure, arousal) shared across the whole head during any active task - a single-condition "vs. zero" test can't distinguish those two explanations on its own.
\nIf you expected significance here and didn't get it, likely causes include: the sample size is simply small relative to between-subject variability in true response magnitude or HRF shape (individual differences in timing/amplitude inflate the variance a t-test divides by); the ROI's channel composition differs slightly across subjects (missing channels get down-weighted or excluded from the inverse-variance average, diluting a real signal); FDR correction across many ROIs is suppressing a modest true effect that would clear an uncorrected threshold; or the condition itself may not reliably engage this region the way you assumed (worth checking the single-subject/individual-level results for this ROI before concluding the group effect isn't there).
\n\n1. Paired ROI Contrast (run_roi_paired_contrast_analysis)
\nFor one condition, subtracts each subject's ROI_A response from their ROI_B response, then tests whether that per-subject difference is reliably non-zero. A significant result is a genuine spatial contrast - the two regions respond differently from each other during this specific condition, with shared systemic noise partially cancelling in the subtraction. It says nothing about whether the condition produced meaningful activity at all (only a relative difference between two places), and its power depends entirely on ROI_A and ROI_B varying together across subjects - an assumption that isn't guaranteed.
\nIf this test underperforms a plain ROI-vs-zero result, which can occur, the most likely explanation is that ROI_A and ROI_B's noise isn't well-correlated across your subjects. The math is variance(A - B) = variance(A) + variance(B) - 2·covariance(A,B): subtraction only helps when the shared/systemic component is large relative to independent noise in each region. If the two regions are picking up largely independent noise sources (motion artifact affecting one side more, different channel quality, etc.), subtracting adds variance rather than removing it, and can turn a detectable single-ROI effect into an underpowered paired one. Small sample size makes this worse, since the covariance itself is poorly estimated with few subjects.
\n\n2. Joint Contrast, ROI-Aggregated (aggregate_channel_contrasts_to_roi + one-sample test)
\nUses a contrast fit jointly within each subject's GLM (Condition A minus Condition B, estimated together), then aggregates that per-channel contrast to ROI level using inverse-variance weighting, and tests it against zero across subjects. A significant result means the two conditions produce reliably different responses at this ROI, with systemic noise largely cancelled at the model-fitting stage itself - the most statistically efficient of the three within-group methods, since the correlation between conditions is handled natively rather than inferred afterward. It does not tell you where the difference is localized on its own - for that, compare the sign/pattern across multiple ROIs: opposite signs across regions indicates a real, spatially-specific effect, while the same sign everywhere suggests diffuse/systemic noise rather than localized activity (as seen when comparing a real task-vs-task contrast against a task-vs-inert-marker contrast).
\nIf this comes back non-significant despite expecting an effect, first check whether the two conditions are actually similar enough in their neural engagement of this ROI that a small or genuinely near-zero contrast is the correct answer - not every ROI should differentiate every pair of tasks, and a null result here can be the right result. Beyond that: FDR correction across every ROI in your regions file can suppress a real but modest contrast; the inverse-variance weighting can be destabilized if a few channels within the ROI have very noisy or near-zero t-statistics (their standard error estimate becomes huge or unstable); and - as always - small subject counts limit the achievable degrees of freedom regardless of how clean the underlying per-channel estimates are.
\n\n
\nWhy channels needed to be aggregated into ROIs: Testing every channel independently means paying a steep multiple-comparisons tax - with dozens of channels, FDR/Bonferroni correction demands very large effect sizes to call anything significant, and at small subject counts (n=5) essentially nothing survives even when a real, consistent effect exists. Collapsing channels into a handful of anatomically meaningful ROIs cuts the number of independent tests from a minimum of ~40 down to 2-8, which lets a genuinely present effect actually clear correction. It also matches the scientific question better: you have a hypothesis about regions (contralateral motor cortex, prefrontal cortex), not about individual source-detector pairs, so testing at the ROI level is testing the thing you actually believe in, using inverse-variance weighting so noisier channels contribute less to the region's combined estimate rather than diluting it equally.
\nWhy some analyses needed contrasts instead of raw values: A single condition's GLM beta is only ever measured relative to the model's implicit intercept, and that intercept absorbs whatever's happening for the rest of the recording - including systemic physiology (blood pressure, arousal, general vascular reactivity) that rises during almost any active task, not just the one you care about. Testing a raw "vs. zero" value can't tell a real, localized neural response apart from that shared full-head noise. A contrast - either a within-subject spatial subtraction (ROI A minus ROI B) or a jointly-fit task contrast (Condition A minus Condition B, estimated together in one GLM) cancels out whatever's common to both halves of the subtraction, leaving something closer to the actual differential signal.
\nWhy a minimum subject count is enforced: Every one of these tests is a t-test, and a t-test's ability to detect a real effect (its power) depends heavily on degrees of freedom - at n=5 (df=4), even a fairly large true effect can produce a middling p-value, and at n=2 (df=1) the test is barely meaningful at all regardless of the underlying data. The min_subjects floor exists to stop a channel or ROI from being silently tested (and potentially reported as significant or non-significant) on a sample too small for the resulting p-value to mean anything reliable - it's better to explicitly skip and flag an underpowered channel than to quietly produce a number that looks statistically legitimate but isn't backed by enough independent observations to trust."""
class InterGroupStatsWidget(InterGroupUIMixin, FlaresBaseWidget): class InterGroupStatsWidget(InterGroupUIMixin, FlaresBaseWidget):
def __init__(self, haemo_dict, cha, df_ind, design_matrix, contrast_results, group): def __init__(self, haemo_dict, cha, df_ind, design_matrix, contrast_results, group, json_location):
super().__init__("InterGroupStats") super().__init__("InterGroupStats")
self.setWindowTitle(f"Inter-Group Stats Viewer - {APP_NAME.upper()}") self.setWindowTitle(f"Inter-Group Stats Viewer - {APP_NAME.upper()}")
self.haemo_dict = haemo_dict self.haemo_dict = haemo_dict
@@ -95,12 +157,12 @@ class InterGroupStatsWidget(InterGroupUIMixin, FlaresBaseWidget):
self.design_matrix = design_matrix self.design_matrix = design_matrix
self.contrast_results = contrast_results self.contrast_results = contrast_results
self.group = group self.group = group
self.json_location = json_location
self.setup_inter_group_ui(["0 (Significance)", "1 (More significasd)", "2 (moreeee)"]) self.setup_inter_group_ui(["0 (ROI vs. Zero)", "1 (Paired ROI Contrast)", "2 (Joint Contrast, ROI-Aggregated)"], 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, self.json_location, self.contrast_results)
if request is None: if request is None:
return return
@@ -137,9 +199,15 @@ class InterGroupStatsWidget(InterGroupUIMixin, FlaresBaseWidget):
for idx in selected_indexes: for idx in selected_indexes:
if idx == 0: if idx == 0:
params = param_values.get(idx, {}) params = param_values.get(idx, {})
p_val = params.get("p_value", 0.05) p_threshold = params.get("p_threshold", 0.05)
min_subjects = params.get("min_subjects", 5)
correction_method = params.get("correction_method", "fdr_bh")
target_chroma = params.get("target_chroma", "hbo")
graph_bounds = params.get("graph_bounds", 0.0) graph_bounds = params.get("graph_bounds", 0.0)
if correction_method == "None":
correction_method = None
if df_group.empty: if df_group.empty:
print("No ROI data (df_ind) found for selected participants.") print("No ROI data (df_ind) found for selected participants.")
continue continue
@@ -165,90 +233,77 @@ class InterGroupStatsWidget(InterGroupUIMixin, FlaresBaseWidget):
else: else:
all_cha_filtered = all_cha all_cha_filtered = all_cha
# ---------------------------------------------------------------------
# run_roi_second_level_analysis
#
# Tests: is this ROI's activation reliably different from zero, for one
# condition, across subjects? (One-sample t-test per ROI.)
#
# CAUTION: "vs zero" includes systemic/global physiology shared across
# the whole head (blood pressure, arousal, etc.), not just localized
# neural response — a significant result here doesn't by itself prove
# the effect is spatially specific to this ROI.
# ---------------------------------------------------------------------
run_roi_second_level_analysis( run_roi_second_level_analysis(
df_roi_all=df_filtered, df_roi_all=df_filtered,
df_cha_all=all_cha_filtered, df_cha_all=all_cha_filtered,
raw_haemo=p_haemo, raw_haemo=p_haemo,
p_threshold=p_val, p_threshold=p_threshold,
min_subjects=len(selected_file_paths), min_subjects=min_subjects,
correction_method='fdr_bh', correction_method=correction_method,
target_chroma='hbo', target_chroma=target_chroma,
graph_bounds=graph_bounds if graph_bounds > 0.0 else None, graph_bounds=graph_bounds if graph_bounds > 0.0 else None,
roi_config=r"C:\Users\tyler\Desktop\research\flares\regions.json" roi_config=self.json_location
) )
elif idx == 1: elif idx == 1:
params = param_values.get(idx, {})
p_threshold = params.get("p_threshold", 0.05)
min_subjects = params.get("min_subjects", 5)
correction_method = params.get("correction_method", "None")
target_chroma = params.get("target_chroma", "hbo")
roi_a = params.get("roi_a", "").strip()
roi_b = params.get("roi_b", "").strip()
if not selected_event: if not selected_event:
print("Paired ROI contrast requires a specific event/condition " print("Paired ROI contrast requires a specific event/condition "
"to be selected pick one from the Event dropdown first.") "to be selected - pick one from the Event dropdown first.")
continue continue
if df_group.empty: if df_group.empty:
print("No ROI data (df_ind) found for selected participants.") print("No ROI data (df_ind) found for selected participants.")
continue continue
params = param_values.get(idx, {}) if correction_method == "None":
roi_a = params.get("roi_a", "").strip() correction_method = None
roi_b = params.get("roi_b", "").strip()
p_val = params.get("p_value", 0.05)
if not roi_a or not roi_b: if not roi_a or not roi_b:
print("Both ROI A and ROI B must be specified.") print("Both ROI A and ROI B must be specified.")
continue continue
# ---------------------------------------------------------------------
# run_roi_paired_contrast_analysis
#
# Tests: within one condition, does ROI_A's activation differ from
# ROI_B's, per subject? (Paired one-sample t-test on the per-subject
# difference, e.g. Right_PFC minus Left_PFC for a laterality check.)
#
# Only gains power over testing ROI_A and ROI_B separately if the two
# ROIs' noise is correlated across subjects (shared systemic component
# cancels in the subtraction). If they vary independently, this test
# can be WEAKER than testing either ROI alone — check per-subject
# correlation between ROI_A and ROI_B if this test underperforms.
run_roi_paired_contrast_analysis( run_roi_paired_contrast_analysis(
df_roi_all=df_group, # unfiltered — function filters internally df_roi_all=df_group,
roi_pairs=(roi_a, roi_b), roi_pairs=(roi_a, roi_b),
condition=selected_event, condition=selected_event,
target_chroma='hbo', target_chroma=target_chroma,
min_subjects=min(5, len(selected_file_paths)), min_subjects=min_subjects,
p_threshold=p_val, p_threshold=p_threshold,
correction_method=None, # single pre-specified contrast correction_method=correction_method,
roi_a_label=roi_a, roi_a_label=roi_a,
roi_b_label=roi_b, roi_b_label=roi_b,
) )
elif idx == 2: elif idx == 2:
params = param_values.get(idx, {})
p_threshold = params.get("p_threshold", 0.05)
min_subjects = params.get("min_subjects", 5)
correction_method = params.get("correction_method", "fdr_bh")
target_chroma = params.get("target_chroma", "hbo")
contrast_name = params.get("contrast_name", "2.0_vs_3.0")
weighted = params.get("weighted", True)
graph_bounds = params.get("graph_bounds", 0.0)
if not selected_event: if not selected_event:
print("Joint contrast ROI analysis requires a specific contrast " print("Joint contrast ROI analysis requires a specific contrast "
"to be selected from the Event dropdown first.") "to be selected from the Event dropdown first.")
continue continue
# Build the channel-level contrast dataframe for selected if not contrast_name:
# participants + selected contrast, same pattern used in print("Contrast name must be specified.")
# GroupViewerWidget.show_brain_images. continue
contrast_name = "15.0_vs_2.0" # <-- change this to test other contrasts
print(f"[TEMP HARDCODE] Using contrast '{contrast_name}' "
f"instead of dropdown selection ('{selected_event}') for option 2.")
# Build the channel-level contrast dataframe for selected
# participants + selected contrast, same pattern used in
# GroupViewerWidget.show_brain_images.
all_contrasts = [] all_contrasts = []
for fp in selected_file_paths: for fp in selected_file_paths:
condition_dfs = self.contrast_results.get(fp) condition_dfs = self.contrast_results.get(fp)
@@ -258,10 +313,6 @@ class InterGroupStatsWidget(InterGroupUIMixin, FlaresBaseWidget):
if contrast_name in condition_dfs: if contrast_name in condition_dfs:
df = condition_dfs[contrast_name].copy() df = condition_dfs[contrast_name].copy()
df["ID"] = fp df["ID"] = fp
# contrast_results dict values don't carry a
# contrast_name column themselves — that's only
# stamped on during CSV export. Add it here since
# aggregate_channel_contrasts_to_roi requires it.
df["contrast_name"] = contrast_name df["contrast_name"] = contrast_name
all_contrasts.append(df) all_contrasts.append(df)
else: else:
@@ -275,16 +326,13 @@ class InterGroupStatsWidget(InterGroupUIMixin, FlaresBaseWidget):
df_contrasts = pd.concat(all_contrasts, ignore_index=True) df_contrasts = pd.concat(all_contrasts, ignore_index=True)
params = param_values.get(idx, {})
p_val = params.get("p_value", 0.05)
graph_bounds = params.get("graph_bounds", 0.0)
try: try:
roi_theta = aggregate_channel_contrasts_to_roi( roi_theta = aggregate_channel_contrasts_to_roi(
df_contrasts, df_contrasts,
roi_json_path=r"C:\Users\tyler\Desktop\research\flares\regions.json", roi_json_path=self.json_location,
weighted=True, weighted=weighted,
) )
except Exception as e: except Exception as e:
print(f"Failed to aggregate contrasts to ROI: {e}") print(f"Failed to aggregate contrasts to ROI: {e}")
continue continue
@@ -294,22 +342,22 @@ class InterGroupStatsWidget(InterGroupUIMixin, 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 # df_cha_all intentionally omitted (None): the topography
# section of run_roi_second_level_analysis expects # section of run_roi_second_level_analysis expects
# single-condition Condition values in df_cha_all, which # single-condition Condition values in df_cha_all, which
# doesn't semantically match a contrast name skip it here # doesn't semantically match a contrast name - skip it here
# rather than pass mismatched data. # 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,
df_cha_all=None, df_cha_all=None,
raw_haemo=p_haemo, raw_haemo=p_haemo,
p_threshold=p_val, p_threshold=p_threshold,
min_subjects=min(5, len(selected_file_paths)), min_subjects=min_subjects,
correction_method='fdr_bh', correction_method=correction_method,
target_chroma='hbo', target_chroma=target_chroma,
graph_bounds=graph_bounds if graph_bounds > 0.0 else None, graph_bounds=graph_bounds if graph_bounds > 0.0 else None,
) )
else: else:
print(f"No method defined for index {idx}") print(f"No method defined for index {idx}")
+5 -5
View File
@@ -69,9 +69,9 @@ 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:
import flares as flares from flares import fold_channels
# Perform the heavy fold_channels logic # Perform the heavy fold_channels logic
channel_results = flares.fold_channels(raw_data, p_name, progress_queue) channel_results = fold_channels(raw_data, p_name, progress_queue)
# Hand back results and signal completion # Hand back results and signal completion
result_queue.put({file_path: channel_results}) result_queue.put({file_path: channel_results})
@@ -736,13 +736,13 @@ class ProcessOrchestrator(QObject):
def run(self): def run(self):
try: try:
# 🟢 [Delay 1 Fix] Instantiate Manager completely off the main thread # Instantiate Manager completely off the main thread
manager = Manager() manager = Manager()
result_queue = manager.Queue() result_queue = manager.Queue()
progress_queue = manager.Queue() progress_queue = manager.Queue()
active_processes = [] active_processes = []
# 🟢 [Delay 2 Fix] Perform heavy pickling loop safely in the background # Perform heavy pickling loop safely in the background
for file_path in self.selected_files: for file_path in self.selected_files:
p = Process( p = Process(
target=self.worker_func, target=self.worker_func,
@@ -896,7 +896,7 @@ class ParticipantFoldChannelsWidget(FlaresBaseWidget):
self.progress_queue = progress_queue self.progress_queue = progress_queue
self.active_processes = active_processes self.active_processes = active_processes
# 🟢 Safely initialize and trigger your polling listener # Safely initialize and trigger the polling listener
self.completed_count = 0 self.completed_count = 0
self.result_timer = QTimer() self.result_timer = QTimer()
self.result_timer.timeout.connect(self.check_parallel_results) self.result_timer.timeout.connect(self.check_parallel_results)
+114 -18
View File
@@ -7,6 +7,7 @@ License: GPL-3.0
""" """
import os import os
import json
from PySide6.QtWidgets import QApplication, QComboBox, QDialog, QGridLayout, QHBoxLayout, QLabel, QLineEdit, QListView, QMessageBox, QPushButton, QScrollArea, QVBoxLayout, QWidget, QFrame, QSpinBox from PySide6.QtWidgets import QApplication, QComboBox, QDialog, QGridLayout, QHBoxLayout, QLabel, QLineEdit, QListView, QMessageBox, QPushButton, QScrollArea, QVBoxLayout, QWidget, QFrame, QSpinBox
from PySide6.QtGui import QStandardItemModel, QStandardItem, QPixmap, QIntValidator, QDoubleValidator from PySide6.QtGui import QStandardItemModel, QStandardItem, QPixmap, QIntValidator, QDoubleValidator
@@ -84,28 +85,53 @@ class ParameterInputDialog(QDialog):
self.params_dict = params_dict self.params_dict = params_dict
self.inputs = {} # {(idx, param_key): QLineEdit} self.inputs = {} # {(idx, param_key): QLineEdit}
layout = QVBoxLayout(self) main_layout = QVBoxLayout(self)
intro_label = QLabel( intro_label = QLabel(
"Some methods require parameters to continue:\n" "Some methods require parameters to continue:\n"
"Clicking OK will simply use default values if input is left empty." "Clicking OK will simply use default values if input is left empty."
) )
layout.addWidget(intro_label) main_layout.addWidget(intro_label)
self.setMinimumWidth(400)
self.scroll = QScrollArea()
self.scroll.setWidgetResizable(True)
self.scroll.setMaximumHeight(800)
self.scroll.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded)
self.scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
self.scroll_content = QWidget()
self.scroll_layout = QVBoxLayout(self.scroll_content)
self.scroll_layout.setContentsMargins(10, 10, 10, 10)
self.scroll.setWidget(self.scroll_content)
main_layout.addWidget(self.scroll)
for idx, param_list in params_dict.items(): for idx, param_list in params_dict.items():
full_text = param_list[0].get('full_text', f"Index [{idx}]") full_text = param_list[0].get('full_text', f"Index [{idx}]")
group_label = QLabel(f"{full_text} requires parameters:") group_label = QLabel(f"{full_text} requires parameters:")
group_label.setStyleSheet("font-weight: bold; margin-top: 10px;") group_label.setStyleSheet("font-weight: bold; margin-top: 10px;")
layout.addWidget(group_label) self.scroll_layout.addWidget(group_label)
for param_info in param_list: for param_info in param_list:
label = QLabel(param_info["label"]) label = QLabel(param_info["label"])
layout.addWidget(label) self.scroll_layout.addWidget(label)
line_edit = QLineEdit(self) if param_info.get("type") == list:
line_edit.setPlaceholderText(str(param_info.get("default", ""))) widget = QComboBox(self)
layout.addWidget(line_edit) # Convert options to string just in case they aren't
options = [str(opt) for opt in param_info.get("options", [])]
widget.addItems(options)
self.inputs[(idx, param_info["key"])] = line_edit # Set default choice if it exists in the options list
default_val = str(param_info.get("default", ""))
if default_val in options:
widget.setCurrentText(default_val)
else:
widget = QLineEdit(self)
widget.setPlaceholderText(str(param_info.get("default", "")))
self.scroll_layout.addWidget(widget)
self.inputs[(idx, param_info["key"])] = widget
# Buttons # Buttons
btn_layout = QHBoxLayout() btn_layout = QHBoxLayout()
@@ -113,7 +139,7 @@ class ParameterInputDialog(QDialog):
cancel_btn = QPushButton("Cancel", self) cancel_btn = QPushButton("Cancel", self)
btn_layout.addWidget(ok_btn) btn_layout.addWidget(ok_btn)
btn_layout.addWidget(cancel_btn) btn_layout.addWidget(cancel_btn)
layout.addLayout(btn_layout) main_layout.addLayout(btn_layout)
ok_btn.clicked.connect(self.accept) ok_btn.clicked.connect(self.accept)
cancel_btn.clicked.connect(self.reject) cancel_btn.clicked.connect(self.reject)
@@ -131,8 +157,11 @@ class ParameterInputDialog(QDialog):
Returns None if validation fails (error dialog shown). Returns None if validation fails (error dialog shown).
""" """
values = {} values = {}
for (idx, param_key), line_edit in self.inputs.items(): for (idx, param_key), widget in self.inputs.items():
text = line_edit.text().strip() if isinstance(widget, QComboBox):
text = widget.currentText().strip()
else:
text = widget.text().strip()
# Find param info dict # Find param info dict
param_info = None param_info = None
@@ -164,14 +193,15 @@ class ParameterInputDialog(QDialog):
val = False val = False
else: else:
raise ValueError(f"Invalid bool value: {text}") raise ValueError(f"Invalid bool value: {text}")
elif param_type == str: elif param_type in (str, list):
val = text val = text
else: else:
val = text # fallback val = text # fallback
except (ValueError, TypeError): except (ValueError, TypeError):
type_name = "list option" if param_type == list else param_type.__name__
self._show_error( self._show_error(
f"Invalid input for index {idx} parameter '{param_key}': '{text}'\n" f"Invalid input for index {idx} parameter '{param_key}': '{text}'\n"
f"Expected type: {param_type.__name__}" f"Expected type: {type_name}"
) )
return None return None
@@ -1055,7 +1085,7 @@ class FlaresBaseWidget(QWidget):
class CrossGroupUIMixin: class CrossGroupUIMixin:
def setup_cross_group_ui(self, index_texts): def setup_cross_group_ui(self, index_texts, placeholder_text=""):
self.group_to_paths = {} self.group_to_paths = {}
for file_path, group_name in self.group_dict.items(): for file_path, group_name in self.group_dict.items():
@@ -1130,6 +1160,10 @@ class CrossGroupUIMixin:
self.scroll_content = QWidget() self.scroll_content = QWidget()
self.grid_layout = QGridLayout(self.scroll_content) self.grid_layout = QGridLayout(self.scroll_content)
self.scroll_area.setWidget(self.scroll_content) self.scroll_area.setWidget(self.scroll_content)
self.placeholder_label = QLabel(placeholder_text)
self.grid_layout.addWidget(self.placeholder_label, 0, 0)
self.placeholder_label.setWordWrap(True)
self.placeholder_label.setScaledContents(True)
self.main_layout.addWidget(self.scroll_area) self.main_layout.addWidget(self.scroll_area)
self.thumb_size = QSize(280, 180) self.thumb_size = QSize(280, 180)
@@ -1307,7 +1341,7 @@ class CSVUIMixin:
class InterGroupUIMixin: class InterGroupUIMixin:
def setup_inter_group_ui(self, index_texts): def setup_inter_group_ui(self, index_texts, placeholder_text=""):
self.show_all_events = True self.show_all_events = True
self._updating_checkstates = False self._updating_checkstates = False
@@ -1366,12 +1400,16 @@ class InterGroupUIMixin:
self.scroll_content = QWidget() self.scroll_content = QWidget()
self.grid_layout = QGridLayout(self.scroll_content) self.grid_layout = QGridLayout(self.scroll_content)
self.scroll.setWidget(self.scroll_content) self.scroll.setWidget(self.scroll_content)
self.placeholder_label = QLabel(placeholder_text)
self.grid_layout.addWidget(self.placeholder_label, 0, 0)
self.placeholder_label.setWordWrap(True)
self.placeholder_label.setScaledContents(True)
self.layout.addWidget(self.scroll) self.layout.addWidget(self.scroll)
self.thumb_size = QSize(280, 180) self.thumb_size = QSize(280, 180)
self.showMaximized() self.showMaximized()
def get_common_request_data(self, parameterized_indexes): def get_common_request_data(self, parameterized_indexes, json_location=None, contrast_dfs=None):
selected_event = self.event_dropdown.currentText() selected_event = self.event_dropdown.currentText()
if selected_event == "<None Selected>": if selected_event == "<None Selected>":
selected_event = None selected_event = None
@@ -1404,8 +1442,6 @@ class InterGroupUIMixin:
print("No participants selected.") print("No participants selected.")
return return
# Only keep indexes 0 and 1 that need parameters
# Inject full_text from index_texts # Inject full_text from index_texts
for idx, params_list in parameterized_indexes.items(): for idx, params_list in parameterized_indexes.items():
@@ -1415,6 +1451,66 @@ class InterGroupUIMixin:
indexes_needing_params = {idx: parameterized_indexes[idx] for idx in selected_indexes if idx in parameterized_indexes} indexes_needing_params = {idx: parameterized_indexes[idx] for idx in selected_indexes if idx in parameterized_indexes}
dynamic_rois = []
# 1. Check for the JSON file and parse ROI names
if os.path.exists(json_location):
try:
with open(json_location, 'r', encoding='utf-8') as f:
regions_data = json.load(f)
# Extract "name" from each region under "regions_of_interest"
regions_list = regions_data.get("regions_of_interest", [])
dynamic_rois = [region["name"] for region in regions_list if "name" in region]
except Exception as e:
# Safe log if JSON is corrupted or unreadable
print(f"Error reading ROI configurations from {json_location}: {e}")
# Fallback to prevent UI crashes if JSON file doesn't exist or is empty
if not dynamic_rois:
dynamic_rois = ["Option 1", "Option 2"]
dynamic_contrasts = []
if contrast_dfs:
contrast_set = set()
for fp in selected_file_paths:
# Get the contrasts dictionary associated with this file path
file_contrasts = contrast_dfs.get(fp, {})
for contrast_name in file_contrasts.keys():
# If no event is selected, display all contrasts.
# If an event is selected, only keep contrasts containing the event name as a substring.
if selected_event is None or selected_event in contrast_name:
contrast_set.add(contrast_name)
# Sort them cleanly for the UI
dynamic_contrasts = sorted(list(contrast_set))
# 2. Loop through the active parameters needing input and intercept 'roi_a' and 'roi_b'
for idx, params_list in indexes_needing_params.items():
for param_info in params_list:
if param_info["key"] == "roi_a":
# Inject options list dynamically
param_info["options"] = dynamic_rois
# Default to the very first item
param_info["default"] = dynamic_rois[0] if dynamic_rois else ""
elif param_info["key"] == "roi_b":
# Inject the same options list
param_info["options"] = dynamic_rois
# Default to the first item not taken (index 1), with safety fallbacks
if len(dynamic_rois) > 1:
param_info["default"] = dynamic_rois[1]
elif len(dynamic_rois) == 1:
param_info["default"] = dynamic_rois[0]
else:
param_info["default"] = ""
elif param_info["key"] == "contrast_name":
param_info["options"] = dynamic_contrasts
param_info["default"] = dynamic_contrasts[0] if dynamic_contrasts else ""
param_values = {} param_values = {}
if indexes_needing_params: if indexes_needing_params:
dialog = ParameterInputDialog(indexes_needing_params, parent=self) dialog = ParameterInputDialog(indexes_needing_params, parent=self)
+2 -2
View File
@@ -24,7 +24,7 @@ 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, folding_bypass): def __init__(self, haemo_dict, epochs_dict, cha_dict, df_ind_dict, design_matrix_dict, config_dict, fig_bytes_dict, contrast_results_dict, folding_bypass, json_location):
super().__init__() super().__init__()
self.setWindowTitle(f"Viewer Launcher - {APP_NAME.upper()}") self.setWindowTitle(f"Viewer Launcher - {APP_NAME.upper()}")
@@ -36,7 +36,7 @@ class ViewerLauncherWidget(QWidget):
("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),
("Inter-Group Functional Connectivity Viewer [BETA]", InterGroupFunctionalConnectivityWidget, [haemo_dict, group_dict, config_dict], True), ("Inter-Group Functional Connectivity Viewer [BETA]", InterGroupFunctionalConnectivityWidget, [haemo_dict, group_dict, config_dict], True),
("Inter-Group Stats Viewer", InterGroupStatsWidget, [haemo_dict, cha_dict, df_ind_dict, design_matrix_dict, contrast_results_dict, group_dict], True), ("Inter-Group Stats Viewer", InterGroupStatsWidget, [haemo_dict, cha_dict, df_ind_dict, design_matrix_dict, contrast_results_dict, group_dict, json_location], True),
("Cross-Group Stats Viewer", CrossGroupStatsWidget, [haemo_dict, cha_dict, df_ind_dict, design_matrix_dict, contrast_results_dict, group_dict], True), ("Cross-Group Stats Viewer", CrossGroupStatsWidget, [haemo_dict, cha_dict, df_ind_dict, design_matrix_dict, contrast_results_dict, group_dict], True),
("Inter-Group Brain and Image Viewer", InterGroupBrainImageWidget, [haemo_dict, cha_dict, df_ind_dict, design_matrix_dict, contrast_results_dict, group_dict], True), ("Inter-Group Brain and Image Viewer", InterGroupBrainImageWidget, [haemo_dict, cha_dict, df_ind_dict, design_matrix_dict, contrast_results_dict, group_dict], True),
("Cross-Group Brain and Image Viewer", CrossGroupBrainImageWidget, [haemo_dict, df_ind_dict, design_matrix_dict, contrast_results_dict, group_dict], True), ("Cross-Group Brain and Image Viewer", CrossGroupBrainImageWidget, [haemo_dict, df_ind_dict, design_matrix_dict, contrast_results_dict, group_dict], True),