From 3a8ffbdf7049e4c1978833bc708aa3609bfc644a Mon Sep 17 00:00:00 2001 From: tyler Date: Wed, 14 Oct 2026 20:17:23 -0700 Subject: [PATCH] mainly whitespace, but also some generic qol improvements --- changelog.md | 18 + flares.py | 693 ++++++++-------- main.py | 779 +++++++++++------- main_unit_tests.py | 10 +- plugin_manager.py | 103 ++- project_manager.py | 91 +- src/analysis/exporttocsv.py | 158 ++-- src/analysis/intergroupbrainimage.py | 8 +- src/analysis/intergroupstats.py | 42 +- src/analysis/intragroupbrainimage.py | 16 +- .../intragroupfunctionalconnectivity.py | 2 +- src/analysis/intragroupstats.py | 33 +- src/analysis/participantbrain.py | 12 +- src/analysis/participantfoldchannels.py | 296 ++++--- .../participantfunctionalconnectivity.py | 14 +- src/analysis/participantimage.py | 6 +- src/shared/flaresbasewidget.py | 189 +++-- src/window/about.py | 2 +- src/window/plugins.py | 439 ++++++++-- src/window/terminal.py | 28 +- src/window/updateevents.py | 64 +- src/window/updateoptodes.py | 28 +- src/window/viewerlauncher.py | 24 +- src/window/welcome.py | 14 +- startup_args.py | 2 +- updater.py | 24 +- 26 files changed, 1871 insertions(+), 1224 deletions(-) diff --git a/changelog.md b/changelog.md index ada2b5c..549ba8a 100644 --- a/changelog.md +++ b/changelog.md @@ -1,3 +1,21 @@ +# Version 1.8.0 + +- Plugin Manager now supports checking and downloading plugin updates to ensure plugins can be updated +- Plugin Manager now properly uninstalls plugins when requested rather than doing nothing +- Installing plugins now displays a progress bar when downloading and no longer hangs the application when downloading a big plugin +- Project loading now displays a loading message when loading big projects and no longer hangs the application when loading +- New preferences options and menus are now available! All existing preferences have been moved into new sub categories +- The UI preference category now has the Show Advanced Parameters preference +- The Updates preference category has two ney options to prevent the automatic checking of application or plugin updates +- The Theme category has remain untouched and all other preferences have been moved to the Processing category +- Added a new preference in the Processing category called "Separate Events". This allows events to have their own values, and not be collapsed into a single value +- Export To CSV Viewer export options now perform the actions they claim rather than having no functionality +- Logs on a per participant basis are now available to be viewed from within the application +- They can be accessed by right clicking a participant bubble and selecting "Show Logs". Logs will be empty if they have not been processed +- Fixed a regression where opening a folder would no longer load all the files in that folder +- Improved code stability and functionality + + # Version 1.7.1 - Added support for Plugins! Plugins can be downloaded from the official repository or from custom ones. Support for creating a plugin can be found on the wiki diff --git a/flares.py b/flares.py index 42ca517..28cd24a 100644 --- a/flares.py +++ b/flares.py @@ -108,7 +108,6 @@ from mne_connectivity import envelope_correlation, spectral_connectivity_epochs, from src.shared.shareddata import PLATFORM_NAME, resource_path, get_app_dir - PRIMARY_COLORS = { "SCI only": "skyblue", # Scalp Coupling Index (Standard MNE) "SNR only": "lightgreen", # Signal-to-Noise Ratio (Original) @@ -301,6 +300,8 @@ GROUP: str = "Default" FOLDING_BYP: bool = False +SEPARATE_EVENTS: bool = False + FEATURE_1: bool = False FEATURE_2: bool = False @@ -326,7 +327,6 @@ logging.basicConfig( logger = logging.getLogger() - def set_config_me(config: dict[str, Any]) -> None: """ Validates and applies the given configuration dictionary. @@ -341,7 +341,7 @@ def set_config_me(config: dict[str, Any]) -> None: globals().update(config) - + def set_metadata(file_path, metadata: dict[str, Any]) -> None: """ Validates and applies the given configuration dictionary. @@ -356,9 +356,9 @@ def set_metadata(file_path, metadata: dict[str, Any]) -> None: globals()['AGE'] = 25 globals()['GENDER'] = "" globals()['GROUP'] = "Default" - + if metadata.get(file_path) is not None: - + file_metadata = metadata.get(file_path, {}) for key in ("AGE", "GENDER", "GROUP"): @@ -398,7 +398,7 @@ def gui_entry(config: dict[str, Any], gui_queue: mp.Queue, progress_queue: mp.Qu "elapsed": elapsed, "speedup": speedup, }) - + try: print("CHILD: Waiting for GUI acknowledgment...") ack_queue.get(timeout=10) @@ -422,6 +422,27 @@ def gui_entry(config: dict[str, Any], gui_queue: mp.Queue, progress_queue: mp.Qu def process_participant_worker(file_path, file_params, file_metadata, result_queue, progress_queue): file_start = time.time() stage_tracker = {"value": 0.0} + + class QueueStreamAlt: + def __init__(self, fp, q): + self.fp = fp + self.q = q + + def write(self, msg): + if msg and self.q: + try: + self.q.put(("log", self.fp, msg)) + except Exception: + pass + + def flush(self): + pass + + old_stdout = sys.stdout + old_stderr = sys.stderr + sys.stdout = QueueStreamAlt(file_path, progress_queue) + sys.stderr = QueueStreamAlt(file_path, progress_queue) + try: set_config_me(file_params) set_metadata(file_path, file_metadata) @@ -446,6 +467,8 @@ def process_participant_worker(file_path, file_params, file_metadata, result_que pass finally: + sys.stdout = old_stdout + sys.stderr = old_stderr try: plt.close('all') gc.collect() @@ -746,7 +769,7 @@ def plot_timechannel_quality_metrics(data, scores, times: list[tuple[float]], co List of time boundaries used to label each score column. color_stops : tuple[list[float], list[float]] Two lists of color values for custom colormaps. - threshold : float, + threshold : float, Threshold value for the color bar. title : Optional[str], optional Base title for the figures, (default is None). @@ -762,7 +785,7 @@ def plot_timechannel_quality_metrics(data, scores, times: list[tuple[float]], co half_ch = len(getattr(data, "ch_names")) // 2 ch_names = getattr(data, "ch_names")[:half_ch] scores = scores[:half_ch, :] - + # Extract rounded time points to use as column headers cols = [np.round(t[0]) for t in times] n_chans = len(ch_names) @@ -816,13 +839,13 @@ def plot_timechannel_quality_metrics(data, scores, times: list[tuple[float]], co color=color ) ax1.set_xlim(right=text_x + 1.5) - + plt.close(fig1) # Create the second figure fig2, ax2 = plt.subplots(figsize=(10, vsize), layout="constrained") # type: ignore fig2.suptitle(title + " - Scores Above Threshold", fontsize=16, fontweight="bold") # type: ignore - + # Create a DataFrame to structure data for the heatmap data_to_plot = DataFrame( data=scores > threshold, @@ -850,7 +873,7 @@ def plot_timechannel_quality_metrics(data, scores, times: list[tuple[float]], co ax2.axvline(x, ls="dashed", lw=0.25, dashes=(25, 15), color="gray") # type: ignore ax2.set_title("Scores > Threshold", fontweight="bold") # type: ignore markbad(data, ax2, ch_names) - + plt.close(fig2) return fig1, fig2 @@ -900,7 +923,7 @@ def scalp_coupling_index_windowed_raw(data, time_window: float = 3.0, l_freq: fl # Band-pass filter the selected fNIRS channels filtered_data = filter_data( - getattr(data, "_data"), + getattr(data, "_data"), getattr(data, "info")["sfreq"], l_freq, h_freq, @@ -1003,25 +1026,25 @@ def build_fnirs_adjacency(raw, threshold_meters=0.03): # Extract channel positions ch_locs = [] ch_names = [] - + for ch in raw.info['chs']: loc = ch['loc'][:3] # Get x, y, z coordinates if not np.isnan(loc).any(): ch_locs.append(loc) ch_names.append(ch['ch_name']) - + ch_locs = np.array(ch_locs) - + # Compute pairwise distances dists = cdist(ch_locs, ch_locs) - + # Build adjacency dictionary adjacency = {} for i, ch_name in enumerate(ch_names): neighbors = [ch_names[j] for j in range(len(ch_names)) if 0 < dists[i, j] < threshold_meters] adjacency[ch_name] = neighbors - + return adjacency @@ -1121,7 +1144,7 @@ def interpolate_fNIRS_bads_weighted_average(raw, max_dist=0.03, min_neighbors=2, bad_pairs.append(i) else: good_pairs.append(i) - + print(f"Total pairs: {len(hbo_names)}") print(f"Good LONG pairs (eligible donors): {len(good_pairs)}") print(f"Good SHORT pairs (excluded from donor pool): {n_short_excluded}") @@ -1175,13 +1198,13 @@ def interpolate_fNIRS_bads_weighted_average(raw, max_dist=0.03, min_neighbors=2, n_bad = len(bad_pairs) n_cols = 4 # Fixed width for horizontal scaling n_rows = int(np.ceil(n_bad / n_cols)) - + # Calculate height: 2.5 inches per row is usually enough for readability fig_height = max(4, n_rows * 2.5) fig_compare, axes = plt.subplots(n_rows, n_cols, figsize=(15, fig_height), constrained_layout=True) if n_bad == 1: axes = [axes] # Handle single subplot case - + axes_flat = np.asarray(axes).ravel() for j in range(n_bad, len(axes_flat)): if j >= n_bad: @@ -1191,10 +1214,10 @@ def interpolate_fNIRS_bads_weighted_average(raw, max_dist=0.03, min_neighbors=2, for i, bad_idx in enumerate(bad_pairs): ax = axes_flat[i] base = hbo_names[bad_idx] - + # Plot "Before" (Dirty data) in light gray/red ax.plot(times, raw_before_data[hbo_picks[bad_idx]], color='red', alpha=0.3, label='Original HbO') - + # Plot "After" (Interpolated data) in solid blue/green if base in interpolated_pairs: ax.plot(times, raw._data[hbo_picks[bad_idx]], color='blue', label='Interpolated HbO') @@ -1203,7 +1226,7 @@ def interpolate_fNIRS_bads_weighted_average(raw, max_dist=0.03, min_neighbors=2, else: status = "FAILED (Isolated)" color = "red" - + ax.set_title(f"Channel {base} | Status: {status}", color=color, fontweight='bold') ax.set_ylabel("Amplitude") if i == 0: ax.legend(loc='upper right') @@ -1243,7 +1266,7 @@ def calculate_signal_noise_ratio(data): - list[str]: A list of channel names that fall below the SNR threshold and are considered bad. - Figure: A matplotlib Figure showing the channels' SNR values. """ - + print("Calculating signal to noise ratio...") # Compute the signal-to-noise ratio values @@ -1314,7 +1337,7 @@ def calculate_peak_power(data: BaseRaw, time_window: int = 3, threshold: float = - Figure: Heatmap of scores above the PSP threshold. """ - + # Compute the PSP _, scores, times = cast(tuple[NDArray[float64], NDArray[float64], list[tuple[float]]], peak_power_fast(data, time_window=time_window, threshold=threshold, l_freq=l_freq, h_freq=h_freq)) @@ -1435,7 +1458,7 @@ def filter_the_data( fig_filter = raw_haemo.compute_psd(fmax=3).plot( average=True, color="r", show=False, amplitude=True ) - + if l_freq == 0 and h_freq != 0: if filter_algorithm == "fir": raw_haemo = raw_haemo.filter(l_freq=None, h_freq=h_freq, filter_length=filter_length, h_trans_bandwidth=h_trans_bandwidth, n_jobs=filter_n_jobs, method=filter_algorithm, phase=filter_phase, fir_window=fir_window, fir_design=fir_design, pad=filter_pad, verbose=verbosity) @@ -1489,23 +1512,23 @@ def safe_create_epochs(raw, events, event_dict, tmin, tmax, baseline, max_shift, preload=True, verbose=False ) return epochs - + except RuntimeError as e: if "Event time samples were not unique" in str(e): # Find duplicates in the events array (column 0 is the sample index) vals, counts = np.unique(events[:, 0], return_counts=True) duplicates = vals[counts > 1] - + # Shift the second occurrence of every duplicate by 1 sample for dup in duplicates: idx = np.where(events[:, 0] == dup)[0][1:] # Get all but the first - events[idx, 0] += 1 - + events[idx, 0] += 1 + print(f"Collision detected. Nudging events by {shift_increment:.4f}s and retrying...") continue else: raise e # Raise if it's a different Runtime Error - + raise RuntimeError("Could not resolve event collisions after 10 attempts.") @@ -1563,7 +1586,7 @@ def epochs_calculations(raw_haemo, events, event_dict, epoch_handling, max_shift clims = dict(hbo=[-1, 1], hbr=[1, -1]) condition_fig = evoked_avg.plot_image(clim=clims, show=False) - + for ax in condition_fig.axes: original_title = ax.get_title() ax.set_title(f"{original_title} - {condition}") @@ -1605,7 +1628,7 @@ def epochs_calculations(raw_haemo, events, event_dict, epoch_handling, max_shift fig_evoked_hbo = evoked_stim1.copy().pick(picks='hbo').plot(time_unit='s', show=False) fig_evoked_hbr = evoked_stim1.copy().pick(picks='hbr').plot(time_unit='s', show=False) - + _enqueue(f"epochs_fig_evoked_hbo_{cond}", fig_evoked_hbo, png_queue) _enqueue(f"epochs_fig_evoked_hbr_{cond}", fig_evoked_hbr, png_queue) @@ -1704,13 +1727,13 @@ def make_design_matrix( events_to_remove = "" filtered_annotations = [ann for ann in raw_haemo.annotations if ann['description'] not in events_to_remove] - + new_annot = Annotations( onset=[ann['onset'] for ann in filtered_annotations], duration=[ann['duration'] for ann in filtered_annotations], description=[ann['description'] for ann in filtered_annotations] ) - + if short_channels: short_chans = get_short_channels(raw_haemo, max_dist=short_channels_threshold) if long_channels: @@ -1747,12 +1770,12 @@ def make_design_matrix( if short_channel_regression and not folding_bypass: if short_chans is not None and len(short_chans.ch_names) > 0: ch_types = short_chans.get_channel_types() - + # Scenario A: Short channels are already converted to Hemoglobin (hbo/hbr) if "hbo" in ch_types or "hbr" in ch_types: hbo_chs = [ch for ch, t in zip(short_chans.ch_names, ch_types) if t == "hbo"] hbr_chs = [ch for ch, t in zip(short_chans.ch_names, ch_types) if t == "hbr"] - + if hbo_chs: hbo_data = short_chans.copy().pick(hbo_chs).get_data() design_matrix["ShortHbO"] = np.mean(hbo_data, axis=0) @@ -1760,7 +1783,7 @@ def make_design_matrix( hbr_data = short_chans.copy().pick(hbr_chs).get_data() design_matrix["ShortHbR"] = np.mean(hbr_data, axis=0) print(f"Successfully added averaged ShortHbO ({len(hbo_chs)} chs) and ShortHbR ({len(hbr_chs)} chs) to the matrix.") - + # Scenario B: Short channels are raw wavelengths (760nm, 850nm, etc.) else: wavelength_groups = {} @@ -1770,7 +1793,7 @@ def make_design_matrix( if match: wl = match.group(1) wavelength_groups.setdefault(wl, []).append(ch_name) - + if wavelength_groups: for wl, chs in wavelength_groups.items(): wl_data = short_chans.copy().pick(chs).get_data() @@ -1842,7 +1865,7 @@ def _find_closest_standard_location(position, reference, *, out="label"): else: assert out == "dists" return dists - + def _source_detector_fold_table(raw, cidx, reference, fold_tbl, interpolate): @@ -1970,7 +1993,7 @@ def _check_load_fold(fold_files, atlas): ) return fold_tbl - + def fold_channels(raw: BaseRaw, p_name: str, atlas: str='Brodmann', progress_queue: Optional[Any]=None) -> dict[str, list[dict[str, Any]]]: """Runs in background process. @@ -1985,7 +2008,7 @@ def fold_channels(raw: BaseRaw, p_name: str, atlas: str='Brodmann', progress_que set_config('MNE_NIRS_FOLD_PATH', fold_dir) hbo_channel_names = cast(list[str], getattr(raw.copy().pick(picks='hbo'), "ch_names")) - + _validate_type(raw, BaseRaw, "raw") reference_locations = generate_montage_locations() @@ -2019,7 +2042,7 @@ def fold_channels(raw: BaseRaw, p_name: str, atlas: str='Brodmann', progress_que def plot_glm_results(file_path, raw_haemo, glm_est, design_matrix): - + fig_glms = [] # List to store figures dm = design_matrix.copy() @@ -2030,7 +2053,7 @@ def plot_glm_results(file_path, raw_haemo, glm_est, design_matrix): rois = dict(AllChannels=range(len(raw_haemo.ch_names))) conditions = design_matrix.columns df_individual = glm_est.to_dataframe_region_of_interest(rois, conditions) - + df_individual["ID"] = file_path # df_individual["theta"] = [t * 1.0e6 for t in df_individual["theta"]] @@ -2050,11 +2073,11 @@ def plot_glm_results(file_path, raw_haemo, glm_est, design_matrix): df_individual_filtered["isCondition"] = [cond in n for n in df_individual_filtered["Condition"]] df_individual_filtered["isDelay"] = ["delay" in n for n in df_individual_filtered["Condition"]] df_individual_filtered = df_individual_filtered.query("isDelay and isCondition") - + # Remove other conditions from design matrix dm_condition_cols = [col for col in dm.columns if cond in col] dm_cond = dm[dm_condition_cols] - + # Add a numeric delay column def extract_delay_number(condition_str): # Extracts the number at the end of a string like 'Activity_delay_5' @@ -2068,24 +2091,24 @@ def plot_glm_results(file_path, raw_haemo, glm_est, design_matrix): vals_hbo = df_hbo["theta"].values vals_hbr = df_hbr["theta"].values - + # Create the plot fig, axes = plt.subplots(nrows=1, ncols=3, figsize=(19, 10)) - + # Scale design matrix components using numpy arrays instead of pandas operations dm_cond_values = dm_cond.values dm_cond_scaled_hbo = dm_cond_values * vals_hbo.reshape(1, -1) dm_cond_scaled_hbr = dm_cond_values * vals_hbr.reshape(1, -1) - + # Create time axis relative to stimulus onset time = dm_cond.index - np.ceil(first_onset_for_cond.get(cond, 0)) - + # Plot axes[0].plot(time, dm_cond_values) axes[1].plot(time, dm_cond_scaled_hbo) axes[2].plot(time, np.sum(dm_cond_scaled_hbo, axis=1), 'r') axes[2].plot(time, np.sum(dm_cond_scaled_hbr, axis=1), 'b') - + # Format plots for ax in range(3): axes[ax].set_xlim(-5, 25) @@ -2101,13 +2124,12 @@ def plot_glm_results(file_path, raw_haemo, glm_est, design_matrix): axes[2].set_ylabel("Haemoglobin (ΔμMol)") axes[2].legend(["Oxyhaemoglobin", "Deoxyhaemoglobin"]) - print(f"Number of FIR bins: {len(vals_hbo)}") print(f"Mean theta (HbO): {np.mean(vals_hbo):.4f}") print(f"Sum of theta (HbO): {np.sum(vals_hbo):.4f}") print(f"Mean theta (HbR): {np.mean(vals_hbr):.4f}") print(f"Sum of theta (HbR): {np.sum(vals_hbr):.4f}") - + fig_glms.append((f"Condition {cond}", fig)) return fig_glms @@ -2155,7 +2177,7 @@ def plot_3d_evoked_array( if subjects_dir is None: subjects_dir = str(data_path()) + "/subjects" # type: ignore os.environ["SUBJECTS_DIR"] = subjects_dir - + if src is None: fname_src_fs = os.path.join( subjects_dir, "fsaverage", "bem", "fsaverage-ico-5-src.fif" @@ -2372,7 +2394,6 @@ def brain_3d_visualization( model_df = cast(DataFrame, statsmodels_to_results(ch_model, order=ch_summary["ch_name"].unique())) - valid_channels = ch_summary["ch_name"].unique().tolist() # type: ignore raw_for_plot = raw_haemo.copy().pick(picks=valid_channels) # type: ignore @@ -2380,7 +2401,7 @@ def brain_3d_visualization( print(f"DEBUG: Raw channels: {len(raw_for_plot.ch_names)}") brain = plot_3d_evoked_array(raw_for_plot.pick(picks="hbo"), model_df, view="dorsal", distance=0.02, colorbar=True, clim=clim, mode="weighted", size=(800, 700)) # type: ignore - + if show_optodes == 'all' or show_optodes == 'sensors': brain.add_sensors(raw_for_plot.pick(picks="hbo").info, trans=Transform('head', 'mri', np.eye(4)), fnirs=["channels", "pairs", "sources", "detectors"], verbose=False) # type: ignore @@ -2407,7 +2428,7 @@ def brain_landmarks_3d(raw_haemo: BaseRaw, show_optodes: Literal['sensors', 'lab if subjects_dir is None: subjects_dir = str(data_path()) + "/subjects" # type: ignore os.environ["SUBJECTS_DIR"] = subjects_dir - + brain = Brain("fsaverage", background="white", size=(800, 700)) # type: ignore distances = source_detector_distances(raw_haemo.info) @@ -2415,7 +2436,7 @@ def brain_landmarks_3d(raw_haemo: BaseRaw, show_optodes: Literal['sensors', 'lab # Add optode text labels manually if show_optodes == 'all' or show_optodes == 'sensors': brain.add_sensors(getattr(raw_haemo, "info"), trans=Transform('head', 'mri', np.eye(4)), fnirs=["channels", "pairs", "sources", "detectors"], verbose=False) # type: ignore - + if show_optodes == 'all' or show_optodes == 'labels': labeled_srcs = set() labeled_dets = set() @@ -2436,19 +2457,19 @@ def brain_landmarks_3d(raw_haemo: BaseRaw, show_optodes: Literal['sensors', 'lab logger.info("-" * 30) if not ch_name or not ch['loc'].any(): continue - + parts = ch_name.split()[0] src_str, det_str = parts.split('_') - + src_num = int(src_str[1:]) det_num = int(det_str[1:]) - + if src_num not in labeled_srcs: src_xyz = ch['loc'][3:6] * 1000 brain._renderer.text3d(src_xyz[0], src_xyz[1], src_xyz[2], src_str, color='red', scale=0.002) labeled_srcs.add(src_num) - + if det_num not in labeled_dets: det_xyz = ch['loc'][6:9] * 1000 brain._renderer.text3d(det_xyz[0], det_xyz[1], det_xyz[2], det_str, @@ -2463,7 +2484,7 @@ def brain_landmarks_3d(raw_haemo: BaseRaw, show_optodes: Literal['sensors', 'lab label_counts[label_text] = label_counts.get(label_text, 0) + 1 if label_counts[label_text] > 1: label_text += f" ({label_counts[label_text]})" - + # Label at channel midpoint mid_xyz = loc[0:3] * 1000 @@ -2476,7 +2497,6 @@ def brain_landmarks_3d(raw_haemo: BaseRaw, show_optodes: Literal['sensors', 'lab scale=0.002 ) - if show_brodmann:# Add Brodmann labels labels = cast(list[Label], read_labels_from_annot("fsaverage", "PALS_B12_Brodmann", "lh", verbose=False)) # type: ignore @@ -2529,13 +2549,13 @@ def brain_3d_contrast(con_model_df: DataFrame, con_model_df_filtered: BaseRaw, c if show_optodes == 'all' or show_optodes == 'sensors': brain.add_sensors(getattr(con_model_df_filtered, "info"), trans=Transform('head', 'mri', np.eye(4)), fnirs=["channels", "pairs", "sources", "detectors"], verbose=False) # type: ignore - + display_text = ('Contrast: ' + first_name + ' - ' + second_name + '\nLooking at: ' + t_or_theta + ' values') # Apply the text onto the brain if show_text: brain.add_text(0.12, 0.70, display_text, "title", font_size=11, color="k") # type: ignore - + def plot_2d_3d_contrasts_between_groups( @@ -2840,7 +2860,7 @@ def load_snirf(file_path: str, downsample_frequency: int, verbosity: bool) -> tu - BaseRaw: The processed data object. - Figure: The corresponding Matplotlib figure. """ - + # Read the snirf file raw = read_raw_snirf(file_path, preload=True, verbose=verbosity) # type: ignore #raw.load_data(verbose=VERBOSITY) # type: ignore redundant since preload is set to true @@ -2883,7 +2903,7 @@ def run_roi_second_level_analysis( graph_bounds: float | None = None, threshold_topo: bool = False, ) -> DataFrame: - + """ Perform group-level ROI analysis, prints stats to console, plots the ROI bar chart, and dynamically plots isolated channel-level group topography maps based on a JSON config. @@ -2904,7 +2924,7 @@ def run_roi_second_level_analysis( for roi in rois: roi_data = df_chroma[df_chroma['ROI'] == roi] sub_data = roi_data.groupby('ID', as_index=False)['theta'].mean() - + n_subs = sub_data['ID'].nunique() if n_subs < min_subjects: continue @@ -2959,12 +2979,12 @@ def run_roi_second_level_analysis( df_sub_avg = df_chroma.groupby(['ROI', 'ID'], as_index=False)['theta'].mean() sns.barplot( - data=df_sub_avg, x='ROI', y='theta', - ax=ax, errorbar=('ci', 95), capsize=0.1, + data=df_sub_avg, x='ROI', y='theta', + ax=ax, errorbar=('ci', 95), capsize=0.1, color='lightgray', edgecolor='black', linewidth=1.5, zorder=1 ) sns.swarmplot( - data=df_sub_avg, x='ROI', y='theta', + data=df_sub_avg, x='ROI', y='theta', ax=ax, color='darkblue', size=8, alpha=0.7, zorder=2 ) @@ -2975,17 +2995,17 @@ def run_roi_second_level_analysis( ax.set_ylim(y_bottom, y_top) if graph_bounds is not None and graph_bounds > 0.0: - if graph_bounds < 0.5: + if graph_bounds < 0.5: ax.set_ylim(-graph_bounds, graph_bounds) for idx, row in df_group.iterrows(): roi_name = row['ROI'] p_val_corr = row['p_corrected'] - + roi_points = df_sub_avg[df_sub_avg['ROI'] == roi_name]['theta'] max_y = roi_points.max() if len(roi_points) > 0 else 0 text_y = max_y + (global_max * 0.03) - + if p_val_corr < 0.001: sig_symbol = "***" elif p_val_corr < 0.01: @@ -3006,7 +3026,7 @@ def run_roi_second_level_analysis( ax.axhline(0, color='black', linewidth=1, linestyle='--') ax.set_ylabel(r'Hemodynamic Response ($\Delta$ HbO / $\mu$mol/L)' if global_max > 1e-3 else r'Hemodynamic Response ($\Delta$ HbO / mol/L)', fontsize=12) ax.set_xlabel('Region of Interest (ROI)', fontsize=12) - + correction_lbl = f"({correction_method} corrected)" if correction_method else "(uncorrected)" ax.set_title( f"Group-Level ROI Activation ({target_chroma.upper()})\nSignificance threshold: p < {p_threshold} {correction_lbl}", @@ -3021,16 +3041,16 @@ def run_roi_second_level_analysis( try: val_col = 'effect' if 'effect' in df_cha_all.columns else 'theta' ch_col = 'ch_name' if 'ch_name' in df_cha_all.columns else 'channel' - + con_summary = df_cha_all[(df_cha_all['Chroma'] == target_chroma) & (df_cha_all['Condition'] == condition)].copy() raw_picked = raw_haemo.copy().pick(picks=target_chroma) - + # Fit channel LME (suppress ConvergenceWarning locally) model_formula = f"{val_col} ~ -1 + {ch_col}:Chroma" with warnings.catch_warnings(): warnings.simplefilter("ignore", ConvergenceWarning) con_model = smf.mixedlm(model_formula, con_summary, groups=con_summary["ID"]).fit(method="nm") - + # Map statsmodels output to MNE result format con_model_df = statsmodels_to_results(con_model, order=raw_picked.ch_names) @@ -3062,24 +3082,24 @@ def run_roi_second_level_analysis( vlim = (-max_abs, max_abs) fig_topo, ax_topo = plt.subplots(figsize=(6, 6)) - + # Dynamic loop: Plot each region independently for i, roi_name in enumerate(unique_rois): roi_ch_names = con_summary[con_summary['ROI'] == roi_name][ch_col].unique().tolist() roi_ch_names = [ch for ch in roi_ch_names if ch in raw_picked.ch_names] - + if not roi_ch_names: continue - + raw_roi = raw_picked.copy().pick(picks=roi_ch_names) show_colorbar = (i == len(unique_rois) - 1) - + # === FIX 2: Filter stats dataframe first to prevent "Reducing GLM results..." warnings === roi_con_model_df = con_model_df[con_model_df['ch_name'].isin(roi_ch_names)].copy() - + plot_glm_group_topo( - raw_roi, - roi_con_model_df, + raw_roi, + roi_con_model_df, colorbar=show_colorbar, threshold=threshold_topo, # Now uses the parameter! axes=ax_topo, @@ -3094,7 +3114,7 @@ def run_roi_second_level_analysis( ) plt.tight_layout() plt.show() - + except Exception as e: logger.error(f"Could not generate topography plot: {e}") @@ -3136,7 +3156,7 @@ def run_inter_group_second_level_analysis( roi_channel_maps: dict[str, dict[str, str]] | None = None, threshold_topo: bool = False, ) -> DataFrame: - + """ Perform cross-group independent statistical analyses (Group A vs Group B), renders a grouped bar chart with significance brackets, and plots a group-contrast topography map. @@ -3144,7 +3164,7 @@ def run_inter_group_second_level_analysis( # 1. Align IDs and filter dataset to selected Event & Chromophore clean_a = set(file_paths_a) clean_b = set(file_paths_b) - + df_roi_all = df_roi_all.copy() # df_roi_all['clean_ID'] = df_roi_all['ID'].apply(clean_subject_id) df_roi_all['clean_ID'] = df_roi_all['ID'] @@ -3154,7 +3174,7 @@ def run_inter_group_second_level_analysis( (df_roi_all['Chroma'] == target_chroma) & (df_roi_all['Condition'] == selected_event) ].copy() - + df_a = df_filtered[df_filtered['clean_ID'].isin(clean_a)].copy() df_b = df_filtered[df_filtered['clean_ID'].isin(clean_b)].copy() @@ -3177,7 +3197,7 @@ def run_inter_group_second_level_analysis( for roi in rois: vals_a = df_a[df_a['ROI'] == roi].groupby('clean_ID')['theta'].mean().values vals_b = df_b[df_b['ROI'] == roi].groupby('clean_ID')['theta'].mean().values - + n_a, n_b = len(vals_a), len(vals_b) if n_a < min_subjects or n_b < min_subjects: continue @@ -3266,10 +3286,10 @@ def run_inter_group_second_level_analysis( for idx, row in df_group.iterrows(): roi_name = row['ROI'] p_val_corr = row['p_corrected'] - + roi_points = combined_df[combined_df['ROI'] == roi_name]['theta'] max_y = roi_points.max() if len(roi_points) > 0 else 0 - + x_a = idx - 0.2 # Approximate left bar X offset x_b = idx + 0.2 # Approximate right bar X offset y_bracket = max_y + (global_max * 0.08) @@ -3301,31 +3321,31 @@ def run_inter_group_second_level_analysis( try: val_col = 'effect' if 'effect' in df_cha_all.columns else 'theta' ch_col = 'ch_name' if 'ch_name' in df_cha_all.columns else 'channel' - + # Match channel levels and clean IDs con_summary = df_cha_all[ (df_cha_all['Chroma'] == target_chroma) & (df_cha_all['Condition'] == selected_event) ].copy() con_summary['clean_ID'] = con_summary['ID'] - + raw_picked = raw_haemo.copy().pick(picks=target_chroma) - + # --- Perform manual Channel-by-Channel Two-Sample t-tests --- contrast_data = [] for ch in raw_picked.ch_names: ch_a = con_summary[(con_summary['clean_ID'].isin(clean_a)) & (con_summary[ch_col] == ch)] ch_b = con_summary[(con_summary['clean_ID'].isin(clean_b)) & (con_summary[ch_col] == ch)] - + vals_a = ch_a[val_col].dropna().values vals_b = ch_b[val_col].dropna().values - + if len(vals_a) >= min_subjects and len(vals_b) >= min_subjects: t_stat, p_val = ttest_ind(vals_a, vals_b, equal_var=False) mean_diff = np.mean(vals_a) - np.mean(vals_b) else: t_stat, p_val, mean_diff = 0.0, 1.0, 0.0 - + contrast_data.append({ 'ch_name': ch, 'Coef.': mean_diff, # Represents Mean A - Mean B @@ -3333,7 +3353,7 @@ def run_inter_group_second_level_analysis( 'P>|t|': p_val, 'Chroma': target_chroma, # For threshold masking }) - + con_model_df = pd.DataFrame(contrast_data) # --- DYNAMIC ROI PARSING --- @@ -3352,24 +3372,24 @@ def run_inter_group_second_level_analysis( vlim = (-max_abs, max_abs) if max_abs > 1e-9 else (None, None) fig_topo, ax_topo = plt.subplots(figsize=(6, 6)) - + # Isolated dynamic plotting loop to prevent spatial bleeding for i, roi_name in enumerate(unique_rois): roi_ch_names = con_summary[con_summary['ROI'] == roi_name][ch_col].unique().tolist() if 'ROI' in con_summary.columns else raw_picked.ch_names roi_ch_names = [ch for ch in roi_ch_names if ch in raw_picked.ch_names] - + if not roi_ch_names: continue - + raw_roi = raw_picked.copy().pick(picks=roi_ch_names) show_colorbar = (i == len(unique_rois) - 1) - + # Filter contrast DF to current ROI channels roi_con_df = con_model_df[con_model_df['ch_name'].isin(roi_ch_names)].copy() - + plot_glm_group_topo( - raw_roi, - roi_con_df, + raw_roi, + roi_con_df, colorbar=show_colorbar, threshold=threshold_topo, axes=ax_topo, @@ -3381,7 +3401,7 @@ def run_inter_group_second_level_analysis( ax_topo.set_title(f"Group Contrast: {group_a_name} - {group_b_name}\n({target_chroma.upper()} - {threshold_text})", fontsize=11, fontweight='bold', pad=10) plt.tight_layout() plt.show() - + except Exception as e: logger.error(f"Could not generate group-contrast topography plot: {e}", exc_info=True) @@ -3403,12 +3423,12 @@ def run_inter_group_laterality_analysis( roi_contra_label: str | None = None, roi_ipsi_label: str | None = None, ) -> DataFrame: - + """ Compare LATERALITY between two independent groups of subjects (e.g. a control group vs. a target group), using Welch's t-test on each subject's within-subject laterality index rather than on raw ROI values. - + -------------------------------------------------------------------- HOW THIS DIFFERS FROM run_cross_group_second_level_analysis -------------------------------------------------------------------- @@ -3427,7 +3447,7 @@ def run_inter_group_laterality_analysis( likely to differ in these generic ways than two subsets of one study population, which makes this ambiguity a real risk here, not a theoretical one. - + run_cross_group_laterality_analysis (this function): - First computes each subject's OWN laterality index (contralateral ROI theta - ipsilateral ROI theta, within that @@ -3449,11 +3469,11 @@ def run_inter_group_laterality_analysis( existing cross-group function is for) — 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. - + Use both, for different questions: the existing function for "is the raw response different between groups," this one for "is the LATERALIZATION different between groups." - + Parameters ---------- df_roi_all_a, df_roi_all_b : pd.DataFrame @@ -3483,7 +3503,7 @@ def run_inter_group_laterality_analysis( ('fdr_bh') if testing several pairs/conditions at once. roi_contra_label, roi_ipsi_label : str or list of str, optional Display labels for the contra/ipsi ROI in each pair. - + Returns ------- pd.DataFrame, one row per tested pair: @@ -3491,23 +3511,23 @@ def run_inter_group_laterality_analysis( 'mean_diff', 't_val', 'p_val', 'p_corrected', 'significant', 'n_A', 'n_B'] """ - + required_cols = ['ROI', 'Condition', 'Chroma', 'theta', 'ID'] for name, df in [('df_roi_all_a', df_roi_all_a), ('df_roi_all_b', df_roi_all_b)]: if not all(col in df.columns for col in required_cols): raise ValueError(f"{name} must include: {required_cols}") - + if isinstance(roi_pairs, tuple): roi_pairs = [roi_pairs] n_pairs = len(roi_pairs) - + if isinstance(condition, str): conditions = [condition] * n_pairs else: if len(condition) != n_pairs: raise ValueError("If passing a list of conditions, it must match len(roi_pairs).") conditions = list(condition) - + def _expand(labels): if labels is None: return [None] * n_pairs @@ -3516,29 +3536,29 @@ def run_inter_group_laterality_analysis( if len(labels) != n_pairs: raise ValueError("Label list length must match len(roi_pairs).") return list(labels) - + contra_labels = _expand(roi_contra_label) ipsi_labels = _expand(roi_ipsi_label) - + def _laterality_per_subject(df_roi_all, roi_contra, roi_ipsi, cond): """Collapse to one laterality index per subject, for one group.""" df_chroma = df_roi_all[df_roi_all['Chroma'] == target_chroma] df_cond = df_chroma[df_chroma['Condition'] == cond] - + contra_vals = df_cond[df_cond['ROI'] == roi_contra].groupby('ID', as_index=False)['theta'].mean() ipsi_vals = df_cond[df_cond['ROI'] == roi_ipsi].groupby('ID', as_index=False)['theta'].mean() - + merged = contra_vals.merge(ipsi_vals, on='ID', suffixes=('_contra', '_ipsi')) merged['laterality'] = merged['theta_contra'] - merged['theta_ipsi'] return merged[['ID', 'laterality']] - + results = [] plot_rows = [] - + for (roi_contra, roi_ipsi), cond, lbl_c, lbl_i in zip(roi_pairs, conditions, contra_labels, ipsi_labels): lat_a = _laterality_per_subject(df_roi_all_a, roi_contra, roi_ipsi, cond) lat_b = _laterality_per_subject(df_roi_all_b, roi_contra, roi_ipsi, cond) - + n_a, n_b = lat_a['ID'].nunique(), lat_b['ID'].nunique() if n_a < min_subjects or n_b < min_subjects: logger.warning( @@ -3546,14 +3566,14 @@ def run_inter_group_laterality_analysis( f"{group_a_name} n={n_a}, {group_b_name} n={n_b}, need at least {min_subjects} in EACH." ) continue - + vals_a = lat_a['laterality'].values vals_b = lat_b['laterality'].values - + t_val, p_val = ttest_ind(vals_a, vals_b, equal_var=False) mean_a, mean_b = np.mean(vals_a), np.mean(vals_b) mean_diff = mean_a - mean_b - + pair_label = f"{lbl_c or roi_contra} - {lbl_i or roi_ipsi}\n({cond})" results.append({ 'roi_contra': roi_contra, @@ -3570,13 +3590,13 @@ def run_inter_group_laterality_analysis( }) plot_rows.append(lat_a.assign(pair=pair_label, Group=group_a_name)) plot_rows.append(lat_b.assign(pair=pair_label, Group=group_b_name)) - + if not results: print("\n[ERROR] No ROI pairs met the minimum subject threshold for BOTH groups.\n") return pd.DataFrame() - + df_group = pd.DataFrame(results) - + if correction_method is not None: reject, p_corrected, _, _ = multipletests(df_group['p_val'].values, method=correction_method) df_group['p_corrected'] = p_corrected @@ -3584,7 +3604,7 @@ def run_inter_group_laterality_analysis( else: df_group['p_corrected'] = df_group['p_val'] df_group['significant'] = df_group['p_val'] <= p_threshold - + # --- Print report --- print("\n" + "=" * 85) print(f" CROSS-GROUP LATERALITY CONTRAST: {group_a_name.upper()} vs {group_b_name.upper()} ({target_chroma.upper()})") @@ -3597,14 +3617,14 @@ def run_inter_group_laterality_analysis( print(df_print[['label', 'condition', 'mean_A', 'mean_B', 'mean_diff', 't_val', 'p_corrected', 'significant', 'n_A', 'n_B']].to_string(index=False)) print("=" * 85 + "\n") - + # --- Plot: grouped bar + swarm per pair, Group A vs Group B --- sns.set_theme(style="whitegrid") fig, ax = plt.subplots(figsize=(max(7, 3 * len(results)), 6)) - + plot_df = pd.concat(plot_rows, ignore_index=True) pair_order = [r['label'] for r in results] - + sns.barplot( data=plot_df, x='pair', y='laterality', hue='Group', order=pair_order, hue_order=[group_a_name, group_b_name], @@ -3617,15 +3637,15 @@ def run_inter_group_laterality_analysis( ax=ax, size=6, palette=['black', 'black'], alpha=0.5, dodge=True, zorder=2, legend=False ) - + ax.axhline(0, color='black', linewidth=1, linestyle='--') - + global_max = plot_df['laterality'].max() for i, row in df_group.iterrows(): pair_points = plot_df[plot_df['pair'] == row['label']]['laterality'] max_y = pair_points.max() if len(pair_points) > 0 else 0 text_y = max_y + (abs(global_max) * 0.1 if global_max else 0.1) - + p_val_corr = row['p_corrected'] if p_val_corr < p_threshold: sig_symbol = "***" if p_val_corr < 0.001 else "**" if p_val_corr < 0.01 else "*" @@ -3634,7 +3654,7 @@ def run_inter_group_laterality_analysis( else: ax.text(i, text_y, f"n.s.\np = {p_val_corr:.3f}", ha='center', va='bottom', fontsize=10, color='gray') - + ax.set_ylabel(r'Laterality Index ($\Delta$ HbO, Contra $-$ Ipsi)', fontsize=12) ax.set_xlabel('') correction_lbl = f"({correction_method} corrected)" if correction_method else "(uncorrected)" @@ -3645,7 +3665,7 @@ def run_inter_group_laterality_analysis( ) plt.tight_layout() plt.show() - + return df_group @@ -3664,7 +3684,7 @@ def run_inter_group_contrast_analysis( correction_method: str = "fdr_bh", weighted: bool = True, ) -> DataFrame: - + """ Compare a JOINT-FIT TASK CONTRAST (e.g. '2.0_vs_3.0'), aggregated to ROI level, between two independent groups. This is the cross-group analog @@ -4107,7 +4127,7 @@ def aggregate_channel_contrasts_to_roi( per-subject, per-ROI values — so a joint-fit task contrast can be tested at the ROI level using the same one-sample machinery as run_roi_second_level_analysis / run_roi_paired_contrast_analysis. - + This exists because mne_nirs.statistics.RegressionResults has a built-in .to_dataframe_region_of_interest() that does inverse-variance-weighted channel combination, but the ContrastResults object returned by @@ -4116,7 +4136,7 @@ def aggregate_channel_contrasts_to_roi( of its GLM fit's variance) manually, on the already-exported contrast dataframe, rather than requiring you to go back and recompute anything from raw GLM objects. - + Parameters ---------- df_contrasts : pd.DataFrame @@ -4138,7 +4158,7 @@ def aggregate_channel_contrasts_to_roi( weighting (weight = 1 / se^2), matching MNE-NIRS's own default behavior for to_dataframe_region_of_interest. If False, channels are weighted equally (a plain mean). - + Returns ------- pd.DataFrame with columns ['ROI', 'Condition', 'Chroma', 'theta', 'ID'], @@ -4146,12 +4166,12 @@ def aggregate_channel_contrasts_to_roi( run_roi_paired_contrast_analysis. 'Condition' holds the contrast name (e.g. '2.0_vs_3.0'), and 'theta' holds the ROI-combined contrast effect. """ - + required_cols = ['ch_name', 'effect', 'stat', 'Chroma', 'contrast_name', 'ID'] print(df_contrasts.columns) if not all(col in df_contrasts.columns for col in required_cols): raise ValueError(f"Input contrast DataFrame must include: {required_cols}") - + df = df_contrasts.copy() df['ch_base'] = df['ch_name'].str.split().str[0] @@ -4161,10 +4181,10 @@ def aggregate_channel_contrasts_to_roi( df['ROI'] = df.apply(lookup, axis=1) df = df.dropna(subset=['ROI']) - + if df.empty: raise ValueError("No channel contrasts matched any subject's ROI mapping.") - + # Recover standard error from the t-statistic: t = effect / se -> se = effect / t with np.errstate(divide='ignore', invalid='ignore'): df['se'] = df['effect'] / df['stat'] @@ -4175,26 +4195,26 @@ def aggregate_channel_contrasts_to_roi( logger.warning(f"Dropping {bad_se.sum()} channel-rows with non-finite " f"standard error (t-stat ~ 0) from ROI aggregation.") df = df[~bad_se] - + if weighted: df['weight'] = 1.0 / (df['se'] ** 2) else: df['weight'] = 1.0 - + group_cols = ['ROI', 'contrast_name', 'Chroma', 'ID'] - + df['_effect_weight'] = df['effect'] * df['weight'] roi_theta = df.groupby(group_cols, as_index=False).agg( _sum_ew=('_effect_weight', 'sum'), _sum_w=('weight', 'sum'), ) roi_theta['theta'] = roi_theta['_sum_ew'] / roi_theta['_sum_w'] - + roi_theta = roi_theta.rename(columns={'contrast_name': 'Condition'}) return roi_theta[['ROI', 'Condition', 'Chroma', 'theta', 'ID']] - - + + def collapse_fir_condition_column(df, value_col, condition_col='Condition', group_cols=None, delay_sep='_delay_'): """ @@ -4204,7 +4224,7 @@ def collapse_fir_condition_column(df, value_col, condition_col='Condition', that condition/subject/unit. SAFE NO-OP for non-FIR data: if no Condition value contains `delay_sep`, the input is returned unchanged — so this can always be called unconditionally, regardless of HRF_MODEL. - + No window (start/end) parameter, deliberately: the set of delay bins to average is read directly from whichever bins actually exist in the data for that condition — which is itself just a reflection of @@ -4212,13 +4232,13 @@ def collapse_fir_condition_column(df, value_col, condition_col='Condition', second, separately-configured window that could drift out of sync with it. This also makes no assumption about response shape or timing, appropriate when response latency is unpredictable (e.g. infant fNIRS). - + This is what lets all six group-level statistics functions work identically whether the underlying GLM used HRF_MODEL='glover'/'spm' (one regressor per condition already) or 'fir' (many delay-bin regressors per condition) — the delay-bin collapsing happens once, upstream, rather than needing separate handling inside each function. - + Parameters ---------- df : pd.DataFrame @@ -4237,7 +4257,7 @@ def collapse_fir_condition_column(df, value_col, condition_col='Condition', delay_sep : str, default '_delay_' Separator used in FIR condition names, matching the naming already used elsewhere in the pipeline ("{condition}_delay_{n}"). - + Returns ------- pd.DataFrame with the same columns as the input, `condition_col` @@ -4246,11 +4266,11 @@ def collapse_fir_condition_column(df, value_col, condition_col='Condition', """ df = df.copy() is_fir_row = df[condition_col].astype(str).str.contains(delay_sep, regex=False) - + if not is_fir_row.any(): # Nothing FIR-shaped here — pass through unchanged. return df - + if group_cols is None: raise ValueError( "group_cols must be specified when FIR delay-bin rows are present " @@ -4258,23 +4278,23 @@ def collapse_fir_condition_column(df, value_col, condition_col='Condition', "otherwise rows from different subjects/ROIs/channels could be " "collapsed together incorrectly." ) - + df_fir = df[is_fir_row].copy() df_other = df[~is_fir_row].copy() # any non-FIR rows pass through untouched - + df_fir['_base_condition'] = df_fir[condition_col].astype(str).str.split(delay_sep, n=1).str[0] - + full_group = group_cols + ['_base_condition'] - + collapsed = ( df_fir.groupby(full_group, as_index=False)[value_col] .mean() .rename(columns={'_base_condition': condition_col}) ) - + if not df_other.empty: collapsed = pd.concat([collapsed, df_other], ignore_index=True) - + return collapsed @@ -4298,7 +4318,7 @@ def _build_axis_split_rois(raw_haemo, axis, names, balance_threshold=0.5): """ Split channels into two ROIs by the sign of one coordinate axis of each channel's source-detector midpoint. - + Parameters ---------- axis : int @@ -4314,7 +4334,7 @@ def _build_axis_split_rois(raw_haemo, axis, names, balance_threshold=0.5): larger — rejects near-degenerate splits (e.g. 27 channels on one side, 1 on the other) where "two ROIs" isn't really giving you two usable regions, without demanding a perfect, unrealistic 50/50. - + Returns ------- dict of two ROIs, or None if geometry is missing/degenerate, every @@ -4322,7 +4342,7 @@ def _build_axis_split_rois(raw_haemo, axis, names, balance_threshold=0.5): — signaling the caller to try the next fallback tier. """ neg_indices, pos_indices = [], [] - + for idx, ch in enumerate(raw_haemo.info['chs']): mid = _channel_midpoint(ch) if mid is None: @@ -4334,10 +4354,10 @@ def _build_axis_split_rois(raw_haemo, axis, names, balance_threshold=0.5): pos_indices.append(idx) # coord == 0 (exact midline) excluded from both — genuinely # ambiguous, not worth guessing a side for. - + if not neg_indices or not pos_indices: return None - + balance = min(len(neg_indices), len(pos_indices)) / max(len(neg_indices), len(pos_indices)) if balance < balance_threshold: logger.warning( @@ -4345,7 +4365,7 @@ def _build_axis_split_rois(raw_haemo, axis, names, balance_threshold=0.5): f"{len(pos_indices)}, ratio {balance:.2f} < {balance_threshold}) — rejecting." ) return None - + return {names[0]: neg_indices, names[1]: pos_indices} @@ -4359,7 +4379,7 @@ def _build_geometric_fallback_rois(raw_haemo): a Front/Back split instead, using the exact same geometry. Returns None if neither axis gives a usable split, signaling the caller to fall back further to one-ROI-per-channel. - + Like the hemisphere-only version this replaces, these are coarse geometric splits, not hand-drawn functional regions — labeled "_Auto" so they're never mistaken for real regions.json ROIs anywhere @@ -4369,13 +4389,13 @@ def _build_geometric_fallback_rois(raw_haemo): if lr is not None: logger.info("Automatic fallback ROIs: Left/Right split (axis available and balanced).") return lr - + logger.warning("Left/Right fallback unavailable or too imbalanced — trying Front/Back split.") fb = _build_axis_split_rois(raw_haemo, axis=1, names=("Back_Auto", "Front_Auto")) if fb is not None: logger.info("Automatic fallback ROIs: Front/Back split (Left/Right was not usable).") return fb - + logger.warning("Neither Left/Right nor Front/Back split is usable for this montage.") return None @@ -4387,7 +4407,7 @@ def _build_per_channel_rois(raw_haemo): pair), each containing that channel's hbo AND hbr indices together — same grouping convention as regions.json (one name -> both chromophores), just derived automatically from whatever channels exist. - + NOT good for statistics — every "region" is a single channel, so this provides none of ROI aggregation's noise-reduction or multiple- comparisons benefit. Only reached if regions.json AND both automatic @@ -4451,7 +4471,7 @@ def iqr_threshold(coeffs: NDArray[float64], k: float = 1.5) -> floating[Any]: q1 = np.percentile(coeffs, 25) q3 = np.percentile(coeffs, 75) iqr = q3 - q1 - + return k * iqr @@ -4468,7 +4488,7 @@ def wavelet_iqr_denoise(signal: NDArray[float64], wavelet: str = 'db4', level: i The type of wavelet to use for decomposition (default is 'db4'). level : int, optional Decomposition level for wavelet transform (default is 3). - + Returns ------- NDArray[float64] @@ -4484,7 +4504,7 @@ def wavelet_iqr_denoise(signal: NDArray[float64], wavelet: str = 'db4', level: i ) if level < 1: raise ValueError(f"wavelet_level must be >= 1, got {level}.") - + # Decompose the signal using wavelet transform and initialize a list with approximation coefficients coeffs: list[NDArray[float64]] = pywt.wavedec(signal, wavelet, level=level) # type: ignore cA = coeffs[0] @@ -4500,7 +4520,7 @@ def wavelet_iqr_denoise(signal: NDArray[float64], wavelet: str = 'db4', level: i # Reconstruct the denoised signal denoised_signal = cast(NDArray[float64], pywt.waverec(denoised_coeffs, wavelet)) # type: ignore return denoised_signal[:len(signal)] - + def calculate_and_apply_wavelet(data: BaseRaw, wavelet_type: str, wavelet_level: int, iqr: float, verbosity: bool) -> tuple[BaseRaw, Figure]: @@ -4727,7 +4747,7 @@ def reconcile_heart_rate_estimates( def _fmt(d: dict[str, float]) -> str: return ", ".join(f"{k}={v:.1f}" for k, v in d.items()) - + pairs = [("psd", "nk_mean"), ("psd", "nk_mode"), ("nk_mean", "nk_mode")] agreeing_pairs = [ (a, b) for a, b in pairs @@ -4760,7 +4780,7 @@ def reconcile_heart_rate_estimates( f"trusting NeuroKit mode alone (more robust to residual dips than its mean)." ) - + def _mode_hr(hr_clean: NDArray[float64], bin_width_bpm: float = 2.0) -> float: """ Histogram-based mode of the HR trace: the center of the most frequently @@ -5033,22 +5053,22 @@ def detect_sensor_dropout(raw, threshold_ratio=0.05): data = raw.get_data() n_samples = data.shape[1] quarter = n_samples // 4 - + ratios = [] dead_idx = [] - + for i in range(len(ch_names)): start_var = np.var(data[i, :quarter]) end_var = np.var(data[i, -quarter:]) - + # Handle zero variance (dead from the start) if start_var == 0: ratio = 0.0 else: ratio = end_var / start_var - + ratios.append(ratio) - + if ratio < threshold_ratio: dead_idx.append(i) print(f"Flagged {ch_names[i]}: Variance dropped to {ratio:.2%} of original.") @@ -5056,24 +5076,24 @@ def detect_sensor_dropout(raw, threshold_ratio=0.05): # Pair-kill logic failed_bases = {ch_names[i].split(' ')[0] for i in dead_idx} bad_names = [ch for ch in ch_names if ch.split(' ')[0] in failed_bases] - + # --- Visualization --- fig_disp, ax = plt.subplots(figsize=(10, 5), constrained_layout=True) - + # Color logic: Coral for channels below the threshold colors = ['coral' if r < threshold_ratio else 'skyblue' for r in ratios] - + ax.bar(range(len(ratios)), ratios, color=colors) ax.axhline(threshold_ratio, color='red', linestyle='--', label=f'Threshold ({threshold_ratio:.0%})') - + ax.set_title("Sensor Dropout Check (Variance Stability)") ax.set_ylabel("Variance Ratio (End / Start)") ax.set_xlabel("Channel Index") ax.set_ylim(0, max(ratios + [threshold_ratio * 2])) # Scale to see the threshold clearly ax.legend() - + plt.close(fig_disp) - + print(f"Dropout Check: Flagged {len(failed_bases)} optode pairs.") return bad_names, fig_disp @@ -5087,34 +5107,34 @@ def detect_spectral_noise_spike(raw, db_limit=-60, freq_div=4, min_freq=0.1, tar ch_names = raw.ch_names sfreq = raw.info['sfreq'] target_freq = sfreq / freq_div - + # Compute PSD spectrum = raw.compute_psd(fmin=min_freq, fmax=sfreq/2) psd_data, freqs = spectrum.get_data(return_freqs=True) - + # Find power near the target frequency f_idx = np.where((freqs >= target_freq - target_bandwith) & (freqs <= target_freq + target_bandwith))[0] power_at_target = np.mean(psd_data[:, f_idx], axis=1) - + abs_threshold = 10 ** (db_limit / 10) noisy_idx = np.where(power_at_target > abs_threshold)[0] - + # Pair-kill logic failed_bases = {ch_names[i].split(' ')[0] for i in noisy_idx} bad_names = [ch for ch in ch_names if ch.split(' ')[0] in failed_bases] - + # --- Visualization --- fig, ax = plt.subplots(figsize=(8, 4)) ax.plot(freqs, 10 * np.log10(psd_data.T), color='gray', alpha=0.2) if len(noisy_idx) > 0: ax.plot(freqs, 10 * np.log10(psd_data[noisy_idx].T), color='plum', label='Noisy Pairs') - + ax.axhline(db_limit, color='red', linestyle='--', label='Threshold') ax.set_title(f"PSD Noise Analysis (Target: {target_freq}Hz)") ax.set_ylabel("Power (dB)") ax.legend() plt.close(fig) - + print(f"Noise Check: Flagged {len(failed_bases)} optode pairs.") return bad_names, fig @@ -5125,24 +5145,24 @@ def find_bad_channels_by_amplitude_range(raw, threshold=4): picks = [ch for ch in raw.ch_names] data = raw.get_data(picks=picks) ranges = np.max(data, axis=1) - np.min(data, axis=1) - + # Calculate Z-Scores median_range = np.median(ranges) mad = np.median(np.abs(ranges - median_range)) z_scores = 0.6745 * (ranges - median_range) / (mad if mad > 0 else 1e-15) - + # Identify failed bases failed_indices = np.where(np.abs(z_scores) > threshold)[0] failed_bases = {picks[i].split(' ')[0] for i in failed_indices} - + # Flag entire pairs bad_names = [ch for ch in picks if ch.split(' ')[0] in failed_bases] - + # --- Visualization --- fig_swing, ax = plt.subplots(figsize=(8, 4)) # We color bars by the specific Z-score of that individual channel colors = ['coral' if np.abs(z) > threshold else 'skyblue' for z in z_scores] - + ax.bar(range(len(z_scores)), z_scores, color=colors) ax.axhline(threshold, color='red', linestyle='--', label='Outlier Threshold') ax.axhline(-threshold, color='red', linestyle='--') @@ -5151,7 +5171,7 @@ def find_bad_channels_by_amplitude_range(raw, threshold=4): ax.set_xlabel("Channel Index") ax.legend() plt.close(fig_swing) - + return bad_names, fig_swing @@ -5161,27 +5181,27 @@ def find_bad_channels_coeff_var(raw, coeff_var_threshold=25.0): Identifies bad fNIRS channels using only the Coefficient of Variation (coeff_var). """ print(f"\n--- Starting coeff_var-Only Quality Check on the channels ---") - + picks = [ch for ch in raw.ch_names] data = raw.get_data(picks=picks) - + # Calculate coeff_var (Coefficient of Variation) stds = np.std(data, axis=1) means = np.mean(data, axis=1) # Using a small epsilon (1e-15) to prevent division by zero coeff_var_scores = (stds / (means + 1e-15)) * 100 - + # Find indices that exceed the threshold bad_coeff_var_indices = np.where(coeff_var_scores > coeff_var_threshold)[0] - + # Pair-kill logic: If one wavelength (HbO or HbR) fails, flag the pair failed_bases = set() for idx in bad_coeff_var_indices: base = picks[idx].split(' ')[0] failed_bases.add(base) - + bad_names = [ch for ch in picks if ch.split(' ')[0] in failed_bases] - + # Summary Prints print(f"coeff_var Check: Found {len(bad_coeff_var_indices)} channels exceeding {coeff_var_threshold}% noise threshold.") if failed_bases: @@ -5195,18 +5215,18 @@ def find_bad_channels_coeff_var(raw, coeff_var_threshold=25.0): # --- Visualization --- fig_qc, ax = plt.subplots(figsize=(10, 5), constrained_layout=True) - + colors = ['coral' if c > coeff_var_threshold else 'skyblue' for c in coeff_var_scores] ax.bar(range(len(coeff_var_scores)), coeff_var_scores, color=colors) ax.axhline(coeff_var_threshold, color='red', linestyle='--', label=f'Threshold ({coeff_var_threshold}%)') - + ax.set_title("Coefficient of Variation (Relative Noise)") ax.set_ylabel("coeff_var %") ax.set_xlabel("Channel Index") ax.legend() - + plt.close(fig_qc) - + return bad_names, fig_qc @@ -5316,7 +5336,7 @@ def hr_calc(raw, seconds_to_strip_hr, l_freq, h_freq, search_min, search_max, ma hr_smooth_nk, mean_hr_nk, mode_hr_nk, final_hr, times_trimmed, overruled, reconciliation_note, hr_window=hr_window ) - + fig = raw.compute_psd().plot(show=False) # Targeted frequency band for downstream calculations (SCI, PSP, etc.), @@ -5364,7 +5384,7 @@ def trim_participant_data(raw, seconds_to_keep: float): def remove_bad_channels(raw, bad_channels, max_bad_channels: int): num_bad = len(bad_channels) - + # Check against the threshold if num_bad > max_bad_channels: raise Exception( @@ -5383,34 +5403,34 @@ def make_and_run_glm(raw_haemo, df_design_matrix, noise_model, bins, n_jobs, ver glm_est = run_glm(raw_haemo, df_design_matrix, noise_model=noise_model, bins=bins, n_jobs=n_jobs, verbose=verbosity) fir_cols = [col for col in df_design_matrix.columns if "_delay_" in col] - + if fir_cols: # --- FIR MODEL HANDLING (Peak Delay Detection) --- logger.info("FIR model detected. Dynamically identifying peak delays...") - + # Extract base task conditions (e.g., "Tapping_Left", "Tapping_Right") base_conditions = list(set(col.split('_delay_')[0] for col in fir_cols)) - + theta_list = glm_est.theta() columns = list(df_design_matrix.columns) - + peak_conditions = [] for cond in base_conditions: cond_delays = [col for col in fir_cols if col.startswith(f"{cond}_delay_")] - + delay_impacts = {} for col in cond_delays: col_idx = columns.index(col) avg_absolute_theta = np.mean(np.abs([ch_theta[col_idx] for ch_theta in theta_list])) delay_impacts[col] = avg_absolute_theta - + peak_delay_col = max(delay_impacts, key=delay_impacts.get) logger.info(f"Condition '{cond}' peak response identified at delay column: {peak_delay_col}") peak_conditions.append(peak_delay_col) - + # Plot only the peak delays for a clean, single-column topomap per condition fig_glm_topo = glm_est.plot_topo(conditions=peak_conditions) - + else: # --- STANDARD HRF MODEL HANDLING --- # Extract only task conditions (ignore drifts, constants, and short channels) @@ -5445,42 +5465,42 @@ def generate_channel_results(glm_est, file_path): df_cha, value_col='theta', condition_col='Condition', group_cols=['ch_name', 'Chroma', 'ID'] ) - + return df_cha def generate_roi_results(raw_haemo, df_design_matrix, glm_est, file_path, json_location): - + rois_formatted = {} try: with open(json_location, 'r') as f: roi_data = json.load(f) - + for region in roi_data.get("regions_of_interest", []): roi_name = region["name"] channels = region["channels"] - + mne_channels = [] for ch in channels: mne_channels.append(f"{ch} hbo") mne_channels.append(f"{ch} hbr") - + valid_indices = [] for ch in mne_channels: if ch in raw_haemo.ch_names: idx = raw_haemo.ch_names.index(ch) valid_indices.append(idx) - + if valid_indices: rois_formatted[roi_name] = valid_indices else: logger.warning(f"No channels from ROI '{roi_name}' found in raw_haemo.") - + except Exception as e: logger.error(f"Failed to load or parse ROI JSON: {e}") rois_formatted = {} - + # --------------------------------------------------------------------- # Tier 2: automatic geometric split — Left/Right, or Front/Back if # Left/Right isn't usable. Used whenever tier 1 produced nothing, whether @@ -5494,7 +5514,7 @@ def generate_roi_results(raw_haemo, df_design_matrix, glm_est, file_path, json_l f"geometric fallback (Left/Right, then Front/Back)." ) rois_formatted = _build_geometric_fallback_rois(raw_haemo) - + # --------------------------------------------------------------------- # Tier 3: per-channel (true last resort — only if neither geometric # split is usable, e.g. missing/degenerate location info). @@ -5507,10 +5527,10 @@ def generate_roi_results(raw_haemo, df_design_matrix, glm_est, file_path, json_l "is fixed." ) rois_formatted = _build_per_channel_rois(raw_haemo) - + # 3. Calculate ROI results for all conditions conditions = df_design_matrix.columns - + # Compute output metrics by custom parsed ROIs (now passing lists of integers) df_roi = glm_est.to_dataframe_region_of_interest(rois_formatted, conditions) df_roi["ID"] = file_path @@ -5530,7 +5550,7 @@ def generate_roi_results(raw_haemo, df_design_matrix, glm_est, file_path, json_l if sub.empty: print(f"No ROI data for chroma '{chroma}' after excluding nuisance conditions.") return - + subject_id = sub['ID'].iloc[0] if 'ID' in sub.columns else '' n_conditions = sub['Condition'].nunique() @@ -5539,17 +5559,17 @@ def generate_roi_results(raw_haemo, df_design_matrix, glm_est, file_path, json_l for roi_name, indices in rois_formatted.items() for idx in indices } - + sns.set_theme(style="whitegrid") fig, ax = plt.subplots(figsize=(max(6, 1.5 * sub['ROI'].nunique()), 5)) - + if n_conditions > 1: sns.barplot(data=sub, x='ROI', y=value_col, hue='Condition', ax=ax, edgecolor='black', linewidth=1.2) else: sns.barplot(data=sub, x='ROI', y=value_col, ax=ax, color='#2b5c8f', edgecolor='black', linewidth=1.2) - + ax.axhline(0, color='black', linewidth=1, linestyle='--') ax.set_ylabel(f'{value_col} ({chroma.upper()})', fontsize=12) ax.set_xlabel('Region of Interest (ROI)', fontsize=12) @@ -5569,13 +5589,13 @@ def generate_contrast_results(df_design_matrix, glm_est, file_path): basic_conts = dict( [(column, contrast_matrix[i]) for i, column in enumerate(df_design_matrix.columns)] ) - + if HRF_MODEL == "fir": all_delay_cols = [col for col in df_design_matrix.columns if "_delay_" in col] all_conditions = sorted({col.split("_delay_")[0] for col in all_delay_cols}) if not all_conditions: raise ValueError("No FIR regressors found in the design matrix.") - + contrast_dict = {} for condition in all_conditions: delay_cols = [col for col in all_delay_cols if col.startswith(f"{condition}_delay_")] @@ -5583,13 +5603,13 @@ def generate_contrast_results(df_design_matrix, glm_est, file_path): continue contrast_vector = np.mean([basic_conts[col] for col in delay_cols], axis=0) contrast_dict[condition] = contrast_vector - + for cond, contrast_vector in contrast_dict.items(): contrast = glm_est.compute_contrast(contrast_vector) df = contrast.to_dataframe() df["ID"] = file_path contrast_results_dict[f"{cond}_vs_Zero"] = df - + for cond_a, cond_b in itertools.combinations(all_conditions, 2): if cond_a not in contrast_dict or cond_b not in contrast_dict: continue @@ -5598,7 +5618,7 @@ def generate_contrast_results(df_design_matrix, glm_est, file_path): df = contrast.to_dataframe() df["ID"] = file_path contrast_results_dict[f"{cond_a}_vs_{cond_b}"] = df - + else: # 0 is NOT a baseline. # AI Explaination: @@ -5606,10 +5626,10 @@ def generate_contrast_results(df_design_matrix, glm_est, file_path): # "Is the signal during Tapping_Right significantly higher than the average signal across the entire run?" # Because of systemic physiology (the global blood pressure rise that happens during almost any active task), # the answer is almost always "Yes, the whole head is higher than the average." - + exclude_list = ["drift", "constant", "short"] task_cols = [c for c in df_design_matrix.columns if not any(ex in c.lower() for ex in exclude_list)] - + for cond in task_cols: vec = np.zeros(len(df_design_matrix.columns)) vec[list(df_design_matrix.columns).index(cond)] = 1 @@ -5617,7 +5637,7 @@ def generate_contrast_results(df_design_matrix, glm_est, file_path): df = contrast.to_dataframe() df["ID"] = file_path contrast_results_dict[f"{cond}_vs_Zero"] = df - + for cond_a, cond_b in itertools.combinations(task_cols, 2): vec = np.zeros(len(df_design_matrix.columns)) vec[list(df_design_matrix.columns).index(cond_a)] = 1 @@ -5627,7 +5647,7 @@ def generate_contrast_results(df_design_matrix, glm_est, file_path): df["ID"] = file_path contrast_results_dict[f"{cond_a}_vs_{cond_b}"] = df - + return contrast_results_dict @@ -5646,7 +5666,7 @@ def _png_worker(png_queue: Queue, fig_bytes_dict: dict, dpi: int = 100): Runs on a single dedicated background thread for the lifetime of one process_participant() call. Consumes (label, fig) pairs and renders them to PNG bytes. - + Safety: figures are `plt.close()`-d by the producer (main thread) the instant they're created, BEFORE being queued. That means this thread never touches pyplot's global figure registry (Gcf) - it only calls @@ -5755,17 +5775,17 @@ def process_participant(file_path, file_start, progress_callback=None): qc["heart_rate_ran"] = HEART_RATE and not FOLDING_BYP if HEART_RATE and not FOLDING_BYP: fig, hr1, hr2, low, high, final_hr = hr_calc( - raw, - seconds_to_strip_hr=SECONDS_TO_STRIP_HR, - l_freq=HR_LOW_FREQ, - h_freq=HR_HIGH_FREQ, - search_min=HR_SEARCH_MIN, - search_max=HR_SEARCH_MAX, - max_low_hr=MAX_LOW_HR, - max_high_hr=MAX_HIGH_HR, - smoothing_window_hr=SMOOTHING_WINDOW_HR, + raw, + seconds_to_strip_hr=SECONDS_TO_STRIP_HR, + l_freq=HR_LOW_FREQ, + h_freq=HR_HIGH_FREQ, + search_min=HR_SEARCH_MIN, + search_max=HR_SEARCH_MAX, + max_low_hr=MAX_LOW_HR, + max_high_hr=MAX_HIGH_HR, + smoothing_window_hr=SMOOTHING_WINDOW_HR, hr_window=HEART_RATE_WINDOW, - short_channels=SHORT_CHANNELS, + short_channels=SHORT_CHANNELS, short_channels_threshold=SHORT_CHANNELS_THRESHOLD, verbosity=VERBOSITY ) @@ -5774,10 +5794,10 @@ def process_participant(file_path, file_start, progress_callback=None): _enqueue('Heart Rate - PSD', hr1, png_queue) _enqueue('Heart Rate - Time', hr2, png_queue) if progress_callback: progress_callback(5) - logger.info("Step 5 Completed.") + logger.info("Step 5 Completed.") step_start = lap(step_start, timings, "Step 5") - # Step 6: Scalp Coupling Index + # Step 6: Scalp Coupling Index bad_sci = [] if SCI and not FOLDING_BYP: if HEART_RATE and SCI_USE_HEART_RATE_BAND: @@ -5894,7 +5914,7 @@ def process_participant(file_path, file_start, progress_callback=None): if progress_callback: progress_callback(15) logger.info("Step 15 Completed.") step_start = lap(step_start, timings, "Step 15") - + # Step 16: Wavelet Filtering if WAVELET and not FOLDING_BYP: raw_od, fig = calculate_and_apply_wavelet(data=raw_od, wavelet_type=WAVELET_TYPE, wavelet_level=WAVELET_LEVEL, iqr=IQR, verbosity=VERBOSITY) @@ -5925,7 +5945,7 @@ def process_participant(file_path, file_start, progress_callback=None): if progress_callback: progress_callback(18) logger.info("Step 18 Completed.") step_start = lap(step_start, timings, "Step 18") - + # Step 19: Filter if FILTER and not FOLDING_BYP: raw_haemo, fig_filter, fig_raw_haemo_filter = filter_the_data( @@ -5973,9 +5993,9 @@ def process_participant(file_path, file_start, progress_callback=None): qc["n_epochs_final"] = 0 if EPOCHS and EVENTS and not FOLDING_BYP: epochs = epochs_calculations( - raw_haemo_evnt, - events, - event_dict, + raw_haemo_evnt, + events, + event_dict, epoch_handling=EPOCH_HANDLING, max_shift=MAX_SHIFT, t_min=T_MIN, @@ -5990,6 +6010,24 @@ def process_participant(file_path, file_start, progress_callback=None): logger.info("Step 21 Completed.") step_start = lap(step_start, timings, "Step 21") + # Step 21.5: Make annotations trial-specific for individual trial GLM/FIR + if SEPARATE_EVENTS: + annots = raw_haemo.annotations + if len(annots) > 0: + new_descriptions = [] + cond_counts = {} + for desc in annots.description: + # Optional: filter for specific task conditions if you have other markers like 'bad' or 'iseg' + cond_counts[desc] = cond_counts.get(desc, 0) + 1 + new_descriptions.append(f"{desc}_trial_{cond_counts[desc]}") + + # Update raw_haemo annotations with trial-specific names + raw_haemo.set_annotations(Annotations( + onset=annots.onset, + duration=annots.duration, + description=new_descriptions + )) + # Step 22: Design Matrix raw_haemo, raw_haemo_dm, df_design_matrix, fig_design_matrix = make_design_matrix( raw_haemo=raw_haemo, @@ -6071,7 +6109,6 @@ def process_participant(file_path, file_start, progress_callback=None): except Exception as e: logger.warning(f"Step 27.5 Failed to extract FIR features: {e}") - # Step 28: Finishing Up png_queue.put(None) # sentinel png_queue.join() # blocks only on remaining unfinished work @@ -6128,7 +6165,7 @@ def _check_epoch_frequency_resolution( f"{message} Raise fmin to >= {min_cycles / epoch_duration:.3f} Hz for this epoch " f"length, use longer epochs, or pass allow_unreliable=True to proceed anyway." ) - + def functional_connectivity_spectral_epochs( epochs: Epochs, @@ -6254,7 +6291,7 @@ def functional_connectivity_spectral_time( if verbose: logger.info(f"[{method}] matrix stats: min={coh.min():.6f}, max={coh.max():.6f}, " f"mean={coh.mean():.6f}, count above vmin({vmin})={np.sum(coh >= vmin)}") - + plot_connectivity_circle( coh, names, title=f"fNIRS Functional Connectivity (HbO - {method}, Time-Resolved, {fmin}-{fmax} Hz, {n_freqs} bins)", @@ -6330,7 +6367,7 @@ def functional_connectivity_betas( f"Only {len(trial_tags)} trials found for event_name={event_name}; " "need at least 4 to compute correlation degrees of freedom." ) - + if verbose: logger.info(f"[betas] trial_tags found: {len(trial_tags)}") @@ -6404,11 +6441,6 @@ def _log_matrix_diagnostics(name: str, mat: np.ndarray) -> None: - - - - - # ============================================================================ # Shared: channel alignment across participants # ============================================================================ @@ -6713,7 +6745,7 @@ def run_group_functional_connectivity_betas( min_participants: int = 3, resample_freq: float = 4.0, ) -> None: - + subject_results = [] for path in selected_paths: raw = haemo_dict.get(path) @@ -6797,18 +6829,11 @@ def run_group_functional_connectivity_epochs( - - - - - - - def sparks_csv_export( haemo_obj: BaseRaw, save_path: str, ) -> None: - + raw = haemo_obj data, times = raw.get_data(return_times=True) ann_col = np.full(times.shape, "", dtype=object) @@ -6827,8 +6852,7 @@ def sparks_csv_export( df.insert(0, "time", times) df.to_csv(save_path, index=False) - - + def peak_power_fast( raw, time_window=10, @@ -6841,7 +6865,7 @@ def peak_power_fast( ): """ Numerically-equivalent drop-in for mne_nirs' peak_power(). - + Same algorithm, same math, same return shape/order. The only change: the periodogram() call is batched across ALL (channel-pair, window) combinations in a single vectorized call instead of one call per @@ -6850,7 +6874,7 @@ def peak_power_fast( shows it's now the bottleneck, that's the next thing to vectorize (via FFT-based batch cross-correlation), flagged separately since it's a bigger, riskier change to get bit-exact. - + Validate before trusting in production: _, scores_orig, times_orig = peak_power(raw, ...) _, scores_fast, times_fast = peak_power_fast(raw, ...) @@ -6858,10 +6882,10 @@ def peak_power_fast( """ raw = raw.copy().load_data() _validate_type(raw, BaseRaw, "raw") - + picks = _validate_nirs_info(raw.info) sfreq = raw.info["sfreq"] - + filtered_data = filter_data( raw._data, sfreq, @@ -6872,42 +6896,42 @@ def peak_power_fast( l_trans_bandwidth=l_trans_bandwidth, h_trans_bandwidth=h_trans_bandwidth, ) - + window_samples = int(np.ceil(time_window * sfreq)) n_windows = int(np.floor(len(raw) / window_samples)) n_pairs = len(picks) // 2 - + scores = np.zeros((len(picks), n_windows)) times = [] - + if n_windows == 0: scores = scores[np.argsort(picks)] return raw, scores, times - + # All windows except the last are guaranteed to be exactly window_samples # long (the last window can be shorter due to the min() clamp below) - # batch-process those; handle the last window with the original scalar path. full_windows = n_windows - 1 corr_len = 2 * window_samples - 1 - + if full_windows > 0: c1_stack = np.empty((n_pairs, full_windows, window_samples)) c2_stack = np.empty((n_pairs, full_windows, window_samples)) - + for window in range(full_windows): start = window * window_samples end = start + window_samples for pi, ii in enumerate(range(0, len(picks), 2)): c1_stack[pi, window] = filtered_data[picks[ii]][start:end] c2_stack[pi, window] = filtered_data[picks[ii + 1]][start:end] - + std1 = c1_stack.std(axis=-1, keepdims=True) std1[std1 == 0] = 1 std2 = c2_stack.std(axis=-1, keepdims=True) std2[std2 == 0] = 1 c1_stack = c1_stack / std1 c2_stack = c2_stack / std2 - + corr_stack = np.empty((n_pairs, full_windows, corr_len)) for pi in range(n_pairs): for window in range(full_windows): @@ -6915,14 +6939,14 @@ def peak_power_fast( np.correlate(c1_stack[pi, window], c2_stack[pi, window], "full") / window_samples ) - + # single vectorized call replaces n_pairs * full_windows separate calls _, pxx = periodogram(corr_stack, fs=sfreq, window="hamming", axis=-1) window_scores = pxx.max(axis=-1) # shape (n_pairs, full_windows) - + scores[0::2, :full_windows] = window_scores scores[1::2, :full_windows] = window_scores - + for window in range(full_windows): start = window * window_samples end = start + window_samples @@ -6936,14 +6960,14 @@ def peak_power_fast( "BAD_PeakPower", ch_names=[raw.ch_names[ii : ii + 2]], ) - + # last (possibly truncated) window - original scalar path, unchanged window = n_windows - 1 start_sample = window * window_samples end_sample = int(np.min([start_sample + window_samples, len(raw) - 1])) t_start, t_stop = raw.times[start_sample], raw.times[end_sample] times.append((t_start, t_stop)) - + for ii in range(0, len(picks), 2): c1 = filtered_data[picks[ii]][start_sample:end_sample] c2 = filtered_data[picks[ii + 1]][start_sample:end_sample] @@ -6958,7 +6982,7 @@ def peak_power_fast( t_start, time_window, "BAD_PeakPower", ch_names=[raw.ch_names[ii : ii + 2]], ) - + scores = scores[np.argsort(picks)] return raw, scores, times @@ -7143,10 +7167,10 @@ METRIC_REGISTRY = { def plot_and_enqueue_waveform_metrics( - roi_curves, - fir_delays, - selected_metrics, - target_condition='reach', + roi_curves, + fir_delays, + selected_metrics, + target_condition='reach', png_queue=None ): """ @@ -7166,14 +7190,14 @@ def plot_and_enqueue_waveform_metrics( for metric_label, roi_dict in metric_data.items(): if not roi_dict: continue - + fig, ax = plt.subplots(figsize=(8, 4.5)) rois = list(roi_dict.keys()) values = list(roi_dict.values()) colors = ['#2b5c8f' if v >= 0 else '#d9534f' for v in values] ax.bar(rois, values, color=colors, alpha=0.85, edgecolor='black') - + ax.axhline(0, color='black', linewidth=0.8, linestyle='--') ax.set_title(f"FIR Waveform Metric: {metric_label} [{target_condition}]", fontsize=12, fontweight='bold') ax.set_xlabel("Region of Interest (ROI)", fontsize=10) @@ -7190,10 +7214,10 @@ def plot_and_enqueue_waveform_metrics( def _compute_roi_fir_curves( - raw=None, - target_condition='reach', + raw=None, + target_condition='reach', fir_delays=np.arange(0, 15), - roi_map={}, + roi_map={}, chromophores=('hbo', 'hbr', 'hbt'), glm_est=None, df_design_matrix=None @@ -7230,10 +7254,10 @@ def _compute_roi_fir_curves( # 1. Extract base measured chromophores (e.g., hbo, hbr) directly from GLM data base_chromos = [c.lower() for c in chromophores if c.lower() != 'hbt'] - + for chromo in base_chromos: chromo_df = fir_df[fir_df[ch_col].str.lower().str.contains(chromo)] if ch_col in fir_df.columns else fir_df - + ch_curves = {} for ch_name, ch_group in chromo_df.groupby(ch_col): pair = ch_name.split(' ')[0] @@ -7256,10 +7280,10 @@ def _compute_roi_fir_curves( def extract_fir_features_real_data( - raw=None, + raw=None, target_condition=None, - fir_delays=np.arange(0, 15), - selected_metrics=('Peak_Amp',), + fir_delays=np.arange(0, 15), + selected_metrics=('Peak_Amp',), roi_map={}, chromophores=('hbo', 'hbr', 'hbt'), glm_est=None, @@ -7275,7 +7299,7 @@ def extract_fir_features_real_data( col for col in df_design_matrix.columns if not any(k in col.lower() for k in ['drift', 'constant', 'short', 'nuisance']) ] - + # Strip '_delay_0', '_delay_1', etc. to get base condition names target_conditions = list(dict.fromkeys( col.split('_delay_')[0] if '_delay_' in col else col @@ -7290,10 +7314,10 @@ def extract_fir_features_real_data( for cond in target_conditions: try: roi_curves = _compute_roi_fir_curves( - raw=raw, - target_condition=cond, + raw=raw, + target_condition=cond, fir_delays=fir_delays, - roi_map=roi_map, + roi_map=roi_map, chromophores=chromophores, glm_est=glm_est, df_design_matrix=df_design_matrix @@ -7309,7 +7333,7 @@ def extract_fir_features_real_data( for (chromo, roi_name), roi_fir_curve in roi_curves.items(): metrics = compute_waveform_metrics(roi_fir_curve, fir_delays=fir_delays, selected_metrics=selected_metrics) collapsed_features.extend(metrics) - + # Prefix feature names with condition AND chromophore feature_names.extend([f"{cond}_{chromo.upper()}_{roi_name}_{m}" for m in metric_labels]) feature_channels.extend([f"{roi_name} ({chromo.upper()})"] * len(metric_labels)) @@ -7379,7 +7403,7 @@ def compute_waveform_metrics(fir_curve, fir_delays, selected_metrics=('Peak_Amp' def normalize_roi_map(roi_map): """ Normalizes ROI mapping inputs into a flat {roi_name: [channel_list]} dict. - + Accepts: 1. String or Path pointing to a JSON file (JSON_LOCATION). 2. Dict with 'regions_of_interest' list (loaded JSON). @@ -7413,9 +7437,24 @@ def normalize_roi_map(roi_map): return {} + +def extract_epoch_features(epochs, file_path, window=(4.0, 12.0)): + """One row per (epoch, channel): mean haemoglobin amplitude within `window` seconds.""" + tmask = (epochs.times >= window[0]) & (epochs.times <= window[1]) + feat = epochs.get_data()[:, :, tmask].mean(axis=2) * 1e6 # (n_epochs, n_channels), µM + id_to_name = {v: k for k, v in epochs.event_id.items()} + df = pd.DataFrame(feat, columns=epochs.ch_names) + df["Condition"] = [id_to_name[e] for e in epochs.events[:, 2]] + df["trial"] = epochs.selection # index of the epoch in the original event list (survives rejection) + long = df.melt(id_vars=["Condition", "trial"], var_name="ch_name", value_name="theta") + long["Chroma"] = long["ch_name"].map(dict(zip(epochs.ch_names, epochs.get_channel_types()))) + long["ID"] = file_path + return long[["ch_name", "Chroma", "ID", "Condition", "trial", "theta"]] + + if __name__ == "__main__": print("This file has no functionality when not used in tandem with the FLARES application.") - + # audit_log = logging.getLogger("memory_audit") # audit_log.setLevel(logging.INFO) # audit_log.propagate = False # This prevents it from talking to other loggers diff --git a/main.py b/main.py index 1433f91..1c0395e 100644 --- a/main.py +++ b/main.py @@ -15,6 +15,7 @@ import traceback import subprocess import configparser import concurrent.futures +from typing import Any from queue import Empty from copy import deepcopy from pathlib import Path @@ -34,11 +35,11 @@ from PySide6.QtWidgets import ( QPushButton, QMainWindow, QLabel, QLineEdit, QGroupBox, QDialog, QMenu, QSpinBox, QTableWidget, QTableWidgetItem ) from PySide6.QtCore import QEvent, QObject, Signal, Qt, QTimer -from PySide6.QtGui import QAction, QActionGroup, QFontMetrics, QKeySequence, QIcon +from PySide6.QtGui import QAction, QActionGroup, QFontMetrics, QKeySequence, QIcon, QTextCursor from PySide6.QtSvgWidgets import QSvgWidget # needed to show svgs when app is not frozen from file_ext_registration import register_file_association, ELEVATION_FLAG -from plugin_manager import PluginManager +from plugin_manager import PluginManager, StartupUpdateWorker from project_manager import ProjectManager from src.window.about import AboutWindow from src.window.plugins import PluginsWindow @@ -49,14 +50,14 @@ from src.window.userguide import UserGuideWindow from src.window.viewerlauncher import ViewerLauncherWidget from src.window.welcome import WelcomeDialog from src.shared.flaresbasewidget import FilePickerWidget, ParamSection, ProgressBubble -from src.shared.shareddata import API_URL, API_URL_SECONDARY, APP_NAME, CURRENT_VERSION, PLATFORM_NAME, DATA_SCHEMA, get_app_dir +from src.shared.shareddata import API_URL, API_URL_SECONDARY, APP_NAME, CURRENT_VERSION, PLATFORM_NAME, DATA_SCHEMA, PLUGINS_URL, get_app_dir from startup_args import parse_startup_args from updater import finish_update_if_needed, UpdateManager, LocalPendingUpdateCheckThread DEFAULT_CONFIG = """ [File] -recent_files = +recent_files = recent_projects = [Edit] @@ -71,13 +72,19 @@ show_welcome_dialog = false first_startup = true [Preferences] -theme = auto -2d_data_bypass = false -incompatible_save_bypass = false -missing_events_bypass = false -analysis_clearing_bypass = false -folding_bypass = false -advanced_parameters = false +theme_mode = auto + +processing_2d_data_bypass = false +processing_incompatible_save_bypass = false +processing_missing_events_bypass = false +processing_analysis_clearing_bypass = false +processing_folding_bypass = false +processing_separate_events = false + +ui_advanced_parameters = false + +updates_check_plugins_automatic = true +updates_check_app_automatic = true [Terminal] @@ -85,6 +92,7 @@ advanced_parameters = false """ + # Selectable parameters on the right side of the window SECTIONS = [ { @@ -334,6 +342,33 @@ BIDS_FIELD_MAP = { +class LogWindow(QDialog): + + def __init__(self, title, file_path, log_text, parent=None): + super().__init__(parent) + self.file_path = file_path + self.setWindowTitle(title) + self.resize(700, 500) + + layout = QVBoxLayout(self) + self.text_edit = QTextEdit(self) + self.text_edit.setReadOnly(True) + self.text_edit.setPlainText(log_text) + + font = self.text_edit.font() + font.setFamily("Courier") + self.text_edit.setFont(font) + + layout.addWidget(self.text_edit) + + def append_log(self, text): + # Auto-scroll to the bottom and insert incoming text chunk + cursor = self.text_edit.textCursor() + cursor.movePosition(QTextCursor.MoveOperation.End) + self.text_edit.setTextCursor(cursor) + self.text_edit.insertPlainText(text) + + class GroupAssignmentDialog(QDialog): """Dialog allowing users to create groups and assign unique metadata values to them.""" @@ -366,7 +401,6 @@ class GroupAssignmentDialog(QDialog): def _init_ui(self): layout = QVBoxLayout(self) - # Field selector field_box_layout = QHBoxLayout() field_box_layout.addWidget(QLabel("Group by:")) @@ -497,7 +531,7 @@ class GroupAssignmentDialog(QDialog): return mappings - + class CustomApplication(QApplication): """ macOS delivers a file-open request (double-clicking a registered @@ -509,14 +543,14 @@ class CustomApplication(QApplication): file on macOS launches the app but it never learns which file to open. """ file_open_requested = Signal(str) - + def event(self, e: QEvent) -> bool: if e.type() == QEvent.Type.FileOpen: self.file_open_requested.emit(e.file()) return True return super().event(e) - + class ThemeChangeWatcher(QObject): def __init__(self, main_window): super().__init__() @@ -541,7 +575,7 @@ class ThemeChangeWatcher(QObject): class DropScrollArea(QScrollArea): """QScrollArea that accepts .snirf file and folder drops.""" - + def __init__(self, on_files_dropped_callback, parent=None): super().__init__(parent) self.on_files_dropped_callback = on_files_dropped_callback @@ -607,25 +641,34 @@ class MainApplication(QMainWindow): self.events_blazes = None self.plugins = None self.terminal = None - self.bubble_widgets = {} - self.param_sections = [] - self.folder_paths = [] self.section_widget = None self.first_run = True - self.show_status_bar = False - self.is_2d_bypass = False - self.incompatible_save_bypass = False - self.missing_events_bypass = False - self.analysis_clearing_bypass = False - self.folding_bypass = False - self.advanced_parameters = False self.is_saved = True self.current_project_path = None + self.current_file = None self.selected_paths = [] self.saved_selected_paths = [] self.files_are_dirty = False + self.file_metadata = {} # AGE, SEX, HAND, GROUP + self.metadata_cache = {} # Internal file/path information metadata cache + self.bubble_widgets = {} # References to the UI "Bubble" objects + self.participant_logs = {} + + self.files_total = 0 # total number of files to process + self.files_done = set() # set of file paths done (success or fail) + self.files_failed = set() # set of failed file paths + self.files_results = {} # dict for successful results (if needed) + + self.pending_update_version = None + self.pending_update_path = None + + # are these used? + self.param_sections = [] + self.folder_paths = [] + self.last_clicked_bubble = None + self.project_manager = ProjectManager(self, file_cfg=file_cfg, cfg_path=cfg_path) self.plugin_manager = PluginManager(self) @@ -633,18 +676,35 @@ class MainApplication(QMainWindow): for item in DATA_SCHEMA: setattr(self, item["key"], {}) - self.file_metadata = {} # AGE, SEX, HAND, GROUP - self.metadata_cache = {} # Internal file/path information metadata cache - self.bubble_widgets = {} # References to the UI "Bubble" objects - self.current_file = None # Tracks the currently selected absolute path + config_mappings = { + "View": ["status_bar"], + "Preferences": [ + "theme_mode", + "processing_2d_data_bypass", + "processing_incompatible_save_bypass", + "processing_missing_events_bypass", + "processing_analysis_clearing_bypass", + "processing_folding_bypass", + "processing_separate_events", + "ui_advanced_parameters", + "updates_check_plugins_automatic", + "updates_check_app_automatic", + ] + } + + for section, keys in config_mappings.items(): + for key in keys: + if section == "Preferences" and key == "theme_mode": + val = file_cfg.get(section, key, fallback="auto") + elif key in ["status_bar", "updates_check_plugins_automatic", "updates_check_app_automatic"]: + val = file_cfg.getboolean(section, key, fallback=True) + else: + val = file_cfg.getboolean(section, key, fallback=False) + setattr(self, key, val) self.metadata_processed.connect(self._safe_ui_update) self.metadata_ui_signal.connect(self._handle_metadata_ui_update) - self.files_total = 0 # total number of files to process - self.files_done = set() # set of file paths done (success or fail) - self.files_failed = set() # set of failed file paths - self.files_results = {} # dict for successful results (if needed) self.updater = UpdateManager( main_window=self, @@ -658,17 +718,23 @@ class MainApplication(QMainWindow): self.init_ui() self.create_menu_bar() - - self.pending_update_version = None - self.pending_update_path = None - self.last_clicked_bubble = None self.installEventFilter(self) - # Start local pending update check thread - 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() + # Conditionally start background network threads using the loaded attributes + if self.updates_check_app_automatic: + 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() + else: + print("[MainApplication] App automatic update check is disabled in preferences.") + + if self.updates_check_plugins_automatic: + self._startup_update_worker = StartupUpdateWorker(self.plugin_manager, [PLUGINS_URL]) + self._startup_update_worker.updates_checked.connect(self._on_startup_updates_found) + self._startup_update_worker.start() + else: + print("[MainApplication] Plugin automatic update check is disabled in preferences.") self.plugin_manager.reload_plugins() @@ -677,7 +743,7 @@ class MainApplication(QMainWindow): # Check if we should pop up the welcome screen should_show_welcome = file_cfg.getboolean("Options", "show_welcome_dialog", fallback=True) first_startup = file_cfg.getboolean("Options", "first_startup", fallback=False) - + if first_startup: file_cfg.set("Options", "first_startup", "false") try: @@ -702,8 +768,7 @@ class MainApplication(QMainWindow): if file_to_open and os.path.exists(file_to_open): self.project_manager.load_project(file_to_open) - - + def init_ui(self): central = QWidget() @@ -728,7 +793,7 @@ class MainApplication(QMainWindow): self.top_left_widget.setReadOnly(True) self.top_left_widget.setPlaceholderText("Click a file below to get started! No files below? Open one with File -> Open File!") top_left_layout.addWidget(self.top_left_widget, stretch=4) - + self.right_column_widget = QWidget() right_column_layout = QVBoxLayout(self.right_column_widget) self.meta_fields = {"AGE": QLineEdit(), "SEX": QLineEdit(), "HAND": QLineEdit(), "GROUP": QLineEdit()} @@ -822,27 +887,49 @@ class MainApplication(QMainWindow): # def check_memory_leak(self): # # 2. Take a snapshot # snapshot = tracemalloc.take_snapshot() - + # # 3. Filter to show the top 10 biggest "stayers" # top_stats = snapshot.statistics('lineno') # print("[ Top 10 Memory Consumers ]") # for stat in top_stats[:10]: # print(stat) - - def create_menu_bar(self): - '''Menu Bar at the top of the screen''' + def _on_startup_updates_found(self, outdated_plugins: list[dict[str, Any]]) -> None: + """Safely displays the update popup on the main thread once the background check finishes.""" + if not outdated_plugins: + return + + update_details = "\n".join( + f"• {p['name']}: Installed v{p['version']} ➔ New v{p['remote_version']}" + for p in outdated_plugins + ) + summary_msg = f"The following plugin updates are available:\n\n{update_details}\n\nYou can update them in the Plugin Manager." + + QMessageBox.information( + self, + "Plugin Updates Available", + summary_msg + ) + + def create_menu_bar(self): + """Menu Bar at the top of the screen built dynamically from a unified schema.""" menu_bar = self.menuBar() self.statusbar = self.statusBar() + self.view_actions = {} + self.pref_actions = {} + def make_action(name, shortcut=None, slot=None, checkable=False, checked=False, icon=None): action = QAction(name, self) - if shortcut: action.setShortcut(QKeySequence(shortcut)) if slot: - action.triggered.connect(slot) + if checkable: + # Checkable actions use 'toggled' to reliably pass the boolean state + action.toggled.connect(slot) + else: + action.triggered.connect(slot) if checkable: action.setCheckable(True) action.setChecked(checked) @@ -850,140 +937,142 @@ class MainApplication(QMainWindow): action.setIcon(QIcon(icon)) return action - # File menu and actions - file_menu = menu_bar.addMenu("File") - file_actions = [ - ("Open File...", "Ctrl+O", self.project_manager.open_file_dialog, resource_path("icons/file_open_24dp_1F1F1F.svg")), - ("Open Folder...", "Ctrl+Alt+O", self.project_manager.open_folder_dialog, resource_path("icons/folder_24dp_1F1F1F.svg")), - ("Load Project...", "Ctrl+L", self.project_manager.load_project_dialog, resource_path("icons/article_24dp_1F1F1F.svg")), - ("Save Project...", "Ctrl+S", lambda: self.project_manager.save_project(ask=False), resource_path("icons/save_24dp_1F1F1F.svg")), - ("Save Project As...", "Ctrl+Shift+S", lambda: self.project_manager.save_project(ask=True), resource_path("icons/save_as_24dp_1F1F1F.svg")), + # Unified Menu Bar Schema + MENU_BAR_SCHEMA = [ + # === FILE MENU === + {"menu": "File", "name": "Open File...", "shortcut": "Ctrl+O", "slot": self.project_manager.open_file_dialog, "icon": resource_path("icons/file_open_24dp_1F1F1F.svg")}, + {"menu": "File", "name": "Open Folder...", "shortcut": "Ctrl+Alt+O", "slot": self.project_manager.open_folder_dialog, "icon": resource_path("icons/folder_24dp_1F1F1F.svg")}, + {"menu": "File", "type": "recent_files"}, + {"menu": "File", "type": "separator"}, + {"menu": "File", "name": "Load Project...", "shortcut": "Ctrl+L", "slot": self.project_manager.load_project_dialog, "icon": resource_path("icons/article_24dp_1F1F1F.svg")}, + {"menu": "File", "type": "recent_projects"}, + {"menu": "File", "type": "separator"}, + {"menu": "File", "name": "Save Project...", "shortcut": "Ctrl+S", "slot": lambda: self.project_manager.save_project(ask=False), "icon": resource_path("icons/save_24dp_1F1F1F.svg")}, + {"menu": "File", "name": "Save Project As...", "shortcut": "Ctrl+Shift+S", "slot": lambda: self.project_manager.save_project(ask=True), "icon": resource_path("icons/save_as_24dp_1F1F1F.svg")}, + {"menu": "File", "type": "separator"}, + {"menu": "File", "name": "Exit", "shortcut": "Ctrl+Q", "slot": QApplication.instance().quit, "icon": resource_path("icons/exit_to_app_24dp_1F1F1F.svg")}, + + # === EDIT MENU === + {"menu": "Edit", "name": "Cut", "shortcut": "Ctrl+X", "slot": self.cut_text, "icon": resource_path("icons/content_cut_24dp_1F1F1F.svg")}, + {"menu": "Edit", "name": "Copy", "shortcut": "Ctrl+C", "slot": self.copy_text, "icon": resource_path("icons/content_copy_24dp_1F1F1F.svg")}, + {"menu": "Edit", "name": "Paste", "shortcut": "Ctrl+V", "slot": self.paste_text, "icon": resource_path("icons/content_paste_24dp_1F1F1F.svg")}, + + # === VIEW MENU === + {"menu": "View", "name": "Toggle Status Bar", "shortcut": "", "slot": self.toggle_statusbar, "checkable": True, "checked": True, "icon": resource_path("icons/short_text_24dp_1F1F1F.svg"), "key": "statusbar"}, + {"menu": "View", "type": "separator"}, + {"menu": "View", "name": "Reset Window Layout", "shortcut": "Ctrl+Shift+R", "slot": self.reset_window_layout, "icon": resource_path("icons/grid_layout_side_24dp_1F1F1F.svg")}, + + # === OPTIONS MENU === + {"menu": "Options", "name": "User Guide", "shortcut": "F1", "slot": self.user_guide, "icon": resource_path("icons/help_24dp_1F1F1F.svg")}, + {"menu": "Options", "name": "Regroup Files from Metadata", "shortcut": "F3", "slot": self.group_metadata, "icon": resource_path("icons/help_24dp_1F1F1F.svg")}, + {"menu": "Options", "name": "Check for Updates", "shortcut": "F5", "slot": self.updater.manual_check_for_updates, "icon": resource_path("icons/update_24dp_1F1F1F.svg")}, + {"menu": "Options", "name": "Show Update Changelog", "shortcut": "F6", "slot": self.show_update_changelog, "icon": resource_path("icons/article_shortcut_24dp_1F1F1F.svg")}, + {"menu": "Options", "type": "separator"}, + {"menu": "Options", "name": "Update events in snirf file (BORIS)...", "shortcut": "F7", "slot": self.update_event_markers, "icon": resource_path("icons/upgrade_24dp_1F1F1F.svg")}, + {"menu": "Options", "name": "Update events in snirf file (BLAZES)...", "shortcut": "F8", "slot": self.update_event_markers_blazes, "icon": resource_path("icons/upgrade_24dp_1F1F1F.svg")}, + {"menu": "Options", "name": "Update optodes in snirf file...", "shortcut": "F9", "slot": self.update_optode_positions, "icon": resource_path("icons/upgrade_24dp_1F1F1F.svg")}, + {"menu": "Options", "type": "separator"}, + {"menu": "Options", "name": "Reset to Default Configuration", "shortcut": "F10", "slot": self.reset_to_default_configuration, "icon": resource_path("icons/reset_settings_24dp_1F1F1F.svg")}, + {"menu": "Options", "type": "separator"}, + {"menu": "Options", "name": "About", "shortcut": "F12", "slot": self.about_window, "icon": resource_path("icons/info_24dp_1F1F1F.svg")}, + + # === PREFERENCES: THEME === + {"menu": "Preferences", "submenu": "Theme", "name": "Auto", "mode": "auto", "slot": partial(self.theme_change_func, "auto"), "icon": resource_path("icons/warning_off_24dp_1F1F1F.svg"), "key": "theme_auto", "group": "theme", "checkable": True}, + {"menu": "Preferences", "submenu": "Theme", "name": "Light", "mode": "light", "slot": partial(self.theme_change_func, "light"), "icon": resource_path("icons/warning_off_24dp_1F1F1F.svg"), "key": "theme_light", "group": "theme", "checkable": True}, + {"menu": "Preferences", "submenu": "Theme", "name": "Dark", "mode": "dark", "slot": partial(self.theme_change_func, "dark"), "icon": resource_path("icons/warning_off_24dp_1F1F1F.svg"), "key": "theme_dark", "group": "theme", "checkable": True}, + + # === PREFERENCES: PROCESSING === + {"menu": "Preferences", "submenu": "Processing", "name": "2D Data Bypass", "key": "processing_2d_data_bypass", "icon": resource_path("icons/warning_off_24dp_1F1F1F.svg")}, + {"menu": "Preferences", "submenu": "Processing", "name": "Incompatible Save Bypass", "key": "processing_incompatible_save_bypass", "icon": resource_path("icons/warning_off_24dp_1F1F1F.svg")}, + {"menu": "Preferences", "submenu": "Processing", "name": "Missing Events Bypass", "key": "processing_missing_events_bypass", "icon": resource_path("icons/warning_off_24dp_1F1F1F.svg")}, + {"menu": "Preferences", "submenu": "Processing", "name": "Analysis Clearing Bypass", "key": "processing_analysis_clearing_bypass", "icon": resource_path("icons/warning_off_24dp_1F1F1F.svg")}, + {"menu": "Preferences", "submenu": "Processing", "name": "Folding Bypass", "key": "processing_folding_bypass", "icon": resource_path("icons/warning_off_24dp_1F1F1F.svg")}, + {"menu": "Preferences", "submenu": "Processing", "name": "Separate Events", "key": "processing_separate_events", "icon": resource_path("icons/warning_off_24dp_1F1F1F.svg")}, + + # === PREFERENCES: UI === + {"menu": "Preferences", "submenu": "UI", "name": "Show Advanced Parameters", "key": "ui_advanced_parameters", "icon": resource_path("icons/warning_off_24dp_1F1F1F.svg")}, + + # === PREFERENCES: UPDATES === + {"menu": "Preferences", "submenu": "Updates", "name": "Automatic App Updates", "key": "updates_check_app_automatic", "icon": resource_path("icons/warning_off_24dp_1F1F1F.svg")}, + {"menu": "Preferences", "submenu": "Updates", "name": "Automatic Plugin Updates", "key": "updates_check_plugins_automatic", "icon": resource_path("icons/warning_off_24dp_1F1F1F.svg")}, + + # === PLUGINS MENU === + {"menu": "Plugins", "name": "Plugin Manager", "shortcut": "Ctrl+Alt+P", "slot": self.plugins_gui, "icon": resource_path("icons/terminal_24dp_1F1F1F.svg")}, + + # === TERMINAL MENU === + {"menu": "Terminal", "name": "New Terminal", "shortcut": "Ctrl+Alt+T", "slot": self.terminal_gui, "icon": resource_path("icons/terminal_24dp_1F1F1F.svg")}, ] - for i, (name, shortcut, slot, icon) in enumerate(file_actions): - file_menu.addAction(make_action(name, shortcut, slot, icon=icon)) - if i == 1: - self.recent_files_menu = file_menu.addMenu("Recent Files") - self.recent_files_menu.setIcon(QIcon(resource_path("icons/history_24dp_1F1F1F.svg"))) # optional icon - file_menu.addSeparator() - elif i == 2: - self.recent_projects_menu = file_menu.addMenu("Recent Projects") - self.recent_projects_menu.setIcon(QIcon(resource_path("icons/history_2_24dp_1F1F1F.svg"))) - file_menu.addSeparator() - - file_menu.addSeparator() - file_menu.addAction(make_action("Exit", "Ctrl+Q", QApplication.instance().quit, icon=resource_path("icons/exit_to_app_24dp_1F1F1F.svg"))) - - # Edit menu - edit_menu = menu_bar.addMenu("Edit") - edit_actions = [ - ("Cut", "Ctrl+X", self.cut_text, resource_path("icons/content_cut_24dp_1F1F1F.svg")), - ("Copy", "Ctrl+C", self.copy_text, resource_path("icons/content_copy_24dp_1F1F1F.svg")), - ("Paste", "Ctrl+V", self.paste_text, resource_path("icons/content_paste_24dp_1F1F1F.svg")) - ] - for name, shortcut, slot, icon in edit_actions: - edit_menu.addAction(make_action(name, shortcut, slot, icon=icon)) - - # View menu - - self.view_actions = {} - - view_menu = menu_bar.addMenu("View") - toggle_statusbar_action = make_action("Toggle Status Bar", "", self.toggle_statusbar, True, True, resource_path("icons/short_text_24dp_1F1F1F.svg")) - view_menu.addAction(toggle_statusbar_action) - self.view_actions["statusbar"] = toggle_statusbar_action - - # Reset Layout Action - view_menu.addSeparator() - reset_layout_action = make_action( - "Reset Window Layout", - "Ctrl+Shift+R", - self.reset_window_layout, - icon=resource_path("icons/grid_layout_side_24dp_1F1F1F.svg") - ) - view_menu.addAction(reset_layout_action) - - # Options menu (Help & About) - options_menu = menu_bar.addMenu("Options") - - options_actions = [ - ("User Guide", "F1", self.user_guide, resource_path("icons/help_24dp_1F1F1F.svg")), - ("Regroup Files from Metadata", "F3", self.group_metadata, resource_path("icons/help_24dp_1F1F1F.svg")), - ("Check for Updates", "F5", self.updater.manual_check_for_updates, resource_path("icons/update_24dp_1F1F1F.svg")), - ("Show Update Changelog", "F6", self.show_update_changelog, resource_path("icons/article_shortcut_24dp_1F1F1.svg")), - ("Update events in snirf file (BORIS)...", "F7", self.update_event_markers, resource_path("icons/upgrade_24dp_1F1F1F.svg")), - ("Update events in snirf file (BLAZES)...", "F8", self.update_event_markers_blazes, resource_path("icons/upgrade_24dp_1F1F1F.svg")), - ("Update optodes in snirf file...", "F9", self.update_optode_positions, resource_path("icons/upgrade_24dp_1F1F1F.svg")), - ("Reset to Default Configuration", "F10", self.reset_to_default_configuration, resource_path("icons/reset_settings_24dp_1F1F1F.svg")), - ("About", "F12", self.about_window, resource_path("icons/info_24dp_1F1F1F.svg")) - ] - - for i, (name, shortcut, slot, icon) in enumerate(options_actions): - options_menu.addAction(make_action(name, shortcut, slot, icon=icon)) - if i == 3 or i == 6 or i == 7: - options_menu.addSeparator() - - self.pref_actions = {} - - preferences_menu = menu_bar.addMenu("Preferences") - theme_menu = preferences_menu.addMenu("Theme") - + # Container references for submenus and groups + menus = {} + submenus = {} theme_group = QActionGroup(self) theme_group.setExclusive(True) - - # 4. Define actions for the submenu - theme_actions = [ - ("Auto", "", "auto", resource_path("icons/warning_off_24dp_1F1F1F.svg"), "theme_auto"), - ("Light", "", "light", resource_path("icons/warning_off_24dp_1F1F1F.svg"), "theme_light"), - ("Dark", "", "dark", resource_path("icons/warning_off_24dp_1F1F1F.svg"), "theme_dark"), - ] - for name, shortcut, mode, icon, config_key in theme_actions: - # Use partial to pass 'mode' to self.theme_change_func on click - slot = partial(self.theme_change_func, mode) - - action = make_action(name, shortcut, slot, icon=icon, checkable=True) - theme_menu.addAction(action) - theme_group.addAction(action) - - self.pref_actions[config_key] = action + for item in MENU_BAR_SCHEMA: + menu_name = item["menu"] + if menu_name not in menus: + menus[menu_name] = menu_bar.addMenu(menu_name) + parent_menu = menus[menu_name] - # Set default selection (e.g., Auto) - self.pref_actions["theme_auto"].setChecked(True) + # Handle Submenus (e.g. Preferences -> Theme/Processing/Updates) + if "submenu" in item: + sub_name = item["submenu"] + sub_key = f"{menu_name}_{sub_name}" + if sub_key not in submenus: + submenus[sub_key] = parent_menu.addMenu(sub_name) + parent_menu = submenus[sub_key] - preferences_actions = [ - ("2D Data Bypass", "", self.is_2d_bypass_func, resource_path("icons/warning_off_24dp_1F1F1F.svg"), "2d_data_bypass"), - ("Incompatible Save Bypass", "", self.incompatable_save_bypass_func, resource_path("icons/warning_off_24dp_1F1F1F.svg"), "incompatible_save_bypass"), - ("Missing Events Bypass", "", self.missing_events_bypass_func, resource_path("icons/warning_off_24dp_1F1F1F.svg"), "missing_events_bypass"), - ("Analysis Clearing Bypass", "", self.analysis_clearing_bypass_func, resource_path("icons/warning_off_24dp_1F1F1F.svg"), "analysis_clearing_bypass"), - ("Folding Bypass", "", self.folding_bypass_func, resource_path("icons/warning_off_24dp_1F1F1F.svg"), "folding_bypass"), - ("Show Advanced Parameters", "", self.advanced_parameters_func, resource_path("icons/warning_off_24dp_1F1F1F.svg"), "advanced_parameters"), - ] + # Handle Special Types (Separators & Dynamic Submenus) + item_type = item.get("type") + if item_type == "separator": + parent_menu.addSeparator() + continue + elif item_type == "recent_files": + self.recent_files_menu = parent_menu.addMenu("Recent Files") + self.recent_files_menu.setIcon(QIcon(resource_path("icons/history_24dp_1F1F1F.svg"))) + parent_menu.addSeparator() + continue + elif item_type == "recent_projects": + self.recent_projects_menu = parent_menu.addMenu("Recent Projects") + self.recent_projects_menu.setIcon(QIcon(resource_path("icons/history_2_24dp_1F1F1F.svg"))) + parent_menu.addSeparator() + continue - for name, shortcut, slot, icon, config_key in preferences_actions: - action = make_action(name, shortcut, slot, icon=icon, checkable=True) - preferences_menu.addAction(action) - self.pref_actions[config_key] = action + # Handle Normal/Preference/View Actions + name = item["name"] + shortcut = item.get("shortcut", "") + key = item.get("key") - plugins_menu = menu_bar.addMenu("Plugins") - plugins_actions = [ - ("Plugin Manager", "Ctrl+Alt+P", self.plugins_gui, resource_path("icons/terminal_24dp_1F1F1F.svg")), - ] - for name, shortcut, slot, icon in plugins_actions: - plugins_menu.addAction(make_action(name, shortcut, slot, icon=icon)) + # If it's a preference toggle item without an explicit slot, bind it to _set_preference + slot = item.get("slot") + checkable = item.get("checkable", False) + checked = item.get("checked", False) - terminal_menu = menu_bar.addMenu("Terminal") - terminal_actions = [ - ("New Terminal", "Ctrl+Alt+T", self.terminal_gui, resource_path("icons/terminal_24dp_1F1F1F.svg")), - ] - for name, shortcut, slot, icon in terminal_actions: - terminal_menu.addAction(make_action(name, shortcut, slot, icon=icon)) + if not slot and key and "theme_" not in key: + slot = partial(self._set_preference, key) + checkable = True + checked = getattr(self, key, False) + + action = make_action(name, shortcut, slot, checkable=checkable, checked=checked, icon=item.get("icon")) + parent_menu.addAction(action) + + # Register into appropriate tracking dictionaries + if key == "statusbar": + self.view_actions["statusbar"] = action + elif item.get("group") == "theme": + theme_group.addAction(action) + self.pref_actions[key] = action + elif key: + self.pref_actions[key] = action + + # Set default theme selection if available + if "theme_auto" in self.pref_actions: + self.pref_actions["theme_auto"].setChecked(True) self.sync_app_with_config() - self.statusbar.showMessage("Ready") - def update_theme(self): text = self.label_desc.text() self.label_desc.setText("") @@ -1039,7 +1128,7 @@ class MainApplication(QMainWindow): # self.update_window_title() # Defensive dictionary lookup for the preference action - pref_action = getattr(self, "pref_actions", {}).get("advanced_parameters") + pref_action = getattr(self, "pref_actions", {}).get("ui_advanced_parameters") show_advanced = pref_action.isChecked() if pref_action is not None else False # Toggle visibility on all built sections @@ -1075,7 +1164,7 @@ class MainApplication(QMainWindow): 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) @@ -1091,7 +1180,7 @@ class MainApplication(QMainWindow): if is_dirty: save_msg = QMessageBox(self) save_msg.setWindowTitle(f"Save Changes - {APP_NAME.upper()}") - + if self.current_project_path: save_msg.setText(f"Do you want to save changes to '{os.path.basename(self.current_project_path)}' before clearing?") else: @@ -1117,9 +1206,9 @@ class MainApplication(QMainWindow): if not saved_successfully: return elif response == QMessageBox.StandardButton.Cancel: - return + return + - self.top_left_widget.clear() if hasattr(self, "last_clicked_bubble"): @@ -1139,7 +1228,7 @@ class MainApplication(QMainWindow): if hasattr(self, "file_executor") and self.file_executor: self.file_executor.shutdown(wait=False, cancel_futures=True) self.file_executor = None - + self.pending_files_count = 0 # Increment session so any 'in-flight' callbacks are ignored if hasattr(self, "loading_session_id"): @@ -1171,13 +1260,13 @@ class MainApplication(QMainWindow): self.bubble_container.setMinimumSize(0, 0) self.bubble_container.resize(0, 0) self.scroll_area.updateGeometry() - + # Data Purge self.bubble_widgets = {} self.files_results = {} self.files_done = set() self.files_failed = set() - + for item in DATA_SCHEMA: setattr(self, item["key"], {}) @@ -1218,7 +1307,7 @@ class MainApplication(QMainWindow): self.check_if_app_is_dirty() elif hasattr(self, "update_window_title"): self.update_window_title() - + self.statusBar().showMessage("All data has been cleared.") #NOTE: leave this here for now. needs other parts uncommented to work @@ -1226,7 +1315,7 @@ class MainApplication(QMainWindow): # self.find_referrers() # snapshot2 = tracemalloc.take_snapshot() - + # # 4. Show the "Compare" - This shows what REFUSED to die # stats = snapshot2.compare_to(snapshot1, 'lineno') # print("[ Memory that stayed after Clear ]") @@ -1248,7 +1337,7 @@ class MainApplication(QMainWindow): self.is_saved = not (files_dirty or params_dirty or meta_dirty) self.update_window_title() - + def reset_window_layout(self): """ Snaps all draggable splitters back to their default proportional positions. @@ -1266,7 +1355,7 @@ class MainApplication(QMainWindow): def open_launcher_window(self): data_map = {item["key"]: getattr(self, item["key"]) for item in DATA_SCHEMA} - + # 2. Extract values in the specific order the widget constructor expects args = [ data_map["raw_haemo_dict"], @@ -1280,7 +1369,7 @@ class MainApplication(QMainWindow): data_map["roi_channel_map_dict"], data_map["fir_feature_dict"], data_map["qc_dict"], - self.folding_bypass, + self.processing_folding_bypass, ] self.launcher_window = ViewerLauncherWidget(*args) @@ -1298,19 +1387,25 @@ class MainApplication(QMainWindow): self.top_left_widget.paste() # Trigger paste self.statusbar.showMessage("Pasted from clipboard") # Show status message - def theme_change_func(self, mode): + def theme_change_func(self, mode: str, checked: bool = True) -> None: + """Handles theme switching when toggled from the menu.""" + if not checked: + return # Ignore the 'False' state when an option gets unchecked + app = QApplication.instance() style_hints = app.styleHints() if mode == "auto": style_hints.setColorScheme(Qt.ColorScheme.Unknown) - elif mode == "light": style_hints.setColorScheme(Qt.ColorScheme.Light) - elif mode == "dark": style_hints.setColorScheme(Qt.ColorScheme.Dark) - + + # Save the change to your config file + self.theme_mode = mode + self._update_config_setting("Preferences", "theme_mode", mode) + def _update_config_setting(self, group, key, value): """Helper to update memory configuration and save to disk.""" # configparser expects string values @@ -1326,30 +1421,12 @@ class MainApplication(QMainWindow): self._update_config_setting("View", "status_bar", checked) self.statusbar.setVisible(checked) - def is_2d_bypass_func(self, checked): - self.is_2d_bypass = checked - self._update_config_setting("Preferences", "2d_data_bypass", checked) - - def incompatable_save_bypass_func(self, checked): - self.incompatible_save_bypass = checked - self._update_config_setting("Preferences", "incompatible_save_bypass", checked) - - def missing_events_bypass_func(self, checked): - self.missing_events_bypass = checked - self._update_config_setting("Preferences", "missing_events_bypass", checked) - - def analysis_clearing_bypass_func(self, checked): - self.analysis_clearing_bypass = checked - self._update_config_setting("Preferences", "analysis_clearing_bypass", checked) - - def folding_bypass_func(self, checked): - self.folding_bypass = checked - self._update_config_setting("Preferences", "folding_bypass", checked) - - def advanced_parameters_func(self, checked): - self.advanced_parameters = checked - self._update_config_setting("Preferences", "advanced_parameters", checked) - self.update_sections(0) + def _set_preference(self, config_key: str, checked: bool) -> None: + """Generic handler for updating boolean preferences and config settings.""" + setattr(self, config_key, checked) + self._update_config_setting("Preferences", config_key, checked) + if config_key == "ui_advanced_parameters": + self.update_sections(0) def about_window(self): if self.about is None or not self.about.isVisible(): @@ -1410,7 +1487,7 @@ class MainApplication(QMainWindow): def reset_to_default_configuration(self): """Asks user for confirmation, then resets all settings to defaults.""" - + reply = QMessageBox.question( self, "Reset Configuration", @@ -1425,11 +1502,11 @@ class MainApplication(QMainWindow): # Overwrite the file with the template string constant with open(cfg_path, "w") as f: f.write(DEFAULT_CONFIG.strip()) - + # Reload the config parser from the freshly written file file_cfg.read(cfg_path) print("Configuration reset to defaults successfully.") - + except Exception as e: print(f"Error resetting config file ({e}). Resetting in-memory only.") # Fallback to loading the string into memory if file writing fails @@ -1441,7 +1518,7 @@ class MainApplication(QMainWindow): 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.""" @@ -1454,32 +1531,65 @@ class MainApplication(QMainWindow): def sync_app_with_config(self): - """Reads values from file_cfg and updates both internal variables and UI checkmarks.""" - # 1. Sync internal application state variables - self.is_2d_bypass = file_cfg.getboolean("Preferences", "2d_data_bypass", fallback=False) - self.incompatible_save_bypass = file_cfg.getboolean("Preferences", "incompatible_save_bypass", fallback=False) - self.missing_events_bypass = file_cfg.getboolean("Preferences", "missing_events_bypass", fallback=False) - self.analysis_clearing_bypass = file_cfg.getboolean("Preferences", "analysis_clearing_bypass", fallback=False) - self.folding_bypass = file_cfg.getboolean("Preferences", "folding_bypass", fallback=False) - self.advanced_parameters = file_cfg.getboolean("Preferences", "advanced_parameters", fallback=False) + """Reads values from file_cfg and updates both internal variables and UI checkmarks, + using fallbacks parsed directly from DEFAULT_CONFIG.""" - self.show_status_bar = file_cfg.getboolean("View", "status_bar", fallback=False) - self.main_h_ratio = file_cfg.getfloat("View", "main_h_ratio", fallback=26 / 45) - self.left_v_ratio = file_cfg.getfloat("View", "left_v_ratio", fallback=0.30) + # Parse DEFAULT_CONFIG to use as the single source of truth for fallbacks + default_parser = configparser.ConfigParser() + default_parser.read_string(DEFAULT_CONFIG) - # 2. Sync the UI Menu checkmarks visually - if hasattr(self, 'pref_actions'): - self.pref_actions["2d_data_bypass"].setChecked(self.is_2d_bypass) - self.pref_actions["incompatible_save_bypass"].setChecked(self.incompatible_save_bypass) - self.pref_actions["missing_events_bypass"].setChecked(self.missing_events_bypass) - self.pref_actions["analysis_clearing_bypass"].setChecked(self.analysis_clearing_bypass) - self.pref_actions["folding_bypass"].setChecked(self.folding_bypass) - self.pref_actions["advanced_parameters"].setChecked(self.advanced_parameters) + # Define mapping of sections, keys, and their expected type + config_mappings = { + "View": [ + ("status_bar", "boolean"), + ("main_h_ratio", "float"), + ("left_v_ratio", "float") + ], + "Preferences": [ + ("theme_mode", "string"), + ("processing_2d_data_bypass", "boolean"), + ("processing_incompatible_save_bypass", "boolean"), + ("processing_missing_events_bypass", "boolean"), + ("processing_analysis_clearing_bypass", "boolean"), + ("processing_folding_bypass", "boolean"), + ("processing_separate_events", "boolean"), + ("ui_advanced_parameters", "boolean"), + ("updates_check_plugins_automatic", "boolean"), + ("updates_check_app_automatic", "boolean"), + ] + } - if hasattr(self, 'view_actions'): - self.view_actions["statusbar"].setChecked(self.show_status_bar) + # 1. Sync internal state variables and UI checkmarks dynamically + for section, items in config_mappings.items(): + for key, val_type in items: + # Retrieve the exact fallback specified in DEFAULT_CONFIG + if val_type == "boolean": + fallback = default_parser.getboolean(section, key) + val = file_cfg.getboolean(section, key, fallback=fallback) + elif val_type == "float": + fallback = default_parser.getfloat(section, key) + val = file_cfg.getfloat(section, key, fallback=fallback) + else: + fallback = default_parser.get(section, key) + val = file_cfg.get(section, key, fallback=fallback) - if self.advanced_parameters: + # Set attribute on self + setattr(self, key, val) + + # Sync UI Menu checkmarks visually + if key in self.pref_actions: + self.pref_actions[key].setChecked(val) + elif key == "status_bar" and "statusbar" in self.view_actions: + self.view_actions["statusbar"].setChecked(val) + + # 2. Sync Theme Radio Action Group based on theme_mode string (e.g., "auto", "light", "dark") + theme_mode = getattr(self, "theme_mode", "auto") + theme_action_key = f"theme_{theme_mode}" + if theme_action_key in self.pref_actions: + self.pref_actions[theme_action_key].setChecked(True) + + # 3. Trigger dependent UI updates and handlers + if getattr(self, "ui_advanced_parameters", False): self.update_sections(0) if hasattr(self, 'recent_files_menu'): @@ -1489,6 +1599,7 @@ class MainApplication(QMainWindow): self.project_manager.update_recent_projects_menu() QTimer.singleShot(0, self.apply_splitter_ratios) + self.statusbar.showMessage("Ready") def save_splitter_ratios(self): @@ -1622,7 +1733,7 @@ class MainApplication(QMainWindow): # index = 0 # if not hasattr(self, 'selected_paths'): # self.selected_paths = [] - + # for folder_path in folder_paths: # if not os.path.isdir(folder_path): # continue @@ -1630,7 +1741,7 @@ class MainApplication(QMainWindow): # snirf_files = [str(f) for f in Path(folder_path).glob("*.snirf")] # for full_path in snirf_files: - + # display_name = f"{os.path.basename(folder_path)} / {os.path.basename(full_path)}" # bubble = ProgressBubble(display_name, full_path) # bubble.set_loading_state(True) @@ -1659,7 +1770,7 @@ class MainApplication(QMainWindow): except Exception as e: print(f"Error reading UI parameters: {e}") return None - + def show_files_as_bubbles_from_list(self, file_list, progress_states=None, filenames=None): if not hasattr(self, 'file_executor') or self.file_executor is None: self.file_executor = concurrent.futures.ProcessPoolExecutor(max_workers=1) @@ -1668,7 +1779,7 @@ class MainApplication(QMainWindow): # Initialize trackers and clear layout if not hasattr(self, 'selected_paths'): self.selected_paths = [] - + self.bubble_widgets = {} while self.bubble_layout.count(): @@ -1680,9 +1791,9 @@ class MainApplication(QMainWindow): # Process the file list for index, file_path in enumerate(file_list): file_path = str(file_path) - + display_name = f"{os.path.basename(os.path.dirname(file_path))} / {os.path.basename(file_path)}" - + # Create bubble bubble = ProgressBubble(display_name, file_path) bubble.clicked.connect(self.on_bubble_clicked) @@ -1690,13 +1801,13 @@ class MainApplication(QMainWindow): if hasattr(self, 'file_metadata') and file_path in self.file_metadata: meta = self.file_metadata[file_path] - + parts = [] for key in ["AGE", "SEX", "HAND", "GROUP"]: value = meta.get(key, "").strip() if value: parts.append(f"{key}: {value}") - + suffix = f"{', '.join(parts)}" if parts else "" bubble.setSuffixText(suffix) @@ -1726,7 +1837,7 @@ class MainApplication(QMainWindow): Otherwise, reads directly from live UI QLineEdits. """ parts = [] - + if data_dict is not None: for key in self.meta_fields.keys(): val = str(data_dict.get(key, '')).strip() @@ -1748,12 +1859,12 @@ class MainApplication(QMainWindow): if self.last_clicked_bubble and self.last_clicked_bubble != bubble: suffix = self.get_suffix_from_meta_fields() self.last_clicked_bubble.setSuffixText(suffix) - + self.last_clicked_bubble = bubble - + # show age / sex / hand / group self.right_column_widget.show() - + file_path = bubble.file_path if not os.path.exists(file_path): self.top_left_widget.setText("File not found.") @@ -1788,7 +1899,7 @@ class MainApplication(QMainWindow): info += f" {k}: {v}\n" self.top_left_widget.setText(info) - + clicked_bubble = self.sender() file_path = clicked_bubble.file_path @@ -1814,6 +1925,7 @@ class MainApplication(QMainWindow): menu = QMenu(self) action1 = menu.addAction(QIcon(resource_path("icons/folder_eye_24dp_1F1F1F.svg")), "Reveal") action2 = menu.addAction(QIcon(resource_path("icons/remove_24dp_1F1F1F.svg")), "Remove") + action3 = menu.addAction(QIcon(resource_path("icons/remove_24dp_1F1F1F.svg")), "View Logs") action = menu.exec(global_pos) if action == action1: @@ -1828,7 +1940,7 @@ class MainApplication(QMainWindow): subprocess.run(["xdg-open", folder]) else: print("File not found:", path) - + elif action == action2: if self.button3.isVisible(): reply = QMessageBox.warning( @@ -1841,7 +1953,7 @@ class MainApplication(QMainWindow): return else: self.button3.setVisible(False) - + self.top_left_widget.clear() self.right_column_widget.hide() @@ -1884,21 +1996,51 @@ class MainApplication(QMainWindow): # Remove from selected_path (if used) if hasattr(self, 'selected_path') and self.selected_path == bubble.file_path: self.selected_path = None - + # for section_widget in self.param_sections: # if hasattr(section_widget, 'update_annotation_dropdown_from_loaded_files'): # if "REMOVE_EVENTS" in section_widget.widgets: # section_widget.update_annotation_dropdown_from_loaded_files(self.bubble_widgets, self.button1) # break - + bubble.setParent(None) bubble.deleteLater() - + if getattr(self, 'last_clicked_bubble', None) is bubble: self.last_clicked_bubble = None self.check_files_dirty_state() + elif action == action3: + self.open_log_window(bubble) + + + def open_log_window(self, bubble): + target_path = bubble.file_path + log_content = getattr(self, "participant_logs", {}).get( + target_path, "No logs generated yet for this participant." + ) + + window_title = f"Logs — {os.path.basename(target_path)}" + + if not hasattr(self, "active_log_windows"): + self.active_log_windows = [] + self.active_log_windows = [ + w for w in self.active_log_windows if w.isVisible() + ] + + # If a log window for this specific file is already open, focus it instead of opening a new one + for win in self.active_log_windows: + if getattr(win, "file_path", None) == target_path: + win.raise_() + win.activateWindow() + return + + log_win = LogWindow(window_title, target_path, log_content, self) + log_win.show() + self.active_log_windows.append(log_win) + + def check_files_dirty_state(self): """Compares currently loaded files against the saved baseline list.""" if not hasattr(self, "saved_selected_paths"): @@ -1936,17 +2078,17 @@ class MainApplication(QMainWindow): ) self.files_are_dirty = False self.evaluate_app_dirty_state() - + def sync_bubble_data(self): """Refreshes the bubble and saves data in real-time.""" if self.current_file and self.last_clicked_bubble: # Save the current state of all fields self.save_metadata(self.current_file) - + # Grab the updated suffix and apply it immediately suffix = self.get_suffix_from_meta_fields() self.last_clicked_bubble.setSuffixText(suffix) - + def placeholder(self): QMessageBox.information(self, "Placeholder", "This feature is not implemented yet.") @@ -1958,7 +2100,7 @@ class MainApplication(QMainWindow): key: field.text() for key, field in self.meta_fields.items() } - + def get_all_metadata(self): # First, make sure current file's edits are saved @@ -1989,7 +2131,7 @@ class MainApplication(QMainWindow): if hasattr(self, "result_timer") and self.result_timer.isActive(): self.result_timer.stop() - + # if hasattr(self, "result_process") and self.result_process.is_alive(): # self.result_process.terminate() # Forcefully terminate the process # self.result_process.join() # Wait for it to properly close @@ -1997,7 +2139,7 @@ class MainApplication(QMainWindow): # # Stop the QTimer if running # if hasattr(self, "result_timer") and self.result_timer.isActive(): # self.result_timer.stop() - + for bubble in self.bubble_widgets.values(): bubble.mark_cancelled() @@ -2010,15 +2152,15 @@ class MainApplication(QMainWindow): def on_run_task(self): #do the check - if not self.analysis_clearing_bypass: + if not self.processing_analysis_clearing_bypass: if self.button3.isVisible(): msg = QMessageBox(self) msg.setWindowTitle("Confirm - FLARES") msg.setText("Processing new data will clear the current analysis and close all other windows. Continue? (If you do not want this dialog box to appear, toggle 'Analysis Clearing Bypass' from the Preferences menu.)") - + # Add the OK and Cancel buttons msg.setStandardButtons(QMessageBox.StandardButton.Ok | QMessageBox.StandardButton.Cancel) - + # Set the default button (highlighted) msg.setDefaultButton(QMessageBox.StandardButton.Cancel) @@ -2031,13 +2173,13 @@ class MainApplication(QMainWindow): return self.button3.setVisible(False) - + for item in DATA_SCHEMA: setattr(self, item["key"], {}) for bubble in self.bubble_widgets.values(): bubble.reset() - + for widget in QApplication.topLevelWidgets(): if widget is not self and widget.isVisible(): widget.close() @@ -2110,19 +2252,22 @@ class MainApplication(QMainWindow): # raw.close() # del raw - + self.files_total = len(snirf_files) self.files_done = set() self.files_failed = set() self.files_results = {} - + all_params = {} for section_widget in self.param_sections: section_params = section_widget.get_param_values() all_params.update(section_params) - if self.folding_bypass: + if self.processing_folding_bypass: all_params['FOLDING_BYP'] = True + if self.processing_separate_events: + all_params['SEPARATE_EVENTS'] = True + collected_data = { "SNIRF_FILES": snirf_files, "PARAMS": all_params, # add this line @@ -2134,7 +2279,7 @@ class MainApplication(QMainWindow): self.result_queue = Queue() self.ack_queue = Queue() self.progress_queue = Queue() - + self.result_process = Process( target=run_gui_entry_wrapper, args=(collected_data, self.result_queue, self.progress_queue, self.ack_queue) @@ -2172,12 +2317,12 @@ class MainApplication(QMainWindow): # print(f"[DEBUG] File Done: {os.path.basename(file_path)}") # print(f"[DEBUG] Progress: {len(self.files_done)} / {self.files_total}") - + if msg.get("success"): results = msg["result"] self.files_results[file_path] = results - + # Simple, clean assignment for item, value in zip(DATA_SCHEMA, results): getattr(self, item["key"])[file_path] = value @@ -2210,7 +2355,7 @@ class MainApplication(QMainWindow): except: pass self.result_timer.stop() self.cleanup_after_process() - + success_count = len(self.files_results) fail_count = self.files_total - success_count elapsed_str = self._format_elapsed(msg.get("elapsed", getattr(self, "_last_elapsed", 0))) @@ -2219,7 +2364,7 @@ class MainApplication(QMainWindow): self.statusbar.showMessage( f"Complete: {success_count} succeeded, {fail_count} failed. | Total time: {elapsed_str}{speedup_str}" ) - + if success_count > 0: self.is_saved = False self.update_window_title() @@ -2230,7 +2375,7 @@ class MainApplication(QMainWindow): pass self.button3.clicked.connect(self.open_launcher_window) - + # Reset the button try: self.button1.clicked.disconnect() except: pass @@ -2258,6 +2403,36 @@ class MainApplication(QMainWindow): _, file_path, step_index = msg self.progress_update_signal.emit(file_path, step_index) + elif isinstance(msg, tuple) and len(msg) == 3 and msg[0] == "log": + _, file_path, text = msg + if not hasattr(self, 'participant_logs'): + self.participant_logs = {} + if file_path not in self.participant_logs: + self.participant_logs[file_path] = "" + self.participant_logs[file_path] += text + + # Push live update to any open log window for this file + if hasattr(self, 'active_log_windows'): + for win in self.active_log_windows: + if getattr(win, 'file_path', None) == file_path: + win.append_log(text) + + elif isinstance(msg, dict) and msg.get("type") == "log": + file_path = msg.get("file") + text = msg.get("message") + if file_path: + if not hasattr(self, 'participant_logs'): + self.participant_logs = {} + if file_path not in self.participant_logs: + self.participant_logs[file_path] = "" + self.participant_logs[file_path] += text + + # Push live update to any open log window for this file + if hasattr(self, 'active_log_windows'): + for win in self.active_log_windows: + if getattr(win, 'file_path', None) == file_path: + win.append_log(text) + except Exception as e: print(f"Error in timer loop: {e}") @@ -2273,12 +2448,12 @@ class MainApplication(QMainWindow): bubble = self.bubble_widgets.get(key) if bubble: bubble.mark_cancelled() - + def show_error_popup(self, title, error_message, traceback_str=""): msgbox = QMessageBox(self) msgbox.setIcon(QMessageBox.Warning) msgbox.setWindowTitle(f"Warning - {APP_NAME.upper()}") - + message = ( f"{APP_NAME.upper()} has encountered an error processing the file {title}.

" "This error was likely due to incorrect parameters on the right side of the screen and not an error with your data. " @@ -2297,10 +2472,10 @@ class MainApplication(QMainWindow): msgbox.setStandardButtons(QMessageBox.Ok) msgbox.show() - - + + def cleanup_after_process(self): - + if hasattr(self, 'result_process'): self.result_process.join(timeout=0) if self.result_process.is_alive(): @@ -2333,14 +2508,14 @@ class MainApplication(QMainWindow): # Check if we already have it (we should?) if file_name in self.metadata_cache: return self.metadata_cache[file_name] - + print(self.metadata_cache) # If the user clicked so fast it's not ready, do a one-off blocking call print(f"Cache miss for {file_name}, fetching now...") future = self.file_executor.submit(self.project_manager.extract_metadata_worker, file_name) return future.result(timeout=5) - + def closeEvent(self, event): # Gracefully shut down multiprocessing children @@ -2397,7 +2572,7 @@ class MainApplication(QMainWindow): event.accept() - + def _on_metadata_ready(self, future, file_path, session_id): if session_id != self.loading_session_id: @@ -2405,7 +2580,7 @@ class MainApplication(QMainWindow): if future.cancelled(): return - + try: result = future.result() @@ -2420,7 +2595,7 @@ class MainApplication(QMainWindow): val = result.get(display_field, '') if val: self.file_metadata[file_path][internal_key] = val - + result = {'status': 'success', 'data': result} except Exception as e: @@ -2435,15 +2610,15 @@ class MainApplication(QMainWindow): if result.get('status') == 'error': # 1. Pop up the warning safely on the main thread QMessageBox.warning( - self, - "Invalid File", + self, + "Invalid File", f"Could not read metadata from: {os.path.basename(file_path)}\n\n" f"Details: {result.get('reason', 'Unknown error')}" ) # 2. Run your clean tracking removal self._remove_file_from_pipeline(file_path) return - + # Success path data = result.get('data', result) norm_path = os.path.normpath(file_path) @@ -2500,7 +2675,7 @@ class MainApplication(QMainWindow): line_edit.setText(val) line_edit.blockSignals(False) - + def _remove_file_from_pipeline(self, file_path): """Completely cleans up and removes all references to a file that failed to load.""" # 1. Decrement pending file count @@ -2525,7 +2700,7 @@ class MainApplication(QMainWindow): def _safe_ui_update(self, file_path): - + # 2. Update the Bubble safely if file_path in self.bubble_widgets: bubble = self.bubble_widgets[file_path] @@ -2640,7 +2815,7 @@ def run_gui_entry_wrapper(config, gui_queue, progress_queue, ack_queue): }) os._exit(1) - + def resource_path(relative_path): """ Get absolute path to resource regardless of running directly or packaged using PyInstaller @@ -2694,13 +2869,13 @@ def exception_hook(exc_type, exc_value, exc_traceback): # Exit the app after user acknowledges if getattr(sys, 'frozen', False): sys.exit(1) - + def show_critical_error(error_msg): msg_box = QMessageBox() msg_box.setIcon(QMessageBox.Icon.Critical) msg_box.setWindowTitle(f"Something went wrong! - {APP_NAME.upper()}") - + if PLATFORM_NAME == "darwin": log_path = os.path.join(os.path.dirname(sys.executable), "../../../flares.log") error_path = os.path.join(os.path.dirname(sys.executable), "../../../flares_error.log") @@ -2766,7 +2941,7 @@ def config_init(): file_cfg.remove_section(section) has_changes = True continue - + for option in list(file_cfg.options(section)): if not ref_cfg.has_option(section, option): file_cfg.remove_option(section, option) @@ -2776,7 +2951,7 @@ def config_init(): if not file_cfg.has_section(section): file_cfg.add_section(section) has_changes = True - + for option in list(ref_cfg.options(section)): if not file_cfg.has_option(section, option): default_val = ref_cfg.get(section, option) @@ -2795,7 +2970,7 @@ def config_init(): print(f"Error validating config file ({e}). Falling back completely to defaults.") file_cfg.read_string(DEFAULT_CONFIG) - + if __name__ == "__main__": @@ -2803,7 +2978,7 @@ if __name__ == "__main__": _, _ext, _prog_id, _app_name = sys.argv[1:5] _ok, _msg = register_file_association(ext=_ext, prog_id=_prog_id, app_name=_app_name) sys.exit(0 if _ok else 1) - + startup_args = parse_startup_args(sys.argv) # Redirect exceptions to the popup window @@ -2832,7 +3007,7 @@ if __name__ == "__main__": freeze_support() # Required for PyInstaller + multiprocessing set_start_method('spawn', force=True) - + # Only run GUI in the main process if current_process().name == 'MainProcess': app = CustomApplication(sys.argv) @@ -2847,4 +3022,4 @@ if __name__ == "__main__": window.show() sys.exit(app.exec()) -# Not 2800 lines yay! \ No newline at end of file +# Not 3100 lines yay! \ No newline at end of file diff --git a/main_unit_tests.py b/main_unit_tests.py index a4c2370..e3cda3b 100644 --- a/main_unit_tests.py +++ b/main_unit_tests.py @@ -25,11 +25,11 @@ main_test.py::test_save_project_actions_pass_correct_ask_parameter main_test.py::test_main_window_opens main_test.py::test_file_recent_submenus_exist main_test.py::test_view_reset_layout -main_test.py::test_preferences_actions[2D Data Bypass-2d_data_bypass] -main_test.py::test_preferences_actions[Incompatible Save Bypass-incompatible_save_bypass] -main_test.py::test_preferences_actions[Missing Events Bypass-missing_events_bypass] -main_test.py::test_preferences_actions[Analysis Clearing Bypass-analysis_clearing_bypass] -main_test.py::test_preferences_actions[Folding Bypass-folding_bypass] +main_test.py::test_preferences_actions[2D Data Bypass-2d_data_bypass] +main_test.py::test_preferences_actions[Incompatible Save Bypass-incompatible_save_bypass] +main_test.py::test_preferences_actions[Missing Events Bypass-missing_events_bypass] +main_test.py::test_preferences_actions[Analysis Clearing Bypass-analysis_clearing_bypass] +main_test.py::test_preferences_actions[Folding Bypass-folding_bypass] main_test.py::test_preferences_actions[Show Advanced Parameters-advanced_parameters] ''' diff --git a/plugin_manager.py b/plugin_manager.py index 3b688b7..b1fd081 100644 --- a/plugin_manager.py +++ b/plugin_manager.py @@ -22,7 +22,7 @@ from typing import Any, cast # External library imports import certifi -from PySide6.QtCore import QObject, QUrl, Signal +from PySide6.QtCore import QObject, QThread, QUrl, Signal from PySide6.QtGui import QDesktopServices from PySide6.QtWidgets import QMainWindow, QMenu, QMessageBox @@ -58,7 +58,7 @@ def parse_version(v_str: str) -> tuple[int, ...]: class PluginManager(QObject): """ - Handles plugin discovery, dynamic loading, menu building, + Handles plugin discovery, dynamic loading, menu building, and plugin lifecycle (install, toggle, uninstall). """ @@ -227,7 +227,7 @@ class PluginManager(QObject): # Load local plugin.json if present manifest: dict[str, Any] = {} manifest_path = (entry / "plugin.json") if entry.is_dir() else None - + if manifest_path and manifest_path.exists(): try: with open(manifest_path, "r", encoding="utf-8") as f: @@ -328,4 +328,99 @@ class PluginManager(QObject): """Opens the local plugins folder in the operating system's file manager.""" if self.plugins_dir.exists(): return QDesktopServices.openUrl(QUrl.fromLocalFile(str(self.plugins_dir))) - return False \ No newline at end of file + return False + + def check_for_updates(self, repo_urls: list[str]) -> list[dict[str, Any]]: + """ + Fetches remote repositories and compares server plugin versions + against installed plugins, returning the installed plugins list + enriched with update metadata. + """ + installed_plugins = self.get_installed_plugins_info() + remote_data, _ = self.fetch_remote_repositories(repo_urls) + + # Index remote plugins by their ID (fallback to name if id is missing) + remote_map: dict[str, dict[str, Any]] = {} + for remote_plugin in remote_data: + plugin_id = remote_plugin.get("id") or remote_plugin.get("name") + if plugin_id: + remote_map[str(plugin_id)] = remote_plugin + + # Compare versions and annotate each installed plugin dictionary + for plugin in installed_plugins: + plugin_id = str(plugin.get("id", "")) + local_version_str = str(plugin.get("version", "0.0.0")) + local_tuple = parse_version(local_version_str) + + remote_plugin = remote_map.get(plugin_id) + if remote_plugin: + remote_version_str = str(remote_plugin.get("version", "0.0.0")) + remote_tuple = parse_version(remote_version_str) + + if remote_tuple > local_tuple: + plugin["update_available"] = True + plugin["remote_version"] = remote_version_str + plugin["download_url"] = str(remote_plugin.get("download_url", "")) + else: + plugin["update_available"] = False + plugin["remote_version"] = remote_version_str + plugin["download_url"] = "" + else: + plugin["update_available"] = False + plugin["remote_version"] = None + plugin["download_url"] = "" + + return installed_plugins + + +class StartupUpdateWorker(QThread): + updates_checked = Signal(list) + + def __init__(self, manager: Any, repo_urls: list[str]) -> None: + super().__init__() + self.manager = manager + self.repo_urls = repo_urls + + def run(self) -> None: + try: + print("[StartupWorker] Starting background update check...") + installed_plugins = self.manager.get_installed_plugins_info() + print(f"[StartupWorker] Found {len(installed_plugins)} installed local plugins.") + + remote_data, all_failed = self.manager.fetch_remote_repositories(self.repo_urls) + print(f"[StartupWorker] Fetch remote complete. Failed: {all_failed}, Items fetched: {len(remote_data)}") + + if all_failed or not remote_data: + print("[StartupWorker] Aborting: Remote fetch failed or returned empty data.") + return + + remote_map: dict[str, dict[str, Any]] = {} + for raw_plugin in remote_data: + if isinstance(raw_plugin, dict): + remote_plugin = cast(dict[str, Any], raw_plugin) + + plugin_id = remote_plugin.get("id") or remote_plugin.get("name") + if isinstance(plugin_id, str): + remote_map[plugin_id] = remote_plugin + + outdated_plugins: list[dict[str, Any]] = [] + for plugin in installed_plugins: + plugin_id = str(plugin.get("id", "")) + local_version_str = str(plugin.get("version", "0.0.0")) + local_tuple = parse_version(local_version_str) + + remote_plugin = remote_map.get(plugin_id) + if remote_plugin: + remote_version_str = str(remote_plugin.get("version", "0.0.0")) + remote_tuple = parse_version(remote_version_str) + print(f"[StartupWorker] Comparing '{plugin.get('name')}': Local v{local_version_str} vs Remote v{remote_version_str}") + + if remote_tuple > local_tuple: + plugin["remote_version"] = remote_version_str + plugin["download_url"] = str(remote_plugin.get("download_url", "")) + outdated_plugins.append(plugin) + + print(f"[StartupWorker] Outdated plugins found: {len(outdated_plugins)}") + self.updates_checked.emit(outdated_plugins) + except Exception as e: + print(f"[StartupWorker] Error during startup update check: {e}") \ No newline at end of file diff --git a/project_manager.py b/project_manager.py index 9e021fb..5d190b5 100644 --- a/project_manager.py +++ b/project_manager.py @@ -38,6 +38,23 @@ if TYPE_CHECKING: from main import MainApplication +class LoadProjectThread(QThread): + finished_signal = Signal(object) # Emits the loaded project data dictionary + error_signal = Signal(str) # Emits the error message string + + def __init__(self, filename: str) -> None: + super().__init__() + self.filename = filename + + def run(self) -> None: + try: + with open(self.filename, "rb") as f: + project_data = pickle.load(f) + self.finished_signal.emit(project_data) + except Exception as e: + self.error_signal.emit(str(e)) + + class SaveProjectThread(QThread): finished_signal = Signal(str) error_signal = Signal(str) @@ -56,6 +73,25 @@ class SaveProjectThread(QThread): self.error_signal.emit(str(e)) +class LoadingOverlay(QDialog): + def __init__(self, parent: Optional[QWidget] = None) -> None: + super().__init__(parent) + self.setWindowFlags(Qt.WindowType.Dialog | Qt.WindowType.FramelessWindowHint) + self.setModal(True) + self.setWindowModality(Qt.WindowModality.ApplicationModal) + self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground) + + layout = QVBoxLayout() + layout.setAlignment(Qt.AlignmentFlag.AlignCenter) + + label = QLabel("Loading Project…") + label.setStyleSheet( + "font-size: 18px; color: white; background-color: rgba(0,0,0,150);" + " padding: 20px; border-radius: 10px;" + ) + layout.addWidget(label) + self.setLayout(layout) + class SavingOverlay(QDialog): def __init__(self, parent: Optional[QWidget] = None) -> None: @@ -64,7 +100,7 @@ class SavingOverlay(QDialog): self.setModal(True) self.setWindowModality(Qt.WindowModality.ApplicationModal) self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground) - + layout = QVBoxLayout() layout.setAlignment(Qt.AlignmentFlag.AlignCenter) @@ -72,7 +108,7 @@ class SavingOverlay(QDialog): label.setStyleSheet("font-size: 18px; color: white; background-color: rgba(0,0,0,150); padding: 20px; border-radius: 10px;") layout.addWidget(label) self.setLayout(layout) - + class ProjectManager: """ @@ -118,7 +154,7 @@ class ProjectManager: return f.read(8) == b"\x89HDF\r\n\x1a\n" except Exception: return False - + def open_file_dialog(self) -> None: """Opens dialog to pick a single .snirf file.""" file_path, _ = QFileDialog.getOpenFileName( @@ -171,6 +207,8 @@ class ProjectManager: ) return + self._load_files_into_pipeline(snirf_files) + def load_dropped_files(self, file_paths: List[str]) -> None: """Public entry point for handling files or folders dropped onto the UI.""" if not file_paths: @@ -217,7 +255,7 @@ class ProjectManager: # Delegate to internal pipeline self._load_files_into_pipeline(valid_files) - + def _load_files_into_pipeline(self, file_paths: List[str]) -> None: """Loads .snirf files into UI using chunked batches and background workers.""" app = self.app @@ -243,7 +281,7 @@ class ProjectManager: # Filter out duplicates AND non-SNIRF files (including macOS ._ shadow files) new_files = [ - p for p in file_paths + p for p in file_paths if p not in app.selected_paths and self.is_valid_snirf(p) ] if not new_files: @@ -339,12 +377,31 @@ class ProjectManager: self.load_project(filename) def load_project(self, filename: str) -> None: - """Loads a .flare project file into the application.""" + """Triggers asynchronous loading of a .flare project file.""" + app = self.app + + # 1. Show the custom loading overlay matching your saver design + self.loading_overlay = LoadingOverlay(app) + self.loading_overlay.resize(app.size()) + self.loading_overlay.move(app.pos()) + self.loading_overlay.show() + + # 2. Fire up the background thread for pickle loading + self.loader_thread = LoadProjectThread(filename) + self.loader_thread.finished_signal.connect( + lambda data: self._handle_loaded_data(filename, data) + ) + self.loader_thread.error_signal.connect(self._handle_load_error) + self.loader_thread.start() + + def _handle_loaded_data(self, filename: str, data: dict) -> None: + # Close overlay + if hasattr(self, "loading_overlay"): + self.loading_overlay.close() + self.loading_overlay.deleteLater() + app = self.app try: - with open(filename, "rb") as f: - data = pickle.load(f) - checks = [ ("version", "<=1.1.7"), ("file_metadata", "<=1.2.2"), @@ -358,7 +415,7 @@ class ProjectManager: f"This project was saved in an earlier version of {APP_NAME.upper()} ({ver_str}) " "and is potentially not compatible with this version. " ) - if getattr(app, "incompatible_save_bypass", False): + if getattr(app, "processing_incompatible_save_bypass", False): QMessageBox.warning( app, f"Warning - {APP_NAME.upper()}", msg + "Attempting load." ) @@ -438,16 +495,19 @@ class ProjectManager: if hasattr(app, "button3"): app.button3.setVisible(has_data) - self.add_to_recent_projects(os.path.normpath(filename)) - - # Reset baselines cleanly self.reset_all_dirty_states() QMessageBox.information(app, "Loaded", f"Project loaded from:\n{filename}") except Exception as e: - QMessageBox.critical(app, "Error", f"Failed to load project:\n{e}") + QMessageBox.critical(app, "Error", f"Failed to process loaded project data:\n{e}") + + def _handle_load_error(self, error_msg: str) -> None: + if hasattr(self, "loading_overlay"): + self.loading_overlay.close() + self.loading_overlay.deleteLater() + QMessageBox.critical(self.app, "Error", f"Failed to read project file:\n{error_msg}") # ========================================================================= # Project Saving (Save / Save As) @@ -863,7 +923,6 @@ def _get_bids_demographics(snirf_path: str) -> dict[str, str]: return {} - def extract_metadata_worker(file_name: str) -> dict[str, Any]: """Runs in the separate worker process. Returns a clean dict.""" @@ -921,7 +980,7 @@ def extract_metadata_worker(file_name: str) -> dict[str, Any]: if "hand" in demographics: snirf_info["BIDS - Handedness"] = demographics["hand"] return snirf_info - + except Exception as e: print(f"Worker safely caught failure on {file_name}: {str(e)}") return {'status': 'error', 'reason': str(e)} diff --git a/src/analysis/exporttocsv.py b/src/analysis/exporttocsv.py index 462ef21..509986a 100644 --- a/src/analysis/exporttocsv.py +++ b/src/analysis/exporttocsv.py @@ -12,6 +12,7 @@ import os from typing import Any # External library imports +from mne import Epochs from pandas import DataFrame from mne.io.base import BaseRaw @@ -25,8 +26,9 @@ from src.shared.shareddata import APP_NAME class ExportToCSVWidget(CSVUIMixin, FlaresBaseWidget): def __init__( - self, - haemo_dict: dict[str, BaseRaw], + self, + haemo_dict: dict[str, BaseRaw], + epochs_dict: dict[str, Epochs], cha_dict: dict[str, DataFrame], df_ind_dict: dict[str, DataFrame], design_matrix_dict: dict[str, DataFrame], @@ -36,10 +38,11 @@ class ExportToCSVWidget(CSVUIMixin, FlaresBaseWidget): fir_feature_dict: dict[str, dict[str, Any]], qc_dict: dict[str, dict[str, Any]], ) -> None: - + super().__init__("ExportToCSV") self.setWindowTitle(f"Export To CSV Viewer - {APP_NAME.upper()}") self.haemo_dict = haemo_dict + self.epochs_dict = epochs_dict self.cha_dict = cha_dict self.df_ind_dict = df_ind_dict self.design_matrix = design_matrix_dict @@ -48,20 +51,19 @@ class ExportToCSVWidget(CSVUIMixin, FlaresBaseWidget): self.config_dict = config_dict self.fir_feature_dict = fir_feature_dict self.qc_dict = qc_dict - + self.setup_csv_ui(["0 (Export Data to CSV)", "1 (CSV for SPARKS)", "2 (Export Configuration to CSV)", "3 (Paragraph of Configuration)", "4 (Export FIR Waveform Features to CSV)", - "5 (Export Quality Control Metrics to CSV)", - "6 (Export Master Consolidated Matrix [All Participants into 1 CSV])" + "5 (Export Quality Control Metrics to CSV)", "6 (Export Master Consolidated Matrix [All Participants into 1 CSV])" ]) - - + + def process_request(self): selected_display_names = self._get_checked_items(self.participant_dropdown) selected_file_paths: list[str] = [] for display_name in selected_display_names: for fp, short_label in self.participant_map.items(): expected_display = f"{short_label} ({os.path.basename(fp)})" - if display_name == expected_display: + if display_name == expected_display: selected_file_paths.append(fp) break @@ -74,7 +76,7 @@ class ExportToCSVWidget(CSVUIMixin, FlaresBaseWidget): return output_dir = QFileDialog.getExistingDirectory(self, "Select Output Folder for CSV Exports") - + if not output_dir: print("Export cancelled: No folder selected.") return @@ -120,78 +122,78 @@ class ExportToCSVWidget(CSVUIMixin, FlaresBaseWidget): magic_string = self.gen_magic_str(first_params) self.placeholder_label.setText(magic_string) - # elif idx == 4: - # # FIR Waveform Features Export - # fir_data = self.fir_feature_dict.get(file_path) - # if fir_data and isinstance(fir_data, dict): - # names = fir_data.get("feature_names", []) - # vals = fir_data.get("features", []) - # chans = fir_data.get("feature_channels", []) + elif idx == 4: + # FIR Waveform Features Export + fir_data = self.fir_feature_dict.get(file_path) + if fir_data: + names = fir_data.get("feature_names", []) + vals = fir_data.get("features", []) + chans = fir_data.get("feature_channels", []) - # fir_df = DataFrame({ - # "Feature_Name": names, - # "Channel": chans if len(chans) == len(names) else ["N/A"] * len(names), - # "Value": vals - # }) - # save_path = os.path.join(output_dir, f"{base_filename}_fir_features.csv") - # fir_df.to_csv(save_path, index=False) - # success_count += 1 + fir_df = DataFrame({ + "Feature_Name": names, + "Channel": chans if len(chans) == len(names) else ["N/A"] * len(names), + "Value": vals + }) + save_path = os.path.join(output_dir, f"{base_filename}_fir_features.csv") + fir_df.to_csv(save_path, index=False) + success_count += 1 - # elif idx == 5: - # # Quality Control (QC) Metrics Export - # qc_data = self.qc_dict.get(file_path) - # if qc_data and isinstance(qc_data, dict): - # qc_df = DataFrame(list(qc_data.items()), columns=["Metric", "Value"]) - # save_path = os.path.join(output_dir, f"{base_filename}_qc_metrics.csv") - # qc_df.to_csv(save_path, index=False) - # success_count += 1 + elif idx == 5: + # Quality Control (QC) Metrics Export + qc_data = self.qc_dict.get(file_path) + if qc_data and isinstance(qc_data, dict): + qc_df = DataFrame(list(qc_data.items()), columns=["Metric", "Value"]) + save_path = os.path.join(output_dir, f"{base_filename}_qc_metrics.csv") + qc_df.to_csv(save_path, index=False) + success_count += 1 - # elif idx == 6: - # # Master Consolidated Matrix (1 single CSV combining all selected participants) - # save_path = os.path.join(output_dir, f"{APP_NAME}_master_consolidated.csv") - # if not os.path.exists(save_path): - # master_rows: list[dict[str, Any]] = [] - # for fp in selected_file_paths: - # abs_path = os.path.abspath(fp) - # grp = self.group_dict.get(fp, "Unknown") - # row: dict[str, Any] = {"Participant": abs_path, "Group": grp} + elif idx == 6: + # Master Consolidated Matrix (1 single CSV combining all selected participants) + save_path = os.path.join(output_dir, f"{APP_NAME}_master_consolidated.csv") + if not os.path.exists(save_path): + master_rows: list[dict[str, Any]] = [] + for fp in selected_file_paths: + abs_path = os.path.abspath(fp) + grp = self.group_dict.get(fp, "Unknown") + row: dict[str, Any] = {"Participant": abs_path, "Group": grp} - # # QC Metrics - # qc_info = self.qc_dict.get(fp, {}) - # if isinstance(qc_info, dict): - # for mk, mv in qc_info.items(): - # row[f"QC_{mk}"] = mv + # QC Metrics + qc_info = self.qc_dict.get(fp, {}) + if isinstance(qc_info, dict): + for mk, mv in qc_info.items(): + row[f"QC_{mk}"] = mv - # # FIR Features - # fir_info = self.fir_feature_dict.get(fp, {}) - # print("1") - # if isinstance(fir_info, dict): - # print("2") - # f_names = fir_info.get("feature_names", []) - # f_vals = fir_info.get("features", []) - # print("3") - # if len(f_names) == len(f_vals): - # print("4") - # for fn, fv in zip(f_names, f_vals): - # row[f"FIR_{fn}"] = fv + # FIR Features + fir_info = self.fir_feature_dict.get(fp, {}) + print("1") + if isinstance(fir_info, dict): + print("2") + f_names = fir_info.get("feature_names", []) + f_vals = fir_info.get("features", []) + print("3") + if len(f_names) == len(f_vals): + print("4") + for fn, fv in zip(f_names, f_vals): + row[f"FIR_{fn}"] = fv - # master_rows.append(row) + master_rows.append(row) - # if master_rows: - # df_master = DataFrame(master_rows) - # df_master.to_csv(save_path, index=False) - # success_count += 1 + if master_rows: + df_master = DataFrame(master_rows) + df_master.to_csv(save_path, index=False) + success_count += 1 else: print(f"No method defined for index {idx}") - + except Exception as e: print(f"Failed to export {file_path} (Type {idx}): {e}") # 4. Final Notification if success_count > 0: QMessageBox.information(self, "Export Complete", f"Successfully saved {success_count} CSV files to:\n{output_dir}") - + # # If SPARKS export was included, show the Event Window once at the end # if 1 in selected_indexes: # win = UpdateEventsWindow( @@ -203,62 +205,62 @@ class ExportToCSVWidget(CSVUIMixin, FlaresBaseWidget): def gen_magic_str(self, all_params: dict[str, str]) -> str: - + magic_str = "To start, the data was loaded into the application. " if all_params['DOWNSAMPLE']: magic_str += f"The data was downsampled to {all_params['DOWNSAMPLE_FREQUENCY']}hz. " else: magic_str += "The data was not downsampled and was retained at its original sampling frequency. " - + if all_params['TRIM']: magic_str += f"The data was then trimmed to only keep {all_params['SECONDS_TO_KEEP']} seconds before the first event, as the earlier data with no events would not be needed. " else: magic_str += "The data was not trimmed and was retained at its original length. " - + if all_params['OPTODE_PLACEMENT']: magic_str += f"A visualization check of the optode locations in 3D space was performed to ensure that they were at the correct location. " - + if all_params['SHORT_CHANNELS']: magic_str += f"A short channel(s) was specified with the maximum length of a short channel being {all_params['SHORT_CHANNELS_THRESHOLD']} metres. Channels that were over this distance but under {all_params['LONG_CHANNELS_THRESHOLD']} metres were retained. " else: magic_str += f"No short channel was specified. Channels between the lengths of {all_params['SHORT_CHANNELS_THRESHOLD']} and {all_params['LONG_CHANNELS_THRESHOLD']} meters were used with all other channles being discarded. " - + if all_params['HEART_RATE']: magic_str += f"The heart rate of the participant was attempted to be calculated. {all_params['SECONDS_TO_STRIP_HR']} seconds were ignored from the start of the file to avoid motion artifacts. " magic_str += f"The heart rate had hard limits of {all_params['MAX_LOW_HR']} to {all_params['MAX_HIGH_HR']} and was smoothed across {all_params['SMOOTHING_WINDOW_HR']} samples. " else: magic_str += "The heart rate of the participants was not attempted to be calculated. " - + if all_params['SCI']: magic_str += f"The Scalp Coupling Index was calculated over {all_params['SCI_TIME_WINDOW']} second windows. The threshold value was {all_params['SCI_THRESHOLD']}. " else: magic_str += "Scalp Coupling Index was not used. " - + if all_params['SNR']: magic_str += f"The Signal to Noise Ratio was calculated. The threshold value was set to {all_params['SNR_THRESHOLD']}db. " else: magic_str += "The Signal to Noise Ratio was not used. " - + if all_params['PSP']: magic_str += f"Peak Spectral Power was calculated over {all_params['PSP_TIME_WINDOW']} second windows. The threshold value was {all_params['PSP_THRESHOLD']}. " else: magic_str += "Peak Spectral Power was not used. " - + if all_params['COEFF_VAR']: magic_str += f"The Coefficient of Variation was calculated. The threshold value was set to {all_params['COEFF_VAR_THRESHOLD']}. " else: magic_str += "The Coefficient of Variation was not used. " - + if all_params['MAD']: magic_str += f"Median Absolute Deviation was calculated. The threshold value was set to {all_params['MAD_THRESHOLD']}. " else: magic_str += "Median Absolute Deviation was not used. " - + if all_params['PSD_NOISE']: magic_str += f"Power Spectral Density Noise was calculated with a target frequency divisor of {all_params['TARGET_FREQ_DIV']} and a decibal limit of {all_params['DB_LIMIT']}. " else: magic_str += f"Power Spectral Density Noise was not used. " - + magic_str += "More coming soon." return magic_str \ No newline at end of file diff --git a/src/analysis/intergroupbrainimage.py b/src/analysis/intergroupbrainimage.py index 7283f01..71e509a 100644 --- a/src/analysis/intergroupbrainimage.py +++ b/src/analysis/intergroupbrainimage.py @@ -68,13 +68,13 @@ DESCRIPTION = """\n1. Group Contrast 2D/3D (plot_2d_3d_contrasts_between_groups) class InterGroupBrainImageWidget(InterGroupUIMixin, FlaresBaseWidget): def __init__( self, - haemo_dict: dict[str, BaseRaw], + haemo_dict: dict[str, BaseRaw], df_ind_dict: dict[str, DataFrame], design_matrix_dict: dict[str, DataFrame], contrast_results_dict: dict[str, dict[str, Any]], group_dict: dict[str, str], ) -> None: - + super().__init__("InterGroupBrainImage") self.setWindowTitle(f"Inter-Group Brain & Image Viewer - {APP_NAME.upper()}") self.haemo_dict = haemo_dict @@ -87,7 +87,7 @@ class InterGroupBrainImageWidget(InterGroupUIMixin, FlaresBaseWidget): def process_request(self): - + request = self.get_common_request_data(PARAMETERIZED_INDEXES) if request is None: return @@ -122,7 +122,7 @@ class InterGroupBrainImageWidget(InterGroupUIMixin, FlaresBaseWidget): print("contrast_df_b empty?", contrast_df_b.empty) all_raw_objs = [self.haemo_dict.get(fp) for fp in all_selected_paths if self.haemo_dict.get(fp)] - + if len(all_raw_objs) > 1: processed_raw = aggregate_fnirs_group_geometry(all_raw_objs) elif len(all_raw_objs) == 1 and all_raw_objs[0] is not None: diff --git a/src/analysis/intergroupstats.py b/src/analysis/intergroupstats.py index 59885a8..63eb454 100644 --- a/src/analysis/intergroupstats.py +++ b/src/analysis/intergroupstats.py @@ -125,7 +125,7 @@ PARAMETERIZED_INDEXES: dict[int, list[dict[str, Any]]] = { }, ], } - + DESCRIPTION = """0. Raw ROI Comparison (run_inter_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. @@ -145,8 +145,8 @@ DESCRIPTION = """0. Raw ROI Comparison (run_inter_group_second_level_analysis) class InterGroupStatsWidget(InterGroupUIMixin, FlaresBaseWidget): def __init__( - self, - haemo_dict: dict[str, BaseRaw], + self, + haemo_dict: dict[str, BaseRaw], cha_dict: dict[str, DataFrame], df_ind_dict: dict[str, DataFrame], design_matrix_dict: dict[str, DataFrame], @@ -154,7 +154,7 @@ class InterGroupStatsWidget(InterGroupUIMixin, FlaresBaseWidget): roi_channel_map_dict: dict[str, dict[str, str]], group_dict: dict[str, str], ) -> None: - + super().__init__("InterGroupStats") self.setWindowTitle(f"Inter-Group Stats Viewer - {APP_NAME.upper()}") self.haemo_dict = haemo_dict @@ -166,7 +166,7 @@ class InterGroupStatsWidget(InterGroupUIMixin, FlaresBaseWidget): self.group_dict = group_dict self.setup_inter_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, self.df_ind_dict, self.contrast_results_dict) @@ -185,7 +185,7 @@ class InterGroupStatsWidget(InterGroupUIMixin, FlaresBaseWidget): valid_chas = [df for df in self.cha_dict.values() if not df.empty] cha_combined = pd.concat(valid_chas, ignore_index=True) if valid_chas else pd.DataFrame() - + sample_path = file_paths_a[0] p_haemo = self.haemo_dict.get(sample_path) @@ -226,7 +226,7 @@ class InterGroupStatsWidget(InterGroupUIMixin, FlaresBaseWidget): 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) @@ -234,36 +234,36 @@ class InterGroupStatsWidget(InterGroupUIMixin, FlaresBaseWidget): target_chroma = params.get("target_chroma", "hbo") roi_a: str = params.get("roi_a", "").strip() roi_b: str = 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: list[str], + file_paths: list[str], dict_source: dict[str, DataFrame] ) -> DataFrame: - + valid_dfs = [ dict_source[fp] for fp in file_paths if fp in dict_source 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_inter_group_laterality_analysis( df_roi_all_a=df_roi_a, df_roi_all_b=df_roi_b, @@ -278,7 +278,7 @@ class InterGroupStatsWidget(InterGroupUIMixin, FlaresBaseWidget): 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) @@ -286,11 +286,11 @@ class InterGroupStatsWidget(InterGroupUIMixin, FlaresBaseWidget): correction_method = params.get("correction_method", "fdr_bh") target_chroma = params.get("target_chroma", "hbo") contrast_name = params.get("contrast_name", "") - + if not contrast_name: print("A contrast name must be specified.") 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 @@ -300,7 +300,7 @@ class InterGroupStatsWidget(InterGroupUIMixin, FlaresBaseWidget): contrast_dict: dict[str, dict[str, pd.DataFrame]], name: str, ) -> pd.DataFrame: - + all_rows: list[DataFrame] = [] for fp in file_paths: condition_dfs = contrast_dict.get(fp) @@ -315,10 +315,10 @@ class InterGroupStatsWidget(InterGroupUIMixin, FlaresBaseWidget): 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 @@ -350,6 +350,6 @@ class InterGroupStatsWidget(InterGroupUIMixin, FlaresBaseWidget): p_threshold=p_threshold, correction_method=correction_method, ) - + else: print("no") \ No newline at end of file diff --git a/src/analysis/intragroupbrainimage.py b/src/analysis/intragroupbrainimage.py index 2395703..7505d45 100644 --- a/src/analysis/intragroupbrainimage.py +++ b/src/analysis/intragroupbrainimage.py @@ -78,7 +78,7 @@ DESCRIPTION = """0. FIR Model Results (plot_fir_model_results) class IntraGroupBrainImageWidget(IntraGroupUIMixin, FlaresBaseWidget): def __init__( self, - haemo_dict: dict[str, BaseRaw], + haemo_dict: dict[str, BaseRaw], cha_dict: dict[str, DataFrame], df_ind_dict: dict[str, DataFrame], design_matrix_dict: dict[str, DataFrame], @@ -94,7 +94,7 @@ class IntraGroupBrainImageWidget(IntraGroupUIMixin, FlaresBaseWidget): self.design_matrix_dict = design_matrix_dict self.contrast_results_dict = contrast_results_dict self.group_dict = group_dict - + self.setup_intra_group_ui(["0 (GLM Results)", "1 (Brain Activity Visualization)"], placeholder_text=DESCRIPTION) @@ -116,18 +116,18 @@ class IntraGroupBrainImageWidget(IntraGroupUIMixin, FlaresBaseWidget): if selected_event: raw_annotations = getattr(haemo_obj, "annotations", None) - + if raw_annotations is not None: annotations = cast(Annotations, raw_annotations) descriptions = cast(list[str], list(annotations.description)) participant_events: set[str] = set(descriptions) else: participant_events: set[str] = set() - + if selected_event not in participant_events: print(f"Skipping {self.participant_map[file_path]}: Event '{selected_event}' not found.") continue - + cha_df = self.cha_dict.get(file_path) if cha_df is not None: all_cha = pd.concat([all_cha, cha_df], ignore_index=True) @@ -144,7 +144,7 @@ class IntraGroupBrainImageWidget(IntraGroupUIMixin, FlaresBaseWidget): df = self.df_ind_dict.get(file_path) if df is not None: df_group = pd.concat([df_group, df], ignore_index=True) - + for idx in selected_indexes: if idx == 0: @@ -170,7 +170,7 @@ class IntraGroupBrainImageWidget(IntraGroupUIMixin, FlaresBaseWidget): continue all_raw_objs = [self.haemo_dict.get(fp) for fp in selected_file_paths if self.haemo_dict.get(fp)] - + if len(all_raw_objs) > 1: processed_raw = aggregate_fnirs_group_geometry(all_raw_objs) elif len(all_raw_objs) == 1 and all_raw_objs[0] is not None: @@ -180,6 +180,6 @@ class IntraGroupBrainImageWidget(IntraGroupUIMixin, FlaresBaseWidget): processed_raw = None brain_3d_visualization(processed_raw, all_cha, selected_event, t_or_theta=t_or_theta, show_optodes=show_optodes, show_text=show_text, brain_bounds=brain_bounds) - + else: print(f"No method defined for index {idx}") \ No newline at end of file diff --git a/src/analysis/intragroupfunctionalconnectivity.py b/src/analysis/intragroupfunctionalconnectivity.py index a53ae54..4f7faac 100644 --- a/src/analysis/intragroupfunctionalconnectivity.py +++ b/src/analysis/intragroupfunctionalconnectivity.py @@ -64,7 +64,7 @@ class IntraGroupFunctionalConnectivityWidget(IntraGroupUIMixin, FlaresBaseWidget epochs_dict: dict[str, Epochs], group_dict: dict[str, str], ) -> None: - + super().__init__("IntraGroupFunctionalConnectivity") self.setWindowTitle(f"Intra-Group Functional Connectivity Viewer [BETA] - {APP_NAME.upper()}") self.haemo_dict = haemo_dict diff --git a/src/analysis/intragroupstats.py b/src/analysis/intragroupstats.py index 82e9a6d..21941a7 100644 --- a/src/analysis/intragroupstats.py +++ b/src/analysis/intragroupstats.py @@ -157,8 +157,8 @@ DESCRIPTION = """0. ROI vs. Zero (run_roi_second_level_analysis) class IntraGroupStatsWidget(IntraGroupUIMixin, FlaresBaseWidget): def __init__( - self, - haemo_dict: dict[str, BaseRaw], + self, + haemo_dict: dict[str, BaseRaw], cha_dict: dict[str, DataFrame], df_ind_dict: dict[str, DataFrame], design_matrix_dict: dict[str, DataFrame], @@ -166,7 +166,7 @@ class IntraGroupStatsWidget(IntraGroupUIMixin, FlaresBaseWidget): roi_channel_map_dict: dict[str, dict[str, str]], group_dict: dict[str, str], ) -> None: - + super().__init__("IntraGroupStats") self.setWindowTitle(f"Intra-Group Stats Viewer - {APP_NAME.upper()}") self.haemo_dict = haemo_dict @@ -185,7 +185,7 @@ class IntraGroupStatsWidget(IntraGroupUIMixin, FlaresBaseWidget): if request is None: return - (selected_event, selected_file_paths, selected_indexes, raw_params) = request + (selected_event, selected_file_paths, selected_indexes, raw_params) = request param_values = cast(dict[int | str, dict[str, Any]], raw_params) @@ -198,20 +198,19 @@ class IntraGroupStatsWidget(IntraGroupUIMixin, FlaresBaseWidget): if selected_event: raw_annotations = getattr(haemo_obj, "annotations", None) - + if raw_annotations is not None: annotations = cast(Annotations, raw_annotations) descriptions = cast(list[str], list(annotations.description)) participant_events: set[str] = set(descriptions) else: participant_events: set[str] = set() - + if selected_event not in participant_events: print(f"Skipping {self.participant_map[file_path]}: Event '{selected_event}' not found.") continue - cha_df = self.cha_dict.get(file_path) if cha_df is not None: all_cha = pd.concat([all_cha, cha_df], ignore_index=True) @@ -288,23 +287,23 @@ class IntraGroupStatsWidget(IntraGroupUIMixin, FlaresBaseWidget): 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: print("Paired ROI contrast requires a specific event/condition " "to be selected - pick one from the Event dropdown first.") continue - + if df_group.empty: print("No ROI data (df_ind) found for selected participants.") continue - + if correction_method == "None": correction_method = None - + if not roi_a or not roi_b: print("Both ROI A and ROI B must be specified.") continue - + run_roi_paired_contrast_analysis( df_roi_all=df_group, roi_pairs=(roi_a, roi_b), @@ -351,12 +350,12 @@ class IntraGroupStatsWidget(IntraGroupUIMixin, FlaresBaseWidget): else: print(f" [MISSING CONTRAST] '{contrast_name}' not " f"available for {self.participant_map.get(fp, fp)}.") - + if not all_contrasts: print(f"No contrast data found for '{contrast_name}' " f"across selected participants.") continue - + df_contrasts = pd.concat(all_contrasts, ignore_index=True) selected_roi_maps = { @@ -378,12 +377,12 @@ class IntraGroupStatsWidget(IntraGroupUIMixin, FlaresBaseWidget): except Exception as e: print(f"Failed to aggregate contrasts to ROI: {e}") continue - + if roi_theta.empty: print("No ROI-level contrast values could be computed " "(check regions.json channel names against this montage).") continue - + run_roi_second_level_analysis( df_roi_all=roi_theta, condition=contrast_name, @@ -395,6 +394,6 @@ class IntraGroupStatsWidget(IntraGroupUIMixin, FlaresBaseWidget): target_chroma=target_chroma, graph_bounds=graph_bounds if graph_bounds > 0.0 else None, ) - + else: print(f"No method defined for index {idx}") \ No newline at end of file diff --git a/src/analysis/participantbrain.py b/src/analysis/participantbrain.py index bffe693..67fda20 100644 --- a/src/analysis/participantbrain.py +++ b/src/analysis/participantbrain.py @@ -67,11 +67,11 @@ PARAMETERIZED_INDEXES: dict[int, list[dict[str, Any]]] = { class ParticipantBrainViewerWidget(ParticipantUIMixin, FlaresBaseWidget): def __init__( - self, - haemo_dict: dict[str, BaseRaw], + self, + haemo_dict: dict[str, BaseRaw], cha_dict: dict[str, DataFrame], ) -> None: - + super().__init__("ParticipantBrain") self.setWindowTitle(f"Participant Brain Viewer - {APP_NAME.upper()}") self.haemo_dict = haemo_dict @@ -86,7 +86,7 @@ class ParticipantBrainViewerWidget(ParticipantUIMixin, FlaresBaseWidget): if request is None: return - (selected_event, selected_file_paths, selected_indexes, raw_params) = request + (selected_event, selected_file_paths, selected_indexes, raw_params) = request param_values = cast(dict[int | str, dict[str, Any]], raw_params) @@ -99,14 +99,14 @@ class ParticipantBrainViewerWidget(ParticipantUIMixin, FlaresBaseWidget): if selected_event: raw_annotations = getattr(haemo_obj, "annotations", None) - + if raw_annotations is not None: annotations = cast(Annotations, raw_annotations) descriptions = cast(list[str], list(annotations.description)) participant_events: set[str] = set(descriptions) else: participant_events: set[str] = set() - + if selected_event not in participant_events: print(f"Skipping {self.participant_map[file_path]}: Event '{selected_event}' not found.") continue diff --git a/src/analysis/participantfoldchannels.py b/src/analysis/participantfoldchannels.py index d10c5be..e67086c 100644 --- a/src/analysis/participantfoldchannels.py +++ b/src/analysis/participantfoldchannels.py @@ -40,7 +40,7 @@ class MultiProgressDialog(QDialog): super().__init__(parent) self.setWindowTitle("fOLD Analysis Progress") self.setFixedWidth(400) - self.setWindowModality(Qt.WindowModality.NonModal) + self.setWindowModality(Qt.WindowModality.NonModal) self.main_layout = QVBoxLayout(self) self.bars: Dict[str, QProgressBar] = {} self.allow_closing = False @@ -52,7 +52,7 @@ class MultiProgressDialog(QDialog): pbar.setMinimum(0) pbar.setMaximum(int(total_steps)) # Ensure this is a strict integer pbar.setValue(0) - + self.main_layout.addWidget(label_widget) self.main_layout.addWidget(pbar) self.bars[clean_key] = pbar @@ -72,7 +72,7 @@ class MultiProgressDialog(QDialog): def force_close(self): self.allow_closing = True self.close() - + def single_participant_worker( file_path: str, @@ -86,7 +86,7 @@ def single_participant_worker( try: # Perform the heavy fold_channels logic channel_results = fold_channels(raw=raw_data, p_name=p_name, progress_queue=progress_queue) - + # Hand back results and signal completion result_queue.put({file_path: channel_results}) progress_queue.put(p_name) @@ -117,11 +117,11 @@ def get_landmark_color_map() -> Dict[str, Tuple[float, float, float, float]]: ] # Sort logically landmarks.sort(key=lambda x: (int(x.split(" - ")[0]) if x.split(" - ")[0].isdigit() else float('inf'))) - + cmap1 = plt.get_cmap('tab20') cmap2 = plt.get_cmap('tab20b') colors = [cmap1(i) for i in range(20)] + [cmap2(i) for i in range(20)] - + return {landmark: colors[i % len(colors)] for i, landmark in enumerate(landmarks)} @@ -137,16 +137,16 @@ class StaticChannelCanvas(FigureCanvas): image_path: Optional[str] = None, parent: Optional[QWidget] = None, ) -> None: - + self.fig = Figure(figsize=(11.0, 5.5)) - + self.ax = self.fig.subplots(1, 2) super().__init__(self.fig) self.setParent(parent) - + self.setMouseTracking(True) - + # --- 1. DATA PREPARATION --- self.wedge_data_list = list(data_list) total_specificity = sum(d['Specificity'] for d in self.wedge_data_list) @@ -157,7 +157,7 @@ class StaticChannelCanvas(FigureCanvas): 'Landmark': 'Other / Unclassified Regions', 'Specificity': remainder }) - + self.specificities = [d['Specificity'] for d in self.wedge_data_list] self.landmarks = [d['Landmark'] for d in self.wedge_data_list] self.colors = [color_map.get(lm, '#ccc') if 'Other' not in lm else '#d3d3d3' for lm in self.landmarks] @@ -175,7 +175,7 @@ class StaticChannelCanvas(FigureCanvas): labeldistance=1.1 ) self.ax[0].axis('equal') - + # --- 3. RIGHT SUBPLOT: PNG IMAGE DISPLAY --- # Note we explicitly target self.ax[1] now if image_path: @@ -189,14 +189,14 @@ class StaticChannelCanvas(FigureCanvas): # Fallback message if no image path is passed down self.ax[1].text(0.5, 0.5, "No Reference Image\nProvided", ha='center', va='center', fontsize=12, fontweight='bold', color='#777') - + # Completely hide the background grid, spines, and axis lines for the image box self.ax[1].axis('off') # --- 4. CANVAS TEXT OVERLAY --- # Main Title centered globally over both subplots self.fig.suptitle(channel_name, fontsize=16, fontweight='bold', y=0.97) - + # Shared info box text overlay centered horizontally across the whole window figure self.info_text = self.ax[0].text( 0.5, 0.04, "", @@ -206,9 +206,9 @@ class StaticChannelCanvas(FigureCanvas): bbox=dict(boxstyle="round,pad=0.5", facecolor="#fdfdfd", edgecolor="#bbb", alpha=0.95) ) self.info_text.set_visible(False) - + self.currently_exploded_idx = None - + # Layout space optimization self.fig.subplots_adjust(left=0.05, bottom=0.1, right=0.95, top=0.85, wspace=0.2) self.draw() @@ -237,10 +237,10 @@ class StaticChannelCanvas(FigureCanvas): if self.currently_exploded_idx != hovered_index: self.currently_exploded_idx = hovered_index self._explode_wedge(hovered_index) - + displayed_pct = self.autotexts[hovered_index].get_text() full_desc = self.landmarks[hovered_index] - + self.info_text.set_text(f"{full_desc} | {displayed_pct}") self.info_text.set_visible(True) self.draw_idle() @@ -250,7 +250,7 @@ class StaticChannelCanvas(FigureCanvas): self.info_text.set_visible(False) self.currently_exploded_idx = None self.draw_idle() - + except Exception as err: print(f"[ERROR] Internal failure inside _on_hover loop: {err}") traceback.print_exc() @@ -281,7 +281,6 @@ class StaticChannelCanvas(FigureCanvas): changed = True if changed: self.draw_idle() - class StandaloneLegendDialog(QWidget): @@ -290,10 +289,10 @@ class StandaloneLegendDialog(QWidget): self.setWindowTitle("Full View - Brodmann Legend") self.setMinimumSize(500, 600) self.resize(500, 900) - + layout = QVBoxLayout(self) layout.setContentsMargins(10, 10, 10, 10) - + # Reuse your exact card creation method to render inside the popup window legend_card = canvas_engine.create_legend_card(title_prefix) layout.addWidget(legend_card) @@ -304,40 +303,40 @@ class InteractiveParticipantGridCanvas(FigureCanvas): Dynamically scales row and column configurations to maintain a crisp 16:9 layout orientation. """ def __init__( - self, - channels_data: dict[str, list[dict[str, Any]]], - color_map: dict[str, str], - is_fullscreen_copy: bool = False, + self, + channels_data: dict[str, list[dict[str, Any]]], + color_map: dict[str, str], + is_fullscreen_copy: bool = False, parent: QWidget | None = None ) -> None: - + self.channels_data = channels_data self.color_map = color_map self.is_fullscreen_copy = is_fullscreen_copy - + num_channels = len(channels_data) - - # --- FIX: DYNAMICALLY CALCULATE OPTIMAL 16:9 COLUMNS --- + + # Dynamically calculate best ratio for 16:9 target_ratio = 16 / 9 best_cols = 4 min_ratio_error = float('inf') - + # Test configurations from 4 columns up to the total number of channels for test_cols in range(4, num_channels + 1): test_rows = (num_channels + test_cols - 1) // test_cols - + # Approximate the visual aspect ratio based on cell dimensions # Mini charts are slightly wider than tall, roughly 1.15 to 1.0 factor current_ratio = (test_cols * 1.15) / (test_rows * 1.0) error = abs(current_ratio - target_ratio) - + if error < min_ratio_error: min_ratio_error = error best_cols = test_cols cols = best_cols rows = (num_channels + cols - 1) // cols - + # Base figure sizing dynamically scales off the optimal matrix constraints if is_fullscreen_copy: # Maximized views stretch cleanly across standard display panels @@ -345,17 +344,17 @@ class InteractiveParticipantGridCanvas(FigureCanvas): else: # Standard thumbnail views scaled down for participant cards figsize = (7.5, 7.5 / target_ratio) - + self.fig = Figure(figsize=figsize) - + super().__init__(self.fig) self.setParent(parent) - + self.axes_data_registry = {} for idx, (channel_name, data_list) in enumerate(channels_data.items()): ax = self.fig.add_subplot(rows, cols, idx + 1) - + padded_data_list = list(data_list) total_specificity = sum(d['Specificity'] for d in padded_data_list) if total_specificity < 100.0: @@ -381,15 +380,15 @@ class InteractiveParticipantGridCanvas(FigureCanvas): title_sz = 6 if num_channels > 30 else (9 if is_fullscreen_copy else 7) ax.pie( - specificities, - startangle=90, + specificities, + startangle=90, colors=colors, labels=labels, textprops={'fontsize': font_sz, 'fontweight': 'bold'}, labeldistance=1.05, radius=0.75 ) - + ax.set_title(channel_name, fontsize=title_sz, fontweight='bold', pad=0, y=1.04) ax.axis('equal') @@ -397,12 +396,12 @@ class InteractiveParticipantGridCanvas(FigureCanvas): # Large multi-column plots require less spacing overhead to prevent clipping label masks h_sp = 0.35 if num_channels > 30 else 0.18 w_sp = 0.25 if num_channels > 30 else 0.10 - + if is_fullscreen_copy: self.fig.subplots_adjust(left=0.02, bottom=0.02, right=0.98, top=0.95, hspace=h_sp, wspace=w_sp) else: self.fig.set_layout_engine('constrained') - + self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding) self.draw() @@ -425,28 +424,28 @@ class InteractiveParticipantGridCanvas(FigureCanvas): background-color: #f8f9fa; } """) - + card_layout = QVBoxLayout(card_frame) card_layout.setContentsMargins(6, 6, 6, 6) card_layout.setSpacing(4) - + # 2. Add header matching the summary card type architecture header = QLabel(f"{title_prefix} - Channels Matrix") header.setStyleSheet("font-weight: bold; font-size: 10pt; border: none; color: #212529; background: transparent;") header.setAlignment(Qt.AlignmentFlag.AlignCenter) card_layout.addWidget(header) - + # 3. Nest this canvas instance cleanly inside the card frame layout self.setParent(card_frame) card_layout.addWidget(self) card_layout.addStretch(0) - + # 4. Make the remaining empty whitespace frame areas trigger the maximization loop card_frame.mouseReleaseEvent = lambda event: self._open_fullscreen_grid() if event.button() == Qt.MouseButton.LeftButton else None - + # Ensure underlying child mouse hits tunnel downstream properly to our parent container frame header.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents, True) - + layout_to_attach_to.addWidget(card_frame) return card_frame @@ -460,7 +459,7 @@ class InteractiveParticipantGridCanvas(FigureCanvas): clicked_subplot_data = self.axes_data_registry.get(event.inaxes) if clicked_subplot_data: self._open_expanded_view( - clicked_subplot_data['channel_name'], + clicked_subplot_data['channel_name'], clicked_subplot_data['data_list'] ) @@ -471,30 +470,30 @@ class InteractiveParticipantGridCanvas(FigureCanvas): fullscreen_window = QWidget(None) fullscreen_window.setWindowTitle("Participant Grid Monitor - Maximized View") fullscreen_window.setWindowFlags( - Qt.WindowType.Window | - Qt.WindowType.WindowMinMaxButtonsHint | + Qt.WindowType.Window | + Qt.WindowType.WindowMinMaxButtonsHint | Qt.WindowType.WindowCloseButtonHint ) - + layout = QVBoxLayout(fullscreen_window) layout.setContentsMargins(0, 0, 0, 0) - + # Instantiate the copy large_grid_canvas = InteractiveParticipantGridCanvas( - self.channels_data, - self.color_map, - is_fullscreen_copy=True, + self.channels_data, + self.color_map, + is_fullscreen_copy=True, parent=fullscreen_window ) - + # Explicitly tag the new canvas object internally to block further clicks large_grid_canvas._is_fullscreen_flag_set = True - + layout.addWidget(large_grid_canvas) - + # Open non-modally so it populates the taskbar and matches OS window behaviors fullscreen_window.showMaximized() - + # Keep a reference alive on the source canvas so Python doesn't garbage collect the window if not hasattr(self, '_fullscreen_refs'): self._fullscreen_refs = [] @@ -506,7 +505,7 @@ class InteractiveParticipantGridCanvas(FigureCanvas): """Sums and normalizes the specificity profile across all channels.""" totals = {} num_channels = len(channels_data) - + if num_channels == 0: return [] @@ -543,16 +542,16 @@ class InteractiveParticipantGridCanvas(FigureCanvas): def _open_expanded_view(self, channel_name: str, data_list: list[dict[str, Any]]) -> None: # 1. Create a plain QWidget with NO parent (None) # This instantly makes it a top-level desktop window - popup = QWidget(None) + popup = QWidget(None) popup.setWindowTitle(f"Channel Specificity Detail - {channel_name}") - + # 2. Add standard window control behaviors popup.setWindowFlags( - Qt.WindowType.Window | - Qt.WindowType.WindowMinMaxButtonsHint | + Qt.WindowType.Window | + Qt.WindowType.WindowMinMaxButtonsHint | Qt.WindowType.WindowCloseButtonHint ) - + # 3. Build layout out exactly as before layout = QVBoxLayout(popup) layout.setContentsMargins(0, 0, 0, 0) # Strip extra outer layout spacing @@ -560,23 +559,23 @@ class InteractiveParticipantGridCanvas(FigureCanvas): target_png_path = resource_path("images/brain.png") expanded_canvas = StaticChannelCanvas( - channel_name, - data_list, - self.color_map, - image_path=target_png_path, + channel_name, + data_list, + self.color_map, + image_path=target_png_path, parent=popup ) - + layout.addWidget(expanded_canvas) popup.resize(900, 520) - + # 4. Display non-modally - popup.show() - + popup.show() + # 5. Keep the reference alive so Python doesn't garbage collect it if not hasattr(self, '_open_popups'): self._open_popups = [] - + # Clean up closed windows from our tracking list to save memory self._open_popups = [w for w in self._open_popups if w.isVisible()] self._open_popups.append(popup) @@ -586,7 +585,7 @@ class InteractiveParticipantGridCanvas(FigureCanvas): """Generates a highly compact, clickable embedded card on the main window showing aggregated data.""" # 1. Calculate the normalized profile data payload using the instance's own data summary_data = self._calculate_total_brodmann_profile(self.channels_data) - + # 2. Create a styled container card frame card_frame = QFrame() card_frame.setFrameShape(QFrame.Shape.StyledPanel) @@ -601,19 +600,19 @@ class InteractiveParticipantGridCanvas(FigureCanvas): background-color: #f8f9fa; /* Slightly shifts background color on hover */ } """) - + card_layout = QVBoxLayout(card_frame) card_layout.setContentsMargins(4, 4, 4, 4) card_layout.setSpacing(2) - + # Add a clear section header label containing the specific participant identity header = QLabel(f"{title_prefix} - Total Profile") header.setStyleSheet("font-weight: bold; font-size: 10pt; border: none; color: #212529;") header.setAlignment(Qt.AlignmentFlag.AlignCenter) card_layout.addWidget(header) - + target_png_path = resource_path("images/brain.png") - + # 3. Instantiate the canvas with a custom size flag or constraint # Adjust your StaticChannelCanvas __init__ to check if it should render in 'compact' mode summary_canvas = StaticChannelCanvas( @@ -623,7 +622,7 @@ class InteractiveParticipantGridCanvas(FigureCanvas): image_path=target_png_path, parent=card_frame, ) - + # --- CRITICAL: SHRINK MATPLOTLIB FIGURE ELEMENTS FOR THE EMBEDDED VIEWER --- # Scale down the underlying canvas container so it doesn't balloon the layout grid if hasattr(summary_canvas, 'fig'): @@ -633,7 +632,7 @@ class InteractiveParticipantGridCanvas(FigureCanvas): for text in ax.texts: text.set_fontsize(6) summary_canvas.draw() - + summary_canvas.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred) card_layout.addWidget(summary_canvas) card_layout.addStretch(0) @@ -642,9 +641,9 @@ class InteractiveParticipantGridCanvas(FigureCanvas): # Only trigger expansion if it's a primary left-click action if event.button() == Qt.MouseButton.LeftButton: self._open_expanded_summary_window(title_prefix, summary_data) - + card_frame.mouseReleaseEvent = handle_card_click - + # Prevent clicks on the text/child elements from being swallowed up instead of passing to frame header.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents, True) summary_canvas.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents, True) @@ -654,21 +653,21 @@ class InteractiveParticipantGridCanvas(FigureCanvas): return card_frame - + def create_legend_card(self, title_prefix: str) -> QFrame: card = QFrame() card.setStyleSheet("QFrame { background-color: #ffffff; border-radius: 8px; border: 1px solid #e9ecef; }") - + layout = QVBoxLayout(card) layout.setContentsMargins(20, 20, 20, 20) layout.setSpacing(10) - + header_label = QLabel(f"{title_prefix}\nLandmarks") header_label.setAlignment(Qt.AlignmentFlag.AlignCenter) header_label.setStyleSheet("font-size: 14px; font-weight: bold; color: #1a252f; border: none;") layout.addWidget(header_label) layout.addSpacing(10) - + scroll_area = QScrollArea() scroll_area.setWidgetResizable(True) scroll_area.setStyleSheet("QScrollArea { border: none; background: transparent; }") @@ -677,76 +676,75 @@ class InteractiveParticipantGridCanvas(FigureCanvas): scroll_layout = QVBoxLayout(scroll_content) scroll_layout.setSpacing(6) scroll_layout.setContentsMargins(0, 0, 0, 0) - true_color_map = get_landmark_color_map() - + # Iterate over the sorted keys directly from your method for landmark_text in true_color_map.keys(): item_row = QHBoxLayout() item_row.setSpacing(12) - + # Extract the RGBA tuple value assigned by matplotlib rgba = true_color_map[landmark_text] # Convert float tuple components (0.0 - 1.0) to standard CSS integer scales (0 - 255) r, g, b = int(rgba[0] * 255), int(rgba[1] * 255), int(rgba[2] * 255) color_hex = f"rgb({r}, {g}, {b})" - + # Format display string nicely: "1 — Primary Somatosensory Cortex" if " - " in landmark_text: num, name = landmark_text.split(" - ", 1) display_string = f"{num} — {name}" else: display_string = f"{landmark_text}" - + dot = QLabel() dot.setFixedSize(14, 14) dot.setStyleSheet(f"background-color: {color_hex}; border-radius: 7px; border: none;") - + label = QLabel(display_string) label.setStyleSheet("font-size: 12px; color: #343a40; border: none;") - + item_row.addWidget(dot) item_row.addWidget(label, 1) scroll_layout.addLayout(item_row) - + scroll_area.setWidget(scroll_content) layout.addWidget(scroll_area) return card - + def _open_expanded_summary_window(self, title_prefix: str, summary_data: List[Any]) -> None: """Pops open a beautifully scaled, independent large window when the card is clicked.""" - popup = QWidget(None) + popup = QWidget(None) popup.setWindowTitle(f"Grand Total Profile Details - {title_prefix}") popup.setWindowFlags( - Qt.WindowType.Window | - Qt.WindowType.WindowMinMaxButtonsHint | + Qt.WindowType.Window | + Qt.WindowType.WindowMinMaxButtonsHint | Qt.WindowType.WindowCloseButtonHint ) - + layout = QVBoxLayout(popup) layout.setContentsMargins(10, 10, 10, 10) - + target_png_path = resource_path("images/brain.png") - + # This one renders full size (900x520) for analytical reading expanded_canvas = StaticChannelCanvas( - f"{title_prefix} - All Channels Aggregated", - summary_data, - self.color_map, - image_path=target_png_path, + f"{title_prefix} - All Channels Aggregated", + summary_data, + self.color_map, + image_path=target_png_path, parent=popup, ) - + layout.addWidget(expanded_canvas) popup.resize(950, 550) popup.show() - + if not hasattr(self, '_summary_popups'): self._summary_popups = [] self._summary_popups.append(popup) - + class ProcessOrchestrator(QObject): # Fires when Manager + Processes are completely ready @@ -760,7 +758,7 @@ class ProcessOrchestrator(QObject): haemo_dict: dict[str, Any], worker_func: Callable[..., Any] ) -> None: - + super().__init__() self.selected_files = selected_files self.haemo_dict = haemo_dict @@ -795,7 +793,7 @@ class ParticipantFoldChannelsWidget(FlaresBaseWidget): haemo_dict: dict[str | Path, BaseRaw], cha_dict: dict[str, DataFrame] ) -> None: - + super().__init__("ParticipantFoldChannels") self.setWindowTitle(f"Participant Fold Channels Viewer - {APP_NAME.upper()}") self.haemo_dict = haemo_dict @@ -816,7 +814,7 @@ class ParticipantFoldChannelsWidget(FlaresBaseWidget): self.participant_dropdown = self._create_multiselect_dropdown(self.participant_dropdown_items) self.participant_dropdown.currentIndexChanged.connect(self.update_participant_dropdown_label) - + self.index_texts = [ "0 (Fold Channels)", ] @@ -841,7 +839,7 @@ class ParticipantFoldChannelsWidget(FlaresBaseWidget): # 2. Create the central canvas widget that inside the scroll block self.scroll_content_widget = QWidget() - + # 3. Establish the strict 3-column layout grid engine self.grid_layout = QGridLayout(self.scroll_content_widget) self.grid_layout.setContentsMargins(12, 12, 12, 12) @@ -850,7 +848,7 @@ class ParticipantFoldChannelsWidget(FlaresBaseWidget): self.grid_layout.setColumnStretch(0, 1) self.grid_layout.setColumnStretch(1, 1) self.grid_layout.setColumnStretch(2, 1) - + # 2. Force a uniform structural minimum width per column # This blocks the dense matrices from hogging space and compressing the summary cards self.grid_layout.setColumnMinimumWidth(0, 400) @@ -860,7 +858,7 @@ class ParticipantFoldChannelsWidget(FlaresBaseWidget): # Bind them together self.scroll_area.setWidget(self.scroll_content_widget) - + # Add the self.scroll_area widget to your root layout view frame panel self.main_layout.addWidget(self.scroll_area) @@ -871,12 +869,12 @@ class ParticipantFoldChannelsWidget(FlaresBaseWidget): def show_fold_images(self): selected_display_names = self._get_checked_items(self.participant_dropdown) selected_indexes = [int(s.split(" ")[0]) for s in self._get_checked_items(self.image_index_dropdown)] - + if not selected_display_names or 0 not in selected_indexes: return selected_files = [path for path, label in self.participant_map.items() if f"{label} ({os.path.basename(path)})" in selected_display_names] - + while self.grid_layout.count(): item = self.grid_layout.takeAt(0) widget = item.widget() @@ -891,7 +889,7 @@ class ParticipantFoldChannelsWidget(FlaresBaseWidget): # Dig out the exact channels list length matching your loop engine logic hbo_channels = getattr(raw_data.copy().pick(picks='hbo'), "ch_names", []) total_channels = len(hbo_channels) if hbo_channels else 1 - + self.multi_progress.add_participant(os.path.basename(file_path), total_channels) self.multi_progress.show() @@ -917,13 +915,13 @@ class ParticipantFoldChannelsWidget(FlaresBaseWidget): self.orchestrator_thread.start() def on_orchestration_success( - self, - manager: Any, - result_queue: Any, - progress_queue: Any, + self, + manager: Any, + result_queue: Any, + progress_queue: Any, active_processes: List[Any] ) -> None: - + """ Executed on the Main GUI Thread once background process setup finishes """ self.manager = manager self.result_queue = result_queue @@ -949,12 +947,12 @@ class ParticipantFoldChannelsWidget(FlaresBaseWidget): while not self.progress_queue.empty(): msg = self.progress_queue.get() - + # CASE 1: Micro-step channel increment (Tuple tracking) if isinstance(msg, tuple): p_name, completed_channels = msg clean_key = str(p_name).strip() - + if hasattr(self, 'multi_progress') and clean_key in self.multi_progress.bars: print(completed_channels) self.multi_progress.update_bar(clean_key, completed_channels) @@ -962,12 +960,12 @@ class ParticipantFoldChannelsWidget(FlaresBaseWidget): # DEBUG LOG: This tells us exactly why a bar isn't moving print(f"[DEBUG WARNING] Progress received for '{clean_key}' but no matching bar was found. Existing bars: {list(self.multi_progress.bars.keys())}") continue - + # CASE 2: Worker process crashed with an error string if isinstance(msg, str) and msg.startswith("ERROR"): print(f"Worker Error: {msg}") #self.completed_count += 1 # Count as finished so the UI doesn't hang - + # CASE 3: Final clean text string signal indicating complete file closure elif isinstance(msg, str): # Max out the progress bar visually on completion @@ -975,7 +973,7 @@ class ParticipantFoldChannelsWidget(FlaresBaseWidget): if msg in self.multi_progress.bars: max_val = self.multi_progress.bars[msg].maximum() self.multi_progress.update_bar(msg, max_val) - + self.completed_count += 1 # Increment the master task tracker print(self.completed_count, time.time()) @@ -987,36 +985,36 @@ class ParticipantFoldChannelsWidget(FlaresBaseWidget): # Clean up when all processes are done if self.completed_count >= len(self.active_processes): self.result_timer.stop() - + # Close the custom multi-progress window if hasattr(self, 'multi_progress'): self.multi_progress.force_close() - + # Clean up processes for p in self.active_processes: if p.is_alive(): p.join(timeout=1) # Give it a second to wrap up p.close() # Explicitly close the process object - + # Shut down the Manager process (the source of the 'rogue' process) if hasattr(self, 'manager'): self.manager.shutdown() - + self.active_processes = [] print("Processing fully complete. All resources released.") if hasattr(self, 'global_channels_data') and self.global_channels_data: color_map = get_landmark_color_map() - + # We feed the entire channel pool directly to your existing canvas engine class global_canvas = InteractiveParticipantGridCanvas(self.global_channels_data, color_map) - + # Create the summary card using your exact visual method global_card = global_canvas.create_total_summary_card( title_prefix="Grand Global Layout", layout_to_attach_to=self.scroll_content_widget.layout() ) - + # Match your exact layout positioning logic to place it next in the grid count = self.grid_layout.count() - 1 row = count // 3 @@ -1031,9 +1029,9 @@ class ParticipantFoldChannelsWidget(FlaresBaseWidget): def handle_legend_click(event): self.active_legend_window = StandaloneLegendDialog(global_canvas, legend_title, self) self.active_legend_window.show() - + legend_card.mousePressEvent = handle_legend_click - + count = self.grid_layout.count() row = count // 3 col = count % 3 @@ -1045,7 +1043,7 @@ class ParticipantFoldChannelsWidget(FlaresBaseWidget): for file_path, channels_data in result_dict.items(): participant_label = self.participant_map.get(file_path, os.path.basename(file_path)) - + if hasattr(self, 'global_channels_data'): for ch_name, ch_data in channels_data.items(): unique_key = f"{participant_label}_{ch_name}" @@ -1060,19 +1058,19 @@ class ParticipantFoldChannelsWidget(FlaresBaseWidget): title_prefix=participant_label, layout_to_attach_to=self.scroll_content_widget.layout() # Maps directly to your grid layout ) - + # Pin Card A to the sequential grid coordinate tracker layout count = self.grid_layout.count() - 1 # Subtract 1 because widget registration steps index values forward - row = count // 3 + row = count // 3 col = count % 3 self.grid_layout.addWidget(matrix_card, row, col) # 3. Build Card B (Total Summary Profile Frame Layout) summary_card = participant_grid_canvas.create_total_summary_card( - title_prefix=participant_label, + title_prefix=participant_label, layout_to_attach_to=self.scroll_content_widget.layout() ) - + # Pin Card B directly next into the 3-column processing loop matrix layout tracker count = self.grid_layout.count() - 1 row = count // 3 @@ -1100,21 +1098,21 @@ class ParticipantFoldChannelsWidget(FlaresBaseWidget): container = QFrame() container.setStyleSheet("background-color: #f9f9f9; border: 1px solid #ccc;") vbox = QVBoxLayout(container) - + title = QLabel("Brodmann Area Legend") title.setAlignment(Qt.AlignmentFlag.AlignCenter) vbox.addWidget(title) - + pixmap = self._bytes_to_pixmap(legend_bytes) legend_label = QLabel() # Legends are usually tall, so we scale it differently or keep it smaller legend_label.setPixmap(pixmap.scaled( - self.thumb_size, - Qt.AspectRatioMode.KeepAspectRatio, + self.thumb_size, + Qt.AspectRatioMode.KeepAspectRatio, Qt.TransformationMode.SmoothTransformation )) legend_label.setAlignment(Qt.AlignmentFlag.AlignCenter) legend_label.mousePressEvent = lambda e, p=pixmap: self._open_full_size(p, "Brodmann Legend") - + vbox.addWidget(legend_label) self.grid_layout.addWidget(container, 0, 0) \ No newline at end of file diff --git a/src/analysis/participantfunctionalconnectivity.py b/src/analysis/participantfunctionalconnectivity.py index 5718122..2f19c1c 100644 --- a/src/analysis/participantfunctionalconnectivity.py +++ b/src/analysis/participantfunctionalconnectivity.py @@ -83,7 +83,7 @@ class ParticipantFunctionalConnectivityWidget(ParticipantUIMixin, FlaresBaseWidg haemo_dict: dict[str, BaseRaw], epochs_dict: dict[str, Epochs], ) -> None: - + super().__init__("ParticipantFunctionalConnectivity") self.setWindowTitle(f"Participant Functional Connectivity Viewer [BETA] - {APP_NAME.upper()}") self.haemo_dict = haemo_dict @@ -101,28 +101,28 @@ class ParticipantFunctionalConnectivityWidget(ParticipantUIMixin, FlaresBaseWidg if request is None: return - (selected_event, selected_file_paths, selected_indexes, raw_params) = request + (selected_event, selected_file_paths, selected_indexes, raw_params) = request param_values = cast(dict[int | str, dict[str, Any]], raw_params) # Pass the necessary arguments to each method for file_path in selected_file_paths: haemo_obj = self.haemo_dict.get(file_path) - epochs_obj = self.epochs_dict.get(file_path) - + epochs_obj = self.epochs_dict.get(file_path) + if haemo_obj is None or epochs_obj is None: continue if selected_event: raw_annotations = getattr(haemo_obj, "annotations", None) - + if raw_annotations is not None: annotations = cast(Annotations, raw_annotations) descriptions = cast(list[str], list(annotations.description)) participant_events: set[str] = set(descriptions) else: participant_events: set[str] = set() - + if selected_event not in participant_events: print(f"Skipping {self.participant_map[file_path]}: Event '{selected_event}' not found.") continue @@ -135,7 +135,7 @@ class ParticipantFunctionalConnectivityWidget(ParticipantUIMixin, FlaresBaseWidg vmin = params.get("vmin", 0.9) fmin = params.get("fmin", 0.04) fmax = params.get("fmax", 0.2) - + functional_connectivity_spectral_epochs(epochs=epochs_obj, n_lines=n_lines, vmin=vmin, fmin=fmin, fmax=fmax, method=method) elif idx == 1: diff --git a/src/analysis/participantimage.py b/src/analysis/participantimage.py index 02b0457..8748062 100644 --- a/src/analysis/participantimage.py +++ b/src/analysis/participantimage.py @@ -25,11 +25,11 @@ from src.shared.shareddata import APP_NAME class ParticipantImageViewerWidget(FlaresBaseWidget): def __init__( - self, + self, haemo_dict: dict[str, BaseRaw], fig_bytes_dict: dict[str, dict[str, bytes]] ) -> None: - + super().__init__("ParticipantImage") self.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose) self.setWindowTitle(f"Participant Image Viewer - {APP_NAME.upper()}") @@ -52,7 +52,7 @@ class ParticipantImageViewerWidget(FlaresBaseWidget): self.participant_dropdown = self._create_multiselect_dropdown(self.participant_dropdown_items) self.participant_dropdown.currentIndexChanged.connect(self.update_participant_dropdown_label) - + first_fig_dict = next(iter(self.fig_bytes_dict.values())) image_label_items = list(first_fig_dict.keys()) diff --git a/src/shared/flaresbasewidget.py b/src/shared/flaresbasewidget.py index 767cc49..5a88269 100644 --- a/src/shared/flaresbasewidget.py +++ b/src/shared/flaresbasewidget.py @@ -14,11 +14,12 @@ import pandas as pd from pandas import DataFrame from PySide6.QtWidgets import QApplication, QComboBox, QDialog, QGridLayout, QHBoxLayout, QLabel, QLineEdit, QListView, QMessageBox, QPushButton, QScrollArea, QSizePolicy, QVBoxLayout, QWidget, QFrame, QSpinBox, QFileDialog -from PySide6.QtGui import QPalette, QStandardItemModel, QStandardItem, QPixmap, QIntValidator, QDoubleValidator +from PySide6.QtGui import QStandardItemModel, QStandardItem, QPixmap, QIntValidator, QDoubleValidator from PySide6.QtCore import QEvent, QPoint, QSize, QTimer, Qt, Signal from src.shared.shareddata import APP_NAME, PIPELINE_STAGES + class FullClickComboBox(QComboBox): def __init__(self, parent=None): super().__init__(parent) @@ -28,16 +29,16 @@ class FullClickComboBox(QComboBox): def eventFilter(self, obj, event): if obj == self.lineEdit(): - + if event.type() == QEvent.MouseButtonPress: return True if event.type() == QEvent.MouseButtonRelease: self.showPopup() return True - + return super().eventFilter(obj, event) - + class ClickableLabel(QLabel): def __init__(self, full_pixmap: QPixmap, thumbnail_pixmap: QPixmap): @@ -47,7 +48,7 @@ class ClickableLabel(QLabel): self.setAlignment(Qt.AlignmentFlag.AlignCenter) self.setFixedSize(thumbnail_pixmap.size()) self.setStyleSheet("border: 1px solid gray; margin: 2px;") - + def mousePressEvent(self, event): viewer = QWidget() viewer.setWindowTitle(f"Expanded View - {APP_NAME.upper()}") @@ -100,12 +101,12 @@ class ParameterInputDialog(QDialog): 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(): @@ -123,7 +124,7 @@ class ParameterInputDialog(QDialog): # Convert options to string just in case they aren't options = [str(opt) for opt in param_info.get("options", [])] widget.addItems(options) - + # Set default choice if it exists in the options list default_val = str(param_info.get("default", "")) if default_val in options: @@ -231,14 +232,14 @@ class FullClickComboBox(QComboBox): def eventFilter(self, obj, event): if obj == self.lineEdit(): - + if event.type() == QEvent.MouseButtonPress: return True if event.type() == QEvent.MouseButtonRelease: self.showPopup() return True - + return super().eventFilter(obj, event) @@ -249,32 +250,32 @@ class FilePickerWidget(QWidget): 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, + "Select Configuration File", + self.line_edit.text(), self.file_filter ) if file_path: @@ -308,7 +309,7 @@ class ParamSection(QWidget): """ dirty_state_changed = Signal(bool) - + def __init__(self, section_data, global_widgets): super().__init__() layout = QVBoxLayout() @@ -317,7 +318,7 @@ class ParamSection(QWidget): self.dependencies = [] self.selected_path = None - self.param_rows = [] + self.param_rows = [] self.header_widgets = [] self.dirty_params = {} self._updating_checkstates = False @@ -344,7 +345,7 @@ class ParamSection(QWidget): # Build label text and tooltips label_text = f"⚠️ {param_name}" if is_advanced else param_name label = QLabel(label_text) - + if is_advanced: label.setToolTip(f"ADVANCED: {help_text}") else: @@ -371,19 +372,19 @@ class ParamSection(QWidget): widget.setCurrentText(str(default_val)) widget.currentTextChanged.connect(lambda _, p=param_name: self.check_if_changed(p)) widget.currentTextChanged.connect(self.notify_global_update) - + elif param_type == int or "int" in type_str: widget = QLineEdit() widget.setValidator(QIntValidator()) widget.setText(str(default_val)) widget.textChanged.connect(lambda _, p=param_name: self.check_if_changed(p)) - + elif param_type == float or "float" in type_str: widget = QLineEdit() widget.setValidator(QDoubleValidator()) widget.setText(str(default_val)) widget.textChanged.connect(lambda _, p=param_name: self.check_if_changed(p)) - + elif param_type == list or "list" in type_str: options = param.get("options", []) if param.get("exclusive", True): @@ -406,12 +407,12 @@ class ParamSection(QWidget): else: widget.setValue(15) widget.valueChanged.connect(lambda _, p=param_name: self.check_if_changed(p)) - + elif param_type == "json_file": widget = FilePickerWidget(default_val=default_val, file_filter="JSON Files (*.json)") widget.textChanged.connect(lambda _, p=param_name: self.check_if_changed(p)) - - else: + + else: widget = QLineEdit() widget.setText(str(default_val)) widget.textChanged.connect(lambda _, p=param_name: self.check_if_changed(p)) @@ -447,7 +448,7 @@ class ParamSection(QWidget): def update_theme_colors(self): self.title_label.style().unpolish(self.title_label) self.title_label.style().polish(self.title_label) - + def is_different(self, val_a, val_b, param_type=None): """Compares two parameter values to determine if they differ.""" type_str = str(param_type).lower() @@ -566,7 +567,7 @@ class ParamSection(QWidget): normal_items.append(item) state = Qt.Checked if item.text() in defaults else Qt.Unchecked item.setCheckState(state) - + # Sync toggle select all item toggle_item = model.item(1) if toggle_item and normal_items: @@ -637,7 +638,7 @@ class ParamSection(QWidget): for hw in self.header_widgets: hw.setVisible(has_visible_rows) - + self.setVisible(has_visible_rows) def has_any_changes(self): @@ -663,7 +664,7 @@ class ParamSection(QWidget): for cond in dep["conditions"]: parent_name = cond.get("parent_name") or cond.get("parent") required_val = str(cond.get("depends_value") if "depends_value" in cond else cond.get("value", "True")) - + parent_info = self.widgets.get(parent_name) if not parent_info: all_conditions_met = False @@ -745,7 +746,7 @@ class ParamSection(QWidget): toggle_item.setCheckState(Qt.Checked if all_checked else Qt.Unchecked) self._updating_checkstates = False - + if param_name: self.update_dropdown_label(param_name) self.check_if_changed(param_name) @@ -813,7 +814,7 @@ class ParamSection(QWidget): selected = self._get_checked_items(widget) widget.lineEdit().setText(", ".join(selected) if selected else "") - + # def update_annotation_dropdown_from_loaded_files(self, bubble_widgets, button1): # file_paths = [bubble.file_path for bubble in bubble_widgets.values()] # if not file_paths: @@ -822,12 +823,12 @@ class ParamSection(QWidget): # # 1. Start the UI immediately # progress = QProgressDialog("Accessing Workers...", "Cancel", 0, len(file_paths), self) # progress.setWindowModality(Qt.WindowModality.WindowModal) - # progress.setMinimumDuration(0) + # progress.setMinimumDuration(0) # progress.setValue(0) - + # # Force the UI to draw the window NOW before we start the loop # progress.show() - # QApplication.processEvents() + # QApplication.processEvents() # annotation_sets = [] @@ -843,7 +844,7 @@ class ParamSection(QWidget): # # This call is now nearly instant because the process is already warm # future = self.file_executor.submit(_extract_annotations, path) # try: - # labels_list = future.result() + # labels_list = future.result() # if labels_list: # annotation_sets.append(set(labels_list)) # except Exception as e: @@ -870,10 +871,10 @@ class ProgressBubble(QWidget): file_path (str): Associated file path stored with the bubble. """ - + clicked = Signal(object) rightClicked = Signal(object, QPoint) - + def __init__(self, display_name, file_path): super().__init__() @@ -964,7 +965,7 @@ class ProgressBubble(QWidget): else: rect.setStyleSheet("background-color: white; border: 1px solid gray;") - + def mark_cancelled(self): for i, rect in enumerate(self.rects): if i < self.current_step: @@ -1074,7 +1075,7 @@ class FlaresBaseWidget(QWidget): model.itemChanged.connect(on_item_changed) combo.setInsertPolicy(QComboBox.NoInsert) return combo - + # def _get_checked_items(self, combo): # model = combo.model() @@ -1084,20 +1085,20 @@ class FlaresBaseWidget(QWidget): # if item.checkState() == Qt.Checked: # checked.append(item.text()) # return checked - + def _get_checked_items( self, combo: QComboBox | None = None ) -> list[str]: target = combo if combo is not None else getattr(self, 'participant_dropdown', None) - + if target is None or target.model() is None: return [] model = target.model() checked_items = [] - + # Exclusion list: any item text that should never be treated as data forbidden = {"Toggle All", "Select All", "", "Toggle"} @@ -1108,7 +1109,7 @@ class FlaresBaseWidget(QWidget): # Only add if it's not a 'UI control' item if text not in forbidden and not text.startswith("Toggle"): checked_items.append(text) - + return checked_items @@ -1125,7 +1126,7 @@ class FlaresBaseWidget(QWidget): # 1. Figure out which dropdown we are talking to target_combo = combo if combo is not None else getattr(self, "participant_dropdown", None) - + if target_combo is None: return # Safety check: nothing to update @@ -1141,11 +1142,11 @@ class FlaresBaseWidget(QWidget): # 3. Conditional trigger for event updates # We only update events if we aren't in one of the excluded viewers excluded_viewers = { - "ParticipantImage", - "ParticipantFoldChannels", - "ExportToCSV", + "ParticipantImage", + "ParticipantFoldChannels", + "ExportToCSV", } - + if getattr(self, "caller", None) not in excluded_viewers: self._update_event_dropdown() @@ -1162,7 +1163,7 @@ class FlaresBaseWidget(QWidget): def _update_event_dropdown(self): is_split_group = hasattr(self, 'participant_dropdown_a') and hasattr(self, 'participant_dropdown_b') - + bypass = False main_win = next((w for w in QApplication.topLevelWidgets() if w.objectName() == "MainApplication" or hasattr(w, "missing_events_bypass")), None) @@ -1199,7 +1200,7 @@ class FlaresBaseWidget(QWidget): if not bypass: final_annotations = set.intersection(*(sets_a + sets_b)) - else: + else: all_events_a = {event for s in sets_a for event in s} all_events_b = {event for s in sets_b for event in s} @@ -1216,7 +1217,7 @@ class FlaresBaseWidget(QWidget): valid_b.add(event) final_annotations = valid_a.intersection(valid_b) - + else: names = self._get_checked_items(self.participant_dropdown) if not names: @@ -1288,8 +1289,7 @@ class FlaresBaseWidget(QWidget): self._updating_checkstates = False model.itemChanged.connect(on_item_changed) - - + def update_participant_list_for_group(self, group_name=None, combo=None): @@ -1323,7 +1323,7 @@ class FlaresBaseWidget(QWidget): # 4. Refresh the Model model = target_combo.model() model.clear() - + for text in ["", "Toggle Select All"]: item = QStandardItem(str(text)) if text == "Toggle Select All": @@ -1343,7 +1343,7 @@ class FlaresBaseWidget(QWidget): target_combo.setEnabled(True) # Get file paths (handles target_group as int or str) group_file_paths = self.group_to_paths.get(target_group, []) - + for i, file_path in enumerate(group_file_paths, start=1): short_label = f"Participant {i}" display_label = f"{short_label} ({os.path.basename(file_path)})" @@ -1363,8 +1363,8 @@ class InterGroupUIMixin: participant_map: dict[str, str] def setup_inter_group_ui( - self, - index_texts: Sequence[str], + self, + index_texts: Sequence[str], placeholder_text: str = "" ) -> None: @@ -1394,7 +1394,7 @@ class InterGroupUIMixin: self.event_dropdown = QComboBox() self.event_dropdown.addItem("") - + self.participant_dropdown_a = self._create_multiselect_dropdown([]) line_edit = self.participant_dropdown_a.lineEdit() assert line_edit is not None, "Dropdown A must be editable to have a lineEdit" @@ -1453,10 +1453,10 @@ class InterGroupUIMixin: def _update_group_b_options(self): """Triggered when Group B changes: Update Group A to exclude B's choice""" selected_b = self.group_b_dropdown.currentText() - + # Refresh Group A and exclude what was just picked in Group B self._refresh_group_dropdown(self.group_a_dropdown, exclude=selected_b) - + # Update the participants for Group B self.update_participant_list_for_group(selected_b, self.participant_dropdown_b) self._update_event_dropdown() @@ -1464,10 +1464,10 @@ class InterGroupUIMixin: def _update_group_a_options(self): """Triggered when Group A changes: Update Group B to exclude A's choice""" selected_a = self.group_a_dropdown.currentText() - + # Refresh Group B and exclude what was just picked in Group A self._refresh_group_dropdown(self.group_b_dropdown, exclude=selected_a) - + # Update the participants for Group A self.update_participant_list_for_group(selected_a, self.participant_dropdown_a) self._update_event_dropdown() @@ -1491,8 +1491,6 @@ class InterGroupUIMixin: dropdown.setCurrentIndex(0) # Reset to "" dropdown.blockSignals(False) - - def _get_file_paths_from_labels(self, labels, group_name): file_paths = [] @@ -1516,14 +1514,14 @@ class InterGroupUIMixin: file_paths.append(file_path) return file_paths - + def get_common_request_data( self, parameterized_indexes: dict[int, list[dict[str, Any]]], df_ind_dict: dict[str, DataFrame] | None = None, contrast_dfs: dict[str, dict[str, Any]] | None = None, ) -> tuple[str | None, list[str], list[str], list[str], list[int], dict[str, Any]] | None: - + selected_event = self.event_dropdown.currentText() if selected_event == "": selected_event = None @@ -1562,7 +1560,7 @@ class InterGroupUIMixin: } dynamic_rois = [] - + if df_ind_dict: roi_set = set() for fp in all_selected_paths: @@ -1572,7 +1570,7 @@ class InterGroupUIMixin: if roi_set: dynamic_rois = sorted(list(roi_set)) - + # Fallback to prevent UI crashes if JSON file doesn't exist or is empty if not dynamic_rois: dynamic_rois = ["Option 1", "Option 2"] @@ -1588,7 +1586,7 @@ class InterGroupUIMixin: # 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)) @@ -1634,15 +1632,15 @@ class InterGroupUIMixin: selected_indexes, param_values, ) - + class CSVUIMixin: def setup_csv_ui( - self, + self, index_texts: Sequence[str] ) -> None: - + # Create mappings: file_path -> participant label and dropdown display text self.participant_map: dict[str, str] = {} # file_path -> "Participant 1" self.participant_dropdown_items: list[str] = [] # "Participant 1 (filename)" @@ -1659,7 +1657,7 @@ class CSVUIMixin: self.participant_dropdown: FullClickComboBox = self._create_multiselect_dropdown(self.participant_dropdown_items) self.participant_dropdown.currentIndexChanged.connect(self.update_participant_dropdown_label) - + self.index_texts = index_texts self.image_index_dropdown: FullClickComboBox = self._create_multiselect_dropdown(self.index_texts) @@ -1693,14 +1691,14 @@ class CSVUIMixin: class IntraGroupUIMixin: def setup_intra_group_ui( - self, - index_texts: Sequence[str], + self, + index_texts: Sequence[str], placeholder_text: str = "" ) -> None: - + self.show_all_events = True self._updating_checkstates = False - + # Create mappings: file_path -> participant label and dropdown display text self.participant_map: dict[str, str] = {} # file_path -> "Participant 1" self.participant_dropdown_items = [] # "Participant 1 (filename)" @@ -1771,7 +1769,7 @@ class IntraGroupUIMixin: df_ind_dict: dict[str, DataFrame] | None = None, contrast_dfs: dict[str, dict[str, Any]] | None = None, ) -> tuple[str | None, list[str], list[int], dict[str, Any]] | None: - + selected_event = self.event_dropdown.currentText() if selected_event == "": selected_event = None @@ -1793,7 +1791,7 @@ class IntraGroupUIMixin: if raw is not None and hasattr(raw, "annotations"): if selected_event in raw.annotations.description: valid_paths.append(fp) - + selected_file_paths = valid_paths selected_indexes = [ @@ -1803,7 +1801,7 @@ class IntraGroupUIMixin: if not selected_file_paths: print("No participants selected.") return - + # Inject full_text from index_texts for idx, params_list in parameterized_indexes.items(): @@ -1814,7 +1812,7 @@ class IntraGroupUIMixin: indexes_needing_params = {idx: parameterized_indexes[idx] for idx in selected_indexes if idx in parameterized_indexes} dynamic_rois = [] - + if df_ind_dict: roi_set = set() for fp in selected_file_paths: @@ -1840,7 +1838,7 @@ class IntraGroupUIMixin: # 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)) @@ -1868,7 +1866,6 @@ class IntraGroupUIMixin: 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) @@ -1878,21 +1875,22 @@ class IntraGroupUIMixin: return else: return - + return ( selected_event, selected_file_paths, selected_indexes, param_values, ) - + + class ParticipantUIMixin: def setup_participant_ui( - self, + self, index_texts: Sequence[str], placeholder_text: str = "" ) -> None: - + # Create mappings: file_path -> participant label and dropdown display text self.participant_map: dict[str, str] = {} # file_path -> "Participant 1" self.participant_dropdown_items = [] # "Participant 1 (filename)" @@ -1913,7 +1911,6 @@ class ParticipantUIMixin: self.event_dropdown = QComboBox() self.event_dropdown.addItem("") - self.index_texts = index_texts self.image_index_dropdown = self._create_multiselect_dropdown(self.index_texts) @@ -1968,8 +1965,6 @@ class ParticipantUIMixin: ] - - # Inject full_text from index_texts for idx, params_list in parameterized_indexes.items(): full_text = self.index_texts[idx] if idx < len(self.index_texts) else f"{idx} (No label found)" @@ -1987,10 +1982,10 @@ class ParticipantUIMixin: return else: return - + return ( - selected_event, - selected_file_paths, - selected_indexes, - param_values, + selected_event, + selected_file_paths, + selected_indexes, + param_values, ) \ No newline at end of file diff --git a/src/window/about.py b/src/window/about.py index a2144b6..37e435a 100644 --- a/src/window/about.py +++ b/src/window/about.py @@ -32,7 +32,7 @@ class AboutWindow(QWidget): label2 = QLabel(f"{APP_NAME_EXPANDED}", self) label3 = QLabel(f"{APP_NAME.upper()} is licensed under the GPL-3.0 licence. For more information, visit https://www.gnu.org/licenses/gpl-3.0.en.html", self) label4 = QLabel(f"Version v{CURRENT_VERSION}") - + layout.addWidget(label) layout.addWidget(label2) layout.addWidget(label3) diff --git a/src/window/plugins.py b/src/window/plugins.py index 82a8314..d7437f0 100644 --- a/src/window/plugins.py +++ b/src/window/plugins.py @@ -8,6 +8,10 @@ License: GPL-3.0 """ # Built-in imports +import io +import shutil +import urllib.request +import zipfile from pathlib import Path from typing import Any, cast @@ -15,36 +19,105 @@ from typing import Any, cast from PySide6.QtCore import Qt, QThread, Signal from PySide6.QtGui import QColor from PySide6.QtWidgets import ( - QHBoxLayout, QLabel, QLineEdit, QListWidget, QListWidgetItem, - QMessageBox, QPushButton, QTabWidget, QTextBrowser, QVBoxLayout, QWidget + QHBoxLayout, QLabel, QLineEdit, QListWidget, QListWidgetItem, QMessageBox, + QPushButton, QTabWidget, QTextBrowser, QVBoxLayout, QWidget, QProgressDialog ) -from plugin_manager import PluginManager, parse_version +from plugin_manager import PluginManager, get_ssl_context, parse_version from src.shared.shareddata import APP_NAME, CURRENT_VERSION, PLUGINS_URL class RemoteFetchWorker(QThread): """Background thread to fetch remote repository data without lagging the main UI.""" - fetched = Signal(list, bool) # (plugins_data, all_failed) + fetched = Signal(list, bool, str) # (plugins_data, all_failed, fetch_type) - def __init__(self, manager: PluginManager, urls: list[str]) -> None: + def __init__(self, manager: PluginManager, urls: list[str], fetch_type: str = "browser") -> None: super().__init__() self.manager = manager self.urls = urls + self.fetch_type = fetch_type def run(self) -> None: plugins, all_failed = self.manager.fetch_remote_repositories(self.urls) - self.fetched.emit(plugins, all_failed) + self.fetched.emit(plugins, all_failed, self.fetch_type) + + +class PluginInstallWorker(QThread): + """Background worker to download, unpack, and install/update plugins with progress reporting.""" + progress = Signal(int, int) # (downloaded_bytes, total_bytes) + status_message = Signal(str) + finished = Signal(bool, str) # (success, message_or_path) + + def __init__( + self, + manager: PluginManager, + download_url: str, + plugin_id: str, + is_update: bool = False, + plugin_path_to_remove: Path | None = None + ) -> None: + super().__init__() + self.manager = manager + self.download_url = download_url + self.plugin_id = plugin_id + self.is_update = is_update + self.plugin_path_to_remove = plugin_path_to_remove + + def run(self) -> None: + try: + self.status_message.emit("Connecting to server...") + req = urllib.request.Request( + self.download_url, + headers={"User-Agent": f"{APP_NAME}-PluginManager"}, + ) + + with urllib.request.urlopen(req, timeout=30, context=get_ssl_context()) as response: + if response.status != 200: + self.finished.emit(False, f"Download failed with HTTP status code {response.status}") + return + + total_size_str = response.headers.get("Content-Length") + total_size = int(total_size_str) if total_size_str and total_size_str.isdigit() else 0 + + self.status_message.emit("Downloading plugin archive...") + chunk_size = 8192 + downloaded = 0 + buffer = io.BytesIO() + + while True: + chunk = response.read(chunk_size) + if not chunk: + break + buffer.write(chunk) + downloaded += len(chunk) + self.progress.emit(downloaded, total_size) + + zip_bytes = buffer.getvalue() + + if self.is_update and self.plugin_path_to_remove and self.plugin_path_to_remove.exists(): + self.status_message.emit("Removing old version...") + if self.plugin_path_to_remove.is_dir(): + shutil.rmtree(self.plugin_path_to_remove) + else: + self.plugin_path_to_remove.unlink() + + self.status_message.emit("Unpacking and extracting files...") + with zipfile.ZipFile(io.BytesIO(zip_bytes)) as zip_ref: + zip_ref.extractall(self.manager.plugins_dir) + + self.finished.emit(True, str(self.manager.plugins_dir)) + except Exception as e: + self.finished.emit(False, str(e)) class PluginsWindow(QWidget): def __init__(self, parent: QWidget | None, plugin_manager: PluginManager) -> None: super().__init__(None, Qt.WindowType.Window) - + self.main_app_window = parent self.manager: PluginManager = plugin_manager - + self.setWindowTitle(f"{APP_NAME.upper()} - Plugins") self.resize(750, 500) @@ -52,13 +125,15 @@ class PluginsWindow(QWidget): self.remote_plugins_data: list[dict[str, Any]] = [] self._has_fetched_remote: bool = False self._fetch_worker: RemoteFetchWorker | None = None + self._install_worker: PluginInstallWorker | None = None + self._progress_dialog: QProgressDialog | None = None # Refresh UI automatically if manager updates state self.manager.plugins_changed.connect(self.refresh_installed_plugins) main_layout = QVBoxLayout(self) self.tab_widget = QTabWidget(self) - + self.installed_tab = self._create_installed_tab() self.browser_tab = self._create_browser_tab() @@ -70,40 +145,75 @@ class PluginsWindow(QWidget): self.setLayout(main_layout) self.refresh_installed_plugins() + self.check_installed_updates() + + def check_installed_updates(self) -> None: + """Asynchronously checks remote repositories for installed plugin updates.""" + if self._fetch_worker is not None and self._fetch_worker.isRunning(): + return + + self._fetch_worker = RemoteFetchWorker(self.manager, self.repository_urls, fetch_type="updates") + self._fetch_worker.fetched.connect(self._on_remote_fetched) + self._fetch_worker.start() def refresh_installed_plugins(self) -> None: - """Refreshes installed list using metadata from PluginManager.""" + """Refreshes installed list using metadata from PluginManager and attaches update info.""" self.installed_list.clear() plugins_info = self.manager.get_installed_plugins_info() + remote_map: dict[str, dict[str, Any]] = {} + if self._has_fetched_remote and self.remote_plugins_data: + for p in self.remote_plugins_data: + p_id = p.get("id") or p.get("name") + if p_id: + remote_map[str(p_id)] = p + for info in plugins_info: + p_id = str(info.get("id", "")) + local_tuple = parse_version(str(info.get("version", "0.0.0"))) + + remote_plugin = remote_map.get(p_id) + if remote_plugin: + remote_ver = str(remote_plugin.get("version", "0.0.0")) + if parse_version(remote_ver) > local_tuple: + info["update_available"] = True + info["remote_version"] = remote_ver + info["download_url"] = str(remote_plugin.get("download_url", "")) + display_text = f"{info['name']} (v{info.get('version', '1.0.0')})" + if info.get("update_available"): + display_text += f" [Update Available: v{info.get('remote_version')}]" if info["is_disabled"]: display_text += " [Disabled]" item = QListWidgetItem(display_text, self.installed_list) item.setData(Qt.ItemDataRole.UserRole, info) - if info["is_disabled"]: + if info.get("update_available"): + item.setForeground(QColor("#1976d2")) + elif info["is_disabled"]: item.setForeground(QColor("#757575")) if self.installed_list.count() == 0: self.installed_list.addItem("No plugins installed.") self._clear_details_panel() - # Re-populate browser list if remote data was already fetched to reflect newly installed/uninstalled plugins - if self._has_fetched_remote and self.remote_plugins_data: + if not self._has_fetched_remote: + self.check_installed_updates() + elif self.remote_plugins_data: self._populate_browser_list() def _on_installed_item_changed(self, current: QListWidgetItem | None, _: Any) -> None: """Updates the right-hand details panel when a plugin is selected.""" if not current: self._clear_details_panel() + self.btn_update_plugin.setEnabled(False) return info = current.data(Qt.ItemDataRole.UserRole) if not isinstance(info, dict): self._clear_details_panel() + self.btn_update_plugin.setEnabled(False) return plugin_info = cast(dict[str, Any], info) @@ -114,83 +224,161 @@ class PluginsWindow(QWidget): desc = str(plugin_info.get("description", "No description provided.")) is_disabled = bool(plugin_info.get("is_disabled", False)) path = str(plugin_info.get("path", "")) + update_available = bool(plugin_info.get("update_available", False)) self.lbl_plugin_title.setText(name) self.lbl_plugin_meta.setText(f"Version: {version}  |  Author: {author}") - if is_disabled: + if update_available: + rem_ver = plugin_info.get("remote_version", "") + self.lbl_plugin_status.setText(f"Status: Update v{rem_ver} Available!") + elif is_disabled: self.lbl_plugin_status.setText("Status: Disabled") else: self.lbl_plugin_status.setText("Status: Active") self.txt_plugin_desc.setHtml(desc) self.lbl_plugin_path.setText(f"Path: {path}") + self.btn_update_plugin.setEnabled(update_available) - def _clear_details_panel(self) -> None: - """Clears details display when nothing is selected.""" - self.lbl_plugin_title.setText("Select a plugin") - self.lbl_plugin_meta.setText("") - self.lbl_plugin_status.setText("") - self.txt_plugin_desc.clear() - self.lbl_plugin_path.setText("") + def execute_plugin_installation(self, download_url: str, plugin_id: str, plugin_name: str, is_update: bool, plugin_path: Path | None) -> None: + """Sets up the progress dialog and starts the background installation/update worker.""" + if self._install_worker is not None and self._install_worker.isRunning(): + return - def toggle_enable_disable(self) -> None: + self._installation_finished_flag = False + action_label = "Updating" if is_update else "Installing" + + self._progress_dialog = QProgressDialog(f"{action_label} '{plugin_name}'...", "Cancel", 0, 100, self) + self._progress_dialog.setMinimumDuration(0) # Show the progress dialog instantly + self._progress_dialog.setWindowTitle(f"{action_label} Plugin") + self._progress_dialog.setWindowModality(Qt.WindowModality.WindowModal) + self._progress_dialog.setAutoClose(False) + self._progress_dialog.setAutoReset(False) + self._progress_dialog.canceled.connect(self._cancel_installation) + self._progress_dialog.show() + + self._install_worker = PluginInstallWorker(self.manager, download_url, plugin_id, is_update, plugin_path) + self._install_worker.progress.connect(self._on_install_progress) + self._install_worker.status_message.connect(self._on_install_status) + self._install_worker.finished.connect(lambda success, msg: self._on_install_finished(success, msg, plugin_name, is_update)) + self._install_worker.start() + + + def _cancel_installation(self) -> None: + """Handles manual user cancellation of the download/install progress.""" + if getattr(self, "_installation_finished_flag", False): + return # Ignore if triggered by closing the dialog programmatically on success + + if self._install_worker and self._install_worker.isRunning(): + self._install_worker.terminate() + self._install_worker.wait() + QMessageBox.warning(self, "Cancelled", "Plugin installation was cancelled.") + + def _on_install_finished(self, success: bool, message: str, plugin_name: str, is_update: bool) -> None: + self._installation_finished_flag = True # Prevent false cancellation triggers + + if self._progress_dialog: + self._progress_dialog.close() + self._progress_dialog.deleteLater() + self._progress_dialog = None + + action_word = "updated" if is_update else "installed" + action_verb = "update" if is_update else "install" + + if success: + self.manager.reload_plugins() + QMessageBox.information(self, "Success", f"Successfully {action_word} '{plugin_name}'!") + self.refresh_installed_plugins() + if self._has_fetched_remote: + self._populate_browser_list() + else: + QMessageBox.critical(self, "Installation Failed", f"Could not {action_verb} plugin:\n{message}") + + def _on_install_progress(self, downloaded: int, total: int) -> None: + if self._progress_dialog: + if total > 0: + self._progress_dialog.setMaximum(total) + self._progress_dialog.setValue(downloaded) + mb_down = downloaded / (1024 * 1024) + mb_total = total / (1024 * 1024) + self._progress_dialog.setLabelText(f"Downloading... ({mb_down:.2f} MB / {mb_total:.2f} MB)") + else: + self._progress_dialog.setMaximum(0) # Infinite marquee progress if size is unknown + mb_down = downloaded / (1024 * 1024) + self._progress_dialog.setLabelText(f"Downloading... ({mb_down:.2f} MB downloaded)") + + def _on_install_status(self, message: str) -> None: + if self._progress_dialog: + self._progress_dialog.setLabelText(message) + + + def update_selected_plugin(self) -> None: + """Triggers the background update process for the selected installed plugin.""" selected = self.installed_list.currentItem() if not selected: return raw_info = selected.data(Qt.ItemDataRole.UserRole) - if isinstance(raw_info, dict): - info = cast(dict[str, Any], raw_info) - plugin_path = info.get("path") - if isinstance(plugin_path, str) and plugin_path: - self.manager.toggle_plugin_state(Path(plugin_path)) - - def uninstall_plugin(self) -> None: - selected = self.installed_list.currentItem() - if not selected: - return - - raw_info = selected.data(Qt.ItemDataRole.UserRole) if not isinstance(raw_info, dict): return info = cast(dict[str, Any], raw_info) + download_url = info.get("download_url", "") + plugin_id = info.get("id", "unnamed") + plugin_name = info.get("name", "Plugin") plugin_path = info.get("path") - plugin_name = info.get("name", "this plugin") - if not isinstance(plugin_path, str) or not plugin_path: + if not download_url: + QMessageBox.warning(self, "Update Failed", "No download URL provided for this update.") return - reply = QMessageBox.question( - self, - "Confirm Uninstall", - f"Are you sure you want to delete '{plugin_name}'?", - QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, - ) - if reply == QMessageBox.StandardButton.Yes: - self.manager.uninstall_plugin(Path(plugin_path)) + path_obj = Path(plugin_path) if isinstance(plugin_path, str) and plugin_path else (plugin_path if isinstance(plugin_path, Path) else None) + self.execute_plugin_installation(download_url, plugin_id, plugin_name, is_update=True, plugin_path=path_obj) - def fetch_remote_plugins(self) -> None: - """Asynchronously fetches remote plugins on a background thread.""" - if self._fetch_worker is not None and self._fetch_worker.isRunning(): + def install_selected_plugin(self) -> None: + """Triggers the background installation/update process from the browser tab.""" + selected = self.browser_list.currentItem() + if not selected: + return + data = selected.data(Qt.ItemDataRole.UserRole) + if not isinstance(data, dict): return - self.browser_list.clear() - self.browser_list.addItem("Fetching remote repositories...") - self.btn_install.setEnabled(False) - self.btn_fetch.setEnabled(False) + plugin_data = cast(dict[str, Any], data) - # Defer network call to background worker to avoid UI freeze - self._fetch_worker = RemoteFetchWorker(self.manager, self.repository_urls) - self._fetch_worker.fetched.connect(self._on_remote_fetched) - self._fetch_worker.start() + update_available = bool(selected.data(Qt.ItemDataRole.UserRole + 3)) + plugin_id = str(plugin_data.get("id", "unnamed")) + plugin_name = str(plugin_data.get("name", "Plugin")) + download_url = str(plugin_data.get("download_url", "")) - def _on_remote_fetched(self, plugins: list[dict[str, Any]], all_failed: bool) -> None: + if not download_url: + QMessageBox.warning(self, "Error", "No download URL provided for this plugin.") + return + + # Find existing local path if updating an installed plugin + existing_path: Path | None = None + installed_info = self.manager.get_installed_plugins_info() + for info in installed_info: + if info.get("id") == plugin_id or info.get("name") == plugin_name: + p = info.get("path") + if isinstance(p, Path): + existing_path = p + elif isinstance(p, str): + existing_path = Path(p) + break + + self.execute_plugin_installation(download_url, plugin_id, plugin_name, is_update=update_available, plugin_path=existing_path) + + def _on_remote_fetched(self, plugins: list[dict[str, Any]], all_failed: bool, fetch_type: str) -> None: """Callback executed on the main UI thread when remote fetching finishes.""" self.remote_plugins_data = plugins self._has_fetched_remote = True self.btn_fetch.setEnabled(True) + if fetch_type == "updates": + self.refresh_installed_plugins() + return + if plugins: self._populate_browser_list() elif all_failed: @@ -200,13 +388,33 @@ class PluginsWindow(QWidget): self.browser_list.clear() self.browser_list.addItem("No plugins found across configured repositories.") + def fetch_remote_plugins(self) -> None: + """Asynchronously fetches remote plugins on a background thread for the browser.""" + if self._fetch_worker is not None and self._fetch_worker.isRunning(): + return + + self.browser_list.clear() + self.browser_list.addItem("Fetching remote repositories...") + self.btn_install.setEnabled(False) + self.btn_fetch.setEnabled(False) + + self._fetch_worker = RemoteFetchWorker(self.manager, self.repository_urls, fetch_type="browser") + self._fetch_worker.fetched.connect(self._on_remote_fetched) + self._fetch_worker.start() + def _populate_browser_list(self) -> None: - """Populates the browser tab list and evaluates compatibility and installation state.""" + """Populates the browser tab list and evaluates compatibility, installation, and updates.""" self.browser_list.clear() installed_info = self.manager.get_installed_plugins_info() - installed_ids = {p.get("id") for p in installed_info if p.get("id")} - installed_names = {p.get("name") for p in installed_info if p.get("name")} + installed_map: dict[str, dict[str, Any]] = {} + for p in installed_info: + p_id = p.get("id") + p_name = p.get("name") + if p_id: + installed_map[str(p_id)] = p + if p_name: + installed_map[str(p_name)] = p for plugin in self.remote_plugins_data: name = plugin.get("name", "Unknown") @@ -220,7 +428,6 @@ class PluginsWindow(QWidget): is_version_ok = self.manager.current_app_version >= parse_version(min_v_str) is_compatible = is_platform_ok and is_version_ok - # Evaluate reasons for incompatibility incompat_reasons: list[str] = [] if not is_platform_ok: plat_str = ", ".join(platforms) if isinstance(platforms, list) else str(platforms) @@ -228,10 +435,20 @@ class PluginsWindow(QWidget): if not is_version_ok: incompat_reasons.append(f"Requires App v{min_v_str}+") - is_installed = (bool(p_id) and p_id in installed_ids) or (bool(name) and name in installed_names) + installed_match = installed_map.get(str(p_id)) or installed_map.get(str(name)) + is_installed = installed_match is not None + + update_available = False + remote_ver_str = str(plugin.get("version", "0.0.0")) + if is_installed: + local_ver_str = str(installed_match.get("version", "0.0.0")) + if parse_version(remote_ver_str) > parse_version(local_ver_str): + update_available = True display_text = f"{name} (v{version}) - {desc}" - if is_installed: + if update_available: + display_text += f" [Update Available: v{remote_ver_str}]" + elif is_installed: display_text += " [Installed]" elif incompat_reasons: reason_str = "; ".join(incompat_reasons) @@ -241,28 +458,14 @@ class PluginsWindow(QWidget): item.setData(Qt.ItemDataRole.UserRole, plugin) item.setData(Qt.ItemDataRole.UserRole + 1, is_compatible) item.setData(Qt.ItemDataRole.UserRole + 2, is_installed) + item.setData(Qt.ItemDataRole.UserRole + 3, update_available) - if is_installed: - item.setForeground(QColor("#2e7d32")) # Green for installed + if update_available: + item.setForeground(QColor("#1976d2")) + elif is_installed: + item.setForeground(QColor("#2e7d32")) elif not is_compatible: - item.setForeground(QColor("#d32f2f")) # Red for incompatible - - def install_selected_plugin(self) -> None: - selected = self.browser_list.currentItem() - if not selected: - return - data = selected.data(Qt.ItemDataRole.UserRole) - if not data: - return - - try: - target_path = self.manager.install_plugin_from_url( - data.get("download_url", ""), - data.get("id", "unnamed") - ) - QMessageBox.information(self, "Success", f"Installed plugin to:\n{target_path}") - except Exception as e: - QMessageBox.critical(self, "Installation Failed", f"Could not install plugin:\n{e}") + item.setForeground(QColor("#d32f2f")) def _on_tab_changed(self, index: int) -> None: if index == 1 and not self._has_fetched_remote: @@ -272,10 +475,19 @@ class PluginsWindow(QWidget): if current: is_compatible = bool(current.data(Qt.ItemDataRole.UserRole + 1)) is_installed = bool(current.data(Qt.ItemDataRole.UserRole + 2)) - - # Button lights up ONLY if plugin is compatible and NOT yet installed - self.btn_install.setEnabled(is_compatible and not is_installed) + update_available = bool(current.data(Qt.ItemDataRole.UserRole + 3)) + + if update_available: + self.btn_install.setText("Update Plugin") + self.btn_install.setEnabled(is_compatible) + elif is_installed: + self.btn_install.setText("Install Plugin") + self.btn_install.setEnabled(False) + else: + self.btn_install.setText("Install Plugin") + self.btn_install.setEnabled(is_compatible) else: + self.btn_install.setText("Install Plugin") self.btn_install.setEnabled(False) def _on_add_repo(self) -> None: @@ -334,14 +546,19 @@ class PluginsWindow(QWidget): btn_layout = QHBoxLayout() btn_refresh = QPushButton("Refresh List", tab) + self.btn_update_plugin = QPushButton("Update Plugin", tab) + self.btn_update_plugin.setEnabled(False) + self.btn_update_plugin.setStyleSheet("font-weight: bold; color: #1976d2;") btn_enable = QPushButton("Enable / Disable", tab) btn_uninstall = QPushButton("Uninstall", tab) btn_refresh.clicked.connect(self.refresh_installed_plugins) + self.btn_update_plugin.clicked.connect(self.update_selected_plugin) btn_enable.clicked.connect(self.toggle_enable_disable) btn_uninstall.clicked.connect(self.uninstall_plugin) btn_layout.addWidget(btn_refresh) + btn_layout.addWidget(self.btn_update_plugin) btn_layout.addStretch() btn_layout.addWidget(btn_enable) btn_layout.addWidget(btn_uninstall) @@ -388,4 +605,54 @@ class PluginsWindow(QWidget): layout.addLayout(repo_layout) layout.addWidget(self.browser_list) layout.addLayout(btn_layout) - return tab \ No newline at end of file + return tab + + def toggle_enable_disable(self) -> None: + selected = self.installed_list.currentItem() + if not selected: + return + raw_info = selected.data(Qt.ItemDataRole.UserRole) + if isinstance(raw_info, dict): + info = cast(dict[str, Any], raw_info) + plugin_path = info.get("path") + if isinstance(plugin_path, str) and plugin_path: + self.manager.toggle_plugin_state(Path(plugin_path)) + elif isinstance(plugin_path, Path): + self.manager.toggle_plugin_state(plugin_path) + + def uninstall_plugin(self) -> None: + selected = self.installed_list.currentItem() + if not selected: + return + + raw_info = selected.data(Qt.ItemDataRole.UserRole) + if not isinstance(raw_info, dict): + return + + info = cast(dict[str, Any], raw_info) + plugin_path = info.get("path") + plugin_name = info.get("name", "this plugin") + + if not plugin_path: + return + + reply = QMessageBox.question( + self, + "Confirm Uninstall", + f"Are you sure you want to delete '{plugin_name}'?", + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, + ) + if reply == QMessageBox.StandardButton.Yes: + if isinstance(plugin_path, str): + self.manager.uninstall_plugin(Path(plugin_path)) + elif isinstance(plugin_path, Path): + self.manager.uninstall_plugin(plugin_path) + + + def _clear_details_panel(self) -> None: + """Clears details display when nothing is selected.""" + self.lbl_plugin_title.setText("Select a plugin") + self.lbl_plugin_meta.setText("") + self.lbl_plugin_status.setText("") + self.txt_plugin_desc.clear() + self.lbl_plugin_path.setText("") \ No newline at end of file diff --git a/src/window/terminal.py b/src/window/terminal.py index 66b2775..46a7f6c 100644 --- a/src/window/terminal.py +++ b/src/window/terminal.py @@ -33,11 +33,11 @@ class _AssocWorker(QThread): window. Used for the non-elevated path too for consistency. """ result_ready = Signal(bool, str) - + def __init__(self, force_admin: bool, parent: QWidget | None = None) -> None: super().__init__(parent) self._force_admin = force_admin - + def run(self) -> None: ok, msg = register_file_association(force_admin=self._force_admin) self.result_ready.emit(ok, msg) @@ -92,7 +92,7 @@ class TerminalWindow(QWidget): self._pending_assoc_confirmation = False self._handle_assoc_confirmation(command_text.strip().lower()) return - + parts = command_text.strip().split() if not parts: return @@ -118,10 +118,10 @@ class TerminalWindow(QWidget): def cmd_help(self, *args: Any) -> str: available_cmds = [cmd for cmd in self.commands.keys() if cmd != "utest"] return f"Available commands: {', '.join(available_cmds)}" - + def cmd_version(self, *args: Any) -> str: return f"{APP_NAME.upper()} is running version {CURRENT_VERSION}." - + def cmd_about(self, *args: Any) -> None: self.about = AboutWindow(self) self.about.show() @@ -134,7 +134,7 @@ class TerminalWindow(QWidget): main_win = self.parent() if not isinstance(main_win, QMainWindow): return "[Error] Main window context not found." - + self.updater = UpdateManager( main_window=main_win, api_url=API_URL, @@ -145,7 +145,7 @@ class TerminalWindow(QWidget): app_name=APP_NAME ) self.output_area.append("Checking for updates...") - + self.updater.manual_check_for_updates() return "See status bar for update information." @@ -155,10 +155,10 @@ class TerminalWindow(QWidget): if PLATFORM_NAME != "windows" or is_windows_admin(): self._run_assoc(force_admin=False) return None - + self._pending_assoc_confirmation = True return "Not running as admin. Register system-wide via UAC elevation? (y/n)" - + def _handle_assoc_confirmation(self, answer: str) -> None: if answer in ("y", "yes"): self._run_assoc(force_admin=True) @@ -166,15 +166,15 @@ class TerminalWindow(QWidget): self._run_assoc(force_admin=False) else: self.output_area.append("Please answer 'y' or 'n'. Run 'assoc' again to retry.") - + def _run_assoc(self, force_admin: bool) -> None: if force_admin: self.output_area.append("Requesting elevation. Check for a UAC prompt...") - + self._assoc_worker = _AssocWorker(force_admin=force_admin, parent=self) self._assoc_worker.result_ready.connect(self._on_assoc_result) self._assoc_worker.start() - + def _on_assoc_result(self, ok: bool, msg: str) -> None: self.output_area.append(msg) self._assoc_worker = None @@ -190,7 +190,7 @@ class TerminalWindow(QWidget): return f"[Error] Target script not found at: {target_script}" self._process = QProcess(self) - + # Stream stdout and stderr live to output_area self._process.readyReadStandardOutput.connect(self._handle_stdout) self._process.readyReadStandardError.connect(self._handle_stderr) @@ -198,7 +198,7 @@ class TerminalWindow(QWidget): # Use current Python interpreter executable. Works when packaged? python_executable = sys.executable - + self.output_area.append(f"Starting {target_script.name}...") self._process.start(python_executable, [str(target_script)]) return None diff --git a/src/window/updateevents.py b/src/window/updateevents.py index c251ac3..1b2994d 100644 --- a/src/window/updateevents.py +++ b/src/window/updateevents.py @@ -102,7 +102,7 @@ def _align_boris_and_write_snirf_worker( for raw_event in boris_events: if not isinstance(raw_event, (list, tuple)): continue - + event = cast(Sequence[Any], raw_event) if len(event) < 3: @@ -179,7 +179,7 @@ def align_boris_and_write_snirf_isolated( time_shift ) return future.result() - + class EventUpdateMode(Enum): WRITE_SNIRF = auto() # destructive @@ -217,7 +217,7 @@ class UpdateEventsWindow(QWidget): self.label_events = QLabel("Events in selected observation:") self.combo_events = QComboBox() self.combo_events.setEnabled(False) - + self.label_snirf_events = QLabel("Events in SNIRF file:") self.combo_snirf_events = QComboBox() self.combo_snirf_events.setEnabled(False) @@ -227,13 +227,13 @@ class UpdateEventsWindow(QWidget): self.btn_clear.clicked.connect(self.clear_files) self.btn_go.clicked.connect(self.go_action) - # --- + # --- layout = QVBoxLayout() self.description = QLabel() self.description.setTextFormat(Qt.TextFormat.RichText) self.description.setTextInteractionFlags(Qt.TextInteractionFlag.TextBrowserInteraction) self.description.setOpenExternalLinks(True) - + self.description.setText("The events that are present in a snirf file may not be the events that are to be studied and examined.
" "Utilizing different software and video recordings, it is easy enough to see when an action actually occured in a file.
" "The software BORIS is used to add these events to video files, and these events can be applied to the snirf file
" @@ -317,7 +317,7 @@ class UpdateEventsWindow(QWidget): help_btn_suffix.setToolTip(help_text_suffix) help_btn_suffix.clicked.connect(lambda: self.show_help_popup(help_text_suffix)) suffix2_layout.addWidget(help_btn_suffix) - + suffix2_container = QWidget() suffix2_container_layout = QHBoxLayout() suffix2_container_layout.setContentsMargins(0, 0, 0, 0) @@ -328,7 +328,7 @@ class UpdateEventsWindow(QWidget): suffix2_layout.addWidget(suffix2_container) layout.addLayout(suffix2_layout) - + snirf_events_layout = QHBoxLayout() help_text_snirf_events = "The event markers extracted from the SNIRF file." @@ -370,7 +370,7 @@ class UpdateEventsWindow(QWidget): try: # Memory leak safe: Extracted in a separate process that terminates immediately annotations: Annotations = load_snirf_annotations_isolated(file_path) - + print(f"Loaded {len(annotations)} annotations from {file_path}") # Build individual event entries @@ -398,7 +398,7 @@ class UpdateEventsWindow(QWidget): file_path, _ = QFileDialog.getOpenFileName(self, "Select BORIS File", "", "BORIS project Files (*.boris)") if file_path: self.line_edit_file_b.setText(file_path) - + try: with open(file_path, 'r', encoding='utf-8') as f: data = json.load(f) @@ -411,7 +411,7 @@ class UpdateEventsWindow(QWidget): except (json.JSONDecodeError, FileNotFoundError, KeyError) as e: QMessageBox.warning(self, "Error", f"Failed to parse BORIS file:\n{e}") - + def extract_boris_observation_keys(self, data: dict[str, Any]) -> List[str]: if "observations" not in data: raise KeyError("Missing 'observations' key in BORIS file.") @@ -419,7 +419,7 @@ class UpdateEventsWindow(QWidget): observations = cast(dict[str, Any], data["observations"]) return list(observations.keys()) - + def on_observation_selected(self): selected_obs = self.combo_suffix.currentText() if not selected_obs or not hasattr(self, 'boris_data'): @@ -454,12 +454,12 @@ class UpdateEventsWindow(QWidget): def clear_files(self) -> None: self.line_edit_file_a.clear() self.line_edit_file_b.clear() - + def go_action(self) -> None: - + file_a = self.line_edit_file_a.text() - suffix = "flare" - + suffix = "flare" + if not hasattr(self, "boris_data") or self.combo_events.count() == 0 or self.combo_snirf_events.count() == 0: QMessageBox.warning(self, "Missing data", "Please make sure a BORIS and SNIRF event are selected.") return @@ -600,7 +600,7 @@ class UpdateEventsBlazesWindow(QWidget): self.label_events = QLabel("Events in selected blazes file:") self.combo_events = QComboBox() self.combo_events.setEnabled(False) - + self.label_snirf_events = QLabel("Events in SNIRF file:") self.combo_snirf_events = QComboBox() self.combo_snirf_events.setEnabled(False) @@ -610,13 +610,13 @@ class UpdateEventsBlazesWindow(QWidget): self.btn_clear.clicked.connect(self.clear_files) self.btn_go.clicked.connect(self.go_action) - # --- + # --- layout = QVBoxLayout() self.description = QLabel() self.description.setTextFormat(Qt.TextFormat.RichText) self.description.setTextInteractionFlags(Qt.TextInteractionFlag.TextBrowserInteraction) self.description.setOpenExternalLinks(True) - + self.description.setText("The events that are present in a snirf file may not be the events that are to be studied and examined.
" "Utilizing different software and video recordings, it is easy enough to see when an action actually occured in a file.
" "The software BLAZES is used to create these events in video files, and these events can be applied to the snirf file
" @@ -679,7 +679,7 @@ class UpdateEventsBlazesWindow(QWidget): help_btn_suffix.setToolTip(help_text_suffix) help_btn_suffix.clicked.connect(lambda: self.show_help_popup(help_text_suffix)) suffix2_layout.addWidget(help_btn_suffix) - + suffix2_container = QWidget() suffix2_container_layout = QHBoxLayout() suffix2_container_layout.setContentsMargins(0, 0, 0, 0) @@ -690,7 +690,7 @@ class UpdateEventsBlazesWindow(QWidget): suffix2_layout.addWidget(suffix2_container) layout.addLayout(suffix2_layout) - + snirf_events_layout = QHBoxLayout() help_text_snirf_events = "The event markers extracted from the SNIRF file." @@ -733,7 +733,7 @@ class UpdateEventsBlazesWindow(QWidget): try: # Memory leak safe: Extracted in a separate process that terminates immediately annotations: Annotations = load_snirf_annotations_isolated(file_path) - + print(f"Loaded {len(annotations)} annotations from {file_path}") # Build individual event entries @@ -761,7 +761,7 @@ class UpdateEventsBlazesWindow(QWidget): file_path, _ = QFileDialog.getOpenFileName(self, "Select JSON Timeline File", "", "JSON Files (*.json)") if file_path: self.line_edit_file_b.setText(file_path) - + try: with open(file_path, 'r', encoding='utf-8') as f: data = json.load(f) @@ -780,7 +780,7 @@ class UpdateEventsBlazesWindow(QWidget): QMessageBox.warning(self, "Error", f"Failed to parse JSON file:\n{e}") self.combo_events.clear() self.combo_events.setEnabled(False) - + def extract_json_observation_strings(self, data: dict[str, Any]) -> List[str]: if "events" not in data: @@ -792,23 +792,23 @@ class UpdateEventsBlazesWindow(QWidget): for event in data["events"]: track_name = event.get("track_name", "Unknown") onset = event.get("start_sec", 0.0) - + display_str = f"{track_name} @ {onset:.3f}s" event_strings.append(display_str) return event_strings - + def clear_files(self) -> None: self.line_edit_file_a.clear() self.line_edit_file_b.clear() - - + + def go_action(self) -> None: file_a = self.line_edit_file_a.text() _ = self.line_edit_file_b.text() suffix = APP_NAME - + if not hasattr(self, "json_data") or self.combo_events.count() == 0 or self.combo_snirf_events.count() == 0: QMessageBox.warning(self, "Missing data", "Please make sure a JSON and SNIRF event are selected.") return @@ -835,14 +835,14 @@ class UpdateEventsBlazesWindow(QWidget): durations: list[float] = [] descriptions: list[str] = [] skipped_count = 0 - + try: events_list = self.json_data.get("events", []) - + for event in events_list: track_name = event.get("track_name", "Unknown") clean_name = track_name.replace("AI: ", "").strip() - + original_start = event.get("start_sec", 0.0) original_end = event.get("end_sec", original_start) duration = original_end - original_start @@ -870,7 +870,7 @@ class UpdateEventsBlazesWindow(QWidget): if self.mode == EventUpdateMode.WRITE_SNIRF: suggested_name = f"{os.path.splitext(os.path.basename(file_a))[0]}_{suffix}.snirf" save_path, _ = QFileDialog.getSaveFileName(self, "Save SNIRF", suggested_name, "SNIRF Files (*.snirf)") - + if not save_path: return if not save_path.lower().endswith(".snirf"): save_path += ".snirf" diff --git a/src/window/updateoptodes.py b/src/window/updateoptodes.py index 9dee9de..fdf1c32 100644 --- a/src/window/updateoptodes.py +++ b/src/window/updateoptodes.py @@ -61,7 +61,7 @@ class UpdateOptodesWindow(QWidget): self.description.setTextFormat(Qt.TextFormat.RichText) self.description.setTextInteractionFlags(Qt.TextInteractionFlag.TextBrowserInteraction) self.description.setOpenExternalLinks(False) # Handle the click internally - + self.description.setText("Some software when creating snirf files will insert a template of optode positions as the correct position of the optodes for the participant.
" "This is rarely correct as each head differs slightly in shape or size, and a lot of calculations require the optodes to be in the correct location.
" "Using a .txt or .xlsx file, we can update the positions in the snirf file to match those of a digitization system such as one from Polhemus or elsewhere.
" @@ -270,12 +270,12 @@ class UpdateOptodesWindow(QWidget): ) def update_optode_positions( - self, - file_a: Union[str, Path], - file_b: Union[str, Path], + self, + file_a: Union[str, Path], + file_b: Union[str, Path], save_path: Union[str, Path] ) -> None: - + fiducials = {} ch_positions = {} @@ -304,33 +304,33 @@ class UpdateOptodesWindow(QWidget): df = pd.read_excel(file_b, sheet_name='Sheet1') # type: ignore def _get_block_data( - target_df: pd.DataFrame, - block_id: int, - row_mapping: Union[Dict[int, str], str], + target_df: pd.DataFrame, + block_id: int, + row_mapping: Union[Dict[int, str], str], scale: float = 0.001 ) -> Dict[str, npt.NDArray[np.float64]]: - + """Isolates a block, cleans numeric data, and returns a scaled dictionary.""" # 1. Isolate and clean block = target_df[target_df['block_id'] == block_id].iloc[:, [1, 2, 3]].copy() block = block.apply(pd.to_numeric, errors='coerce') - + # 2. Extract into dictionary based on mapping result: Dict[str, npt.NDArray[np.float64]] = {} - + # If row_mapping is a dict (like {0: 'nz'}), use it directly if isinstance(row_mapping, dict): for row_idx, key in row_mapping.items(): if row_idx < len(block): result[key] = block.iloc[row_idx].to_numpy(dtype=float) * scale - + # If row_mapping is a string prefix (like 'D' or 'S'), auto-generate keys else: for i in range(len(block)): result[f"{row_mapping}{i+1}"] = block.iloc[i].to_numpy(dtype=float) * scale - + return result - + # Identify blocks is_empty = df.isnull().all(axis=1) df['block_id'] = is_empty.cumsum() diff --git a/src/window/viewerlauncher.py b/src/window/viewerlauncher.py index e567ae4..0a5c0fe 100644 --- a/src/window/viewerlauncher.py +++ b/src/window/viewerlauncher.py @@ -48,12 +48,12 @@ class ViewerLauncherWidget(QWidget): qc_dict: dict[str, dict[str, Any]], folding_bypass: bool, ) -> None: - + super().__init__() self.setWindowTitle(f"Viewer Launcher - {APP_NAME.upper()}") group_dict = {f: c.get("GROUP", "Unknown") for f, c in config_dict.items()} - + btn_data: list[tuple[str, Type[QWidget], list[Any], bool]] = [ ("Participant Image Viewer", ParticipantImageViewerWidget, [haemo_dict, fig_bytes_dict], True), ("Participant Brain Viewer", ParticipantBrainViewerWidget, [haemo_dict, cha_dict], True), @@ -64,7 +64,7 @@ class ViewerLauncherWidget(QWidget): ("Inter-Group Stats Viewer", InterGroupStatsWidget, [haemo_dict, cha_dict, df_ind_dict, design_matrix_dict, contrast_results_dict, roi_channel_map_dict, group_dict], True), ("Intra-Group Brain and Image Viewer", IntraGroupBrainImageWidget, [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, 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, contrast_results_dict, group_dict, config_dict, fir_feature_dict, qc_dict], True) + ("Export To CSV Viewer", ExportToCSVWidget, [haemo_dict, epochs_dict, cha_dict, df_ind_dict, design_matrix_dict, contrast_results_dict, group_dict, config_dict, fir_feature_dict, qc_dict], True) ] layout = QVBoxLayout(self) @@ -84,23 +84,23 @@ class ViewerLauncherWidget(QWidget): def callback(_checked: bool = False) -> None: self._open_viewer(widget_class, btn, *args) return callback - + def _open_viewer( - self, - widget_class: Type[QWidget], - btn: QPushButton, + self, + widget_class: Type[QWidget], + btn: QPushButton, *args: Any ) -> None: - + # Instantiate and show dynamically self.active_viewer = widget_class(*args) self.active_viewer.show() self._trigger_success(btn) def _launch( - self, - func: Callable[..., Any], - btn: QPushButton, + self, + func: Callable[..., Any], + btn: QPushButton, *args: Any ) -> None: @@ -112,7 +112,7 @@ class ViewerLauncherWidget(QWidget): original_text = button.text() button.setText(f"{original_text} ✔") button.setStyleSheet("color: green; font-weight: bold;") - + # Revert after 1 second QTimer.singleShot(1000, lambda: self._revert_button(button, original_text)) diff --git a/src/window/welcome.py b/src/window/welcome.py index 99eb390..435e773 100644 --- a/src/window/welcome.py +++ b/src/window/welcome.py @@ -22,9 +22,9 @@ class WelcomeDialog(QDialog): self.setWindowTitle(f"What's New - {APP_NAME.upper()}") self.setMinimumSize(550, 450) self.resize(800, 500) - + layout = QVBoxLayout(self) - + header_layout = QHBoxLayout() logo_label = QLabel(self) @@ -41,7 +41,7 @@ class WelcomeDialog(QDialog): header_layout.addWidget(title_label) header_layout.addStretch() layout.addLayout(header_layout) - + self.text_browser = QTextBrowser(self) self.text_browser.setHtml("

Loading latest updates from server...

") @@ -49,17 +49,17 @@ class WelcomeDialog(QDialog): self.text_browser.setOpenLinks(False) self.text_browser.anchorClicked.connect(QDesktopServices.openUrl) layout.addWidget(self.text_browser) - + footer_layout = QHBoxLayout() - + ok_button = QPushButton("OK", self) ok_button.setDefault(True) ok_button.clicked.connect(self.accept) - + footer_layout.addStretch() footer_layout.addWidget(ok_button) layout.addLayout(footer_layout) - + # Fetch markdown from the web asynchronously self.network_manager = QNetworkAccessManager(self) self.network_manager.finished.connect(self._on_download_complete) diff --git a/startup_args.py b/startup_args.py index 3432c3e..1b0217d 100644 --- a/startup_args.py +++ b/startup_args.py @@ -30,7 +30,7 @@ def parse_startup_args(argv: list[str]) -> StartupArgs: a fixed position, so it survives regardless of whether --finish-update precedes it. """ - + finish_update = "--finish-update" in argv[1:] initial_file = None diff --git a/updater.py b/updater.py index 73aed27..15f80b8 100644 --- a/updater.py +++ b/updater.py @@ -37,7 +37,7 @@ class UpdateDownloadThread(QThread): download_url (str): URL of the update zip file to download. latest_version (str): Version string of the latest update. """ - + update_ready = Signal(str, str) error_occurred = Signal(str) @@ -48,7 +48,7 @@ class UpdateDownloadThread(QThread): platform_name: str, app_name: str, ) -> None: - + super().__init__() self.download_url = download_url self.latest_version = latest_version @@ -124,7 +124,7 @@ class UpdateCheckThread(QThread): platform_name: str, app_name: str, ) -> None: - + super().__init__() self.api_url = api_url self.api_url_sec = api_url_sec @@ -171,7 +171,7 @@ class UpdateCheckThread(QThread): if not latest: continue - + tag = latest["tag_name"].lstrip("v") for asset in latest.get("assets", []): @@ -203,7 +203,7 @@ class LocalPendingUpdateCheckThread(QThread): platform_name: str, app_name: str, ) -> None: - + super().__init__() self.current_version = current_version self.platform_suffix = platform_suffix @@ -237,7 +237,7 @@ class LocalPendingUpdateCheckThread(QThread): break except Exception: pass - + if not found: self.no_pending_update.emit() @@ -247,7 +247,7 @@ class UpdateManager(QObject): Orchestrates the update process. Main apps should instantiate this and call check_for_updates(). """ - + def __init__( self, main_window: QMainWindow, @@ -258,7 +258,7 @@ class UpdateManager(QObject): platform_suffix: str, app_name: str, ) -> None: - + super().__init__(main_window) self.main_window: QMainWindow = main_window self.api_url = api_url @@ -267,7 +267,7 @@ class UpdateManager(QObject): self.platform_name = platform_name self.platform_suffix = platform_suffix self.app_name = app_name - + self.pending_update_version: Optional[str] = None self.pending_update_path: Optional[str] = None @@ -331,7 +331,7 @@ class UpdateManager(QObject): except Exception as e: if self.main_window.statusBar(): self.main_window.statusBar().showMessage(f"Failed to delete old update folder: {e}") - + # Clear pending update info so new download proceeds self.pending_update_version = None self.pending_update_path = None @@ -471,7 +471,7 @@ def finish_update_if_needed(platform_name: str, app_name: str, cfg_path: str, fi return print("Finishing update...") - + # 1. Reset welcome dialog config flag update_cfg = configparser.ConfigParser() try: @@ -480,7 +480,7 @@ def finish_update_if_needed(platform_name: str, app_name: str, cfg_path: str, fi if not update_cfg.has_section("Options"): update_cfg.add_section("Options") update_cfg.set("Options", "show_welcome_dialog", "true") - + with open(cfg_path, "w") as f: update_cfg.write(f) print("Welcome dialog flag successfully reset to 'true' for next run.")