diff --git a/changelog.md b/changelog.md index c2f7ed9..2ec877e 100644 --- a/changelog.md +++ b/changelog.md @@ -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/) diff --git a/changelog_major.md b/changelog_major.md index 7081060..c8779f1 100644 --- a/changelog_major.md +++ b/changelog_major.md @@ -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/) diff --git a/flares.py b/flares.py index 3ef32ee..a8918ab 100644 --- a/flares.py +++ b/flares.py @@ -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) diff --git a/main.py b/main.py index 912037d..088cf8a 100644 --- a/main.py +++ b/main.py @@ -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('Why are these useful?') 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() diff --git a/src/analysis/crossgroupstats.py b/src/analysis/crossgroupstats.py index 8f9bcc3..90fe79c 100644 --- a/src/analysis/crossgroupstats.py +++ b/src/analysis/crossgroupstats.py @@ -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, diff --git a/src/analysis/intergroupstats.py b/src/analysis/intergroupstats.py index af14331..96acec0 100644 --- a/src/analysis/intergroupstats.py +++ b/src/analysis/intergroupstats.py @@ -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) diff --git a/src/shared/flaresbasewidget.py b/src/shared/flaresbasewidget.py index 84e4bba..b46da8c 100644 --- a/src/shared/flaresbasewidget.py +++ b/src/shared/flaresbasewidget.py @@ -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 == "": 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) diff --git a/src/shared/shareddata.py b/src/shared/shareddata.py index be12859..b00086d 100644 --- a/src/shared/shareddata.py +++ b/src/shared/shareddata.py @@ -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" ] diff --git a/src/window/terminal.py b/src/window/terminal.py index d733254..a8f8697 100644 --- a/src/window/terminal.py +++ b/src/window/terminal.py @@ -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}" \ No newline at end of file + 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." \ No newline at end of file diff --git a/src/window/viewerlauncher.py b/src/window/viewerlauncher.py index e3898e7..baa55e2 100644 --- a/src/window/viewerlauncher.py +++ b/src/window/viewerlauncher.py @@ -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) diff --git a/tempCodeRunnerFile.py b/tempCodeRunnerFile.py new file mode 100644 index 0000000..77c430a --- /dev/null +++ b/tempCodeRunnerFile.py @@ -0,0 +1 @@ +update \ No newline at end of file