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 0000000..c869255
Binary files /dev/null and b/images/brain.png differ
diff --git a/main.py b/main.py
index 740ba9f..cf7b318 100644
--- a/main.py
+++ b/main.py
@@ -16,6 +16,7 @@ import shutil
import platform
import traceback
import subprocess
+import configparser
import concurrent.futures
from queue import Empty
from enum import Enum, auto
@@ -24,6 +25,7 @@ from datetime import datetime
from multiprocessing import Process, current_process, freeze_support, Manager, Queue
# External library imports
+from matplotlib.figure import Figure
import numpy as np
import pandas as pd
import psutil
@@ -38,21 +40,82 @@ from mne_nirs.channels import get_short_channels # type: ignore
from mne import Annotations
from PySide6.QtWidgets import (
- QApplication, QWidget, QMessageBox, QVBoxLayout, QHBoxLayout, QTextEdit, QScrollArea, QComboBox, QGridLayout, QSplitter,
+ QApplication, QTextBrowser, QWidget, QMessageBox, QVBoxLayout, QHBoxLayout, QTextEdit, QScrollArea, QComboBox, QGridLayout, QSplitter,
QPushButton, QMainWindow, QFileDialog, QLabel, QLineEdit, QFrame, QSizePolicy, QGroupBox, QDialog, QListView, QMenu, QSpinBox, QProgressBar
)
-from PySide6.QtCore import QThread, Signal, Qt, QTimer, QEvent, QSize, QPoint
-from PySide6.QtGui import QAction, QKeySequence, QIcon, QIntValidator, QDoubleValidator, QPixmap, QStandardItemModel, QStandardItem, QImage
+from PySide6.QtCore import QThread, Signal, Qt, QTimer, QEvent, QSize, QPoint, QUrl
+from PySide6.QtGui import QAction, QDesktopServices, QKeySequence, QIcon, QIntValidator, QDoubleValidator, QPixmap, QStandardItemModel, QStandardItem, QImage
from PySide6.QtSvgWidgets import QSvgWidget # needed to show svgs when app is not frozen
+from PySide6.QtNetwork import QNetworkAccessManager, QNetworkRequest
-CURRENT_VERSION = "1.4.3"
+CURRENT_VERSION = "1.5.0"
APP_NAME = "flares"
API_URL = f"https://git.research.dezeeuw.ca/api/v1/repos/tyler/{APP_NAME}/releases"
API_URL_SECONDARY = f"https://git.research2.dezeeuw.ca/api/v1/repos/tyler/{APP_NAME}/releases"
PLATFORM_NAME = platform.system().lower()
+DEFAULT_CONFIG = """
+[File]
+recent_files =
+recent_projects =
+
+[Edit]
+
+[View]
+status_bar = true
+left_top = 0
+left_bottom = 0
+right = 0
+
+[Options]
+show_welcome_dialog = true
+
+[Preferences]
+2d_data_bypass = false
+incompatible_save_bypass = false
+missing_events_bypass = false
+analysis_clearing_bypass = false
+folding_bypass = false
+
+[Terminal]
+
+[General]
+
+"""
+
+PIPELINE_STAGES = [
+ "Preprocessing",
+ "Trimming",
+ "Verify Optode Placement",
+ "Short/Long Channels",
+ "Heart Rate",
+ "Scalp Coupling Index",
+ "Signal to Noise Ratio",
+ "Peak Spectral Power",
+ "Cross Validation",
+ "Median Absolute Deviation",
+ "Power Spectral Density Noise",
+ "Channel Variance",
+ "Bad Channels Handling",
+ "Optical Density",
+ "Temporal Derivative Distribution Repair Filtering",
+ "Wavelet Filtering",
+ "Haemoglobin Concentration",
+ "Enhance Negative Correlation",
+ "Filter",
+ "Extracting Events",
+ "Epoch Calculations",
+ "Design Matrix",
+ "General Linear Model",
+ "Generate GLM Results",
+ "Generate Channel Significance",
+ "Generate Channel, Region of Interest, and Contrast Results",
+ "Compute Contrast Results",
+ "Finishing Up"
+]
+
# Selectable parameters on the right side of the window
SECTIONS = [
{
@@ -262,6 +325,72 @@ SECTIONS = [
+class WelcomeDialog(QDialog):
+ def __init__(self, parent=None, direct=True):
+ super().__init__(parent)
+ self.setWindowTitle(f"What's New - {APP_NAME.upper()}")
+ self.setMinimumSize(550, 450)
+ self.resize(800, 500)
+
+ # Main Layout
+ layout = QVBoxLayout(self)
+
+ # Header Layout (Logo + App Name)
+ header_layout = QHBoxLayout()
+ logo_label = QLabel(self)
+ logo_label.setPixmap(QIcon(resource_path("icons/main.ico")).pixmap(48, 48)) # Fits cleanly in a header
+ if direct:
+ title_label = QLabel(f"
{APP_NAME.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")