improvements for 1.5.2

This commit is contained in:
2026-07-29 17:20:23 -07:00
parent 8b017005c5
commit f15a5d9433
8 changed files with 750 additions and 218 deletions
+38 -18
View File
@@ -1,6 +1,26 @@
# Version 1.5.2
- Opening both a file or a folder now contains support for reading some metadata from the BIDS structure. Both options will still function if this metadata is not present
- Currently only the AGE metadata is grabbed and is auto populated into the participants AGE field and displayed on their bubble
- If the participants have metadata, a popup will be displayed asking if the user wants to group the participants by their metadata
- If the user wants to create groups, a dialog box is presented allowing them to do so. Once completed, the GROUP value will be automatically applied to all applicable participants
- Added two new options to the Export to CSV Viewer: Export Configuration to CSV and Paragraph of Configuration
- Export Configuration to CSV will export the parameters utilized for each file into a CSV formatted file to provide additional validation of what parameters were used
- Paragraph of Configuration will generate a ready-to-go paragraph explaining in a more friendly and easy to follow manner of what parameters were used
- Chunked the loading of folders to provide more feedback to the user instead of hanging the application waiting for all files to load
- Fixed an issue with the build script not properly updating the version string causing the application to falsely think that an update was always available
- Fixed an issue where parameters that were dependent on SHORT_CHANNELS were not properly being updated
- Fixed an issue where files that failed processing were not having their progress bar turn red at the step that failed
- Fixed an issue where progress bars were not updating when MAX_WORKERS was set to a value higher than default
- Fixed an issue where a high value of MAX_WORKERS was never hit the targeted value due to not being able to spawn the workers fast enough
- Fixed an issue causing numerous 'Invalid File' popups to appear upon closing the application while files were still having their initial metadata being grabbed
- Added a time elapsed message to the status bar denoting how long processing has been going on for. When processing is complete, it will change to be the time elapsed
- Added a speedup multiplier to show how much time was saved by having MAX_WORKERS not set to 1. This multiplier is calculated using a naive approach and may not be accurate
# Version 1.5.1
- Open a folder is now minimally BIDS compatible. It will recursively go through all folders, but not load external metadata
- Opening a folder is now minimally BIDS compatible. It will recursively go through all folders, but not load external metadata
- Renamed options in the Viewer Launcher window to better denote what actions they can perform
- 2 new analysis options have been added in the viewer launcher window: Inter-Group Stats and Cross-Group Stats
- Each of these analysis options have lots of text explaining what they do when their respective windows are opened
@@ -9,7 +29,7 @@
- The description when clicking "Why are these useful?" underneath the per-file parameters is no longer placeholder text. Fixes [Issue 85](https://git.research.dezeeuw.ca/tyler/flares/issues/85)
- Added a new parameter section "Region of Interest". It has one parameter of "JSON_LOCATION", a json file containing Region of Interest data
- Temporarily removed the parameter REMOVE_EVENTS due to its functionality being removed because of a memory leak
- Fixed participant metadata remaing in the background when the participant was removed. Fixes [Issue 82](https://git.research.dezeeuw.ca/tyler/flares/issues/82)
- Fixed participant metadata remaining in the background when the participant was removed. Fixes [Issue 82](https://git.research.dezeeuw.ca/tyler/flares/issues/82)
- Fixed processing remaining active hidden in the background when the "Clear" button was pushed. Fixes [Issue 81](https://git.research.dezeeuw.ca/tyler/flares/issues/81)
- Now when "Clear" is pushed while data is processing, a popup will appear ensuring that the user understands that pushing "Clear" will stop processing
- Reset to Default Configuration will now properly reset all of the parameters to their default values. [Issue 90](https://git.research.dezeeuw.ca/tyler/flares/issues/90)
@@ -20,8 +40,8 @@
- Changed the parameters "SHORT_CHANNEL", "SHORT_CHANNEL_THRESH", and "LONG_CHANNEL_THRESH" to now be "SHORT_CHANNELS", "SHORT_CHANNELS_THRESHOLD", and "LONG_CHANNELS_THRESHOLD"
- Changed the parameter section "Channel Variance" to now be "Sensor Dropout" to better reflect the action it performs, not the method employed to calculate it
- Changed the parameters "CHANNEL_VAR" and "CHANNEL_THRESH" to now be "SENSOR_DROPOUT" and "SENSOR_DROPOUT_VARIANCE_THRESHOLD"
- Changed the messaged displayed when the application crashes to better reflect what occured
- Changed the processing stages 25-27 and updates their messages to better reflect their actions that they now perforn
- Changed the messaged displayed when the application crashes to better reflect what occurred
- Changed the processing stages 25-27 and updates their messages to better reflect their actions that they now perform
- Changed stage 25 from "Generate Channel Significance" to "Generate Channel Results"
- Changed stage 26 from "Generate Channel, Region of Interest, and Contrast Results" to "Generate Region of Interest Results"
- Changed stage 27 from "Compute Contrast Results" to "Generate Contrast Results"
@@ -41,16 +61,16 @@
- This release features an almost complete rewrite of backend files. If you encounter any problems, please raise an issue at [this link.](https://git.research.dezeeuw.ca/tyler/flares/issues/new)
- 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
- Recent files and recent projects are now saved and appear under the File menu for quick resuming
- The new option "Reset to Default Configuration" under the Options menu will reset the configuration file back to it's default values and remove any recent files
- The new option "Reset to Default Configuration" under the Options menu will reset the configuration file back to its default values and remove any recent files
- A welcome dialog will now display the changelog on first startup and after every update. This popup will only appear once but can be reopened under the Options menu through the button "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)
- Overall pie charts on an individual and global basis are now generated. 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)
- Fixed an issue where the fOLD analysis progress window would go unresponsive before processing participants. Fixes [Issue 45](https://git.research.dezeeuw.ca/tyler/flares/issues/45), Fixes [Issue 34](https://git.research.dezeeuw.ca/tyler/flares/issues/34)
- 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)
- 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)
@@ -81,7 +101,7 @@
- It is still possible to load older saves by enabling 'Incompatible Save Bypass' from the Preferences menu, but your mileage may vary
- AGE, GENDER, GROUP, loaded files, and all the parameters on the right side of the screen can now be saved before any data has been processed
- If processing has not been completed, the process button will be visible. If processing has completed, the analysis button will be visible
- If the values fail to load, they will fallback to the previous logic of retreiving these values after processing has occured. Fixes [Issue 66](https://git.research.dezeeuw.ca/tyler/flares/issues/66)
- If the values fail to load, they will fallback to the previous logic of retrieving these values after processing has occurred. Fixes [Issue 66](https://git.research.dezeeuw.ca/tyler/flares/issues/66)
- Added new parameters to the right side of the screen: MAX_SHIFT, T_MIN, T_MAX, MAX_BAD_CHANNELS. Fixes [Issue 69](https://git.research.dezeeuw.ca/tyler/flares/issues/69)
- Participants that are bad on channels will no longer continue to the GLM stage. Fixes [Issue 18](https://git.research.dezeeuw.ca/tyler/flares/issues/18)
- Added new sections to the right side of the screen to mark bad channels: Cross Validation, Median Absolute Deviation, PSD Noise, & Channel Variance
@@ -107,7 +127,7 @@
- This is potentially a save-changing release due to a new parameter being saved. Please update your project files to ensure compatibility
- It is still possible to load older saves by enabling 'Incompatible Save Bypass' from the Preferences menu, but your mileage may vary
- Fixed workers not releasing memory when processing multiple participants. Fixes [Issue 55](https://git.research.dezeeuw.ca/tyler/flares/issues/55)
- Fixed part of an issue where memory could increase over time despite clicking the clear button. There is still some edge cases where this can occur
- Fixed part of an issue where memory could increase over time despite clicking the clear button. There are still some edge cases where this can occur
- Fixed an issue when clearing a bubble, reloading the same file, and clicking it again would cause the app to crash. Fixes [Issue 57](https://git.research.dezeeuw.ca/tyler/flares/issues/57)
- Picking a .txt or .xlsx file now has both in the same file selection instead of having to select which extension was desired
- Added a new parameter to the right side of the screen: EPOCH_HANDLING. Fixes [Issue 58](https://git.research.dezeeuw.ca/tyler/flares/issues/58)
@@ -115,10 +135,10 @@
- The label for ENHANCE_NEGATIVE_CORRELATION no longer gets cut off by its dropdown selection
- Loading in files and folders have changes to immediately show their bubbles having a respective loading symbol on each bubble
- Once the file has been completely loaded and processed, the loading symbol will change to a green checkmark and clicking will be enabled
- The metadata in the File infomation widget is now saved to prevent recalculations every time the bubble is selected
- The metadata in the File information widget is now saved to prevent recalculations every time the bubble is selected
- The status bar will now say loading while the bubbles are being processed, and loaded once the processing has completed
- This new loading method will prevent the application from hanging when loading lots of files at once. Fixes [Issue 59](https://git.research.dezeeuw.ca/tyler/flares/issues/59)
- Fixed text allignment for the first paragraph when a bubble is selected in the 'File information' widget
- Fixed text alignment for the first paragraph when a bubble is selected in the 'File information' widget
- The three main widgets are now resizable! All of them have minimum widths to ensure they do not get too squished
- Added a new option 'Reset Window Layout' under the View menu that will resize all widgets back to their default sizes
- Added a new terminal command 'version' that will print the applications current version
@@ -128,7 +148,7 @@
- Added 'Update events in snirf file (BLAZES)...' and renamed 'Update events in snirf file...' to 'Update events in snirf file (BORIS)...' under the Options menu
- The BLAZES option will assign events that are exported directly from the software [BLAZES](https://git.research.dezeeuw.ca/tyler/blazes)
- Moved the updating logic to a seperate file for better reusability and generalization
- Moved the updating logic to a separate file for better reusability and generalization
- Fixed 'Toggle Status Bar' having no effect on the visibility of the status bar
- Fixed a bug when updating optode positions that would prevent .txt files from being selected. Fixes [Issue 54](https://git.research.dezeeuw.ca/tyler/flares/issues/54)
- Fixed a missing dependency in the standalone application when attempting to use an .xlsx file to update optode positions
@@ -161,7 +181,7 @@
- These parameters include SHOW_OPTODE_NAMES, SECONDS_TO_STRIP_HR, MAX_LOW_HR, MAX_HIGH_HR, SMOOTHING_WINDOW_HR, HEART_RATE_WINDOW, BAD_CHANNELS_HANDLING, MAX_DIST, MIN_NEIGHBORS, L_TRANS_BANDWIDTH, H_TRANS_BANDWIDTH, RESAMPLE, RESAMPLE_FREQ, STIM_DUR, HRF_MODEL, HIGH_PASS, DRIFT_ORDER, FIR_DELAYS, MIN_ONSET, OVERSAMPLING, SHORT_CHANNEL_REGRESSION, NOISE_MODEL, BINS, and VERBOSITY.
- Certain parameters now have dependencies on other parameters and will now grey out if they are not used
- All the new parameters have default values matching the underlying values in version 1.1.7
- The order of the parameters have changed to match the order that the code runs when the Process button is clicked
- The order of the parameters has been changed to match the order that the code runs when the Process button is clicked
- Moved TIME_WINDOW_START and TIME_WINDOW_END to the 'Other' category
- Fixed a bug causing SCI to not work when HEART_RATE was set to False
- Bad channels can now be dealt with by taking no action, removing them completely, or interpolating them based on their neighbours. Interpolation remains the default option
@@ -171,11 +191,11 @@
- Fixed a crash when attempting to fOLD channels without the fOLD dataset installed
- Lowered the number of rectangles in the progress bar to 24 after combining some actions
- Fixed the User Guide window to properly display information about the 24 stages and added a link to the Git wiki page
- MAX_WORKERS should now properly repect the value set
- MAX_WORKERS should now properly respect the value set
- Added a new CSV export option to be used by other applications
- Added support for updating optode positions directly from an .xlsx file from a Polhemius system
- Fixed an issue where the dropdowns in the Viewer windows would immediately open and close when using a trackpad
- glover and spm hrf models now function as intended without crashing. Currently, group analysis is still only supported by fir. Fixes [Issue 8](https://git.research.dezeeuw.ca/tyler/flares/issues/8)
- glover and spm HRF models now function as intended without crashing. Currently, group analysis is still only supported by fir. Fixes [Issue 8](https://git.research.dezeeuw.ca/tyler/flares/issues/8)
- Clicking 'Clear' should now properly clear all data. Fixes [Issue 9](https://git.research.dezeeuw.ca/tyler/flares/issues/9)
- Revamped the fold channels viewer to not hang the application and to better process multiple participants at once. Fixes [Issue 34](https://git.research.dezeeuw.ca/tyler/flares/issues/34), [Issue 31](https://git.research.dezeeuw.ca/tyler/flares/issues/31)
- Added a Preferences menu to the navigation bar
@@ -214,13 +234,13 @@
- Fixed Windows saves not being able to be opened by a Mac (hopefully the other way too!)
- Added the option to right click loaded snirf files to reveal them in a file browser or delete them if they are no longer desired
- Changed the way folders are opened to store the files seperately rather than the folder as a whole to allow for the removal of files
- Changed the way folders are opened to store the files separately rather than the folder as a whole to allow for the removal of files
- Fixed issues with dropdowns and bubbles not populating correctly when opening a single file and temporarily removed the option to open multiple folders
- Improved crash handling and the message that is displayed to the user if the application crashes
- Progress bar will now colour the stage that fails as red if a file fails during processing
- A warning message will be displayed when a file fails to process with information on what went wrong. This message does not halt the rest of the processing of the other files
- Fixed the number of rectangles in the progress bar to 20 (was incorrect in v1.1.1)
- Added validation to ensure loaded files do not have 2 dimensional data when clicking process to prevent inaccurate results from being generated
- Added validation to ensure loaded files do not have 2-dimensional data when clicking process to prevent inaccurate results from being generated
- Added more metadata information to the top left information panel
- Changed the Status Bar message when processing is complete to state how many were successful and how many were not
- Added a clickable link below the selected file's metadata explaining the independent parameters and why they are useful
@@ -283,7 +303,7 @@
# Version 1.0.1
- Two new options have been added when clicking on a participant's file.
- First new option is Age which allows the option to specify age on a per-file basis to calculate DPF by using the Scholkmann & Wolf calculation instead of having one DPF for all files. Fixes [Issue 1](https://git.research.dezeeuw.ca/tyler/flares/issues/1). This also allows for proper calculation of DPF for the two seperate wavelengths.
- First new option is Age which allows the option to specify age on a per-file basis to calculate DPF by using the Scholkmann & Wolf calculation instead of having one DPF for all files. Fixes [Issue 1](https://git.research.dezeeuw.ca/tyler/flares/issues/1). This also allows for proper calculation of DPF for the two separate wavelengths.
- If no age is set by the user, the code will fall back to the age of 25
- Second new option is Gender to be used in future analysis options (currently not implemented).
- UI elements have been shuffled to make room for the new parameters.
+28 -7
View File
@@ -1,6 +1,26 @@
# Version 1.5.2
- Opening both a file or a folder now contains support for reading some metadata from the BIDS structure. Both options will still function if this metadata is not present
- Currently only the AGE metadata is grabbed and is auto populated into the participants AGE field and displayed on their bubble
- If the participants have metadata, a popup will be displayed asking if the user wants to group the participants by their metadata
- If the user wants to create groups, a dialog box is presented allowing them to do so. Once completed, the GROUP value will be automatically applied to all applicable participants
- Added two new options to the Export to CSV Viewer: Export Configuration to CSV and Paragraph of Configuration
- Export Configuration to CSV will export the parameters utilized for each file into a CSV formatted file to provide additional validation of what parameters were used
- Paragraph of Configuration will generate a ready-to-go paragraph explaining in a more friendly and easy to follow manner of what parameters were used
- Chunked the loading of folders to provide more feedback to the user instead of hanging the application waiting for all files to load
- Fixed an issue with the build script not properly updating the version string causing the application to falsely think that an update was always available
- Fixed an issue where parameters that were dependent on SHORT_CHANNELS were not properly being updated
- Fixed an issue where files that failed processing were not having their progress bar turn red at the step that failed
- Fixed an issue where progress bars were not updating when MAX_WORKERS was set to a value higher than default
- Fixed an issue where a high value of MAX_WORKERS was never hit the targeted value due to not being able to spawn the workers fast enough
- Fixed an issue causing numerous 'Invalid File' popups to appear upon closing the application while files were still having their initial metadata being grabbed
- Added a time elapsed message to the status bar denoting how long processing has been going on for. When processing is complete, it will change to be the time elapsed
- Added a speedup multiplier to show how much time was saved by having MAX_WORKERS not set to 1. This multiplier is calculated using a naive approach and may not be accurate
# Version 1.5.1
- Open a folder is now minimally BIDS compatible. It will recursively go through all folders, but not load external metadata
- Opening a folder is now minimally BIDS compatible. It will recursively go through all folders, but not load external metadata
- Renamed options in the Viewer Launcher window to better denote what actions they can perform
- 2 new analysis options have been added in the viewer launcher window: Inter-Group Stats and Cross-Group Stats
- Each of these analysis options have lots of text explaining what they do when their respective windows are opened
@@ -9,7 +29,7 @@
- The description when clicking "Why are these useful?" underneath the per-file parameters is no longer placeholder text. Fixes [Issue 85](https://git.research.dezeeuw.ca/tyler/flares/issues/85)
- Added a new parameter section "Region of Interest". It has one parameter of "JSON_LOCATION", a json file containing Region of Interest data
- Temporarily removed the parameter REMOVE_EVENTS due to its functionality being removed because of a memory leak
- Fixed participant metadata remaing in the background when the participant was removed. Fixes [Issue 82](https://git.research.dezeeuw.ca/tyler/flares/issues/82)
- Fixed participant metadata remaining in the background when the participant was removed. Fixes [Issue 82](https://git.research.dezeeuw.ca/tyler/flares/issues/82)
- Fixed processing remaining active hidden in the background when the "Clear" button was pushed. Fixes [Issue 81](https://git.research.dezeeuw.ca/tyler/flares/issues/81)
- Now when "Clear" is pushed while data is processing, a popup will appear ensuring that the user understands that pushing "Clear" will stop processing
- Reset to Default Configuration will now properly reset all of the parameters to their default values. [Issue 90](https://git.research.dezeeuw.ca/tyler/flares/issues/90)
@@ -20,8 +40,8 @@
- Changed the parameters "SHORT_CHANNEL", "SHORT_CHANNEL_THRESH", and "LONG_CHANNEL_THRESH" to now be "SHORT_CHANNELS", "SHORT_CHANNELS_THRESHOLD", and "LONG_CHANNELS_THRESHOLD"
- Changed the parameter section "Channel Variance" to now be "Sensor Dropout" to better reflect the action it performs, not the method employed to calculate it
- Changed the parameters "CHANNEL_VAR" and "CHANNEL_THRESH" to now be "SENSOR_DROPOUT" and "SENSOR_DROPOUT_VARIANCE_THRESHOLD"
- Changed the messaged displayed when the application crashes to better reflect what occured
- Changed the processing stages 25-27 and updates their messages to better reflect their actions that they now perforn
- Changed the messaged displayed when the application crashes to better reflect what occurred
- Changed the processing stages 25-27 and updates their messages to better reflect their actions that they now perform
- Changed stage 25 from "Generate Channel Significance" to "Generate Channel Results"
- Changed stage 26 from "Generate Channel, Region of Interest, and Contrast Results" to "Generate Region of Interest Results"
- Changed stage 27 from "Compute Contrast Results" to "Generate Contrast Results"
@@ -31,6 +51,7 @@
- Fixed a crucial bug where short channels were not being processed and filtered the same way as long channels before being used as regressors
- Fixed a crucial bug where short channels were being presented to the design matrix as normal long channels
- Fixed a crucial bug where long channels could be interpolated from short channels. Short channels are still potentially interpolated from long channels. See [this link](https://git.research.dezeeuw.ca/tyler/flares/issues/80) for more information regarding this issue.
- Decreased unnecessary processing time when fOLDing channels by an order of magnitude
- Added a welcome message when the terminal is opened, resized the terminal, and added more commands
@@ -40,14 +61,14 @@
- This release features an almost complete rewrite of backend files. If you encounter any problems, please raise an issue at [this link.](https://git.research.dezeeuw.ca/tyler/flares/issues/new)
- 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
- Recent files and recent projects are now saved and appear under the File menu for quick resuming
- The new option "Reset to Default Configuration" under the Options menu will reset the configuration file back to it's default values and remove any recent files
- The new option "Reset to Default Configuration" under the Options menu will reset the configuration file back to its default values and remove any recent files
- A welcome dialog will now display the changelog on first startup and after every update. This popup will only appear once but can be reopened under the Options menu through the button "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)
- Overall pie charts on an individual and global basis are now generated. 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)
- Fixed an issue where the fOLD analysis progress window would go unresponsive before processing participants. Fixes [Issue 45](https://git.research.dezeeuw.ca/tyler/flares/issues/45), Fixes [Issue 34](https://git.research.dezeeuw.ca/tyler/flares/issues/34)
- 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)
- 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)
+193 -130
View File
@@ -8,24 +8,26 @@ License: GPL-3.0
# Built-in imports
import os
from pathlib import Path
import sys
import platform
import threading
import logging
from io import BytesIO
from typing import Any, Optional, Sequence, cast, Literal, Union
from itertools import compress
from copy import deepcopy
from multiprocessing import Queue, Pool
import os.path as op
import gc
import re
import traceback
from concurrent.futures import ProcessPoolExecutor, as_completed
from queue import Empty
import sys
import json
import time
import multiprocessing as mp
import logging
import platform
import warnings
import threading
import traceback
import itertools
import os.path as op
from io import BytesIO
from queue import Empty
from pathlib import Path
from copy import deepcopy
import multiprocessing as mp
from itertools import compress
from multiprocessing import Queue
from typing import Any, Optional, Sequence, cast, Literal, Union
# External library imports
import matplotlib.pyplot as plt
@@ -50,10 +52,11 @@ from nilearn.glm.regression import OLSModel
import statsmodels.formula.api as smf # type: ignore
from statsmodels.stats.multitest import multipletests
from statsmodels.tools.sm_exceptions import ConvergenceWarning
from scipy.spatial.distance import cdist
from scipy.signal import welch, butter, filtfilt # type: ignore
from scipy.stats import pearsonr, zscore, t
from scipy.stats import pearsonr, zscore, ttest_1samp, ttest_ind, sem
import pywt # type: ignore
import neurokit2 as nk # type: ignore
@@ -67,7 +70,7 @@ import xlrd
# External library imports for mne
from mne import (
EvokedArray, SourceEstimate, Info, Epochs, Label, Annotations,
events_from_annotations, read_source_spaces,
events_from_annotations, read_source_spaces, create_info,
stc_near_sensors, pick_types, grand_average, get_config, set_config, read_labels_from_annot
) # type: ignore
from mne.source_space import SourceSpaces
@@ -99,21 +102,6 @@ from mne_connectivity.viz import plot_connectivity_circle
from mne_connectivity import envelope_correlation, spectral_connectivity_epochs, spectral_connectivity_time
import os
import json
import warnings
import numpy as np
import pandas as pd
import scipy.stats as stats
import matplotlib.pyplot as plt
import seaborn as sns
import statsmodels.formula.api as smf
from statsmodels.stats.multitest import multipletests
from statsmodels.tools.sm_exceptions import ConvergenceWarning
from mne_nirs.statistics import statsmodels_to_results
from mne_nirs.visualisation import plot_glm_group_topo
import logging
# Needs to be set for mne
os.environ["SUBJECTS_DIR"] = str(data_path()) + "/subjects" # type: ignore
@@ -383,6 +371,7 @@ def set_metadata(file_path, metadata: dict[str, Any]) -> None:
globals()[key] = val
def gui_entry(config: dict[str, Any], gui_queue: Queue, progress_queue: Queue, ack_queue: Queue) -> None:
start_time = time.time()
try:
file_paths = config['SNIRF_FILES']
file_params = config['PARAMS']
@@ -393,7 +382,24 @@ def gui_entry(config: dict[str, Any], gui_queue: Queue, progress_queue: Queue, a
file_paths, file_params, file_metadata, progress_queue, gui_queue, max_workers
)
gui_queue.put({"type": "FINISHED_SUCCESSFULLY", "success": True})
elapsed = time.time() - start_time
success_count = getattr(process_multiple_participants, "_success_count", 0)
total_duration = getattr(process_multiple_participants, "_duration_total", 0.0)
failed_stages = getattr(process_multiple_participants, "_failed_stages", [])
speedup = None
if success_count > 0 and elapsed > 0:
avg_success_duration = total_duration / success_count
failed_credit = sum(stage * avg_success_duration for stage in failed_stages)
naive_serial_estimate = total_duration + failed_credit
speedup = min(naive_serial_estimate / elapsed, max_workers)
gui_queue.put({
"type": "FINISHED_SUCCESSFULLY",
"success": True,
"elapsed": elapsed,
"speedup": speedup,
})
try:
print("CHILD: Waiting for GUI acknowledgment...")
@@ -406,122 +412,186 @@ def gui_entry(config: dict[str, Any], gui_queue: Queue, progress_queue: Queue, a
"type": "FINISHED_SUCCESSFULLY",
"success": False,
"error": str(e),
"traceback": traceback.format_exc()
"traceback": traceback.format_exc(),
"elapsed": time.time() - start_time
})
finally:
pass
def process_participant_worker(file_path, file_params, file_metadata, result_queue, progress_queue):
file_name = os.path.basename(file_path)
def process_participant_worker(file_path, file_params, file_metadata, result_queue, progress_queue):
file_start = time.time()
stage_tracker = {"value": 0.0}
try:
# 1. Setup
set_config_me(file_params)
set_metadata(file_path, file_metadata)
def progress_callback(step_idx):
stage_tracker["value"] = min(step_idx / 28, 1.0)
if progress_queue:
# We use put_nowait to prevent the worker from hanging on a full queue
try:
progress_queue.put_nowait(('progress', file_path, step_idx))
except: pass
except Exception:
pass
# 2. Process
result = process_participant(file_path, progress_callback=progress_callback)
# 3. Report Success
result_queue.put((file_path, result, None))
duration = time.time() - file_start
result_queue.put((file_path, result, None, duration, 1.0))
except Exception as e:
duration = time.time() - file_start
try:
result_queue.put((file_path, None, str(e)))
except: pass
result_queue.put((file_path, None, f"{e}\n{traceback.format_exc()}", duration, stage_tracker["value"]))
except Exception:
pass
finally:
try:
sys.stdout.flush()
sys.stderr.flush()
# Give the queue thread a moment to send the data before we vanish
time.sleep(0.2)
except: pass
sys.exit(0)
plt.close('all')
gc.collect()
except Exception:
pass
def process_multiple_participants(file_paths, file_params, file_metadata, progress_queue=None, gui_queue=None, max_workers=6):
#audit_log.info(f"--- SESSION START: {len(file_paths)} files ---")
def process_multiple_participants(file_paths, file_params, file_metadata,
progress_queue=None, gui_queue=None, max_workers=6):
ctx = mp.get_context("spawn")
result_queue = ctx.Queue()
pending_files = list(file_paths)
active_processes = []
pending_lock = threading.Lock()
results_by_file = {}
results_lock = threading.Lock()
stop_event = threading.Event()
manager = mp.Manager()
result_queue = manager.Queue()
active_lock = threading.Lock()
active_processes = [] # tracked only for emergency cleanup on error
start_time = time.time()
duration_total = {"value": 0.0}
success_count = {"value": 0}
failed_stages = {"value": []}
def elapsed_heartbeat():
# Ticks once a second so the GUI can show a live-updating timer,
# independent of when files actually finish.
while not stop_event.is_set():
if gui_queue:
try:
gui_queue.put({"type": "elapsed", "seconds": time.time() - start_time})
except Exception:
pass
stop_event.wait(1.0)
def relay_progress():
while not stop_event.is_set():
try:
msg = progress_queue.get(timeout=0.05)
except Empty:
continue
except (EOFError, OSError):
break
if gui_queue:
try:
gui_queue.put(msg)
except Exception:
pass
def result_collector():
# Runs continuously in its own thread, fully decoupled from spawning.
while not stop_event.is_set():
try:
res_path, result, error, duration, stage = result_queue.get(timeout=0.05)
except Empty:
continue
except (EOFError, OSError):
break
if error is None:
duration_total["value"] += duration
success_count["value"] += 1
else:
failed_stages["value"].append(stage)
if gui_queue:
try:
gui_queue.put({
"type": "file_done",
"file": res_path,
"success": error is None,
"result": result if error is None else None,
"error": error,
})
except Exception:
pass
else:
with results_lock:
results_by_file[res_path] = result
def slot_worker():
# Each slot independently: grab a file, spawn+join, repeat.
# Replacement happens immediately on exit -- no shared polling loop.
while not stop_event.is_set():
with pending_lock:
if not pending_files:
return
file_path = pending_files.pop(0)
p = ctx.Process(
target=process_participant_worker,
args=(file_path, file_params, file_metadata, result_queue, progress_queue)
)
p.daemon = False
p.start()
with active_lock:
active_processes.append(p)
p.join()
with active_lock:
if p in active_processes:
active_processes.remove(p)
relay_thread = None
if progress_queue is not None:
relay_thread = threading.Thread(target=relay_progress, daemon=True)
relay_thread.start()
collector_thread = threading.Thread(target=result_collector, daemon=True)
collector_thread.start()
heartbeat_thread = threading.Thread(target=elapsed_heartbeat, daemon=True)
heartbeat_thread.start()
n_slots = max(1, min(max_workers, len(pending_files)))
slot_threads = [threading.Thread(target=slot_worker, daemon=True) for _ in range(n_slots)]
try:
while pending_files or active_processes:
for p, f_path in active_processes[:]:
if not p.is_alive():
p.join(timeout=0.1)
active_processes.remove((p, f_path))
while len(active_processes) < max_workers and pending_files:
file_path = pending_files.pop(0)
p = mp.Process(
target=process_participant_worker,
args=(file_path, file_params, file_metadata, result_queue, progress_queue)
)
p.start()
active_processes.append((p, file_path))
if progress_queue:
while not progress_queue.empty():
try:
prog_msg = progress_queue.get_nowait()
if gui_queue:
# Forward straight to GUI
gui_queue.put(prog_msg, timeout=0.1)
except: break
while not result_queue.empty():
try:
res_path, result, error = result_queue.get(timeout=0.01)
if gui_queue:
gui_queue.put({
"type": "file_done",
"file": res_path,
"success": error is None,
"result": result if error is None else None,
"error": error if error else None
}, timeout=2)
else:
results_by_file[res_path] = result
except:
break
if not pending_files and not active_processes:
print("DEBUG: Loop finished naturally.")
break
# should no longer hang because pipes are being drained
time.sleep(0.1)
for t in slot_threads:
t.start() # all slots fire their first spawn essentially at once
for t in slot_threads:
t.join()
except Exception as e:
print(f"MAIN LOOP ERROR: {e}")
finally:
# Cleanup
for p, _ in active_processes:
try:
if p.is_alive():
p.terminate()
p.join(timeout=0.1)
except: pass
stop_event.set()
with active_lock:
for p in active_processes:
try:
if p.is_alive():
p.terminate()
p.join(timeout=1)
except Exception:
pass
if relay_thread:
relay_thread.join(timeout=2)
collector_thread.join(timeout=2)
heartbeat_thread.join(timeout=2)
if manager is not None:
try:
manager.shutdown()
except: pass
process_multiple_participants._duration_total = duration_total ["value"]
process_multiple_participants._success_count = success_count["value"]
process_multiple_participants._failed_stages = failed_stages["value"]
return results_by_file
@@ -2172,8 +2242,6 @@ def aggregate_fnirs_group_geometry(raw_list: Sequence[BaseRaw | None]) -> BaseRa
2. Average by Individual Optode (S, D) across all averaged pairings.
Returns a unified MNE Raw object with exactly one dot per optode.
"""
import mne
import numpy as np
channel_locs = {}
all_ch_names = []
@@ -2223,11 +2291,11 @@ def aggregate_fnirs_group_geometry(raw_list: Sequence[BaseRaw | None]) -> BaseRa
final_chs.append(new_ch)
# Create the final MNE Info
fake_info = mne.create_info(ch_names=all_ch_names, sfreq=ref_raw.info['sfreq'], ch_types='hbo')
fake_info = create_info(ch_names=all_ch_names, sfreq=ref_raw.info['sfreq'], ch_types='hbo')
with fake_info._unlock():
fake_info['chs'] = final_chs
return mne.io.RawArray(np.zeros((len(all_ch_names), 1)), fake_info)
return RawArray(np.zeros((len(all_ch_names), 1)), fake_info)
@@ -2840,9 +2908,9 @@ def run_roi_second_level_analysis(
continue
Y = sub_data['theta'].values
t_val, p_val = stats.ttest_1samp(Y, 0)
t_val, p_val = ttest_1samp(Y, 0)
mean_beta = np.mean(Y)
std_err = stats.sem(Y)
std_err = sem(Y)
group_results.append({
'ROI': roi,
@@ -3136,7 +3204,7 @@ def run_cross_group_second_level_analysis(
continue
# Welch's t-test (assumes unequal variances)
t_val, p_val = stats.ttest_ind(vals_a, vals_b, equal_var=False)
t_val, p_val = ttest_ind(vals_a, vals_b, equal_var=False)
mean_a, mean_b = np.mean(vals_a), np.mean(vals_b)
diff_val = mean_a - mean_b
@@ -3274,7 +3342,7 @@ def run_cross_group_second_level_analysis(
vals_b = ch_b[val_col].dropna().values
if len(vals_a) >= min_subjects and len(vals_b) >= min_subjects:
t_stat, p_val = stats.ttest_ind(vals_a, vals_b, equal_var=False)
t_stat, p_val = ttest_ind(vals_a, vals_b, equal_var=False)
mean_diff = np.mean(vals_a) - np.mean(vals_b)
else:
t_stat, p_val, mean_diff = 0.0, 1.0, 0.0
@@ -3511,7 +3579,7 @@ def run_cross_group_laterality_analysis(
vals_a = lat_a['laterality'].values
vals_b = lat_b['laterality'].values
t_val, p_val = stats.ttest_ind(vals_a, vals_b, equal_var=False)
t_val, p_val = ttest_ind(vals_a, vals_b, equal_var=False)
mean_a, mean_b = np.mean(vals_a), np.mean(vals_b)
mean_diff = mean_a - mean_b
@@ -3735,7 +3803,7 @@ def run_cross_group_contrast_analysis(
)
continue
t_val, p_val = stats.ttest_ind(vals_a, vals_b, equal_var=False)
t_val, p_val = ttest_ind(vals_a, vals_b, equal_var=False)
mean_a, mean_b = np.mean(vals_a), np.mean(vals_b)
mean_diff = mean_a - mean_b
@@ -3958,7 +4026,7 @@ def run_roi_paired_contrast_analysis(
continue
Y = merged['diff'].values
t_val, p_val = stats.ttest_1samp(Y, 0)
t_val, p_val = ttest_1samp(Y, 0)
mean_diff = np.mean(Y)
results.append({
@@ -4750,8 +4818,6 @@ def plot_heart_rate(
return fig1, fig2
# import numpy as np
# def mark_bads_by_db_threshold(raw, db_limit=-60):
# """
# Converts a dB threshold to absolute power and marks channels
@@ -4914,8 +4980,6 @@ def find_flatline_at_end(raw, threshold_ratio=0.05):
# plt.show(block=True)
# import numpy as np
def detect_sensor_dropout(raw, threshold_ratio=0.05):
@@ -6060,7 +6124,6 @@ def run_group_functional_connectivity(
all_z_matrices.append(z_mat)
common_names = names
from scipy.stats import ttest_1samp
# 1. Convert list to 3D array: (Participants, Channels, Channels)
group_z_data = np.array(all_z_matrices)
+389 -45
View File
@@ -22,6 +22,7 @@ from datetime import datetime
from multiprocessing import Process, current_process, freeze_support, Queue
# External library imports
import pandas as pd
import psutil
from mne.io import read_raw_snirf
@@ -29,8 +30,8 @@ from mne.preprocessing.nirs import source_detector_distances
from mne_nirs.channels import get_short_channels # type: ignore
from PySide6.QtWidgets import (
QApplication, QWidget, QMessageBox, QVBoxLayout, QHBoxLayout, QTextEdit, QScrollArea, QComboBox, QGridLayout, QSplitter,
QPushButton, QMainWindow, QFileDialog, QLabel, QLineEdit, QFrame, QSizePolicy, QGroupBox, QDialog, QMenu, QSpinBox
QApplication, QWidget, QMessageBox, QVBoxLayout, QHBoxLayout, QTextEdit, QScrollArea, QComboBox, QGridLayout, QSplitter, QDialogButtonBox, QHeaderView,
QPushButton, QMainWindow, QFileDialog, QLabel, QLineEdit, QFrame, QSizePolicy, QGroupBox, QDialog, QMenu, QSpinBox, QTableWidget, QTableWidgetItem
)
from PySide6.QtCore import QThread, Signal, Qt, QTimer, QPoint
from PySide6.QtGui import QAction, QKeySequence, QIcon
@@ -105,7 +106,7 @@ SECTIONS = [
"title": "Short/Long Channels",
"params": [
{"name": "SHORT_CHANNELS", "default": True, "type": bool, "help": "This should be set to True if the data has a short channel present in the data. For more information about short channels, please visit the Wiki."},
{"name": "SHORT_CHANNELS_THRESHOLD", "default": 0.015, "type": float, "depends_on": "SHORT_CHANNEL", "help": "The maximum distance the short channel can be in metres before it is no longer considered a short channel."},
{"name": "SHORT_CHANNELS_THRESHOLD", "default": 0.015, "type": float, "depends_on": "SHORT_CHANNELS", "help": "The maximum distance the short channel can be in metres before it is no longer considered a short channel."},
{"name": "LONG_CHANNELS_THRESHOLD", "default": 0.045, "type": float, "help": "The maximum distance channels can be in metres. Any channel longer than this distance will be discarded."},
]
},
@@ -259,7 +260,7 @@ SECTIONS = [
{"name": "OVERSAMPLING", "default": 50, "type": int, "help": "Oversampling factor used in temporal convolutions."},
# TODO: Re-implement this without causing a memory leak
# {"name": "REMOVE_EVENTS", "default": "None", "type": list, "help": "Remove events matching the names provided before generating the Design Matrix"},
{"name": "SHORT_CHANNEL_REGRESSION", "default": True, "type": bool, "depends_on": "SHORT_CHANNEL", "help": "Should short channel regression be used to create the design matrix? This will use the 'signal' from the short channel and regress it out of all other channels."},
{"name": "SHORT_CHANNEL_REGRESSION", "default": True, "type": bool, "depends_on": "SHORT_CHANNELS", "help": "Should short channel regression be used to create the design matrix? This will use the 'signal' from the short channel and regress it out of all other channels."},
]
},
{
@@ -349,18 +350,109 @@ class SavingOverlay(QDialog):
class GroupAssignmentDialog(QDialog):
"""Dialog allowing users to create groups and assign unique metadata values to them."""
def __init__(self, parent=None, unique_values=None, field_name="AGE"):
super().__init__(parent)
self.setWindowTitle(f"Assign Groups by {field_name}")
self.resize(520, 420)
self.unique_values = unique_values or []
self.field_name = field_name
# Initial default groups
self.groups = ["Group A", "Group B"]
self.combos = []
self._init_ui()
def _init_ui(self):
layout = QVBoxLayout(self)
# Header info
info_label = QLabel(
f"Found <b>{len(self.unique_values)}</b> unique <i>{self.field_name}</i> value(s) "
f"across loaded files.<br>Create custom group names and assign each value below:"
)
info_label.setWordWrap(True)
layout.addWidget(info_label)
# Group Creation Bar
group_box_layout = QHBoxLayout()
self.group_input = QLineEdit()
self.group_input.setPlaceholderText(
"Enter new group name (e.g. Infants, Control)..."
)
self.group_input.returnPressed.connect(self._add_group)
add_btn = QPushButton("Add Group")
add_btn.clicked.connect(self._add_group)
group_box_layout.addWidget(self.group_input)
group_box_layout.addWidget(add_btn)
layout.addLayout(group_box_layout)
# Mapping Table (Values -> Group Dropdown)
self.table = QTableWidget(len(self.unique_values), 2)
self.table.setHorizontalHeaderLabels(
[f"{self.field_name} Value", "Assigned Group"]
)
self.table.horizontalHeader().setSectionResizeMode(
QHeaderView.ResizeMode.Stretch
)
for row, val in enumerate(self.unique_values):
# Metadata Value Column (Read-Only)
val_item = QTableWidgetItem(str(val))
val_item.setFlags(val_item.flags() ^ Qt.ItemFlag.ItemIsEditable)
self.table.setItem(row, 0, val_item)
# Assigned Group Column (ComboBox)
combo = QComboBox()
self.combos.append(combo)
self.table.setCellWidget(row, 1, combo)
self._refresh_combos()
layout.addWidget(self.table)
# Dialog Buttons
buttons = QDialogButtonBox(
QDialogButtonBox.StandardButton.Ok
| QDialogButtonBox.StandardButton.Cancel
)
buttons.accepted.connect(self.accept)
buttons.rejected.connect(self.reject)
layout.addWidget(buttons)
def _add_group(self):
"""Adds a new group to the available options."""
name = self.group_input.text().strip()
if name and name not in self.groups:
self.groups.append(name)
self.group_input.clear()
self._refresh_combos()
def _refresh_combos(self):
"""Refreshes all dropdown choices while preserving active selections."""
for combo in self.combos:
current_selection = combo.currentText()
combo.clear()
combo.addItem("-- Select Group --")
combo.addItems(self.groups)
# Preserve previous selection if it still exists
if current_selection in self.groups:
combo.setCurrentText(current_selection)
def get_mappings(self) -> dict:
"""Returns a mapping dictionary: { metadata_value: assigned_group_name }"""
mappings = {}
for row, val in enumerate(self.unique_values):
assigned = self.combos[row].currentText()
if assigned and assigned != "-- Select Group --":
mappings[str(val)] = assigned
return mappings
@@ -389,6 +481,8 @@ class ProgressBubble(QWidget):
self.spinner_idx = 0
self.is_loading = False
self.base_text = display_name
self.status_icon = ""
self.suffix_text = ""
self.label.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.label.setStyleSheet("""
QLabel {
@@ -426,6 +520,17 @@ class ProgressBubble(QWidget):
# Resize policy to make bubbles responsive
self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Minimum)
def _update_label_text(self):
"""Combines base text, green checkmark/spinner, and metadata into one display label."""
text = self.base_text
if self.status_icon:
text += f" {self.status_icon}"
if self.suffix_text:
text += f" ({self.suffix_text})"
self.label.setText(text)
def set_loading_state(self, loading=True):
self.is_loading = loading
if loading:
@@ -433,7 +538,15 @@ class ProgressBubble(QWidget):
else:
self.loading_timer.stop()
# Transition to a green checkmark
self.setSuffixText(" <span style='color: green;'>✔</span>")
self.status_icon = "<span style='color: green;'>✔</span>"
self._update_label_text()
def setSuffixText(self, suffix):
"""Updates the metadata text portion without destroying the checkmark."""
self.suffix_text = suffix if suffix else ""
self._update_label_text()
def update_progress(self, step_index, active=True):
self.current_step = step_index
@@ -446,10 +559,16 @@ class ProgressBubble(QWidget):
else:
rect.setStyleSheet("background-color: white; border: 1px solid gray;")
def mark_cancelled(self):
if 0 <= self.current_step < len(self.rects):
rect = self.rects[self.current_step]
rect.setStyleSheet("background-color: red; border: 1px solid gray;")
for i, rect in enumerate(self.rects):
if i < self.current_step:
rect.setStyleSheet("background-color: green; border: 1px solid gray;")
elif i == self.current_step:
rect.setStyleSheet("background-color: red; border: 1px solid gray;")
else:
rect.setStyleSheet("background-color: white; border: 1px solid gray;")
def mousePressEvent(self, event):
if event.button() == Qt.MouseButton.LeftButton:
@@ -458,17 +577,13 @@ class ProgressBubble(QWidget):
self.rightClicked.emit(self, event.globalPosition().toPoint())
super().mousePressEvent(event)
def setSuffixText(self, suffix):
if suffix:
self.label.setText(f"{self.base_text} {suffix}")
else:
self.label.setText(self.base_text)
def _rotate_spinner(self):
frame = self.spinner_frames[self.spinner_idx % len(self.spinner_frames)]
# Using HTML in setText allows us to style the spinner specifically
self.setSuffixText(f" <span style='color: #555;'>{frame}</span>")
self.status_icon = f"<span style='color: #555;'>{frame}</span>"
self.spinner_idx += 1
self._update_label_text()
@@ -1280,29 +1395,42 @@ class MainApplication(QMainWindow):
if not hasattr(self, 'pending_files_count'): self.pending_files_count = 0
self.pending_files_count += len(new_files)
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)
bubble = ProgressBubble(display_name, path)
bubble.setCursor(Qt.CursorShape.WaitCursor)
bubble.set_loading_state(True)
self.bubble_widgets[path] = bubble
self.bubble_layout.addWidget(bubble)
# 4. Queue the background work
future = self.file_executor.submit(_extract_metadata_worker, path)
# Use lambda with defaults to freeze the path and session at this moment
future.add_done_callback(
lambda f, p=path, s=current_session: self._on_metadata_ready(f, p, s)
)
self.button1.setVisible(True)
self.statusBar().showMessage(f"Loading {len(new_files)} new file(s)...")
# Queue chunked widget creation
CHUNK_SIZE = 10
def process_chunk(file_queue):
# Extract the next batch of files to build
chunk = file_queue[:CHUNK_SIZE]
remaining = file_queue[CHUNK_SIZE:]
for path in chunk:
self.selected_paths.append(path)
self.add_to_recent_files(path)
display_name = os.path.basename(path)
bubble = ProgressBubble(display_name, path)
bubble.setCursor(Qt.CursorShape.WaitCursor)
bubble.set_loading_state(True)
self.bubble_widgets[path] = bubble
self.bubble_layout.addWidget(bubble)
# Submit background task as each bubble is constructed
future = self.file_executor.submit(_extract_metadata_worker, path)
future.add_done_callback(
lambda f, p=path, s=current_session: self._on_metadata_ready(f, p, s)
)
# If more files remain, schedule the next batch without locking the UI
if remaining:
QTimer.singleShot(0, lambda: process_chunk(remaining))
# Trigger the first batch
process_chunk(new_files)
# TODO: Is this needed?
# def open_multiple_folders_dialog(self):
@@ -1779,12 +1907,25 @@ class MainApplication(QMainWindow):
self.statusBar().showMessage(msg)
def get_suffix_from_meta_fields(self):
def get_suffix_from_meta_fields(self, data_dict=None):
"""
Returns formatted suffix string.
If data_dict is passed, uses stored dictionary values.
Otherwise, reads directly from live UI QLineEdits.
"""
parts = []
for key, line_edit in self.meta_fields.items():
val = line_edit.text().strip()
if val:
parts.append(f"{key}: {val}")
if data_dict is not None:
for key in self.meta_fields.keys():
val = str(data_dict.get(key, '')).strip()
if val:
parts.append(f"{key}: {val}")
else:
for key, line_edit in self.meta_fields.items():
val = line_edit.text().strip()
if val:
parts.append(f"{key}: {val}")
return ", ".join(parts)
def on_bubble_clicked(self, bubble):
@@ -2117,7 +2258,6 @@ 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
@@ -2150,7 +2290,13 @@ class MainApplication(QMainWindow):
self.statusbar.showMessage("Task started in separate process.")
def _format_elapsed(self, seconds: float) -> str:
seconds = int(seconds)
h, rem = divmod(seconds, 3600)
m, s = divmod(rem, 60)
if h:
return f"{h:d}:{m:02d}:{s:02d}"
return f"{m:02d}:{s:02d}"
def check_for_pipeline_results(self):
try:
@@ -2176,14 +2322,25 @@ class MainApplication(QMainWindow):
for item, value in zip(DATA_SCHEMA, results):
getattr(self, item["key"])[file_path] = value
self.statusbar.showMessage(f"Processed: {os.path.basename(file_path)}")
elapsed_str = self._format_elapsed(getattr(self, "_last_elapsed", 0))
self.statusbar.showMessage(
f"Processed: {os.path.basename(file_path)} | Elapsed: {elapsed_str}"
)
else:
self.files_failed.add(file_path)
error_msg = msg.get("error", "Unknown worker error")
print(f"[DEBUG] File Failed: {os.path.basename(file_path)} - {error_msg}")
self.mark_file_failed(file_path)
self.show_error_popup(f"Error: {file_path}", error_msg, msg.get("traceback", ""))
self.statusbar.showMessage(f"Failed: {os.path.basename(file_path)}")
elapsed_str = self._format_elapsed(getattr(self, "_last_elapsed", 0))
self.statusbar.showMessage(
f"Failed: {os.path.basename(file_path)} | Elapsed: {elapsed_str}"
)
elif isinstance(msg, dict) and msg.get("type") == "elapsed":
# Live tick, once a second, independent of file completions
self._last_elapsed = msg["seconds"]
self.statusbar.showMessage(f"Processing... Elapsed: {self._format_elapsed(msg['seconds'])}")
elif isinstance(msg, dict) and msg.get("type") == "FINISHED_SUCCESSFULLY":
# The child has finished its work AND its own cleanup.
@@ -2196,7 +2353,12 @@ class MainApplication(QMainWindow):
success_count = len(self.files_results)
fail_count = self.files_total - success_count
self.statusbar.showMessage(f"Complete: {success_count} succeeded, {fail_count} failed.")
elapsed_str = self._format_elapsed(msg.get("elapsed", getattr(self, "_last_elapsed", 0)))
speedup = msg.get("speedup")
speedup_str = f" | Speedup: {speedup:.1f}x" if speedup else ""
self.statusbar.showMessage(
f"Complete: {success_count} succeeded, {fail_count} failed. | Total time: {elapsed_str}{speedup_str}"
)
if success_count > 0:
self.button3.setVisible(True)
@@ -2214,6 +2376,9 @@ class MainApplication(QMainWindow):
elif isinstance(msg, dict) and (msg.get("success") is False or msg.get("type") == "error"):
file_path = msg.get("file", "Process")
error_msg = msg.get("error", "Unknown error")
if file_path:
self.mark_file_failed(file_path)
self.files_done.add(file_path)
self.show_error_popup(f"Error: {file_path}", error_msg, msg.get("traceback", ""))
self.files_done.add(file_path)
if msg.get("success") is False: # Fatal crash
@@ -2233,6 +2398,14 @@ class MainApplication(QMainWindow):
self.result_timer.stop()
def mark_file_failed(self, file_path):
if not file_path:
return
key = os.path.normpath(file_path)
bubble = self.bubble_widgets.get(key)
if bubble:
bubble.mark_cancelled()
def show_error_popup(self, title, error_message, traceback_str=""):
msgbox = QMessageBox(self)
msgbox.setIcon(QMessageBox.Warning)
@@ -2305,6 +2478,23 @@ class MainApplication(QMainWindow):
# Gracefully shut down multiprocessing children
print("Window is closing. Cleaning up...")
if hasattr(self, 'loading_session_id'):
self.loading_session_id += 1
if hasattr(self, 'file_executor') and self.file_executor is not None:
try:
# cancel_futures=True drops pending tasks (Python 3.9+)
self.file_executor.shutdown(wait=False, cancel_futures=True)
except TypeError:
# Fallback for older Python versions
self.file_executor.shutdown(wait=False)
self.file_executor = None
if hasattr(self, 'result_process') and self.result_process is not None:
if self.result_process.is_alive():
self.result_process.terminate()
self.result_process.join(timeout=0.2)
if hasattr(self, 'manager'):
self.manager.shutdown()
@@ -2322,6 +2512,9 @@ class MainApplication(QMainWindow):
if session_id != self.loading_session_id:
return
if future.cancelled():
return
try:
result = future.result()
@@ -2331,6 +2524,14 @@ class MainApplication(QMainWindow):
elif 'status' not in result:
# Wrap the raw extraction dictionary into our unified UI format
extracted_age = result.get('age', '')
# Save AGE directly into file_metadata tracking
if file_path not in self.file_metadata:
self.file_metadata[file_path] = {}
if extracted_age:
self.file_metadata[file_path]['AGE'] = extracted_age
result = {'status': 'success', 'data': result}
except Exception as e:
@@ -2355,7 +2556,34 @@ class MainApplication(QMainWindow):
return
# Success path
self.metadata_cache[file_path] = result.get('data', result)
data = result.get('data', result)
norm_path = os.path.normpath(file_path)
self.metadata_cache[norm_path] = data
# 3. Store extracted BIDS age into file_metadata store
if not hasattr(self, 'file_metadata'):
self.file_metadata = {}
if norm_path not in self.file_metadata:
self.file_metadata[norm_path] = {}
# 1. Store extracted BIDS metadata into file_metadata map
extracted_age = data.get('age', '')
if extracted_age:
self.file_metadata[norm_path]['AGE'] = str(extracted_age)
# 4. Update ONLY the text suffix on the bubble (spinner stays active!)
bubble = self.bubble_widgets.get(norm_path) or self.bubble_widgets.get(file_path)
if bubble:
suffix = self.get_suffix_from_meta_fields(self.file_metadata[norm_path])
bubble.setSuffixText(suffix)
# DO NOT call set_loading_state(False) here.
# The spinner keeps running while the rest of the pipeline executes.
# 5. Sync active form if this file is currently selected in UI
current_active = getattr(self, 'current_file', None)
if current_active and os.path.normpath(current_active) == norm_path:
self.populate_metadata_fields(file_path)
self.metadata_processed.emit(file_path, session_id)
@@ -2398,6 +2626,70 @@ class MainApplication(QMainWindow):
if self.pending_files_count <= 0:
self._cleanup_executor()
self.statusbar.showMessage("All files loaded sucessfully.")
has_metadata = any(
bool(meta_dict)
for meta_dict in getattr(self, "file_metadata", {}).values()
)
if has_metadata:
reply = QMessageBox.question(
self,
"Metadata Detected",
"Extracted metadata was found in the loaded files. Would you like to assign groups based on the metadata?",
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
QMessageBox.StandardButton.Yes,
)
if reply == QMessageBox.StandardButton.Yes:
# A. Collect all unique non-empty AGE values across loaded files
unique_ages = sorted(
list(
{
str(meta.get("AGE", "")).strip()
for meta in getattr(
self, "file_metadata", {}
).values()
if str(meta.get("AGE", "")).strip()
}
)
)
if not unique_ages:
QMessageBox.information(
self,
"No Groupable Metadata",
"No 'AGE' values were found in the metadata to group.",
)
return
# B. Open Group Assignment Dialog
dialog = GroupAssignmentDialog(
self, unique_values=unique_ages, field_name="AGE"
)
if dialog.exec() == QDialog.DialogCode.Accepted:
mappings = dialog.get_mappings()
# C. Update 'GROUP' in self.file_metadata for matching files
for path_key, meta in self.file_metadata.items():
file_age = str(meta.get("AGE", "")).strip()
if file_age in mappings:
meta["GROUP"] = mappings[file_age]
# D. Update the text on ALL bubbles immediately
for path_key, b_widget in self.bubble_widgets.items():
normalized_k = os.path.normpath(path_key)
meta_dict = self.file_metadata.get(
normalized_k, self.file_metadata.get(path_key, {})
)
suffix = self.get_suffix_from_meta_fields(meta_dict)
b_widget.setSuffixText(suffix)
# E. Refresh form fields if a file is currently active in UI
current_active = getattr(self, "current_file", None)
if current_active:
self.populate_metadata_fields(current_active)
def _cleanup_executor(self):
"""Safely shuts down the executor and clears the reference."""
@@ -2407,6 +2699,51 @@ class MainApplication(QMainWindow):
print("[System] Background worker dismissed. RAM reclaimed.")
def _get_bids_age(snirf_path: str) -> str:
"""Traverses the path of a SNIRF file to extract age from BIDS TSV files."""
path = Path(snirf_path)
# Extract sub-XX and ses-YY labels from the path
sub_id = next((part for part in path.parts if part.startswith("sub-")), None)
ses_id = next((part for part in path.parts if part.startswith("ses-")), None)
if not sub_id:
return ""
# 1. Look for sub-<id>/sub-<id>_sessions.tsv
sub_dir = next((p for p in path.parents if p.name == sub_id), None)
if sub_dir and ses_id:
sessions_tsv = sub_dir / f"{sub_id}_sessions.tsv"
if sessions_tsv.exists():
try:
df = pd.read_csv(sessions_tsv, sep="\t")
# Handle session_id matching whether it includes 'ses-' or not
matching = df[df["session_id"].astype(str).str.replace("ses-", "") == ses_id.replace("ses-", "")]
if not matching.empty and "age" in matching.columns:
val = matching.iloc[0]["age"]
if pd.notna(val):
return str(val)
except Exception:
pass
# 2. Fallback: Check dataset root participants.tsv
bids_root = sub_dir.parent if sub_dir else None
if bids_root:
participants_tsv = bids_root / "participants.tsv"
if participants_tsv.exists():
try:
df = pd.read_csv(participants_tsv, sep="\t")
matching = df[df["participant_id"].astype(str).str.replace("sub-", "") == sub_id.replace("sub-", "")]
if not matching.empty and "age" in matching.columns:
val = matching.iloc[0]["age"]
if pd.notna(val):
return str(val)
except Exception:
pass
return ""
def _extract_metadata_worker(file_name):
"""Runs in the separate worker process. Returns a clean dict."""
@@ -2454,6 +2791,7 @@ def _extract_metadata_worker(file_name):
else:
snirf_info['Annotations'] = "No annotations found"
snirf_info["age"] = _get_bids_age(file_name)
return snirf_info
except Exception as e:
@@ -2473,6 +2811,12 @@ def run_gui_entry_wrapper(config, gui_queue, progress_queue, ack_queue):
"""
Where the processing happens
"""
# TODO: Are these needed?
os.environ["OMP_NUM_THREADS"] = "1"
os.environ["OPENBLAS_NUM_THREADS"] = "1"
os.environ["MKL_NUM_THREADS"] = "1"
os.environ["NUMEXPR_NUM_THREADS"] = "1"
os.environ["VECLIB_MAXIMUM_THREADS"] = "1"
try:
import flares as flares
+2 -2
View File
@@ -10,7 +10,7 @@ from pathlib import Path
import numpy as np
from scipy import linalg
from scipy.special import lpmv, sph_harm
from scipy.special import lpmv, sph_harm_y
from .. import __version__
from .._fiff.compensator import make_compensator
@@ -1487,7 +1487,7 @@ def _sss_basis_basic(exp, coils, mag_scale=100.0, method="standard"):
S_in_out = list()
grads_in_out = list()
# Same spherical harmonic is used for both internal and external
sph = sph_harm(order, degree, az, pol)
sph = sph_harm_y(order, degree, az, pol)
sph_norm = _sph_harm_norm(order, degree)
# Compute complex gradient for all integration points
# in spherical coordinates (Eq. 6). The gradient for rad, az, pol
+85 -5
View File
@@ -33,6 +33,7 @@ class ExportToCSVWidget(CSVUIMixin, FlaresBaseWidget):
design_matrix_dict: dict[str, DataFrame],
contrast_results_dict: dict[str, dict[str, Any]],
group_dict: dict[str, str],
config_dict: dict[str, str],
) -> None:
super().__init__("ExportToCSV")
@@ -43,8 +44,9 @@ class ExportToCSVWidget(CSVUIMixin, FlaresBaseWidget):
# self.design_matrix = design_matrix_dict
# self.contrast_results_dict = contrast_results_dict
# self.group = group_dict
self.config_dict = config_dict
self.setup_csv_ui(["0 (Export Data to CSV)", "1 (CSV for SPARKS)",])
self.setup_csv_ui(["0 (Export Data to CSV)", "1 (CSV for SPARKS)", "2 (Export Configuration to CSV)", "3 (Paragraph of Configuration)"])
def process_request(self):
@@ -91,10 +93,26 @@ class ExportToCSVWidget(CSVUIMixin, FlaresBaseWidget):
success_count += 1
elif idx == 1:
# SPARKS Export
save_path = os.path.join(output_dir, f"{base_filename}_sparks.csv")
sparks_csv_export(haemo_obj, save_path)
success_count += 1
# SPARKS Export
save_path = os.path.join(output_dir, f"{base_filename}_sparks.csv")
sparks_csv_export(haemo_obj, save_path)
success_count += 1
elif idx == 2:
formatted_data = {
os.path.basename(path.replace("\\", "/")): inner_dict
for path, inner_dict in self.config_dict.items()
}
df = DataFrame(formatted_data)
df.index.name = "Parameter"
save_path = os.path.join(output_dir, f"{APP_NAME}_configuration.csv")
df.to_csv(save_path)
success_count += 1
elif idx == 3:
first_params = next(iter(self.config_dict.values()))
magic_string = self.gen_magic_str(first_params)
self.placeholder_label.setText(magic_string)
else:
print(f"No method defined for index {idx}")
@@ -114,3 +132,65 @@ class ExportToCSVWidget(CSVUIMixin, FlaresBaseWidget):
# caller="Video Alignment Tool"
# )
# win.show()
def gen_magic_str(self, all_params):
magic_str = "To start, the data was loaded into the application. "
if all_params['DOWNSAMPLE']:
magic_str += f"The data was downsampled to {all_params['DOWNSAMPLE_FREQUENCY']}hz. "
else:
magic_str += "The data was not downsampled and was retained at its original sampling frequency. "
if all_params['TRIM']:
magic_str += f"The data was then trimmed to only keep {all_params['SECONDS_TO_KEEP']} seconds before the first event, as the earlier data with no events would not be needed. "
else:
magic_str += "The data was not trimmed and was retained at its original length. "
if all_params['OPTODE_PLACEMENT']:
magic_str += f"A visualization check of the optode locations in 3D space was performed to ensure that they were at the correct location. "
if all_params['SHORT_CHANNELS']:
magic_str += f"A short channel(s) was specified with the maximum length of a short channel being {all_params['SHORT_CHANNELS_THRESHOLD']} metres. Channels that were over this distance but under {all_params['LONG_CHANNELS_THRESHOLD']} metres were retained. "
else:
magic_str += f"No short channel was specified. Channels between the lengths of {all_params['SHORT_CHANNELS_THRESHOLD']} and {all_params['LONG_CHANNELS_THRESHOLD']} meters were used with all other channles being discarded. "
if all_params['HEART_RATE']:
magic_str += f"The heart rate of the participant was attempted to be calculated. {all_params['SECONDS_TO_STRIP_HR']} seconds were ignored from the start of the file to avoid motion artifacts. "
magic_str += f"The heart rate had hard limits of {all_params['MAX_LOW_HR']} to {all_params['MAX_HIGH_HR']} and was smoothed across {all_params['SMOOTHING_WINDOW_HR']} samples. "
else:
magic_str += "The heart rate of the participants was not attempted to be calculated. "
if all_params['SCI']:
magic_str += f"The Scalp Coupling Index was calculated over {all_params['SCI_TIME_WINDOW']} second windows. The threshold value was {all_params['SCI_THRESHOLD']}. "
else:
magic_str += "Scalp Coupling Index was not used. "
if all_params['SNR']:
magic_str += f"The Signal to Noise Ratio was calculated. The threshold value was set to {all_params['SNR_THRESHOLD']}db. "
else:
magic_str += "The Signal to Noise Ratio was not used. "
if all_params['PSP']:
magic_str += f"Peak Spectral Power was calculated over {all_params['PSP_TIME_WINDOW']} second windows. The threshold value was {all_params['PSP_THRESHOLD']}. "
else:
magic_str += "Peak Spectral Power was not used. "
if all_params['COEFF_VAR']:
magic_str += f"The Coefficient of Variation was calculated. The threshold value was set to {all_params['COEFF_VAR_THRESHOLD']}. "
else:
magic_str += "The Coefficient of Variation was not used. "
if all_params['MAD']:
magic_str += f"Median Absolute Deviation was calculated. The threshold value was set to {all_params['MAD_THRESHOLD']}. "
else:
magic_str += "Median Absolute Deviation was not used. "
if all_params['PSD_NOISE']:
magic_str += f"Power Spectral Density Noise was calculated with a target frequency divisor of {all_params['TARGET_FREQ_DIV']} and a decibal limit of {all_params['DB_LIMIT']}. "
else:
magic_str += f"Power Spectral Density Noise was not used. "
magic_str += "More coming soon."
return magic_str
+4
View File
@@ -1478,6 +1478,10 @@ class CSVUIMixin:
self.scroll_content = QWidget()
self.grid_layout = QGridLayout(self.scroll_content)
self.scroll.setWidget(self.scroll_content)
self.placeholder_label = QLabel("")
self.grid_layout.addWidget(self.placeholder_label, 0, 0)
self.placeholder_label.setWordWrap(True)
self.placeholder_label.setScaledContents(True)
self.layout.addWidget(self.scroll)
self.thumb_size = QSize(280, 180)
+1 -1
View File
@@ -40,7 +40,7 @@ class ViewerLauncherWidget(QWidget):
("Cross-Group Stats Viewer", CrossGroupStatsWidget, [haemo_dict, cha_dict, df_ind_dict, design_matrix_dict, contrast_results_dict, group_dict, json_location], True),
("Inter-Group Brain and Image Viewer", InterGroupBrainImageWidget, [haemo_dict, cha_dict, df_ind_dict, design_matrix_dict, contrast_results_dict, group_dict], True),
("Cross-Group Brain and Image Viewer", CrossGroupBrainImageWidget, [haemo_dict, df_ind_dict, design_matrix_dict, contrast_results_dict, group_dict], True),
("Export To CSV Viewer", ExportToCSVWidget, [haemo_dict, cha_dict, df_ind_dict, design_matrix_dict, contrast_results_dict, group_dict], True)
("Export To CSV Viewer", ExportToCSVWidget, [haemo_dict, cha_dict, df_ind_dict, design_matrix_dict, contrast_results_dict, group_dict, config_dict], True)
]
layout = QVBoxLayout(self)