preparation for 1.5.1

This commit is contained in:
2026-07-17 23:59:42 -07:00
parent 0680718398
commit 2b019c1bc0
11 changed files with 350 additions and 59 deletions
+36
View File
@@ -1,3 +1,39 @@
# Version 1.5.1
- Open a folder is now minimally BIDS compatible. It will recursively go through all folders, but not load external metadata
- Renamed options in the Viewer Launcher window to better denote what actions they can perform
- 2 new analysis options have been added in the viewer launcher window: Inter-Group Stats and Cross-Group Stats
- Each of these analysis options have lots of text explaining what they do when their respective windows are opened
- The parameter input dialog in the analysis windows will now be able to scroll down when lots of parameters are required
- When a project is saved, it automatically will populate into the Recent Projects menu. Fixes [Issue 86](https://git.research.dezeeuw.ca/tyler/flares/issues/86)
- The description when clicking "Why are these useful?" underneath the per-file parameters is no longer placeholder text. Fixes [Issue 85](https://git.research.dezeeuw.ca/tyler/flares/issues/85)
- Added a new parameter section "Region of Interest". It has one parameter of "JSON_LOCATION", a json file containing Region of Interest data
- Temporarily removed the parameter REMOVE_EVENTS due to its functionality being removed because of a memory leak
- Fixed participant metadata remaing in the background when the participant was removed. Fixes [Issue 82](https://git.research.dezeeuw.ca/tyler/flares/issues/82)
- Fixed processing remaining active hidden in the background when the "Clear" button was pushed. Fixes [Issue 81](https://git.research.dezeeuw.ca/tyler/flares/issues/81)
- Now when "Clear" is pushed while data is processing, a popup will appear ensuring that the user understands that pushing "Clear" will stop processing
- Reset to Default Configuration will now properly reset all of the parameters to their default values. [Issue 90](https://git.research.dezeeuw.ca/tyler/flares/issues/90)
- A confirmation popup will now display once the application has been reset to default in addition to the status bar message
- Changed the improper display text of "Cross Validation" to now properly read as "Coefficient of Variation"
- Changed the parameters "CV" and "CV_THRESHOLD" to now be "COEFF_VAR and COEFF_VAR_THRESHOLD"
- Changed the improper display text of "Mean Absolute Deviation" to now properly read as "Median Absolute Deviation"
- Changed the parameters "SHORT_CHANNEL", "SHORT_CHANNEL_THRESH", and "LONG_CHANNEL_THRESH" to now be "SHORT_CHANNELS", "SHORT_CHANNELS_THRESHOLD", and "LONG_CHANNELS_THRESHOLD"
- Changed the parameter section "Channel Variance" to now be "Sensor Dropout" to better reflect the action it performs, not the method employed to calculate it
- Changed the parameters "CHANNEL_VAR" and "CHANNEL_THRESH" to now be "SENSOR_DROPOUT" and "SENSOR_DROPOUT_VARIANCE_THRESHOLD"
- Changed the messaged displayed when the application crashes to better reflect what occured
- Changed the processing stages 25-27 and updates their messages to better reflect their actions that they now perforn
- Changed stage 25 from "Generate Channel Significance" to "Generate Channel Results"
- Changed stage 26 from "Generate Channel, Region of Interest, and Contrast Results" to "Generate Region of Interest Results"
- Changed stage 27 from "Compute Contrast Results" to "Generate Contrast Results"
- Changed backend code to only load required methods and not load all methods every time
- Fixed string parameters not going blue and bold when their value was different than default
- Fixed all parameters not going blue and bold when a save file is loaded containing values that differ from default
- Fixed a crucial bug where short channels were not being processed and filtered the same way as long channels before being used as regressors
- Fixed a crucial bug where short channels were being presented to the design matrix as normal long channels
- Fixed a crucial bug where long channels could be interpolated from short channels. Short channels are still potentially interpolated from long channels. See [this link](https://git.research.dezeeuw.ca/tyler/flares/issues/80) for more information regarding this issue.
- Added a welcome message when the terminal is opened, resized the terminal, and added more commands
# Version 1.5.0
- This release introduces a new configuration file that may break existing installs. If your application does not update correctly, please download fresh from [this link.](https://git.research.dezeeuw.ca/tyler/flares/releases/)
+36
View File
@@ -1,3 +1,39 @@
# Version 1.5.1
- Open a folder is now minimally BIDS compatible. It will recursively go through all folders, but not load external metadata
- Renamed options in the Viewer Launcher window to better denote what actions they can perform
- 2 new analysis options have been added in the viewer launcher window: Inter-Group Stats and Cross-Group Stats
- Each of these analysis options have lots of text explaining what they do when their respective windows are opened
- The parameter input dialog in the analysis windows will now be able to scroll down when lots of parameters are required
- When a project is saved, it automatically will populate into the Recent Projects menu. Fixes [Issue 86](https://git.research.dezeeuw.ca/tyler/flares/issues/86)
- The description when clicking "Why are these useful?" underneath the per-file parameters is no longer placeholder text. Fixes [Issue 85](https://git.research.dezeeuw.ca/tyler/flares/issues/85)
- Added a new parameter section "Region of Interest". It has one parameter of "JSON_LOCATION", a json file containing Region of Interest data
- Temporarily removed the parameter REMOVE_EVENTS due to its functionality being removed because of a memory leak
- Fixed participant metadata remaing in the background when the participant was removed. Fixes [Issue 82](https://git.research.dezeeuw.ca/tyler/flares/issues/82)
- Fixed processing remaining active hidden in the background when the "Clear" button was pushed. Fixes [Issue 81](https://git.research.dezeeuw.ca/tyler/flares/issues/81)
- Now when "Clear" is pushed while data is processing, a popup will appear ensuring that the user understands that pushing "Clear" will stop processing
- Reset to Default Configuration will now properly reset all of the parameters to their default values. [Issue 90](https://git.research.dezeeuw.ca/tyler/flares/issues/90)
- A confirmation popup will now display once the application has been reset to default in addition to the status bar message
- Changed the improper display text of "Cross Validation" to now properly read as "Coefficient of Variation"
- Changed the parameters "CV" and "CV_THRESHOLD" to now be "COEFF_VAR and COEFF_VAR_THRESHOLD"
- Changed the improper display text of "Mean Absolute Deviation" to now properly read as "Median Absolute Deviation"
- Changed the parameters "SHORT_CHANNEL", "SHORT_CHANNEL_THRESH", and "LONG_CHANNEL_THRESH" to now be "SHORT_CHANNELS", "SHORT_CHANNELS_THRESHOLD", and "LONG_CHANNELS_THRESHOLD"
- Changed the parameter section "Channel Variance" to now be "Sensor Dropout" to better reflect the action it performs, not the method employed to calculate it
- Changed the parameters "CHANNEL_VAR" and "CHANNEL_THRESH" to now be "SENSOR_DROPOUT" and "SENSOR_DROPOUT_VARIANCE_THRESHOLD"
- Changed the messaged displayed when the application crashes to better reflect what occured
- Changed the processing stages 25-27 and updates their messages to better reflect their actions that they now perforn
- Changed stage 25 from "Generate Channel Significance" to "Generate Channel Results"
- Changed stage 26 from "Generate Channel, Region of Interest, and Contrast Results" to "Generate Region of Interest Results"
- Changed stage 27 from "Compute Contrast Results" to "Generate Contrast Results"
- Changed backend code to only load required methods and not load all methods every time
- Fixed string parameters not going blue and bold when their value was different than default
- Fixed all parameters not going blue and bold when a save file is loaded containing values that differ from default
- Fixed a crucial bug where short channels were not being processed and filtered the same way as long channels before being used as regressors
- Fixed a crucial bug where short channels were being presented to the design matrix as normal long channels
- Fixed a crucial bug where long channels could be interpolated from short channels. Short channels are still potentially interpolated from long channels. See [this link](https://git.research.dezeeuw.ca/tyler/flares/issues/80) for more information regarding this issue.
- Added a welcome message when the terminal is opened, resized the terminal, and added more commands
# Version 1.5.0
- This release introduces a new configuration file that may break existing installs. If your application does not update correctly, please download fresh from [this link.](https://git.research.dezeeuw.ca/tyler/flares/releases/)
+24 -11
View File
@@ -967,19 +967,29 @@ def interpolate_fNIRS_bads_weighted_average(raw, max_dist=0.03, min_neighbors=2)
if hbo_names[i] != hbr_names[i]:
raise RuntimeError(f"Channel pairs do not match: {hbo_names[i]} vs {hbr_names[i]}")
all_distances = source_detector_distances(raw.info)
pair_distances = all_distances[hbo_picks]
# Identify bad pairs if either channel in pair is bad
bad_pairs = []
good_pairs = []
n_short_excluded = 0
for i, base in enumerate(hbo_names):
hbo_ch = raw.ch_names[hbo_picks[i]]
hbr_ch = raw.ch_names[hbr_picks[i]]
if (hbo_ch in raw.info['bads']) or (hbr_ch in raw.info['bads']):
is_bad = (hbo_ch in raw.info['bads']) or (hbr_ch in raw.info['bads'])
is_short = pair_distances[i] < SHORT_CHANNELS_THRESHOLD
if is_bad:
bad_pairs.append(i)
elif is_short:
n_short_excluded += 1
else:
good_pairs.append(i)
print(f"Total pairs: {len(hbo_names)}")
print(f"Good pairs: {len(good_pairs)}")
print(f"Good LONG pairs (eligible donors): {len(good_pairs)}")
print(f"Good SHORT pairs (excluded from donor pool): {n_short_excluded}")
print(f"Bad pairs to interpolate: {len(bad_pairs)}")
if len(bad_pairs) == 0:
@@ -1488,8 +1498,9 @@ def epochs_calculations(raw_haemo, events, event_dict):
def make_design_matrix(raw_haemo):
events_to_remove = REMOVE_EVENTS
# events_to_remove = REMOVE_EVENTS
events_to_remove = ""
filtered_annotations = [ann for ann in raw_haemo.annotations if ann['description'] not in events_to_remove]
new_annot = Annotations(
@@ -2742,7 +2753,7 @@ def load_snirf(file_path: str) -> tuple[BaseRaw, Figure]:
# Read the snirf file
raw = read_raw_snirf(file_path, preload=True, verbose=VERBOSITY) # type: ignore
raw.load_data(verbose=VERBOSITY) # type: ignore
#raw.load_data(verbose=VERBOSITY) # type: ignore redundant since preload is set to true
# TODO: Why was this commented again?
# Maybe this should be a bypass parameter?
@@ -3217,7 +3228,6 @@ def run_cross_group_second_level_analysis(df_roi_all, file_paths_a, file_paths_b
ax.set_xlabel('Region of Interest (ROI)', fontsize=12)
ax.set_title(f"Cross-Group Comparison: {group_a_name} vs {group_b_name}\n({target_chroma.upper()} - {selected_event})", fontsize=13, fontweight='bold', pad=15)
plt.tight_layout()
plt.show()
# 4. Channel-by-Channel Group-Contrast Topography Map (Zero Hardcoding)
if df_cha_all is not None and raw_haemo is not None:
@@ -3231,7 +3241,7 @@ def run_cross_group_second_level_analysis(df_roi_all, file_paths_a, file_paths_b
(df_cha_all['Chroma'] == target_chroma) &
(df_cha_all['Condition'] == selected_event)
].copy()
con_summary['clean_ID'] = con_summary['ID'].apply(clean_subject_id)
con_summary['clean_ID'] = con_summary['ID']
raw_picked = raw_haemo.copy().pick(picks=target_chroma)
@@ -3254,7 +3264,8 @@ def run_cross_group_second_level_analysis(df_roi_all, file_paths_a, file_paths_b
'ch_name': ch,
'Coef.': mean_diff, # Represents Mean A - Mean B
't': t_stat,
'P>|t|': p_val # For threshold masking
'P>|t|': p_val,
'Chroma': target_chroma, # For threshold masking
})
con_model_df = pd.DataFrame(contrast_data)
@@ -5401,6 +5412,8 @@ def process_participant(file_path, progress_callback=None):
if k in globals() and k != "REQUIRED_KEYS"
}
print(config_dict)
# Step 1: Preprocessing
raw = load_snirf(file_path)
fig_raw = raw.plot(duration=raw.times[-1], n_channels=raw.info['nchan'], title="Loaded Raw", show=False)
@@ -5417,7 +5430,7 @@ def process_participant(file_path, progress_callback=None):
# Step 3: Verify Optode Placement
if OPTODE_PLACEMENT:
fig_optodes = raw.plot_sensors(show_names=SHOW_OPTODE_NAMES, to_sphere=True, show=False) # type: ignore
fig_optodes = raw.plot_sensors(show_names=SHOW_OPTODE_NAMES, to_sphere=True, show=False, verbose=VERBOSITY) # type: ignore
fig_individual["Plot Sensors"] = fig_optodes
if progress_callback: progress_callback(3)
logger.info("Step 3 Completed.")
@@ -5426,7 +5439,7 @@ def process_participant(file_path, progress_callback=None):
if SHORT_CHANNELS and not FOLDING_BYP:
#NOTE: Have to split again later but since needed for heart rate, this will stay at step 4. Will split later again.
_short_chans = get_short_channels(raw, max_dist=SHORT_CHANNELS_THRESHOLD) # JUST FOR PLOTTING THEM SEPERATELY
fig_short_chans = _short_chans.plot(duration=raw.times[-1], n_channels=raw.info['nchan'], title="Short Channels Only", show=False)
fig_short_chans = _short_chans.plot(duration=raw.times[-1], n_channels=raw.info['nchan'], title="Short Channels Only", show=False, verbose=VERBOSITY)
fig_individual["Short Channels Raw Data"] = fig_short_chans
raw = get_long_channels(raw, min_dist=0, max_dist=LONG_CHANNELS_THRESHOLD)
if progress_callback: progress_callback(4)
+82 -13
View File
@@ -43,7 +43,7 @@ from src.window.updateoptodes import UpdateOptodesWindow
from src.window.userguide import UserGuideWindow
from src.window.viewerlauncher import ViewerLauncherWidget
from src.window.welcome import WelcomeDialog
from src.shared.flaresbasewidget import ParamSection
from src.shared.flaresbasewidget import FilePickerWidget, ParamSection
from src.shared.shareddata import API_URL, API_URL_SECONDARY, APP_NAME, CURRENT_VERSION, PIPELINE_STAGES, PLATFORM_NAME
from updater import finish_update_if_needed, UpdateManager, LocalPendingUpdateCheckThread
@@ -91,7 +91,7 @@ SECTIONS = [
"title": "Trimming",
"params": [
{"name": "TRIM", "default": True, "type": bool, "help": "Should the start of the files be trimmed?"},
{"name": "SECONDS_TO_KEEP", "default": 5, "type": float, "depends_on": "TRIM", "help": "Seconds to keep at the beginning of all loaded snirf files before the first annotation/event occurs. Calculation is done seperatly on all loaded snirf files. Setting this to 0 will have the first annotation/event be at time point 0. Only used if TRIM is set to True."},
{"name": "SECONDS_TO_KEEP", "default": 5.0, "type": float, "depends_on": "TRIM", "help": "Seconds to keep at the beginning of all loaded snirf files before the first annotation/event occurs. Calculation is done seperatly on all loaded snirf files. Setting this to 0 will have the first annotation/event be at time point 0. Only used if TRIM is set to True."},
]
},
{
@@ -257,7 +257,8 @@ SECTIONS = [
{"name": "FIR_DELAYS", "default": 15, "type": range, "depends_on": "HRF_MODEL", "depends_value": "fir", "help": "In case of FIR design, yields the array of delays used in the FIR model (in scans)."},
{"name": "MIN_ONSET", "default": -24, "type": int, "help": "Minimal onset relative to frame times (in seconds)"},
{"name": "OVERSAMPLING", "default": 50, "type": int, "help": "Oversampling factor used in temporal convolutions."},
{"name": "REMOVE_EVENTS", "default": "None", "type": list, "help": "Remove events matching the names provided before generating the Design Matrix"},
# TODO: Re-implement this without causing a memory leak
# {"name": "REMOVE_EVENTS", "default": "None", "type": list, "help": "Remove events matching the names provided before generating the Design Matrix"},
{"name": "SHORT_CHANNEL_REGRESSION", "default": True, "type": bool, "depends_on": "SHORT_CHANNEL", "help": "Should short channel regression be used to create the design matrix? This will use the 'signal' from the short channel and regress it out of all other channels."},
]
},
@@ -272,7 +273,7 @@ SECTIONS = [
{
"title": "Region of Interest",
"params": [
{"name": "JSON_LOCATION", "default": "", "type": str, "help": "Location of the JSON file containing region of interest results for significance calculations."},
{"name": "JSON_LOCATION", "default": "", "type": "json_file", "help": "Location of the JSON file containing region of interest results for significance calculations."},
]
},
{
@@ -505,7 +506,7 @@ class MainApplication(QMainWindow):
self.missing_events_bypass = False
self.analysis_clearing_bypass = False
self.folding_bypass = False
self.json_location = r"C:\Users\tyler\Desktop\research\flares\regions.json"
self.json_location = ""
# Initialization to ensure that saving can occur
@@ -525,15 +526,13 @@ class MainApplication(QMainWindow):
self.files_failed = set() # set of failed file paths
self.files_results = {} # dict for successful results (if needed)
self.platform_suffix = "-" + PLATFORM_NAME
self.updater = UpdateManager(
main_window=self,
api_url=API_URL,
api_url_sec=API_URL_SECONDARY,
current_version=CURRENT_VERSION,
platform_name=PLATFORM_NAME,
platform_suffix=self.platform_suffix,
platform_suffix="-" + PLATFORM_NAME,
app_name=APP_NAME
)
@@ -546,7 +545,7 @@ class MainApplication(QMainWindow):
self.installEventFilter(self)
# Start local pending update check thread
self.local_check_thread = LocalPendingUpdateCheckThread(CURRENT_VERSION, self.platform_suffix, PLATFORM_NAME, APP_NAME)
self.local_check_thread = LocalPendingUpdateCheckThread(CURRENT_VERSION, "-" + PLATFORM_NAME, PLATFORM_NAME, APP_NAME)
self.local_check_thread.pending_update_found.connect(self.updater.on_pending_update_found)
self.local_check_thread.no_pending_update.connect(self.updater.on_no_pending_update)
self.local_check_thread.start()
@@ -617,7 +616,7 @@ class MainApplication(QMainWindow):
label_desc = QLabel('<a href="#">Why are these useful?</a>')
label_desc.setTextInteractionFlags(Qt.TextInteractionFlag.TextBrowserInteraction)
label_desc.linkActivated.connect(lambda: QMessageBox.information(None, "Info", "Parameter Info..."))
label_desc.linkActivated.connect(lambda: QMessageBox.information(None, f"Info - {APP_NAME.upper()} ", "Age: Used in determing the participants PPF.\nGender: Not currently used or implemented.\nGroup: Used to split participants into groups for comparisons between them."))
right_column_layout.addWidget(label_desc)
right_column_layout.addStretch()
self.right_column_widget.hide()
@@ -835,6 +834,23 @@ class MainApplication(QMainWindow):
and resets the memory heap.
"""
if hasattr(self, "result_process") and self.result_process and self.result_process.is_alive():
msg = QMessageBox(self)
msg.setWindowTitle(f"Confirm Clear - {APP_NAME.upper()}")
msg.setText("Data processing is currently active in the background. "
"Clearing now will forcefully kill all tasks and lose current progress.\n\n"
"Are you sure you want to proceed?")
msg.setStandardButtons(QMessageBox.StandardButton.Ok | QMessageBox.StandardButton.Cancel)
msg.setDefaultButton(QMessageBox.StandardButton.Cancel)
response = msg.exec()
if response == QMessageBox.StandardButton.Ok:
self.cancel_task()
else:
return
self.top_left_widget.clear()
if hasattr(self, "last_clicked_bubble"):
@@ -898,6 +914,16 @@ class MainApplication(QMainWindow):
self.metadata_cache = {}
self.file_metadata = {}
if hasattr(self, "meta_fields"):
for field in self.meta_fields.values():
field.blockSignals(True)
field.clear()
field.blockSignals(False)
self.current_file = None
if hasattr(self, "selected_paths"): self.selected_paths = []
if hasattr(self, "selected_path"): self.selected_path = None
@@ -1176,9 +1202,20 @@ class MainApplication(QMainWindow):
file_cfg.read_string(DEFAULT_CONFIG)
self.sync_app_with_config()
self.update_sections(0)
QTimer.singleShot(100, self._show_reset_success_dialog)
def _show_reset_success_dialog(self):
"""Helper method triggered after the UI has completely finished redrawing."""
QMessageBox.information(
self,
"Reset Successful",
"All application settings have been successfully restored to their default values."
)
self.statusbar.showMessage("All settings have been reset to their default values.", 5000)
self.statusbar.showMessage("All settings have been reset to their default values.", 5000)
def sync_app_with_config(self):
"""Reads values from file_cfg and updates both internal variables and UI checkmarks."""
@@ -1418,6 +1455,8 @@ class MainApplication(QMainWindow):
project_data = sanitize(project_data)
self.add_to_recent_projects(os.path.normpath(filename))
self.saving_overlay = SavingOverlay(self)
self.saving_overlay.resize(self.size()) # Cover the main window
self.saving_overlay.show()
@@ -1582,6 +1621,12 @@ class MainApplication(QMainWindow):
widget.blockSignals(False)
widget.update()
elif isinstance(widget, FilePickerWidget):
widget.blockSignals(True)
widget.setText(str(value)) # Updates the internal QLineEdit text safely
widget.blockSignals(False)
widget.update()
# QComboBox (bool, list)
elif isinstance(widget, QComboBox):
widget.blockSignals(True)
@@ -1599,10 +1644,19 @@ class MainApplication(QMainWindow):
widget.blockSignals(False)
widget.update()
if hasattr(section_widget, 'check_if_changed'):
if isinstance(widget, (QLineEdit, FilePickerWidget)):
section_widget.check_if_changed(name, widget.text())
elif isinstance(widget, QComboBox):
section_widget.check_if_changed(name, widget.currentText())
elif isinstance(widget, QSpinBox):
section_widget.check_if_changed(name, widget.value())
# After restoring, make sure dependencies are updated
if hasattr(section_widget, 'update_dependencies'):
section_widget.update_dependencies()
#TODO: Update blue bold text too
# def show_files_as_bubbles(self, folder_paths):
@@ -1659,7 +1713,6 @@ class MainApplication(QMainWindow):
current_ui_config = {}
try:
for section in self.param_sections:
# This calls the get_param_values() method you shared earlier
section_values = section.get_param_values()
current_ui_config.update(section_values)
return current_ui_config
@@ -1839,6 +1892,19 @@ class MainApplication(QMainWindow):
self.top_left_widget.clear()
self.right_column_widget.hide()
target_path = bubble.file_path
if hasattr(self, 'file_metadata'):
self.file_metadata.pop(target_path, None)
if getattr(self, 'current_file', None) == target_path:
self.current_file = None
if hasattr(self, 'meta_fields'):
for field in self.meta_fields.values():
field.blockSignals(True)
field.clear()
field.blockSignals(False)
parent_layout = bubble.parent().layout()
if parent_layout is not None:
parent_layout.removeWidget(bubble)
@@ -2055,11 +2121,14 @@ class MainApplication(QMainWindow):
if self.folding_bypass:
all_params['FOLDING_BYP'] = True
self.json_location = all_params['JSON_LOCATION']
collected_data = {
"SNIRF_FILES": snirf_files,
"PARAMS": all_params, # add this line
"METADATA": self.get_all_metadata(), # optionally add metadata if needed
}
# Start processing
if current_process().name == 'MainProcess':
self.result_queue = Queue()
+12 -25
View File
@@ -40,12 +40,6 @@ PARAMETERIZED_INDEXES = {
"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",
@@ -81,14 +75,14 @@ PARAMETERIZED_INDEXES = {
{
"key": "roi_a",
"label": "ROI A (e.g. contralateral region name from regions.json)",
"default": "",
"type": str,
"default": [],
"type": list,
},
{
"key": "roi_b",
"label": "ROI B (e.g. ipsilateral region name from regions.json)",
"default": "",
"type": str,
"default": [],
"type": list,
}
],
2: [
@@ -116,17 +110,11 @@ PARAMETERIZED_INDEXES = {
"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,
"default": [],
"type": list,
},
],
}
@@ -148,7 +136,7 @@ DESCRIPTION = """0. Raw ROI Comparison (run_cross_group_second_level_analysis)
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, json_location):
super().__init__("CrossGroupStats")
self.setWindowTitle(f"Cross-Group Stats Viewer - {APP_NAME.upper()}")
self.haemo_dict = haemo_dict
@@ -157,12 +145,13 @@ class CrossGroupStatsWidget(CrossGroupUIMixin, FlaresBaseWidget):
self.design_matrix_dict = design_matrix_dict
self.contrast_results_dict = contrast_results_dict
self.group_dict = group_dict
self.json_location = json_location
self.setup_cross_group_ui(["0 (Raw ROI Comparison)", "1 (Laterality Comparison)", "2 (Contrast Comparison)",], placeholder_text=DESCRIPTION)
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_dict)
if request is None:
return
@@ -195,7 +184,6 @@ class CrossGroupStatsWidget(CrossGroupUIMixin, FlaresBaseWidget):
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(
@@ -211,7 +199,7 @@ class CrossGroupStatsWidget(CrossGroupUIMixin, FlaresBaseWidget):
correction_method=correction_method,
target_chroma=target_chroma,
selected_event=selected_event,
roi_config=roi_config,
roi_config=self.json_location,
threshold_topo=threshold_topo # Shows the raw difference map (Unthresholded)
)
elif idx == 1:
@@ -273,11 +261,10 @@ class CrossGroupStatsWidget(CrossGroupUIMixin, FlaresBaseWidget):
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').")
print("A contrast name must be specified.")
continue
# Build each group's channel-level contrast dataframe
@@ -311,7 +298,7 @@ class CrossGroupStatsWidget(CrossGroupUIMixin, FlaresBaseWidget):
df_contrasts_a=df_contrasts_a,
df_contrasts_b=df_contrasts_b,
contrast_name=contrast_name,
roi_json_path=roi_config,
roi_json_path=self.json_location,
group_a_name=self.group_a_dropdown.currentText(),
group_b_name=self.group_b_dropdown.currentText(),
target_chroma=target_chroma,
+2 -2
View File
@@ -271,7 +271,7 @@ class InterGroupStatsWidget(InterGroupUIMixin, FlaresBaseWidget):
print("Both ROI A and ROI B must be specified.")
continue
print(min_subjects)
run_roi_paired_contrast_analysis(
df_roi_all=df_group,
roi_pairs=(roi_a, roi_b),
@@ -290,7 +290,7 @@ class InterGroupStatsWidget(InterGroupUIMixin, FlaresBaseWidget):
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")
contrast_name = params.get("contrast_name", "")
weighted = params.get("weighted", True)
graph_bounds = params.get("graph_bounds", 0.0)
+117 -1
View File
@@ -239,6 +239,55 @@ class FullClickComboBox(QComboBox):
return super().eventFilter(obj, event)
from PySide6.QtWidgets import QWidget, QHBoxLayout, QLineEdit, QPushButton, QFileDialog
from PySide6.QtCore import Signal
class FilePickerWidget(QWidget):
# This custom signal lets our container mimic a standard QLineEdit
textChanged = Signal(str)
def __init__(self, default_val="", file_filter="JSON Files (*.json);;All Files (*)", parent=None):
super().__init__(parent)
# Inline layout to hold line edit and button side-by-side
layout = QHBoxLayout(self)
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(5)
self.line_edit = QLineEdit()
self.line_edit.setText(str(default_val))
self.line_edit.setPlaceholderText("Select file path...")
# Forward internal text updates out through our custom component signal
self.line_edit.textChanged.connect(self.textChanged.emit)
self.browse_btn = QPushButton("Browse...")
self.browse_btn.clicked.connect(self.open_file_dialog)
layout.addWidget(self.line_edit)
layout.addWidget(self.browse_btn)
self.file_filter = file_filter
def open_file_dialog(self):
# Open PySide6 native file browser
file_path, _ = QFileDialog.getOpenFileName(
self,
"Select Configuration File",
self.line_edit.text(),
self.file_filter
)
if file_path:
self.line_edit.setText(file_path)
# Mimic standard text getter/setter behaviors so parent systems remain unbothered
def text(self):
return self.line_edit.text()
def setText(self, text):
self.line_edit.setText(text)
class ParamSection(QWidget):
"""
A widget section that dynamically creates labeled input fields from parameter metadata.
@@ -339,9 +388,15 @@ class ParamSection(QWidget):
else:
widget.setValue(15) # Default fallback
widget.valueChanged.connect(lambda val, p=param["name"]: self.check_if_changed(p, val))
elif param["type"] == "json_file":
# Create our custom dual-element compound layout widget
widget = FilePickerWidget(default_val=default_val, file_filter="JSON Files (*.json)")
# Your existing tracking signals connect seamlessly!
widget.textChanged.connect(lambda val, p=param["name"]: self.check_if_changed(p, val))
else:
widget = QLineEdit()
widget.setText(str(default_val))
widget.textChanged.connect(lambda val, p=param["name"]: self.check_if_changed(p, val))
if "depends_on" in param:
self.dependencies.append({
@@ -567,6 +622,8 @@ class ParamSection(QWidget):
values[name] = range(widget.value())
else:
values[name] = range(15) # Fallback
elif expected_type == "json_file":
values[name] = widget.text()
else:
raw_text = widget.text()
try:
@@ -1236,7 +1293,7 @@ class CrossGroupUIMixin:
return file_paths
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()
if selected_event == "<None Selected>":
selected_event = None
@@ -1274,6 +1331,65 @@ class CrossGroupUIMixin:
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 all_selected_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 = {}
if indexes_needing_params:
dialog = ParameterInputDialog(indexes_needing_params, parent=self)
+3 -3
View File
@@ -43,9 +43,9 @@ PIPELINE_STAGES = [
"Design Matrix",
"General Linear Model",
"Generate GLM Results",
"Generate Channel Significance",
"Generate Channel, Region of Interest, and Contrast Results",
"Compute Contrast Results",
"Generate Channel Results",
"Generate Region of Interest Results",
"Generate Contrast Results",
"Finishing Up"
]
+36 -3
View File
@@ -9,14 +9,16 @@ License: GPL-3.0
from PySide6.QtWidgets import QWidget, QVBoxLayout, QTextEdit, QLineEdit
from PySide6.QtCore import Qt
from src.shared.shareddata import APP_NAME, CURRENT_VERSION
from src.shared.shareddata import API_URL, API_URL_SECONDARY, APP_NAME, CURRENT_VERSION, PLATFORM_NAME
from src.window.about import AboutWindow
from updater import LocalPendingUpdateCheckThread, UpdateManager
class TerminalWindow(QWidget):
def __init__(self, parent=None):
super().__init__(parent, Qt.WindowType.Window)
self.setWindowTitle(f"Terminal - {APP_NAME.upper()}")
self.resize(320, 180)
self.output_area = QTextEdit()
self.output_area.setReadOnly(True)
@@ -32,8 +34,16 @@ class TerminalWindow(QWidget):
"hello": self.cmd_hello,
"help": self.cmd_help,
"version": self.cmd_version,
"about": self.cmd_about,
"update": self.cmd_update,
}
self.output_area.append(f"Welcome to {APP_NAME.upper()}. You are running version {CURRENT_VERSION}.")
self.output_area.append("Type 'help' for a list of available commands.\n")
self.input_line.setFocus()
def handle_command(self):
command_text = self.input_line.text()
self.input_line.clear()
@@ -65,4 +75,27 @@ class TerminalWindow(QWidget):
return f"Available commands: {', '.join(self.commands.keys())}"
def cmd_version(self, *args):
return f"{CURRENT_VERSION}"
return f"{APP_NAME.upper()} is running version {CURRENT_VERSION}."
def cmd_about(self, *args):
self.about = AboutWindow(self)
self.about.show()
def cmd_update(self, *args):
main_win = self.parent()
if main_win is None:
return "[Error] Main window context not found."
self.updater = UpdateManager(
main_window=main_win,
api_url=API_URL,
api_url_sec=API_URL_SECONDARY,
current_version=CURRENT_VERSION,
platform_name=PLATFORM_NAME,
platform_suffix="-" + PLATFORM_NAME,
app_name=APP_NAME
)
self.output_area.append("Checking for updates...")
self.updater.manual_check_for_updates()
return "See status bar for update information."
+1 -1
View File
@@ -37,7 +37,7 @@ class ViewerLauncherWidget(QWidget):
("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 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, json_location], 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),
("Export To CSV Viewer", ExportToCSVWidget, [haemo_dict, cha_dict, df_ind_dict, design_matrix_dict, group_dict, contrast_results_dict], True)
+1
View File
@@ -0,0 +1 @@
update