From 766bf75dd7392333fb4a041b82907488e505de6c Mon Sep 17 00:00:00 2001 From: tyler Date: Sun, 28 Jun 2026 08:17:36 -0700 Subject: [PATCH] massive changes for 1.5.0 --- .gitignore | 3 +- changelog.md | 17 + flares.py | 327 +++--- icons/article_shortcut_24dp_1F1F1.svg | 1 + icons/history_24dp_1F1F1F.svg | 1 + icons/history_2_24dp_1F1F1F.svg | 1 + icons/reset_settings_24dp_1F1F1F.svg | 1 + icons/warning_off_24dp_1F1F1F.svg | 1 + images/brain.png | Bin 0 -> 56078 bytes main.py | 1497 +++++++++++++++++++++++-- updater.py | 15 +- 11 files changed, 1603 insertions(+), 261 deletions(-) create mode 100644 icons/article_shortcut_24dp_1F1F1.svg create mode 100644 icons/history_24dp_1F1F1F.svg create mode 100644 icons/history_2_24dp_1F1F1F.svg create mode 100644 icons/reset_settings_24dp_1F1F1F.svg create mode 100644 icons/warning_off_24dp_1F1F1F.svg create mode 100644 images/brain.png diff --git a/.gitignore b/.gitignore index 1c2b2a7..ee449fc 100644 --- a/.gitignore +++ b/.gitignore @@ -180,4 +180,5 @@ cython_debug/ *.snirf *.json flares-* -*.flare \ No newline at end of file +*.flare +*.cfg \ No newline at end of file diff --git a/changelog.md b/changelog.md index f0a8edd..6763373 100644 --- a/changelog.md +++ b/changelog.md @@ -1,3 +1,20 @@ +# Version 1.5.0 + +- This release introduces a new configuration file that may break existing installs. If your application does not update correctly, please download fresh from [this link.](https://git.research.dezeeuw.ca/tyler/flares/releases/) +- New configuration file has been added! Now your choices of preferences will be saved when the application is closed and re-opened. If the configuration file is missing, a new one will be generated +- The new option "Reset to Default Configuration" will reset the configuration file to it's default values +- Recent files and recent projects are now saved and appear under the File menu for quick resuming +- A welcome dialog will now display the changelog after every update. This popup will only appear once but can be reopened under the Options menu through "Show Update Changelog" +- Changed the hotkey for "Update optodes in snirf file..." to be F9 instead of F6 +- Revamped the fOLD channels window. Images containing the pie charts are now interactable! Click whitespace to expand the whole image and click a chart to expand it. +- fOLD progress bar when processing now updates the percentages live. Fixes [Issue 76](https://git.research.dezeeuw.ca/tyler/flares/issues/76) +- Overall pie charts on an individal and global basis are now genereted. Fixes [Issue 78](https://git.research.dezeeuw.ca/tyler/flares/issues/78) +- Brodmann images are now available when examining a pie chart to understand which area is being reported. Fixes [Issue 77](https://git.research.dezeeuw.ca/tyler/flares/issues/77) +- Added a new option 'Folding Bypass' to the Preferences Menu. This skips most processing steps and the only analysis option available will be to fold. Parameters on the right will be ignored. Fixes [Issue 75](https://git.research.dezeeuw.ca/tyler/flares/issues/75) +- Added a feature to hover over the 28 stage progress bar and see which state the progress bar is at. Fixes [Issue 74](https://git.research.dezeeuw.ca/tyler/flares/issues/74) +- Loading a broken snirf file no longer hangs its processing and can now be removed from the list. Fixes [Issue 73](https://git.research.dezeeuw.ca/tyler/flares/issues/73) + + # Version 1.4.3 - Fixed an issue where the fOLD files could not be located diff --git a/flares.py b/flares.py index bab68ef..5f708ec 100644 --- a/flares.py +++ b/flares.py @@ -213,6 +213,8 @@ AGE: int = 25 # Assume 25 if not set from the GUI. This will result in a reason GENDER: str = "" GROUP: str = "Default" +FOLDING_BYP: bool = False + # These are parameters that are required for the analysis REQUIRED_KEYS: dict[str, Any] = { @@ -1487,7 +1489,7 @@ def make_design_matrix(raw_haemo, short_chans): pass # 2) Create design matrix - if SHORT_CHANNEL_REGRESSION: + if SHORT_CHANNEL_REGRESSION and not FOLDING_BYP: design_matrix = make_first_level_design_matrix( raw=raw_haemo, stim_dur=STIM_DUR, @@ -1739,161 +1741,197 @@ def resource_path(relative_path): -def fold_channels(raw: BaseRaw) -> None: +# def fold_channels(raw: BaseRaw) -> None: - # Locate the fOLD excel files - if getattr(sys, 'frozen', False): - set_config('MNE_NIRS_FOLD_PATH', resource_path("./mne_data/fOLD/fOLD-public-master/Supplementary")) # type: ignore - else: - path = os.path.expanduser("~") + "/mne_data/fOLD/fOLD-public-master/Supplementary" - set_config('MNE_NIRS_FOLD_PATH', resource_path(path)) # type: ignore +# # Locate the fOLD excel files +# if getattr(sys, 'frozen', False): +# set_config('MNE_NIRS_FOLD_PATH', resource_path("./mne_data/fOLD/fOLD-public-master/Supplementary")) # type: ignore +# else: +# path = os.path.expanduser("~") + "/mne_data/fOLD/fOLD-public-master/Supplementary" +# set_config('MNE_NIRS_FOLD_PATH', resource_path(path)) # type: ignore - output = None +# output = None - # List to store the results - landmark_specificity_data: list[dict[str, Any]] = [] +# # List to store the results +# landmark_specificity_data: list[dict[str, Any]] = [] - # Filter the data to only what we want - hbo_channel_names = cast(list[str], getattr(raw.copy().pick(picks='hbo'), "ch_names")) # type: ignore +# # Filter the data to only what we want +# hbo_channel_names = cast(list[str], getattr(raw.copy().pick(picks='hbo'), "ch_names")) # type: ignore - # Format the output to make it slightly easier to read +# # Format the output to make it slightly easier to read - if True: - num_channels = len(hbo_channel_names) - rows, cols = 4, 7 # 6 rows and 4 columns of pie charts - fig, axes = plt.subplots(rows, cols, figsize=(16, 10), constrained_layout=True) - axes = axes.flatten() # Flatten the axes array for easier indexing +# if True: +# num_channels = len(hbo_channel_names) +# rows, cols = 4, 7 # 6 rows and 4 columns of pie charts +# fig, axes = plt.subplots(rows, cols, figsize=(16, 10), constrained_layout=True) +# axes = axes.flatten() # Flatten the axes array for easier indexing - # If more pie charts than subplots, create extra subplots - if num_channels > rows * cols: - fig, axes = plt.subplots((num_channels // cols) + 1, cols, figsize=(16, 10), constrained_layout=True) - axes = axes.flatten() +# # If more pie charts than subplots, create extra subplots +# if num_channels > rows * cols: +# fig, axes = plt.subplots((num_channels // cols) + 1, cols, figsize=(16, 10), constrained_layout=True) +# axes = axes.flatten() - # Create a list for consistent color mapping - landmarks = [ - "1 - Primary Somatosensory Cortex", - "2 - Primary Somatosensory Cortex", - "3 - Primary Somatosensory Cortex", - "4 - Primary Motor Cortex", - "5 - Somatosensory Association Cortex", - "6 - Pre-Motor and Supplementary Motor Cortex", - "7 - Somatosensory Association Cortex", - "8 - Includes Frontal eye fields", - "9 - Dorsolateral prefrontal cortex", - "10 - Frontopolar area", - "11 - Orbitofrontal area", - "17 - Primary Visual Cortex (V1)", - "18 - Visual Association Cortex (V2)", - "19 - V3", - "20 - Inferior Temporal gyrus", - "21 - Middle Temporal gyrus", - "22 - Superior Temporal Gyrus", - "23 - Ventral Posterior cingulate cortex", - "24 - Ventral Anterior cingulate cortex", - "25 - Subgenual cortex", - "32 - Dorsal anterior cingulate cortex", - "37 - Fusiform gyrus", - "38 - Temporopolar area", - "39 - Angular gyrus, part of Wernicke's area", - "40 - Supramarginal gyrus part of Wernicke's area", - "41 - Primary and Auditory Association Cortex", - "42 - Primary and Auditory Association Cortex", - "43 - Subcentral area", - "44 - pars opercularis, part of Broca's area", - "45 - pars triangularis Broca's area", - "46 - Dorsolateral prefrontal cortex", - "47 - Inferior prefrontal gyrus", - "48 - Retrosubicular area", - "Brain_Outside", - ] +# # Create a list for consistent color mapping +# landmarks = [ +# "1 - Primary Somatosensory Cortex", +# "2 - Primary Somatosensory Cortex", +# "3 - Primary Somatosensory Cortex", +# "4 - Primary Motor Cortex", +# "5 - Somatosensory Association Cortex", +# "6 - Pre-Motor and Supplementary Motor Cortex", +# "7 - Somatosensory Association Cortex", +# "8 - Includes Frontal eye fields", +# "9 - Dorsolateral prefrontal cortex", +# "10 - Frontopolar area", +# "11 - Orbitofrontal area", +# "17 - Primary Visual Cortex (V1)", +# "18 - Visual Association Cortex (V2)", +# "19 - V3", +# "20 - Inferior Temporal gyrus", +# "21 - Middle Temporal gyrus", +# "22 - Superior Temporal Gyrus", +# "23 - Ventral Posterior cingulate cortex", +# "24 - Ventral Anterior cingulate cortex", +# "25 - Subgenual cortex", +# "32 - Dorsal anterior cingulate cortex", +# "37 - Fusiform gyrus", +# "38 - Temporopolar area", +# "39 - Angular gyrus, part of Wernicke's area", +# "40 - Supramarginal gyrus part of Wernicke's area", +# "41 - Primary and Auditory Association Cortex", +# "42 - Primary and Auditory Association Cortex", +# "43 - Subcentral area", +# "44 - pars opercularis, part of Broca's area", +# "45 - pars triangularis Broca's area", +# "46 - Dorsolateral prefrontal cortex", +# "47 - Inferior prefrontal gyrus", +# "48 - Retrosubicular area", +# "Brain_Outside", +# ] - cmap1 = plt.get_cmap('tab20') # First 20 colors - cmap2 = plt.get_cmap('tab20b') # Next 20 colors +# cmap1 = plt.get_cmap('tab20') # First 20 colors +# cmap2 = plt.get_cmap('tab20b') # Next 20 colors - # Combine the colors from both colormaps - colors = [cmap1(i) for i in range(20)] + [cmap2(i) for i in range(20)] # Total 40 colors +# # Combine the colors from both colormaps +# colors = [cmap1(i) for i in range(20)] + [cmap2(i) for i in range(20)] # Total 40 colors - landmarks.sort(key=lambda x: (int(x.split(" - ")[0]) if x.split(" - ")[0].isdigit() else float('inf'))) +# landmarks.sort(key=lambda x: (int(x.split(" - ")[0]) if x.split(" - ")[0].isdigit() else float('inf'))) - landmark_color_map = {landmark: colors[i % len(colors)] for i, landmark in enumerate(landmarks)} +# landmark_color_map = {landmark: colors[i % len(colors)] for i, landmark in enumerate(landmarks)} - # Iterate over each channel - print(len(hbo_channel_names)) +# # Iterate over each channel +# print(len(hbo_channel_names)) - for idx, channel_name in enumerate(hbo_channel_names): +# for idx, channel_name in enumerate(hbo_channel_names): - print(idx, channel_name) - # Run the fOLD on the selected channel - channel_data = raw.copy().pick(picks=channel_name) # type: ignore +# print(idx, channel_name) +# # Run the fOLD on the selected channel +# channel_data = raw.copy().pick(picks=channel_name) # type: ignore - output = cast(list[DataFrame], fold_channel_specificity_normal(channel_data, interpolate=True, atlas='Brodmann')) +# output = cast(list[DataFrame], fold_channel_specificity_normal(channel_data, interpolate=True, atlas='Brodmann')) - # Process each DataFrame that fold_channel_specificity returns - for df_data in output: +# # Process each DataFrame that fold_channel_specificity returns +# for df_data in output: - # Extract the relevant columns - useful_data = df_data[['Landmark', 'Specificity']] +# # Extract the relevant columns +# useful_data = df_data[['Landmark', 'Specificity']] - # Store the results - landmark_specificity_data.append({ - 'Channel': channel_name, - 'Data': useful_data, - }) +# # Store the results +# landmark_specificity_data.append({ +# 'Channel': channel_name, +# 'Data': useful_data, +# }) - # Plot the results - # TODO: Fix this - if True: - unique_landmarks = sorted(useful_data['Landmark'].unique()) - color_list = [landmark_color_map[landmark] for landmark in useful_data['Landmark']] +# # Plot the results +# # TODO: Fix this +# if True: +# unique_landmarks = sorted(useful_data['Landmark'].unique()) +# color_list = [landmark_color_map[landmark] for landmark in useful_data['Landmark']] - # Plot specificity for each channel - ax = axes[idx] +# # Plot specificity for each channel +# ax = axes[idx] - labels = [f'{landmark.split(" - ")[0]}' if landmark != 'Brain_Outside' else 'B' for landmark in useful_data['Landmark']] +# labels = [f'{landmark.split(" - ")[0]}' if landmark != 'Brain_Outside' else 'B' for landmark in useful_data['Landmark']] - wedges, texts, autotexts = ax.pie( - useful_data['Specificity'], - autopct='%1.1f%%', - startangle=90, - labels=labels, - labeldistance=1.05, - colors=color_list) +# wedges, texts, autotexts = ax.pie( +# useful_data['Specificity'], +# autopct='%1.1f%%', +# startangle=90, +# labels=labels, +# labeldistance=1.05, +# colors=color_list) - ax.set_title(f'{channel_name}') - ax.axis('equal') +# ax.set_title(f'{channel_name}') +# ax.axis('equal') - landmark_specificity_data = [] +# landmark_specificity_data = [] - # TODO: Fix this - if True: - handles = [ - plt.Line2D([0], [0], marker='o', color='w', label=landmark, markersize=10, - markerfacecolor=landmark_color_map[landmark]) - for landmark in landmarks - ] - n_landmarks = len(landmarks) +# # TODO: Fix this +# if True: +# handles = [ +# plt.Line2D([0], [0], marker='o', color='w', label=landmark, markersize=10, +# markerfacecolor=landmark_color_map[landmark]) +# for landmark in landmarks +# ] +# n_landmarks = len(landmarks) - # Calculate the figure size based on number of rows and columns - fig_width = 5 - fig_height = n_landmarks / 4 +# # Calculate the figure size based on number of rows and columns +# fig_width = 5 +# fig_height = n_landmarks / 4 - # Create a new figure window for the legend - legend_fig = plt.figure(figsize=(fig_width, fig_height)) - legend_axes = legend_fig.add_subplot(111) - legend_axes.axis('off') # Turn off axis for the legend window - legend_axes.legend(handles=handles, loc='center', fontsize=10, title="Landmarks") +# # Create a new figure window for the legend +# legend_fig = plt.figure(figsize=(fig_width, fig_height)) +# legend_axes = legend_fig.add_subplot(111) +# legend_axes.axis('off') # Turn off axis for the legend window +# legend_axes.legend(handles=handles, loc='center', fontsize=10, title="Landmarks") - for ax in axes[len(hbo_channel_names):]: - ax.axis('off') +# for ax in axes[len(hbo_channel_names):]: +# ax.axis('off') - #plt.show() - fig_dict = {"main": fig, "legend": legend_fig} - return convert_fig_dict_to_png_bytes(fig_dict) +# #plt.show() +# fig_dict = {"main": fig, "legend": legend_fig} +# return convert_fig_dict_to_png_bytes(fig_dict) +def fold_channels(raw: BaseRaw, p_name: str, progress_queue=None) -> dict[str, list[dict[str, Any]]]: + """Runs in background process. + Does only heavy math/lookup. Returns data instead of a static image. + """ + if getattr(sys, 'frozen', False): + set_config('MNE_NIRS_FOLD_PATH', resource_path("./mne_data/fOLD/fOLD-public-master/Supplementary")) + else: + path = os.path.expanduser("~") + "/mne_data/fOLD/fOLD-public-master/Supplementary" + set_config('MNE_NIRS_FOLD_PATH', resource_path(path)) + + hbo_channel_names = cast(list[str], getattr(raw.copy().pick(picks='hbo'), "ch_names")) + + # Store clean, picklable data lists instead of complex DataFrames + channel_results = {} + + step_idx = 0 + + for channel_name in hbo_channel_names: + channel_data = raw.copy().pick(picks=channel_name) + output = cast(list[DataFrame], fold_channel_specificity_normal(channel_data, interpolate=True, atlas='Brodmann')) + + channel_results[channel_name] = [] + for df_data in output: + # Extract just raw primitive types so they transfer over process channels flawlessly + for _, row in df_data.iterrows(): + channel_results[channel_name].append({ + 'Landmark': str(row['Landmark']), + 'Specificity': float(row['Specificity']) + }) + step_idx += 1 + if progress_queue is not None: + progress_queue.put((p_name, step_idx)) + + # Return raw data dictionary to the result_queue + return channel_results + def individual_significance(raw_haemo, glm_est): @@ -3939,6 +3977,7 @@ def hr_calc(raw): def process_participant(file_path, progress_callback=None): fig_individual: dict[str, Figure] = {} + logger.info(f"Folding Bypass: {FOLDING_BYP}") # Step 1: Preprocessing raw = load_snirf(file_path) @@ -3949,7 +3988,7 @@ def process_participant(file_path, progress_callback=None): # Step 2: Trimming # TODO: Clean this into a method - if TRIM: + if TRIM and not FOLDING_BYP: if hasattr(raw, 'annotations') and len(raw.annotations) > 0: # Get time of first event first_event_time = raw.annotations.onset[0] @@ -3985,7 +4024,7 @@ def process_participant(file_path, progress_callback=None): logger.info("Step 3 Completed.") # Step 4: Short/Long Channels - if SHORT_CHANNEL: + if SHORT_CHANNEL and not FOLDING_BYP: short_chans = get_short_channels(raw, max_dist=SHORT_CHANNEL_THRESH) fig_short_chans = short_chans.plot(duration=raw.times[-1], n_channels=raw.info['nchan'], title="Short Channels Only", show=False) fig_individual["short"] = fig_short_chans @@ -3996,7 +4035,7 @@ def process_participant(file_path, progress_callback=None): logger.info("Step 4 Completed.") # Step 5: Heart Rate - if HEART_RATE: + if HEART_RATE and not FOLDING_BYP: fig, hr1, hr2, low, high = hr_calc(raw) fig_individual["PSD"] = fig fig_individual['HeartRate_PSD'] = hr1 @@ -4017,7 +4056,7 @@ def process_participant(file_path, progress_callback=None): # Step 6: Scalp Coupling Index bad_sci = [] - if SCI: + if SCI and not FOLDING_BYP: if HEART_RATE: bad_sci, fig_sci_1, fig_sci_2 = calculate_scalp_coupling(raw, low, high) else: @@ -4029,7 +4068,7 @@ def process_participant(file_path, progress_callback=None): # Step 7: Signal to Noise Ratio bad_snr = [] - if SNR: + if SNR and not FOLDING_BYP: bad_snr, fig_snr = calculate_signal_noise_ratio(raw) fig_individual["SNR1"] = fig_snr if progress_callback: progress_callback(7) @@ -4037,7 +4076,7 @@ def process_participant(file_path, progress_callback=None): # Step 8: Peak Spectral Power bad_psp = [] - if PSP: + if PSP and not FOLDING_BYP: bad_psp, fig_psp1, fig_psp2 = calculate_peak_power(raw) fig_individual["PSP1"] = fig_psp1 fig_individual["PSP2"] = fig_psp2 @@ -4045,35 +4084,35 @@ def process_participant(file_path, progress_callback=None): logger.info("Step 8 Completed.") bad_cv = [] - if CV: + if CV and not FOLDING_BYP: bad_cv, fig_cv = find_bad_channels_cv(raw, cv_threshold=CV_THRESHOLD) fig_individual['cv'] = fig_cv if progress_callback: progress_callback(9) logger.info("Step 9 Completed.") bad_range = [] - if MAD: + if MAD and not FOLDING_BYP: bad_range, fig_range = find_bad_channels_range(raw, threshold=MAD_THRESHOLD) fig_individual['range'] = fig_range if progress_callback: progress_callback(10) logger.info("Step 10 Completed.") bad_noise = [] - if PSD_NOISE: + if PSD_NOISE and not FOLDING_BYP: bad_noise, fig_noise = detect_high_freq_noise(raw, db_limit=DB_LIMIT, freq_div=TARGET_FREQ_DIV) fig_individual['psd_noise'] = fig_noise if progress_callback: progress_callback(11) logger.info("Step 11 Completed.") bad_disp = [] - if CHANNEL_VAR: + if CHANNEL_VAR and not FOLDING_BYP: bad_disp, fig_disp = detect_sensor_displacement(raw, threshold_ratio=CHANNEL_THRESH) fig_individual['displacement'] = fig_disp if progress_callback: progress_callback(12) logger.info("Step 12 Completed.") # Step 9: Bad Channels Handling - if BAD_CHANNELS_HANDLING != "None": + if BAD_CHANNELS_HANDLING != "None" and not FOLDING_BYP: raw, fig_dropped, fig_raw_before, bad_channels = mark_bads(raw, bad_sci, bad_snr, bad_psp, bad_cv, bad_range, bad_noise, bad_disp) if fig_dropped and fig_raw_before is not None: fig_individual["fig2"] = fig_dropped @@ -4108,7 +4147,7 @@ def process_participant(file_path, progress_callback=None): logger.info("Step 14 Completed.") # Step 11: Temporal Derivative Distribution Repair Filtering - if TDDR: + if TDDR and not FOLDING_BYP: raw_od = temporal_derivative_distribution_repair(raw_od) fig_raw_od_tddr = raw_od.plot(duration=raw.times[-1], n_channels=raw.info['nchan'], title="After TDDR (Motion Correction)", show=False) fig_individual["TDDR"] = fig_raw_od_tddr @@ -4116,7 +4155,7 @@ def process_participant(file_path, progress_callback=None): logger.info("Step 15 Completed.") # Step 12: Wavelet Filtering - if WAVELET: + if WAVELET and not FOLDING_BYP: raw_od, fig = calculate_and_apply_wavelet(raw_od) fig_individual["Wavelet"] = fig if progress_callback: progress_callback(16) @@ -4130,7 +4169,7 @@ def process_participant(file_path, progress_callback=None): logger.info("Step 17 Completed.") # Step 14: Enhance Negative Correlation - if ENHANCE_NEGATIVE_CORRELATION: + if ENHANCE_NEGATIVE_CORRELATION and not FOLDING_BYP: raw_haemo = enhance_negative_correlation(raw_haemo) fig_raw_haemo_enc = raw_haemo.plot(duration=raw_haemo.times[-1], n_channels=raw_haemo.info['nchan'], title="Enhance Negative Correlation", show=False) fig_individual["ENC"] = fig_raw_haemo_enc @@ -4138,7 +4177,7 @@ def process_participant(file_path, progress_callback=None): logger.info("Step 18 Completed.") # Step 15: Filter - if FILTER: + if FILTER and not FOLDING_BYP: raw_haemo, fig_filter, fig_raw_haemo_filter = filter_the_data(raw_haemo) fig_individual["filter1"] = fig_filter fig_individual["filter2"] = fig_raw_haemo_filter @@ -4146,16 +4185,18 @@ def process_participant(file_path, progress_callback=None): logger.info("Step 19 Completed.") # Step 16: Extracting Events - events, event_dict = events_from_annotations(raw_haemo) - fig_events = plot_events(events, event_id=event_dict, sfreq=raw_haemo.info["sfreq"], show=False) - fig_individual["events"] = fig_events + if not FOLDING_BYP: + events, event_dict = events_from_annotations(raw_haemo) + fig_events = plot_events(events, event_id=event_dict, sfreq=raw_haemo.info["sfreq"], show=False) + fig_individual["events"] = fig_events if progress_callback: progress_callback(20) logger.info("Step 20 Completed.") # Step 17: Epoch Calculations - epochs, fig_epochs = epochs_calculations(raw_haemo, events, event_dict) - for name, fig in fig_epochs: - fig_individual[f"epochs_{name}"] = fig + if not FOLDING_BYP: + epochs, fig_epochs = epochs_calculations(raw_haemo, events, event_dict) + for name, fig in fig_epochs: + fig_individual[f"epochs_{name}"] = fig if progress_callback: progress_callback(21) logger.info("Step 21 Completed.") @@ -4274,6 +4315,8 @@ def process_participant(file_path, progress_callback=None): # Step 24: Finishing Up fig_bytes = convert_fig_dict_to_png_bytes(fig_individual) + if FOLDING_BYP: + epochs = None sanitize_paths_for_pickle(raw_haemo, epochs) if progress_callback: progress_callback(28) diff --git a/icons/article_shortcut_24dp_1F1F1.svg b/icons/article_shortcut_24dp_1F1F1.svg new file mode 100644 index 0000000..280cf15 --- /dev/null +++ b/icons/article_shortcut_24dp_1F1F1.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/icons/history_24dp_1F1F1F.svg b/icons/history_24dp_1F1F1F.svg new file mode 100644 index 0000000..c0c0c8a --- /dev/null +++ b/icons/history_24dp_1F1F1F.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/icons/history_2_24dp_1F1F1F.svg b/icons/history_2_24dp_1F1F1F.svg new file mode 100644 index 0000000..772d6dd --- /dev/null +++ b/icons/history_2_24dp_1F1F1F.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/icons/reset_settings_24dp_1F1F1F.svg b/icons/reset_settings_24dp_1F1F1F.svg new file mode 100644 index 0000000..608d6b9 --- /dev/null +++ b/icons/reset_settings_24dp_1F1F1F.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/icons/warning_off_24dp_1F1F1F.svg b/icons/warning_off_24dp_1F1F1F.svg new file mode 100644 index 0000000..b5c3297 --- /dev/null +++ b/icons/warning_off_24dp_1F1F1F.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/images/brain.png b/images/brain.png new file mode 100644 index 0000000000000000000000000000000000000000..c869255e842087f3636e5175e6e11dee0ce53d11 GIT binary patch literal 56078 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

EHY*^QIR1nB1vCVUy7gI*{u& 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.upper()} has been sucessfully updated to version {CURRENT_VERSION}!", self) + else: + title_label = QLabel(f"

{APP_NAME.upper()} is currently running version {CURRENT_VERSION}.

", self) + + header_layout.addWidget(logo_label) + header_layout.addWidget(title_label) + header_layout.addStretch() + layout.addLayout(header_layout) + + # Text Browser Area (Automatically converts Markdown syntax into clean formatted UI text) + self.text_browser = QTextBrowser(self) + self.text_browser.setHtml("

Loading latest updates from server...

") + self.text_browser.setOpenLinks(False) # Don't open links inside the viewer + self.text_browser.anchorClicked.connect(QDesktopServices.openUrl) + layout.addWidget(self.text_browser) + + # Footer Controls Layout + footer_layout = QHBoxLayout() + + ok_button = QPushButton("OK", self) + ok_button.setDefault(True) + ok_button.clicked.connect(self.accept) # Closes the dialog with a success signal + + 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) + + md_url = "https://git.research.dezeeuw.ca/tyler/flares/raw/branch/main/changelog_major.md" + self.network_manager.get(QNetworkRequest(QUrl(md_url))) + + + def _on_download_complete(self, reply): + """Processes the downloaded markdown and drops it into the view frame.""" + if reply.error() == reply.NetworkError.NoError: + raw_bytes = reply.readAll() + # Convert raw bytes to standard text string + markdown_text = str(raw_bytes, encoding='utf-8') + # Qt's QTextBrowser natively renders markdown arrays beautifully! + self.text_browser.setMarkdown(markdown_text) + else: + self.text_browser.setHtml( + f"

Failed to load content.
Error: {reply.errorString()}

" + ) + reply.deleteLater() + + + class SaveProjectThread(QThread): finished_signal = Signal(str) error_signal = Signal(str) @@ -399,34 +528,8 @@ class UserGuideWindow(QWidget): layout = QVBoxLayout() label = QLabel("Progress Bar Stages:", self) - label2 = QLabel("Stage 1: Preprocessing\n" - "Stage 2: Trimming\n" - "Stage 3: Verify Optode Placement\n" - "Stage 4: Short/Long Cannels\n" - "Stage 5: Heart Rate\n" - "Stage 6: Scalp Coupling Index\n" - "Stage 7: Signal to Noise Ratio\n" - "Stage 8: Peak Spectral Power\n" - "Stage 9: Cross Validation\n" - "Stage 10: Median Absolute Deviation\n" - "Stage 11: Power Spectral Density Noise\n" - "Stage 12: Channel Variance\n" - "Stage 13: Bad Channels Handling\n" - "Stage 14: Optical Density\n" - "Stage 15: Temporal Derivative Distribution Repair Filtering\n" - "Stage 16: Wavelet Filtering\n" - "Stage 17: Haemoglobin Concentration\n" - "Stage 18: Enhance Negative Correlation\n" - "Stage 19: Filter\n" - "Stage 20: Extracting Events\n" - "Stage 21: Epoch Calculations\n" - "Stage 22: Design Matrix\n" - "Stage 23: General Linear Model\n" - "Stage 24: Generate GLM Results\n" - "Stage 25: Generate Channel Significance\n" - "Stage 26: Generate Channel, Region of Interest, and Contrast Results\n" - "Stage 27: Compute Contrast Results\n" - "Stage 28: Finishing Up\n", self) + label2_text = "\n".join(f"Stage {idx + 1}: {name}" for idx, name in enumerate(PIPELINE_STAGES)) + "\n" + label2 = QLabel(label2_text, self) label3 = QLabel(f"For more information, visit the Git wiki page
here.", self) label3.setTextFormat(Qt.TextFormat.RichText) @@ -1586,10 +1689,12 @@ class ProgressBubble(QWidget): self.progress_layout = QHBoxLayout() self.rects = [] - for _ in range(28): + for i in range(28): rect = QFrame() rect.setFixedSize(10, 18) rect.setStyleSheet("background-color: white; border: 1px solid gray;") + stage_name = PIPELINE_STAGES[i] + rect.setToolTip(f"Stage {i + 1}: {stage_name}") self.progress_layout.addWidget(rect) self.rects.append(rect) @@ -3286,22 +3391,26 @@ class MultiProgressDialog(QDialog): super().__init__(parent) self.setWindowTitle("fOLD Analysis Progress") self.setFixedWidth(400) - # Ensure it doesn't block the main thread self.setWindowModality(Qt.WindowModality.NonModal) self.layout = QVBoxLayout(self) self.bars = {} def add_participant(self, label, total_steps): - label_widget = QLabel(f"Analyzing {label}...") + clean_key = str(label).strip() + label_widget = QLabel(f"Analyzing {clean_key}...") pbar = QProgressBar() - pbar.setMaximum(total_steps) + pbar.setMinimum(0) + pbar.setMaximum(int(total_steps)) # Ensure this is a strict integer + pbar.setValue(0) + self.layout.addWidget(label_widget) self.layout.addWidget(pbar) self.bars[label] = pbar def update_bar(self, label, value): if label in self.bars: - self.bars[label].setValue(value) + # Force integers to prevent QProgressBar from breaking or flickering + self.bars[label].setValue(int(value)) @@ -3311,17 +3420,671 @@ def single_participant_worker(file_path, raw_data, result_queue, progress_queue) try: import flares # Perform the heavy fold_channels logic - print("we are here") - png_bytes = flares.fold_channels(raw_data) + channel_results = flares.fold_channels(raw_data, p_name, progress_queue) # Hand back results and signal completion - result_queue.put({file_path: png_bytes}) - progress_queue.put(p_name) + result_queue.put({file_path: channel_results}) + progress_queue.put(p_name) + except Exception as e: progress_queue.put(f"ERROR: {p_name} - {str(e)}") + +def get_landmark_color_map(): + """Generates the unified 40-color map for fOLD landmarks.""" + landmarks = [ + "1 - Primary Somatosensory Cortex", "2 - Primary Somatosensory Cortex", + "3 - Primary Somatosensory Cortex", "4 - Primary Motor Cortex", + "5 - Somatosensory Association Cortex", "6 - Pre-Motor and Supplementary Motor Cortex", + "7 - Somatosensory Association Cortex", "8 - Includes Frontal eye fields", + "9 - Dorsolateral prefrontal cortex", "10 - Frontopolar area", + "11 - Orbitofrontal area", "17 - Primary Visual Cortex (V1)", + "18 - Visual Association Cortex (V2)", "19 - V3", "20 - Inferior Temporal gyrus", + "21 - Middle Temporal gyrus", "22 - Superior Temporal Gyrus", + "23 - Ventral Posterior cingulate cortex", "24 - Ventral Anterior cingulate cortex", + "25 - Subgenual cortex", "32 - Dorsal anterior cingulate cortex", + "37 - Fusiform gyrus", "38 - Temporopolar area", + "39 - Angular gyrus, part of Wernicke's area", "40 - Supramarginal gyrus part of Wernicke's area", + "41 - Primary and Auditory Association Cortex", "42 - Primary and Auditory Association Cortex", + "43 - Subcentral area", "44 - pars opercularis, part of Broca's area", + "45 - pars triangularis Broca's area", "46 - Dorsolateral prefrontal cortex", + "47 - Inferior prefrontal gyrus", "48 - Retrosubicular area", "Brain_Outside" + ] + # 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)} + +import numpy as np +import matplotlib.pyplot as plt +from matplotlib.backends.backend_qtagg import FigureCanvasQTAgg as FigureCanvas +from PySide6.QtWidgets import QToolTip +from PySide6.QtCore import QPoint +import traceback + + +import numpy as np +import matplotlib.pyplot as plt +import matplotlib.image as mpimg # CRITICAL: For loading the PNG asset natively +from matplotlib.backends.backend_qtagg import FigureCanvasQTAgg as FigureCanvas +import traceback + +class StaticChannelCanvas(FigureCanvas): + """The Pop-up Window Canvas. + Renders the interactive pie chart on the left, and a matching PNG image on the right. + """ + def __init__(self, channel_name, data_list, color_map, image_path=None, parent=None): + # Create a 1-row, 2-column subplot array + # figsize=(11.0, 5.5) creates a wide 2:1 widescreen aspect window layout + self.fig, self.ax = plt.subplots(1, 2, figsize=(11.0, 5.5)) + 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) + if total_specificity < 100.0: + remainder = 100.0 - total_specificity + if remainder > 0.01: + self.wedge_data_list.append({ + '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] + self.labels = [f"{lm.split(' - ')[0]}" if 'Other' not in lm and lm != 'Brain_Outside' else 'Other' if 'Other' in lm else 'B' for lm in self.landmarks] + + # --- 2. LEFT SUBPLOT: PIE CHART --- + # Note we explicitly target self.ax[0] now + self.wedges, self.texts, self.autotexts = self.ax[0].pie( + self.specificities, + autopct='%1.1f%%', + startangle=90, + labels=self.labels, + colors=self.colors, + textprops={'fontsize': 10, 'fontweight': 'bold'}, + 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: + try: + img = mpimg.imread(image_path) + self.ax[1].imshow(img) + except Exception as e: + self.ax[1].text(0.5, 0.5, f"Failed to load image:\n{e}", + ha='center', va='center', fontsize=10, color='red') + else: + # 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, "", + transform=self.fig.transFigure, + ha="center", va="bottom", + fontsize=12, fontweight="bold", + 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() + + self.mpl_connect('motion_notify_event', self._on_hover) + + def _on_hover(self, event): + try: + # FIX: Only track mouse events when hovering over the LEFT axis frame containing the pie chart + if event.inaxes != self.ax[0]: + if self.currently_exploded_idx is not None: + self._reset_wedges() + self.info_text.set_visible(False) + self.currently_exploded_idx = None + self.draw_idle() + return + + hovered_index = None + for idx, wedge in enumerate(self.wedges): + contained, _ = wedge.contains(event) + if contained: + hovered_index = idx + break + + if hovered_index is not None: + 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() + else: + if self.currently_exploded_idx is not None: + self._reset_wedges() + self.info_text.set_visible(False) + self.currently_exploded_idx = None + self.draw_idle() + + except Exception as err: + print("[ERROR] Internal failure inside _on_hover loop:") + traceback.print_exc() + + def _explode_wedge(self, index_to_expand): + changed = False + for idx, wedge in enumerate(self.wedges): + if idx == index_to_expand: + theta = np.deg2rad((wedge.theta1 + wedge.theta2) / 2.0) + explode_distance = 0.08 + new_x = explode_distance * np.cos(theta) + new_y = explode_distance * np.sin(theta) + if wedge.center != (new_x, new_y): + wedge.set_center((new_x, new_y)) + changed = True + else: + if wedge.center != (0.0, 0.0): + wedge.set_center((0.0, 0.0)) + changed = True + if changed: + self.draw_idle() + + def _reset_wedges(self): + changed = False + for wedge in self.wedges: + if wedge.center != (0.0, 0.0): + wedge.set_center((0.0, 0.0)) + changed = True + if changed: + self.draw_idle() + + +from matplotlib.backends.backend_qtagg import FigureCanvasQTAgg as FigureCanvas +from matplotlib.figure import Figure +from PySide6.QtWidgets import QDialog, QVBoxLayout +from PySide6.QtCore import Qt + +class StandaloneLegendDialog(QWidget): + def __init__(self, canvas_engine, title_prefix, parent=None): + super().__init__(None) + 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, self) + layout.addWidget(legend_card) + + +class InteractiveParticipantGridCanvas(FigureCanvas): + """The Big Grid Canvas. + Dynamically scales row and column configurations to maintain a crisp 16:9 layout orientation. + """ + def __init__(self, channels_data, color_map, is_fullscreen_copy=False, parent=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 --- + 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 + figsize = (14.0, 14.0 / target_ratio) + 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: + remainder = 100.0 - total_specificity + if remainder > 0.01: + padded_data_list.append({ + 'Landmark': 'Other / Unclassified Regions', + 'Specificity': remainder + }) + + self.axes_data_registry[ax] = { + 'channel_name': channel_name, + 'data_list': padded_data_list + } + + specificities = [d['Specificity'] for d in padded_data_list] + landmarks = [d['Landmark'] for d in padded_data_list] + colors = [color_map.get(lm, '#ccc') if 'Other' not in lm else '#d3d3d3' for lm in landmarks] + labels = [f"{lm.split(' - ')[0]}" if 'Other' not in lm and lm != 'Brain_Outside' else 'O' if 'Other' in lm else 'B' for lm in landmarks] + + # Adjust label sizing dynamically based on how crowded the grid gets + font_sz = 5 if num_channels > 30 else (7 if is_fullscreen_copy else 6) + title_sz = 6 if num_channels > 30 else (9 if is_fullscreen_copy else 7) + + ax.pie( + 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') + + # --- FIX: ADAPTIVE PADDING BOUNDS FOR EXTRA DENSE PLOTS --- + # 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() + self.mpl_connect('button_press_event', self._on_canvas_click) + + + def create_matrix_card(self, title_prefix, layout_to_attach_to): + """Wraps the channel matrix layout inside a responsive, matching hover-stylized card frame.""" + # 1. Create matching styled container card frame + card_frame = QFrame() + card_frame.setFrameShape(QFrame.Shape.StyledPanel) + card_frame.setStyleSheet(""" + QFrame { + background-color: #ffffff; + border: 2px solid #ced4da; + border-radius: 6px; + } + QFrame:hover { + border: 2px solid #4dabf7; + 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 + + def _on_canvas_click(self, event): + # CASE 1: Whitespace Clicked -> Open full 25-matrix in fullscreen window + if event.inaxes is None: + self._open_fullscreen_grid() + return + + # CASE 2: Specific Slice Clicked -> Open standard individual detailed channel popup + 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['data_list'] + ) + + def _open_fullscreen_grid(self): + """Creates a maximized dialog window duplicating the full participant matrix view.""" + if getattr(self, 'is_fullscreen_copy', False) or hasattr(self, '_is_fullscreen_flag_set'): + return + fullscreen_window = QWidget(None) + fullscreen_window.setWindowTitle("Participant Grid Monitor - Maximized View") + fullscreen_window.setWindowFlags( + 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, + 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 = [] + self._fullscreen_refs = [w for w in self._fullscreen_refs if w.isVisible()] + self._fullscreen_refs.append(fullscreen_window) + + def _calculate_total_brodmann_profile(self, channels_data): + """Sums and normalizes the specificity profile across all channels.""" + totals = {} + num_channels = len(channels_data) + + if num_channels == 0: + return [] + + # Sum up specificities across all channels + for channel_name, data_list in channels_data.items(): + for entry in data_list: + landmark = entry['Landmark'] + specificity = entry['Specificity'] + totals[landmark] = totals.get(landmark, 0.0) + specificity + + # Normalize back down to 100% total scale + normalized_data_list = [] + for landmark, total_val in totals.items(): + # If a landmark hit 20% in 10 channels, it's normalized relative to total channels + normalized_val = total_val / num_channels + if normalized_val > 0.01: + normalized_data_list.append({ + 'Landmark': landmark, + 'Specificity': normalized_val + }) + + # Ensure "Other / Unclassified" fills any remaining precision gap + total_normalized = sum(d['Specificity'] for d in normalized_data_list) + if total_normalized < 100.0: + remainder = 100.0 - total_normalized + if remainder > 0.01: + normalized_data_list.append({ + 'Landmark': 'Other / Unclassified Regions', + 'Specificity': remainder + }) + + return normalized_data_list + + def _open_expanded_view(self, channel_name, data_list): + # 1. Create a plain QWidget with NO parent (None) + # This instantly makes it a top-level desktop window + 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.WindowCloseButtonHint + ) + + # 3. Build layout out exactly as before + layout = QVBoxLayout(popup) + layout.setContentsMargins(0, 0, 0, 0) # Strip extra outer layout spacing + + target_png_path = "images/brain.png" + + expanded_canvas = StaticChannelCanvas( + 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() + + # 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) + + + def create_total_summary_card(self, title_prefix, layout_to_attach_to): + """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) + card_frame.setStyleSheet(""" + QFrame { + background-color: #ffffff; + border: 2px solid #ced4da; + border-radius: 6px; + } + QFrame:hover { + border: 2px solid #4dabf7; /* Gives a subtle visual cue that it is clickable */ + 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 = "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( + channel_name=f"{title_prefix} Combined", + data_list=summary_data, + color_map=self.color_map, + 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'): + summary_canvas.fig.subplots_adjust(left=0.02, bottom=0.02, right=0.98, top=0.92, wspace=0.10) + + for ax in summary_canvas.fig.axes: + 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) + + def handle_card_click(event): + # 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) + + # 4. Inject completed card frame container assembly into target window layout position + layout_to_attach_to.addWidget(card_frame) + return card_frame + + + + def create_legend_card(self, title_prefix, layout_to_attach_to): + 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; }") + scroll_content = QWidget() + scroll_content.setStyleSheet("background: transparent;") + 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, summary_data): + """Pops open a beautifully scaled, independent large window when the card is clicked.""" + popup = QWidget(None) + popup.setWindowTitle(f"Grand Total Profile Details - {title_prefix}") + popup.setWindowFlags( + Qt.WindowType.Window | + Qt.WindowType.WindowMinMaxButtonsHint | + Qt.WindowType.WindowCloseButtonHint + ) + + layout = QVBoxLayout(popup) + layout.setContentsMargins(10, 10, 10, 10) + + target_png_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, + 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 ParticipantFoldChannelsWidget(FlaresBaseWidget): def __init__(self, haemo_dict, cha_dict): super().__init__("ParticipantFoldChannels") @@ -3364,12 +4127,37 @@ class ParticipantFoldChannelsWidget(FlaresBaseWidget): self.top_bar.addWidget(self.image_index_dropdown) self.top_bar.addWidget(self.submit_button) - self.scroll = QScrollArea() - self.scroll.setWidgetResizable(True) - self.scroll_content = QWidget() - self.grid_layout = QGridLayout(self.scroll_content) - self.scroll.setWidget(self.scroll_content) - self.layout.addWidget(self.scroll) + self.scroll_area = QScrollArea(self) + self.scroll_area.setWidgetResizable(True) + self.scroll_area.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) + self.scroll_area.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded) + self.scroll_area.setStyleSheet("QScrollArea { border: none; background-color: #f1f3f5; }") + + # 2. Create the central canvas widget that inside the scroll block + self.scroll_content_widget = QWidget() + self.scroll_content_widget.setStyleSheet("background-color: #f1f3f5;") + + # 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) + self.grid_layout.setSpacing(15) # Controls breathing room gaps between cards + + 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) + self.grid_layout.setColumnMinimumWidth(1, 400) + self.grid_layout.setColumnMinimumWidth(2, 400) + # ---------------------------------------------------------- + + # 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.layout.addWidget(self.scroll_area) self.thumb_size = QSize(280, 180) self.showMaximized() @@ -3390,9 +4178,17 @@ class ParticipantFoldChannelsWidget(FlaresBaseWidget): if widget: widget.deleteLater() + self.global_channels_data = {} + self.multi_progress = MultiProgressDialog(self) for file_path in selected_files: - self.multi_progress.add_participant(os.path.basename(file_path), 1) + raw_data = self.haemo_dict[file_path] + # 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() @@ -3425,13 +4221,35 @@ class ParticipantFoldChannelsWidget(FlaresBaseWidget): while not self.progress_queue.empty(): msg = self.progress_queue.get() - self.completed_count += 1 - if msg.startswith("ERROR"): + # 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) + else: + # 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}") - else: - # msg is p_name here - self.multi_progress.update_bar(msg, 1) + #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 + if hasattr(self, 'multi_progress'): + 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()) # Pull images as they become available while not self.result_queue.empty(): @@ -3459,48 +4277,125 @@ class ParticipantFoldChannelsWidget(FlaresBaseWidget): 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 + col = count % 3 + self.grid_layout.addWidget(global_card, row, col) + + legend_title = "Grand Total Brodmann Mapping Profile" + legend_card = global_canvas.create_legend_card( + title_prefix=legend_title, + layout_to_attach_to=self.scroll_content_widget.layout() + ) + + 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 + self.grid_layout.addWidget(legend_card, row, col) + + + - def add_images_to_grid(self, result_dict): - """ - result_dict format: { file_path: {"main": bytes, "legend": bytes} } - """ - for file_path, images in result_dict.items(): + # def add_images_to_grid(self, result_dict): + # """ + # result_dict format: { file_path: {"main": bytes, "legend": bytes} } + # """ + # for file_path, images in result_dict.items(): - if self.grid_layout.count() == 0 and "legend" in images: - self._add_legend_to_grid(images["legend"]) + # if self.grid_layout.count() == 0 and "legend" in images: + # self._add_legend_to_grid(images["legend"]) - # Create a container for this participant's results - container = QFrame() - container.setFrameShape(QFrame.StyledPanel) - vbox = QVBoxLayout(container) + # # Create a container for this participant's results + # container = QFrame() + # container.setFrameShape(QFrame.StyledPanel) + # vbox = QVBoxLayout(container) + # participant_label = self.participant_map.get(file_path, os.path.basename(file_path)) + # title = QLabel(f"{participant_label}") + # title.setAlignment(Qt.AlignCenter) + # vbox.addWidget(title) + + # # We primarily want to show the 'main' plot in the grid + # if "main" in images: + # pixmap = self._bytes_to_pixmap(images["main"]) + # img_label = QLabel() + # # Scale it to fit the thumbnail size defined in __init__ + # img_label.setPixmap(pixmap.scaled( + # self.thumb_size, + # Qt.KeepAspectRatio, + # Qt.SmoothTransformation + # )) + # img_label.setAlignment(Qt.AlignCenter) + + # # Optional: Click to open full size + # img_label.mousePressEvent = lambda e, p=pixmap, t=participant_label: self._open_full_size(p, t) + + # vbox.addWidget(img_label) + + # # Determine grid position (row-major order) + # count = self.grid_layout.count() + # row = count // 3 # 3 columns wide + # col = count % 3 + # self.grid_layout.addWidget(container, row, col) + + def add_images_to_grid(self, result_dict): + color_map = get_landmark_color_map() + + for file_path, channels_data in result_dict.items(): participant_label = self.participant_map.get(file_path, os.path.basename(file_path)) - title = QLabel(f"{participant_label}") - title.setAlignment(Qt.AlignCenter) - vbox.addWidget(title) + + if hasattr(self, 'global_channels_data'): + for ch_name, ch_data in channels_data.items(): + unique_key = f"{participant_label}_{ch_name}" + self.global_channels_data[unique_key] = ch_data - # We primarily want to show the 'main' plot in the grid - if "main" in images: - pixmap = self._bytes_to_pixmap(images["main"]) - img_label = QLabel() - # Scale it to fit the thumbnail size defined in __init__ - img_label.setPixmap(pixmap.scaled( - self.thumb_size, - Qt.KeepAspectRatio, - Qt.SmoothTransformation - )) - img_label.setAlignment(Qt.AlignCenter) - - # Optional: Click to open full size - img_label.mousePressEvent = lambda e, p=pixmap, t=participant_label: self._open_full_size(p, t) - - vbox.addWidget(img_label) + # 1. Instantiate the background calculation engine matrix + participant_grid_canvas = InteractiveParticipantGridCanvas(channels_data, color_map) - # Determine grid position (row-major order) - count = self.grid_layout.count() - row = count // 3 # 3 columns wide + # 2. Build Card A (Channels Matrix Frame Layout) + # The matrix automatically installs inside its layout box container slot + matrix_card = participant_grid_canvas.create_matrix_card( + 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 col = count % 3 - self.grid_layout.addWidget(container, row, col) + 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, + 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 + col = count % 3 + self.grid_layout.addWidget(summary_card, row, col) + def _bytes_to_pixmap(self, png_bytes): """Converts raw bytes from the multiprocess queue to a QPixmap.""" @@ -4430,7 +5325,7 @@ class GroupBrainViewerWidget(FlaresBaseWidget): class ViewerLauncherWidget(QWidget): - def __init__(self, haemo_dict, config_dict, fig_bytes_dict, cha_dict, contrast_results_dict, df_ind, design_matrix, epochs_dict): + def __init__(self, haemo_dict, config_dict, fig_bytes_dict, cha_dict, contrast_results_dict, df_ind, design_matrix, epochs_dict, folding_bypass): super().__init__() self.setWindowTitle(f"Viewer Launcher - {APP_NAME.upper()}") @@ -4447,27 +5342,34 @@ class ViewerLauncherWidget(QWidget): btn1 = QPushButton("Open Participant Viewer") btn1.clicked.connect(lambda: launch(self.open_participant_viewer, btn1, haemo_dict, fig_bytes_dict)) + btn1.setEnabled(not folding_bypass) btn2 = QPushButton("Open Participant Brain Viewer") btn2.clicked.connect(lambda: launch(self.open_participant_brain_viewer, btn2, haemo_dict, cha_dict)) - + btn2.setEnabled(not folding_bypass) + btn3 = QPushButton("Open Participant Fold Channels Viewer") btn3.clicked.connect(lambda: launch(self.open_participant_fold_channels_viewer, btn3, haemo_dict, cha_dict)) btn7 = QPushButton("Open Functional Connectivity Viewer [BETA]") btn7.clicked.connect(lambda: launch(self.open_participant_functional_connectivity_viewer, btn7, haemo_dict, epochs_dict)) + btn7.setEnabled(not folding_bypass) btn8 = QPushButton("Open Group Functional Connectivity Viewer [BETA]") btn8.clicked.connect(lambda: launch(self.open_group_functional_connectivity_viewer, btn8, haemo_dict, group_dict, config_dict)) + btn8.setEnabled(not folding_bypass) btn4 = QPushButton("Open Inter-Group Viewer") btn4.clicked.connect(lambda: launch(self.open_group_viewer, btn4, haemo_dict, cha_dict, df_ind, design_matrix, contrast_results_dict, group_dict)) + btn4.setEnabled(not folding_bypass) btn5 = QPushButton("Open Cross Group Brain Viewer") btn5.clicked.connect(lambda: launch(self.open_group_brain_viewer, btn5, haemo_dict, df_ind, design_matrix, group_dict, contrast_results_dict)) + btn5.setEnabled(not folding_bypass) btn6 = QPushButton("Open Export Data As CSV Viewer") btn6.clicked.connect(lambda: launch(self.open_export_data_as_csv_viewer, btn6, haemo_dict, cha_dict, df_ind, design_matrix, group_dict, contrast_results_dict)) + btn6.setEnabled(not folding_bypass) layout.addWidget(btn1) layout.addWidget(btn2) @@ -4532,6 +5434,7 @@ class MainApplication(QMainWindow): progress_update_signal = Signal(str, int) metadata_processed = Signal(str, int) + metadata_ui_signal = Signal(dict, str, int) def __init__(self): super().__init__() @@ -4555,6 +5458,7 @@ class MainApplication(QMainWindow): self.incompatible_save_bypass = False self.missing_events_bypass = False self.analysis_clearing_bypass = False + self.folding_bypass = False # Initialization to ensure that saving can occur @@ -4573,6 +5477,7 @@ class MainApplication(QMainWindow): self.current_file = None # Tracks the currently selected absolute path 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) @@ -4604,6 +5509,22 @@ class MainApplication(QMainWindow): 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() + + self.show() + + # Check if we should pop up the welcome screen + should_show_welcome = file_cfg.getboolean("Options", "show_welcome_dialog", fallback=True) + + if should_show_welcome: + file_cfg.set("Options", "show_welcome_dialog", "false") + try: + with open(cfg_path, "w") as f: + file_cfg.write(f) + except Exception as e: + print(f"Warning: Could not save preference: {e}") + + welcome = WelcomeDialog(self, direct=True) + welcome.show() def init_ui(self): @@ -4627,7 +5548,7 @@ class MainApplication(QMainWindow): self.top_left_widget = QTextEdit() self.top_left_widget.setReadOnly(True) - self.top_left_widget.setPlaceholderText("Click a file below to get started!") + 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() @@ -4746,7 +5667,13 @@ class MainApplication(QMainWindow): for i, (name, shortcut, slot, icon) in enumerate(file_actions): file_menu.addAction(make_action(name, shortcut, slot, icon=icon)) - if i == 1: # after the first 3 actions (0,1,2) + 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() @@ -4785,26 +5712,34 @@ class MainApplication(QMainWindow): options_actions = [ ("User Guide", "F1", self.user_guide, resource_path("icons/help_24dp_1F1F1F.svg")), ("Check for Updates", "F5", self.updater.manual_check_for_updates, resource_path("icons/update_24dp_1F1F1F.svg")), - ("Update optodes in snirf file...", "F6", self.update_optode_positions, resource_path("icons/upgrade_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 == 1 or i == 4: # after the first 2 actions (0,1) + if i == 2 or i == 5 or i == 6 or i == 7: options_menu.addSeparator() + self.pref_actions = {} + preferences_menu = menu_bar.addMenu("Preferences") preferences_actions = [ - ("2D Data Bypass", "", self.is_2d_bypass_func, resource_path("icons/info_24dp_1F1F1F.svg")), - ("Incompatible Save Bypass", "", self.incompatable_save_bypass_func, resource_path("icons/info_24dp_1F1F1F.svg")), - ("Missing Events Bypass", "", self.missing_events_bypass_func, resource_path("icons/info_24dp_1F1F1F.svg")), - ("Analysis Clearing Bypass", "", self.analysis_clearing_bypass_func, resource_path("icons/info_24dp_1F1F1F.svg")) + ("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"), ] - for name, shortcut, slot, icon in preferences_actions: - preferences_menu.addAction(make_action(name, shortcut, slot, icon=icon, checkable=True, checked=False)) + + 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 terminal_menu = menu_bar.addMenu("Terminal") terminal_actions = [ @@ -4813,6 +5748,8 @@ class MainApplication(QMainWindow): for name, shortcut, slot, icon in terminal_actions: terminal_menu.addAction(make_action(name, shortcut, slot, icon=icon)) + self.sync_app_with_config() + self.statusbar.showMessage("Ready") @@ -4937,6 +5874,118 @@ class MainApplication(QMainWindow): # print("Top 10 growing object types in RAM:") # objgraph.show_most_common_types(limit=10) + def update_recent_projects_menu(self): + """Clears and rebuilds the Recent Projects submenu items.""" + self.recent_projects_menu.clear() + + raw_projects = file_cfg.get("File", "recent_projects", fallback="") + projects = [p.strip() for p in raw_projects.split(",") if p.strip()] + + if not projects: + no_recent = self.recent_projects_menu.addAction("No Recent Projects") + no_recent.setEnabled(False) + return + + for i, project_path in enumerate(projects): + action = QAction(f"{i+1}: {project_path}", self) + action.setToolTip(project_path) + action.triggered.connect(lambda checked, path=project_path: self.open_recent_project(path)) + self.recent_projects_menu.addAction(action) + + def add_to_recent_projects(self, project_path): + """Adds a project path, moves it to the top, and hard caps at 10.""" + raw_projects = file_cfg.get("File", "recent_projects", fallback="") + projects = [p.strip() for p in raw_projects.split(",") if p.strip()] + + if project_path in projects: + projects.remove(project_path) + + projects.insert(0, project_path) + projects = projects[:10] # Hard cap of 10 items + + file_cfg.set("File", "recent_projects", ",".join(projects)) + try: + with open(cfg_path, "w") as f: + file_cfg.write(f) + except Exception as e: + print(f"Warning: Could not save config history: {e}") + + self.update_recent_projects_menu() + + def open_recent_project(self, project_path): + """The slot that executes when a recent project entry is clicked.""" + if os.path.exists(project_path): + print(f"Opening recent project: {project_path}") + + self.project_loader(project_path) + + self.add_to_recent_projects(project_path) + else: + QMessageBox.warning(self, "Project Not Found", f"The project file could not be found:\n{project_path}") + # Clean out the broken path + raw_projects = file_cfg.get("File", "recent_projects", fallback="") + projects = [p.strip() for p in raw_projects.split(",") if p.strip() and p.strip() != project_path] + file_cfg.set("File", "recent_projects", ",".join(projects)) + self.update_recent_projects_menu() + + + + def update_recent_files_menu(self): + """Clears and rebuilds the Recent Files submenu items.""" + self.recent_files_menu.clear() + + raw_files = file_cfg.get("File", "recent_files", fallback="") + files = [f.strip() for f in raw_files.split(",") if f.strip()] + + if not files: + no_recent = self.recent_files_menu.addAction("No Recent Files") + no_recent.setEnabled(False) + return + + for i, file_path in enumerate(files): + # Display just the file name (e.g. 'data.snirf'), but keep the full path as a tool tip + action = QAction(f"{i+1}: {file_path}", self) + # Connect it so it passes the specific path when clicked + action.triggered.connect(lambda checked, path=file_path: self.open_recent_file(path)) + self.recent_files_menu.addAction(action) + + + def add_to_recent_files(self, file_path): + """Adds a path, moves it to the top, and hard caps the list at 10.""" + raw_files = file_cfg.get("File", "recent_files", fallback="") + files = [f.strip() for f in raw_files.split(",") if f.strip()] + + if file_path in files: + files.remove(file_path) + + files.insert(0, file_path) + files = files[:10] + + file_cfg.set("File", "recent_files", ",".join(files)) + try: + with open(cfg_path, "w") as f: + file_cfg.write(f) + except Exception as e: + print(f"Warning: Could not save config history: {e}") + + self.update_recent_files_menu() + + def open_recent_file(self, file_path): + """The slot that executes when someone clicks a recent file entry.""" + if os.path.exists(file_path): + print(f"Opening recent file: {file_path}") + self._load_files_into_pipeline([os.path.normpath(file_path)]) + + # Refresh position to top + self.add_to_recent_files(file_path) + else: + QMessageBox.warning(self, "File Not Found", f"The file could not be found:\n{file_path}") + # Clean up the broken link from history + raw_files = file_cfg.get("File", "recent_files", fallback="") + files = [f.strip() for f in raw_files.split(",") if f.strip() and f.strip() != file_path] + file_cfg.set("File", "recent_files", ",".join(files)) + self.update_recent_files_menu() + def reset_window_layout(self): """ @@ -4956,7 +6005,7 @@ class MainApplication(QMainWindow): def open_launcher_window(self): - self.launcher_window = ViewerLauncherWidget(self.raw_haemo_dict, self.config_dict, self.fig_bytes_dict, self.cha_dict, self.contrast_results_dict, self.df_ind_dict, self.design_matrix_dict, self.epochs_dict) + self.launcher_window = ViewerLauncherWidget(self.raw_haemo_dict, self.config_dict, self.fig_bytes_dict, self.cha_dict, self.contrast_results_dict, self.df_ind_dict, self.design_matrix_dict, self.epochs_dict, self.folding_bypass) self.launcher_window.show() def copy_text(self): @@ -4971,17 +6020,35 @@ class MainApplication(QMainWindow): self.top_left_widget.paste() # Trigger paste self.statusbar.showMessage("Pasted from clipboard") # Show status message + def _update_config_setting(self, key, value): + """Helper to update memory configuration and save to disk.""" + # configparser expects string values + file_cfg.set("Preferences", key, str(value).lower()) + try: + with open(cfg_path, "w") as f: + file_cfg.write(f) + except Exception as e: + print(f"Warning: Could not save setting '{key}' to disk: {e}") + def is_2d_bypass_func(self, checked): self.is_2d_bypass = checked + self._update_config_setting("2d_data_bypass", checked) def incompatable_save_bypass_func(self, checked): self.incompatible_save_bypass = checked + self._update_config_setting("incompatible_save_bypass", checked) def missing_events_bypass_func(self, checked): self.missing_events_bypass = checked + self._update_config_setting("missing_events_bypass", checked) def analysis_clearing_bypass_func(self, checked): self.analysis_clearing_bypass = checked + self._update_config_setting("analysis_clearing_bypass", checked) + + def folding_bypass_func(self, checked): + self.folding_bypass = checked + self._update_config_setting("folding_bypass", checked) def about_window(self): if self.about is None or not self.about.isVisible(): @@ -5013,6 +6080,67 @@ class MainApplication(QMainWindow): self.events = UpdateEventsBlazesWindow(self, EventUpdateMode.WRITE_SNIRF, "Manual SNIRF Edit") self.events.show() + def show_update_changelog(self): + welcome = WelcomeDialog(self, direct=False) + welcome.show() + + def reset_to_default_configuration(self): + """Asks user for confirmation, then resets all settings to defaults.""" + + reply = QMessageBox.question( + self, + "Reset Configuration", + "Are you sure you want to reset the application and all settings to their default values? This cannot be undone.", + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, + QMessageBox.StandardButton.No # Default focus on 'No' + ) + + # 2. If the user confirmed, perform the reset + if reply == QMessageBox.StandardButton.Yes: + try: + # 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 + file_cfg.read_string(DEFAULT_CONFIG) + + self.sync_app_with_config() + + self.statusbar.showMessage("All settings have been reset to their default values.", 5000) + + + def sync_app_with_config(self): + """Reads values from file_cfg and updates both internal variables and UI checkmarks.""" + # 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.show_welcome_dialog = file_cfg.getboolean("Options", "show_welcome_dialog", fallback=True) + + # 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) + + if hasattr(self, 'recent_files_menu'): + self.update_recent_files_menu() + + if hasattr(self, 'recent_projects_menu'): + self.update_recent_projects_menu() + def open_file_dialog(self): file_path, _ = QFileDialog.getOpenFileName(self, "Open File", "", "SNIRF Files (*.snirf);;All Files (*)") if file_path: @@ -5054,6 +6182,7 @@ class MainApplication(QMainWindow): for path in new_files: self.selected_paths.append(path) + self.add_to_recent_files(path) # Create the UI Bubble (Disconnected by default) display_name = os.path.basename(path) @@ -5271,6 +6400,11 @@ class MainApplication(QMainWindow): ) if not filename: return + + self.project_loader(filename=filename) + + + def project_loader(self, filename): try: with open(filename, "rb") as f: @@ -5361,6 +6495,8 @@ class MainApplication(QMainWindow): self.button1.setVisible(not has_data) self.button3.setVisible(has_data) + self.add_to_recent_projects(os.path.normpath(filename)) + QMessageBox.information(self, "Loaded", f"Project loaded from:\n{filename}") except Exception as e: @@ -5868,7 +7004,10 @@ class MainApplication(QMainWindow): for section_widget in self.param_sections: section_params = section_widget.get_param_values() all_params.update(section_params) - + + if self.folding_bypass: + all_params['FOLDING_BYP'] = True + collected_data = { "SNIRF_FILES": snirf_files, "PARAMS": all_params, # add this line @@ -6077,14 +7216,68 @@ class MainApplication(QMainWindow): def _on_metadata_ready(self, future, file_path, session_id): + + if session_id != self.loading_session_id: + return + try: - data = future.result() - if data: - self.metadata_cache[file_path] = data - self.metadata_processed.emit(file_path, session_id) + result = future.result() + + if result is None: + result = {'status': 'error', 'reason': 'Worker returned no data.'} + # If it's a successful extraction, it won't have 'status' set yet + + elif 'status' not in result: + # Wrap the raw extraction dictionary into our unified UI format + result = {'status': 'success', 'data': result} + except Exception as e: - print(f"Error pre-fetching {file_path}: {e}") - self.metadata_processed.emit(file_path, session_id) + result = {'status': 'error', 'reason': str(e)} + + # Safely emit to the Main thread. No brittle QMetaObject needed! + self.metadata_ui_signal.emit(result, file_path, session_id) + + + def _handle_metadata_ui_update(self, result, file_path, session_id): + """Executes safely on the MAIN GUI thread via Signal connection.""" + if result.get('status') == 'error': + # 1. Pop up the warning safely on the main thread + QMessageBox.warning( + 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 + self.metadata_cache[file_path] = result.get('data', result) + self.metadata_processed.emit(file_path, session_id) + + + 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 + if hasattr(self, 'pending_files_count') and self.pending_files_count > 0: + self.pending_files_count -= 1 + + # 2. Remove the UI widget cleanly + if hasattr(self, 'bubble_widgets') and file_path in self.bubble_widgets: + bubble = self.bubble_widgets.pop(file_path) + self.bubble_layout.removeWidget(bubble) + bubble.deleteLater() # Safely schedules the widget for deletion in Qt + + # 3. Remove from tracking lists + if hasattr(self, 'selected_paths') and file_path in self.selected_paths: + self.selected_paths.remove(file_path) + + # 4. Update Status Bar + if hasattr(self, 'pending_files_count') and self.pending_files_count == 0: + self.statusBar().showMessage("Ready.", 3000) + else: + self.statusBar().showMessage(f"Loading pending files... ({self.pending_files_count} left)") def _safe_ui_update(self, file_path): @@ -6115,8 +7308,10 @@ class MainApplication(QMainWindow): def _extract_metadata_worker(file_name): """Runs in the separate worker process. Returns a clean dict.""" + # 1. Use preload=False! We only need metadata. + raw = None + try: - # 1. Use preload=False! We only need metadata. raw = read_raw_snirf(file_name, preload=False, verbose="ERROR") snirf_info = {} @@ -6157,13 +7352,20 @@ def _extract_metadata_worker(file_name): else: snirf_info['Annotations'] = "No annotations found" - # 7. Explicit cleanup inside worker - raw.close() return snirf_info - + except Exception as e: - print(f"Worker failed on {file_name}: {e}") - return None + print(f"Worker safely caught failure on {file_name}: {str(e)}") + return {'status': 'error', 'reason': str(e)} + + finally: + if raw is not None: + try: + raw.close() + except: + pass + + def run_gui_entry_wrapper(config, gui_queue, progress_queue, ack_queue): """ @@ -6281,16 +7483,75 @@ def show_critical_error(error_msg): msg_box.exec() + +def config_init(): + + ref_cfg.read_string(DEFAULT_CONFIG) + + if not os.path.exists(cfg_path): + try: + with open(cfg_path, "w") as f: + f.write(DEFAULT_CONFIG.strip()) + print(f"Created default configuration file at {cfg_path}") + file_cfg.read_string(DEFAULT_CONFIG) + except Exception as e: + print(f"Warning: Could not create config file ({e}). Using in-memory defaults.") + file_cfg.read_string(DEFAULT_CONFIG) + + else: + try: + # Load the user's actual file first + file_cfg.read(cfg_path) + has_changes = False + + for section in file_cfg.sections(): + if not ref_cfg.has_section(section): + file_cfg.remove_section(section) + has_changes = True + continue + + for option in file_cfg.options(section): + if not ref_cfg.has_option(section, option): + file_cfg.remove_option(section, option) + has_changes = True + + for section in ref_cfg.sections(): + if not file_cfg.has_section(section): + file_cfg.add_section(section) + has_changes = True + + for option in ref_cfg.options(section): + if not file_cfg.has_option(section, option): + default_val = ref_cfg.get(section, option) + file_cfg.set(section, option, default_val) + has_changes = True + + # 4. If we added or removed anything, save the sanitized file back to disk + if has_changes: + with open(cfg_path, "w") as f: + file_cfg.write(f) + print("Configuration file synchronized: removed old keys and appended new ones.") + else: + print("Configuration loaded successfully. Schema is up to date.") + + except Exception as e: + print(f"Error validating config file ({e}). Falling back completely to defaults.") + file_cfg.read_string(DEFAULT_CONFIG) + + + + if __name__ == "__main__": # Redirect exceptions to the popup window sys.excepthook = exception_hook - # Set up application logging + # Set up application logging and configuration if PLATFORM_NAME == "darwin": log_path = os.path.join(os.path.dirname(sys.executable), f"../../../{APP_NAME}.log") + cfg_path = os.path.join(os.path.dirname(sys.executable), f"../../../{APP_NAME}.cfg") else: log_path = os.path.join(os.getcwd(), f"{APP_NAME}.log") - + cfg_path = os.path.join(os.getcwd(), f"{APP_NAME}.cfg") try: os.remove(log_path) except: @@ -6300,12 +7561,16 @@ if __name__ == "__main__": sys.stderr = sys.stdout print(f"\n=== App started at {datetime.now()} ===\n") + file_cfg = configparser.ConfigParser() + ref_cfg = configparser.ConfigParser() + config_init() + freeze_support() # Required for PyInstaller + multiprocessing # Only run GUI in the main process if current_process().name == 'MainProcess': app = QApplication(sys.argv) - finish_update_if_needed(PLATFORM_NAME, APP_NAME) + finish_update_if_needed(PLATFORM_NAME, APP_NAME, cfg_path) window = MainApplication() if PLATFORM_NAME == "darwin": diff --git a/updater.py b/updater.py index be1272c..9c5819d 100644 --- a/updater.py +++ b/updater.py @@ -16,6 +16,7 @@ import shutil import zipfile import traceback import subprocess +import configparser # External library imports import psutil @@ -415,13 +416,24 @@ def wait_for_process_to_exit(process_name, timeout=10): return False -def finish_update_if_needed(platform_name, app_name): +def finish_update_if_needed(platform_name, app_name, cfg_path): """ Completes a pending application update if '--finish-update' is present in the command-line arguments. """ if "--finish-update" in sys.argv: print("Finishing update...") + + update_cfg = configparser.ConfigParser() + try: + update_cfg.read(cfg_path) + 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.") + except Exception as e: + print(f"Warning: Could not update welcome dialog preference flag: {e}") if platform_name == 'darwin': app_dir = f'/tmp/{app_name}tempupdate' @@ -519,7 +531,6 @@ def finish_update_if_needed(platform_name, app_name): except Exception as e: print(f"Failed to delete update folder: {e}") - QMessageBox.information(None, "Update Complete", "The application has been successfully updated.") sys.argv.remove("--finish-update")