11 Commits
Author SHA1 Message Date
tyler defd4ec1c8 1.5.2 time 2026-07-31 01:17:29 -07:00
tyler 3e0d83ca9b implement the new preferences in the backend 2026-07-30 16:15:33 -07:00
tyler ebd13927e2 new preferences and bug fixes 2026-07-30 14:18:26 -07:00
tyler f15a5d9433 improvements for 1.5.2 2026-07-29 17:20:23 -07:00
tyler 8b017005c5 pylance standardization 2026-07-21 00:54:51 -07:00
tyler 2b019c1bc0 preparation for 1.5.1 2026-07-17 23:59:42 -07:00
tyler 0680718398 unhardcoding and general cleanup 2026-07-17 00:07:56 -07:00
tyler 2ff7cda93a more stats 2026-07-15 22:45:07 -07:00
tyler d300dfb7f7 continuation of the rewrite 2026-07-15 21:43:22 -07:00
tyler 12afc5d3bc small rewrite to impose dry principles 2026-07-15 21:21:35 -07:00
tyler ffa14693b3 start to figure out stats 2026-07-15 16:24:13 -07:00
28 changed files with 6405 additions and 3277 deletions
+84 -14
View File
@@ -1,19 +1,89 @@
# 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
- A new options menu item has been added: Regroup Files from Metadata. This will allow the dialog to be opened at a later time with a popup appearing if there is no metadata present
- 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
- New parameters have been added to the right side of the screen! This allows for more flexibility and customizability when processing
- A new Preference Menu option has been added: Show Advanced Parameters. This keeps some of the parameters hidden when not checked. Since this is a preference, it will be saved when reopening the application
- Advanced parameters should only be changed if you know what you are doing, and will have a yellow warning symbol next to them to avoid potential confusion on what parameters are advanced
- Optimized some of the calculations in Scalp Coupling Index to speed up Step 6 by ~25%
- Removed duplicate/redundant calculations in Peak Spectral Power to speed up Step 8 by ~50%
- Changed how the figures are generated when processing to speed up Step 28 by ~85%
- Removed unused methods inside the processing file to slightly speed up application load time
- 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
- Fixed an issue where both 'Update events in snirf file...' windows could not be open at the same time
- Fixed an issue where externally updating a participant's metadata when they were selected would cause the application to crash
- Fixed an issue where there was a redundant checking of dependencies when the parameters on the right side of the screen were being calculated
- Fixed an issue in which clicking menu options too fast would cause the application to hard crash
- 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
- Removed redundant font styling when parameters were changed from their default values
# Version 1.5.1
- 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
- The parameter input dialog in the analysis windows will now be able to scroll down when lots of parameters are required
- When a project is saved, it automatically will populate into the Recent Projects menu. Fixes [Issue 86](https://git.research.dezeeuw.ca/tyler/flares/issues/86)
- The description when clicking "Why are these useful?" underneath the per-file parameters is no longer placeholder text. Fixes [Issue 85](https://git.research.dezeeuw.ca/tyler/flares/issues/85)
- Added a new parameter section "Region of Interest". It has one parameter of "JSON_LOCATION", a json file containing Region of Interest data
- Temporarily removed the parameter REMOVE_EVENTS due to its functionality being removed because of a memory leak
- Fixed participant metadata 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)
- A confirmation popup will now display once the application has been reset to default in addition to the status bar message
- Changed the improper display text of "Cross Validation" to now properly read as "Coefficient of Variation"
- Changed the parameters "CV" and "CV_THRESHOLD" to now be "COEFF_VAR and COEFF_VAR_THRESHOLD"
- Changed the improper display text of "Mean Absolute Deviation" to now properly read as "Median Absolute Deviation"
- Changed the parameters "SHORT_CHANNEL", "SHORT_CHANNEL_THRESH", and "LONG_CHANNEL_THRESH" to now be "SHORT_CHANNELS", "SHORT_CHANNELS_THRESHOLD", and "LONG_CHANNELS_THRESHOLD"
- Changed the parameter section "Channel Variance" to now be "Sensor Dropout" to better reflect the action it performs, not the method employed to calculate it
- Changed the parameters "CHANNEL_VAR" and "CHANNEL_THRESH" to now be "SENSOR_DROPOUT" and "SENSOR_DROPOUT_VARIANCE_THRESHOLD"
- Changed the messaged displayed when the application crashes to better reflect what 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"
- Changed backend code to only load required methods and not load all methods every time
- Fixed string parameters not going blue and bold when their value was different than default
- Fixed all parameters not going blue and bold when a save file is loaded containing values that differ from default
- Fixed a crucial bug where short channels were not being processed and filtered the same way as long channels before being used as regressors
- Fixed a crucial bug where short channels were being presented to the design matrix as normal long channels
- Fixed a crucial bug where long channels could be interpolated from short channels. Short channels are still potentially interpolated from long channels. See [this link](https://git.research.dezeeuw.ca/tyler/flares/issues/80) for more information regarding this issue.
- 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
# 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/)
- 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)
@@ -44,7 +114,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
@@ -70,7 +140,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)
@@ -78,10 +148,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
@@ -91,7 +161,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
@@ -124,7 +194,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
@@ -134,11 +204,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
@@ -177,13 +247,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
@@ -246,7 +316,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.
+73 -3
View File
@@ -1,17 +1,87 @@
# 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
- A new options menu item has been added: Regroup Files from Metadata. This will allow the dialog to be opened at a later time with a popup appearing if there is no metadata present
- 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
- New parameters have been added to the right side of the screen! This allows for more flexibility and customizability when processing
- A new Preference Menu option has been added: Show Advanced Parameters. This keeps some of the parameters hidden when not checked. Since this is a preference, it will be saved when reopening the application
- Advanced parameters should only be changed if you know what you are doing, and will have a yellow warning symbol next to them to avoid potential confusion on what parameters are advanced
- Optimized some of the calculations in Scalp Coupling Index to speed up Step 6 by ~25%
- Removed duplicate/redundant calculations in Peak Spectral Power to speed up Step 8 by ~50%
- Changed how the figures are generated when processing to speed up Step 28 by ~85%
- Removed unused methods inside the processing file to slightly speed up application load time
- 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
- Fixed an issue where both 'Update events in snirf file...' windows could not be open at the same time
- Fixed an issue where externally updating a participant's metadata when they were selected would cause the application to crash
- Fixed an issue where there was a redundant checking of dependencies when the parameters on the right side of the screen were being calculated
- Fixed an issue in which clicking menu options too fast would cause the application to hard crash
- 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
- Removed redundant font styling when parameters were changed from their default values
# Version 1.5.1
- 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
- The parameter input dialog in the analysis windows will now be able to scroll down when lots of parameters are required
- When a project is saved, it automatically will populate into the Recent Projects menu. Fixes [Issue 86](https://git.research.dezeeuw.ca/tyler/flares/issues/86)
- The description when clicking "Why are these useful?" underneath the per-file parameters is no longer placeholder text. Fixes [Issue 85](https://git.research.dezeeuw.ca/tyler/flares/issues/85)
- Added a new parameter section "Region of Interest". It has one parameter of "JSON_LOCATION", a json file containing Region of Interest data
- Temporarily removed the parameter REMOVE_EVENTS due to its functionality being removed because of a memory leak
- Fixed participant metadata 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)
- A confirmation popup will now display once the application has been reset to default in addition to the status bar message
- Changed the improper display text of "Cross Validation" to now properly read as "Coefficient of Variation"
- Changed the parameters "CV" and "CV_THRESHOLD" to now be "COEFF_VAR and COEFF_VAR_THRESHOLD"
- Changed the improper display text of "Mean Absolute Deviation" to now properly read as "Median Absolute Deviation"
- Changed the parameters "SHORT_CHANNEL", "SHORT_CHANNEL_THRESH", and "LONG_CHANNEL_THRESH" to now be "SHORT_CHANNELS", "SHORT_CHANNELS_THRESHOLD", and "LONG_CHANNELS_THRESHOLD"
- Changed the parameter section "Channel Variance" to now be "Sensor Dropout" to better reflect the action it performs, not the method employed to calculate it
- Changed the parameters "CHANNEL_VAR" and "CHANNEL_THRESH" to now be "SENSOR_DROPOUT" and "SENSOR_DROPOUT_VARIANCE_THRESHOLD"
- Changed the messaged displayed when the application crashes to better reflect what 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"
- Changed backend code to only load required methods and not load all methods every time
- Fixed string parameters not going blue and bold when their value was different than default
- Fixed all parameters not going blue and bold when a save file is loaded containing values that differ from default
- Fixed a crucial bug where short channels were not being processed and filtered the same way as long channels before being used as regressors
- Fixed a crucial bug where short channels were being presented to the design matrix as normal long channels
- Fixed a crucial bug where long channels could be interpolated from short channels. Short channels are still potentially interpolated from long channels. See [this link](https://git.research.dezeeuw.ca/tyler/flares/issues/80) for more information regarding this issue.
- 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
# 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/)
- 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)
+2948 -1571
View File
File diff suppressed because it is too large Load Diff
+694 -199
View File
File diff suppressed because it is too large Load Diff
+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
+157
View File
@@ -0,0 +1,157 @@
"""
Filename: crossgroupbrainimage.py
Description: Logic for the Cross-Group Brain & Image analysis window
Note: Compliant with pylance strict type checking
Author: Tyler de Zeeuw
License: GPL-3.0
"""
# Built-in Imports
from pathlib import Path
from typing import Any, cast
# External library imports
from mne.io.base import BaseRaw
import pandas as pd
from pandas import DataFrame
from flares import aggregate_fnirs_group_geometry, plot_2d_3d_contrasts_between_groups
from src.shared.flaresbasewidget import CrossGroupUIMixin, FlaresBaseWidget
from src.shared.shareddata import APP_NAME
PARAMETERIZED_INDEXES: dict[int, list[dict[str, Any]]] = {
0: [
{
"key": "show_optodes",
"label": "Determine what is rendered above the brain. Valid values are 'sensors', 'labels', 'none', 'all'.",
"default": "all",
"type": str,
},
{
"key": "t_or_theta",
"label": "Specify if t values or theta values should be plotted. Valid values are 't', 'theta'",
"default": "theta",
"type": str,
},
{
"key": "show_text",
"label": "Display informative text on the top left corner about the contrast.",
"default": "True",
"type": bool,
},
{
"key": "brain_bounds",
"label": "Graph Upper/Lower Limit",
"default": "1.0",
"type": float,
},
{
"key": "is_3d",
"label": "Should we display the results in a 3D interactive window?",
"default": "True",
"type": bool,
}
],
}
class CrossGroupBrainImageWidget(CrossGroupUIMixin, FlaresBaseWidget):
def __init__(
self,
haemo_dict: dict[str | Path, BaseRaw],
df_ind_dict: dict[str, DataFrame],
design_matrix_dict: dict[str, DataFrame],
contrast_results_dict: dict[str, dict[str, Any]],
group_dict: dict[str, str],
) -> None:
super().__init__("CrossGroupBrainImage")
self.setWindowTitle(f"Cross-Group Brain & Image Viewer - {APP_NAME.upper()}")
self.haemo_dict = haemo_dict
self.df_ind_dict = df_ind_dict
self.design_matrix_dict = design_matrix_dict
self.contrast_results_dict = contrast_results_dict
self.group_dict = group_dict
self.setup_cross_group_ui(["0 (Contrast Image)"])
def process_request(self):
request = self.get_common_request_data(PARAMETERIZED_INDEXES)
if request is None:
return
(selected_event, file_paths_a, file_paths_b, all_selected_paths, selected_indexes, raw_params) = request
param_values = cast(dict[int | str, dict[str, Any]], raw_params)
# Build group-level contrast DataFrames
def concat_group_contrasts(file_paths: list[str], event: str | None) -> pd.DataFrame:
group_df = pd.DataFrame()
for fp in file_paths:
print(f"Looking up contrast for: {fp}")
event_con_dict = self.contrast_results_dict.get(fp, {})
print("Available events for this file:", list(event_con_dict.keys()))
if event and event in event_con_dict:
df = event_con_dict[event]
print(f"Appending contrast df for event: {event}")
group_df = pd.concat([group_df, df], ignore_index=True)
else:
print(f"Event '{event}' not found for {fp}")
return group_df
print("Selected event:", selected_event)
print("File paths A:", file_paths_a)
print("File paths B:", file_paths_b)
contrast_df_a = concat_group_contrasts(file_paths_a, selected_event)
contrast_df_b = concat_group_contrasts(file_paths_b, selected_event)
print("contrast_df_a empty?", contrast_df_a.empty)
print("contrast_df_b empty?", contrast_df_b.empty)
all_raw_objs = [self.haemo_dict.get(fp) for fp in all_selected_paths if self.haemo_dict.get(fp)]
if len(all_raw_objs) > 1:
processed_raw = aggregate_fnirs_group_geometry(all_raw_objs)
elif len(all_raw_objs) == 1 and all_raw_objs[0] is not None:
processed_raw = all_raw_objs[0].copy()
processed_raw.pick(picks="hbo") # type: ignore
else:
processed_raw = None
# Visualizations
for idx in selected_indexes:
if idx == 0:
params = param_values.get(idx, {})
show_optodes = params.get("show_optodes", None)
t_or_theta = params.get("t_or_theta", None)
show_text = params.get("show_text", None)
brain_bounds = params.get("brain_bounds", None)
is_3d = params.get("is_3d", None)
if show_optodes is None or t_or_theta is None or show_text is None or brain_bounds is None or is_3d is None:
print(f"Missing parameters for index {idx}, skipping.")
continue
if not contrast_df_a.empty and not contrast_df_b.empty and processed_raw:
plot_2d_3d_contrasts_between_groups(
contrast_df_a,
contrast_df_b,
raw_haemo=processed_raw,
group_a_name=self.group_a_dropdown.currentText(),
group_b_name=self.group_b_dropdown.currentText(),
is_3d=is_3d,
t_or_theta=t_or_theta,
show_optodes=show_optodes,
show_text=show_text,
brain_bounds=brain_bounds
)
else:
print(f"No method defined for index {idx}")
+335
View File
@@ -0,0 +1,335 @@
"""
Filename: crossgroupstats.py
Description: Cross-Group stats analysis window
Note: Compliant with pylance strict type checking
Author: Tyler de Zeeuw
License: GPL-3.0
"""
# Built-in imports
from pathlib import Path
from typing import Any, cast
# External library imports
import pandas as pd
from pandas import DataFrame
from mne.io.base import BaseRaw
from flares import run_cross_group_contrast_analysis, run_cross_group_laterality_analysis, run_cross_group_second_level_analysis
from src.shared.flaresbasewidget import CrossGroupUIMixin, FlaresBaseWidget
from src.shared.shareddata import APP_NAME
PARAMETERIZED_INDEXES: dict[int, list[dict[str, Any]]] = {
0: [
{
"key": "p_threshold",
"label": "Significance threshold P-value (e.g. 0.05)",
"default": "0.05",
"type": float,
},
{
"key": "min_subjects",
"label": "Minimum number of participants to process",
"default": "3",
"type": int,
},
{
"key": "correction_method",
"label": "Correction method to utilize. Valid values are 'fdr_bh', 'None'",
"default": "fdr_bh",
"type": str,
},
{
"key": "target_chroma",
"label": "Which chroma to target. Valid values are 'hbo', 'hbr'",
"default": "hbo",
"type": str,
},
{
"key": "threshold_topo",
"label": "threshold_topo: TBD",
"default": False,
"type": bool,
}
],
1: [
{
"key": "p_threshold",
"label": "Significance threshold P-value (e.g. 0.05)",
"default": "0.05",
"type": float,
},
{
"key": "min_subjects",
"label": "Minimum number of participants to process",
"default": "3",
"type": int,
},
{
"key": "correction_method",
"label": "Correction method to utilize. Valid values are 'fdr_bh', 'None'",
"default": "None",
"type": str,
},
{
"key": "target_chroma",
"label": "Which chroma to target. Valid values are 'hbo', 'hbr'",
"default": "hbo",
"type": str,
},
{
"key": "roi_a",
"label": "ROI A (e.g. contralateral region name from regions.json)",
"default": [],
"type": list,
},
{
"key": "roi_b",
"label": "ROI B (e.g. ipsilateral region name from regions.json)",
"default": [],
"type": list,
}
],
2: [
{
"key": "p_value",
"label": "Significance threshold P-value (e.g. 0.05)",
"default": "0.05",
"type": float,
},
{
"key": "min_subjects",
"label": "Minimum number of participants to process",
"default": "3",
"type": int,
},
{
"key": "correction_method",
"label": "Correction method to utilize. Valid values are 'fdr_bh', 'None'",
"default": "fdr_bh",
"type": str,
},
{
"key": "target_chroma",
"label": "Which chroma to target. Valid values are 'hbo', 'hbr'",
"default": "hbo",
"type": str,
},
{
"key": "contrast_name",
"label": "Name of the contrast to use",
"default": [],
"type": list,
},
],
}
DESCRIPTION = """0. Raw ROI Comparison (run_cross_group_second_level_analysis)
\nCompares one ROI's raw response magnitude between two independent groups (e.g. control vs. target) for a given condition, using Welch's t-test. A significant result means the two populations differ in this ROI's response magnitude for this condition. It does not tell you whether that difference is a real, localized, task-specific effect or a generic between-population difference - different overall vascular reactivity, arousal, or skull/scalp optical properties can produce the exact same statistical signature, and two independently recruited groups (especially patients vs. healthy controls) are considerably more likely to differ this way than two subsets of one study population.
\nIf you expected a group difference and didn't find one, the most common cause is within-group heterogeneity swallowing a real between-group difference - a "target" population (e.g. a clinical group) is often more variable than a tightly-screened control group, and that added within-group variance directly weakens a between-group t-test even if the group means truly differ. Small per-group sample sizes compound this. It's also possible the true difference between your groups isn't in raw magnitude at all, but in spatial specificity or task-differentiation - which is exactly why the laterality and contrast-comparison methods exist alongside this one; a null result here doesn't rule those out.
\n\n1. Laterality Comparison (run_cross_group_laterality_analysis)
\nComputes each subject's own contralateral-minus-ipsilateral laterality index first, then compares those indices between the two groups with Welch's t-test. A significant result means the degree of spatial specificity/lateralization differs between the two populations - a claim about lateralization itself, harder to explain away as a generic population confound since person-level differences in overall reactivity largely cancel before the group comparison happens. It says nothing about overall response magnitude between groups (a group could have identical laterality but very different raw amplitude), and it only uses subjects who have both the contra and ipsi ROI valid, so it can lose subjects the raw-ROI comparison would have kept.
\nNon-significance here has two likely sources, and it's worth distinguishing them. First, the same covariance issue from the within-group paired test applies across a whole group: if contra/ipsi responses aren't well-correlated within subjects, the laterality index itself is noisier than either ROI alone, and that added noise now has to clear a between-group test on top of it - a double power cost at small N. Second, and more informative if true: the groups may genuinely have similar lateralization but differ in overall magnitude instead, in which case this test correctly returns null while method 4 (raw comparison) should be the one to look at.
\n\n2. Contrast Comparison (run_cross_group_contrast_analysis)
\nCompares a jointly-fit task contrast (e.g. Task A minus Task B, estimated together within each subject's GLM), aggregated to ROI level, between two independent groups. A significant result means one group differentiates between the two tasks more or less than the other does, at this specific ROI - with systemic noise cancelled at the model-fitting stage, the same benefit that makes the within-group version of this method the strongest of that trio. As with the within-group version, it does not by itself say where a difference is localized unless you compare sign/pattern across multiple ROIs - opposite-signed group differences across regions point to something spatially specific, same-signed differences everywhere point to a diffuse/non-specific group difference (e.g. one group simply has stronger contrast responses across the whole head).
\nIf this comes back non-significant despite an expected group difference, check first whether the underlying single-subject contrast estimates are noisy for either group - small per-group N means the joint contrast's precision depends on the same limited subject count as everything else, and a noisy input propagates all the way through the ROI aggregation. It's also possible for a real, localized sub-regional effect to get washed out by ROI averaging itself: if only part of an ROI's channels actually show the group difference while others don't, the inverse-variance-weighted average can dilute it toward null - in that case, a finer-grained ROI definition (splitting the region further) may recover the effect that a coarser ROI averaged away. Finally, FDR correction across every ROI tested reduces power exactly as it does everywhere else in this framework - a real but modest effect can fail to survive correction even when the raw p-value would have looked convincing on its own.
\n\n
\nWhy channels needed to be aggregated into ROIs: Testing every channel independently means paying a steep multiple-comparisons tax - with dozens of channels, FDR/Bonferroni correction demands very large effect sizes to call anything significant, and at small subject counts (n=5) essentially nothing survives even when a real, consistent effect exists. Collapsing channels into a handful of anatomically meaningful ROIs cuts the number of independent tests from a minimum of ~40 down to 2-8, which lets a genuinely present effect actually clear correction. It also matches the scientific question better: you have a hypothesis about regions (contralateral motor cortex, prefrontal cortex), not about individual source-detector pairs, so testing at the ROI level is testing the thing you actually believe in, using inverse-variance weighting so noisier channels contribute less to the region's combined estimate rather than diluting it equally.
\nWhy some analyses needed contrasts instead of raw values: A single condition's GLM beta is only ever measured relative to the model's implicit intercept, and that intercept absorbs whatever's happening for the rest of the recording - including systemic physiology (blood pressure, arousal, general vascular reactivity) that rises during almost any active task, not just the one you care about. Testing a raw "vs. zero" value can't tell a real, localized neural response apart from that shared full-head noise. A contrast - either a within-subject spatial subtraction (ROI A minus ROI B) or a jointly-fit task contrast (Condition A minus Condition B, estimated together in one GLM) cancels out whatever's common to both halves of the subtraction, leaving something closer to the actual differential signal.
\nWhy a minimum subject count is enforced: Every one of these tests is a t-test, and a t-test's ability to detect a real effect (its power) depends heavily on degrees of freedom - at n=5 (df=4), even a fairly large true effect can produce a middling p-value, and at n=2 (df=1) the test is barely meaningful at all regardless of the underlying data. The min_subjects floor exists to stop a channel or ROI from being silently tested (and potentially reported as significant or non-significant) on a sample too small for the resulting p-value to mean anything reliable - it's better to explicitly skip and flag an underpowered channel than to quietly produce a number that looks statistically legitimate but isn't backed by enough independent observations to trust."""
class CrossGroupStatsWidget(CrossGroupUIMixin, FlaresBaseWidget):
def __init__(
self,
haemo_dict: dict[str | Path, BaseRaw],
cha_dict: dict[str, DataFrame],
df_ind_dict: dict[str, DataFrame],
design_matrix_dict: dict[str, DataFrame],
contrast_results_dict: dict[str, dict[str, Any]],
group_dict: dict[str, str],
json_location: str | Path
) -> None:
super().__init__("CrossGroupStats")
self.setWindowTitle(f"Cross-Group Stats Viewer - {APP_NAME.upper()}")
self.haemo_dict = haemo_dict
self.cha_dict = cha_dict
self.df_ind_dict = df_ind_dict
self.design_matrix_dict = design_matrix_dict
self.contrast_results_dict = contrast_results_dict
# self.group_dict = group_dict
self.json_location = json_location
self.setup_cross_group_ui(["0 (Raw ROI Comparison)", "1 (Laterality Comparison)", "2 (Contrast Comparison)",], placeholder_text=DESCRIPTION)
def process_request(self):
request = self.get_common_request_data(PARAMETERIZED_INDEXES, self.json_location, self.contrast_results_dict)
if request is None:
return
(selected_event, file_paths_a, file_paths_b, _, selected_indexes, raw_params) = request
param_values = cast(dict[int | str, dict[str, Any]], raw_params)
valid_dfs = [df for df in self.df_ind_dict.values() if not df.empty]
if valid_dfs:
df_ind_combined = pd.concat(valid_dfs, ignore_index=True)
else:
df_ind_combined = pd.DataFrame()
valid_chas = [df for df in self.cha_dict.values() if not df.empty]
cha_combined = pd.concat(valid_chas, ignore_index=True) if valid_chas else pd.DataFrame()
sample_path = file_paths_a[0]
p_haemo = self.haemo_dict.get(sample_path)
# Visualizations
for idx in selected_indexes:
if idx == 0:
params = param_values.get(idx, {})
p_threshold = params.get("p_threshold", 0.05)
min_subjects = params.get("min_subjects", 3)
correction_method = params.get("correction_method", "fdr_bh")
target_chroma = params.get("target_chroma", "hbo")
threshold_topo = params.get("threshold_topo", False)
run_cross_group_second_level_analysis(
df_roi_all=df_ind_combined, # Individual stats dataframe
file_paths_a=file_paths_a,
file_paths_b=file_paths_b,
group_a_name=self.group_a_dropdown.currentText(),
group_b_name=self.group_b_dropdown.currentText(),
df_cha_all=cha_combined,
raw_haemo=p_haemo,
p_threshold=p_threshold,
min_subjects=min_subjects,
correction_method=correction_method,
target_chroma=target_chroma,
selected_event=selected_event,
roi_config=self.json_location,
threshold_topo=threshold_topo # Shows the raw difference map (Unthresholded)
)
elif idx == 1:
if not selected_event:
print("Laterality comparison requires a specific event/condition "
"to be selected first.")
continue
params = param_values.get(idx, {})
p_threshold = params.get("p_threshold", 0.05)
min_subjects = params.get("min_subjects", 3)
correction_method = params.get("correction_method", "None")
target_chroma = params.get("target_chroma", "hbo")
roi_a: str = params.get("roi_a", "").strip()
roi_b: str = params.get("roi_b", "").strip()
if not roi_a or not roi_b:
print("Both a contralateral and ipsilateral ROI name must be specified.")
continue
if correction_method == "None":
correction_method = None
# Build each group's dataframe directly from the dict using
# the file-path lists as keys - no ID cleaning/matching needed.
def _build_group_df(
file_paths: list[str],
dict_source: dict[str, DataFrame]
) -> DataFrame:
valid_dfs = [
dict_source[fp] for fp in file_paths
if fp in dict_source and not dict_source[fp].empty
]
return pd.concat(valid_dfs, ignore_index=True) if valid_dfs else pd.DataFrame()
df_roi_a = _build_group_df(file_paths_a, self.df_ind_dict)
df_roi_b = _build_group_df(file_paths_b, self.df_ind_dict)
if df_roi_a.empty or df_roi_b.empty:
print("No ROI data (df_ind) found for one or both groups.")
continue
run_cross_group_laterality_analysis(
df_roi_all_a=df_roi_a,
df_roi_all_b=df_roi_b,
roi_pairs=(roi_a, roi_b),
condition=selected_event,
group_a_name=self.group_a_dropdown.currentText(),
group_b_name=self.group_b_dropdown.currentText(),
target_chroma=target_chroma,
min_subjects=min_subjects,
p_threshold=p_threshold,
correction_method=correction_method,
roi_contra_label=roi_a,
roi_ipsi_label=roi_b,
)
elif idx == 2:
params = param_values.get(idx, {})
p_threshold = params.get("p_threshold", 0.05)
min_subjects = params.get("min_subjects", 3)
correction_method = params.get("correction_method", "fdr_bh")
target_chroma = params.get("target_chroma", "hbo")
contrast_name = params.get("contrast_name", "")
if not contrast_name:
print("A contrast name must be specified.")
continue
# Build each group's channel-level contrast dataframe
# directly from contrast_results_dict, keyed by file path -
# same dict-key approach as the laterality patch, avoids
# any ID-string matching.
def _build_group_contrast_df(
file_paths: list[str],
contrast_dict: dict[str, dict[str, pd.DataFrame]],
name: str,
) -> pd.DataFrame:
all_rows: list[DataFrame] = []
for fp in file_paths:
condition_dfs = contrast_dict.get(fp)
if condition_dfs is None:
print(f" [MISSING] '{fp}' not found in contrast_results.")
continue
if name in condition_dfs:
df = condition_dfs[name].copy()
df["ID"] = fp
df["contrast_name"] = name
all_rows.append(df)
else:
print(f" [MISSING CONTRAST] '{name}' not available for '{fp}'.")
return pd.concat(all_rows, ignore_index=True) if all_rows else pd.DataFrame()
df_contrasts_a = _build_group_contrast_df(file_paths_a, self.contrast_results_dict, contrast_name)
df_contrasts_b = _build_group_contrast_df(file_paths_b, self.contrast_results_dict, contrast_name)
if df_contrasts_a.empty or df_contrasts_b.empty:
print("No contrast data found for one or both groups.")
continue
run_cross_group_contrast_analysis(
df_contrasts_a=df_contrasts_a,
df_contrasts_b=df_contrasts_b,
contrast_name=contrast_name,
roi_json_path=self.json_location,
group_a_name=self.group_a_dropdown.currentText(),
group_b_name=self.group_b_dropdown.currentText(),
target_chroma=target_chroma,
min_subjects=min_subjects,
p_threshold=p_threshold,
correction_method=correction_method,
)
else:
print("no")
-166
View File
@@ -1,166 +0,0 @@
"""
Filename: exportcsv.py
Description: Export data as csv analysis window for FLARES
Author: Tyler de Zeeuw
License: GPL-3.0
"""
import os
import numpy as np
import pandas as pd
from PySide6.QtWidgets import QFileDialog, QGridLayout, QHBoxLayout, QMessageBox, QPushButton, QScrollArea, QWidget, QVBoxLayout, QLabel
from PySide6.QtCore import QSize
from src.shared.flaresbasewidget import FlaresBaseWidget
from src.shared.shareddata import APP_NAME
class ExportDataAsCSVViewerWidget(FlaresBaseWidget):
def __init__(self, haemo_dict, cha_dict, df_ind, design_matrix, group, contrast_results_dict):
super().__init__("ExportDataAsCSVViewer")
self.setWindowTitle(f"Export Data As CSV Viewer - {APP_NAME.upper()}")
self.haemo_dict = haemo_dict
self.cha_dict = cha_dict
self.df_ind = df_ind
self.design_matrix = design_matrix
self.group = group
self.contrast_results_dict = contrast_results_dict
# Create mappings: file_path -> participant label and dropdown display text
self.participant_map = {} # file_path -> "Participant 1"
self.participant_dropdown_items = [] # "Participant 1 (filename)"
for i, file_path in enumerate(self.haemo_dict.keys(), start=1):
short_label = f"Participant {i}"
display_label = f"{short_label} ({os.path.basename(file_path)})"
self.participant_map[file_path] = short_label
self.participant_dropdown_items.append(display_label)
self.layout = QVBoxLayout(self)
self.top_bar = QHBoxLayout()
self.layout.addLayout(self.top_bar)
self.participant_dropdown = self._create_multiselect_dropdown(self.participant_dropdown_items)
self.participant_dropdown.currentIndexChanged.connect(self.update_participant_dropdown_label)
self.index_texts = [
"0 (Export Data to CSV)",
"1 (CSV for SPARKS)",
# "2 (third image)",
# "3 (fourth image)",
]
self.image_index_dropdown = self._create_multiselect_dropdown(self.index_texts)
self.image_index_dropdown.currentIndexChanged.connect(self.update_image_index_dropdown_label)
self.submit_button = QPushButton("Submit")
self.submit_button.clicked.connect(self.generate_and_save_csv)
self.top_bar.addWidget(QLabel("Participants:"))
self.top_bar.addWidget(self.participant_dropdown)
self.top_bar.addWidget(QLabel("Export Type:"))
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.thumb_size = QSize(280, 180)
self.showMaximized()
def generate_and_save_csv(self):
selected_display_names = self._get_checked_items(self.participant_dropdown)
selected_file_paths = []
for display_name in selected_display_names:
for fp, short_label in self.participant_map.items():
expected_display = f"{short_label} ({os.path.basename(fp)})"
if display_name == expected_display:
selected_file_paths.append(fp)
break
selected_indexes = [
int(s.split(" ")[0]) for s in self._get_checked_items(self.image_index_dropdown)
]
if not selected_file_paths or not selected_indexes:
QMessageBox.warning(self, "Selection Missing", "Please select at least one participant and one export type.")
return
# 2. ASK ONCE: Select Output Directory
output_dir = QFileDialog.getExistingDirectory(self, "Select Output Folder for CSV Exports")
if not output_dir:
print("Export cancelled: No folder selected.")
return
success_count = 0
# Pass the necessary arguments to each method
for file_path in selected_file_paths:
base_filename = os.path.splitext(os.path.basename(file_path))[0]
haemo_obj = self.haemo_dict.get(file_path)
if haemo_obj is None:
continue
cha = self.cha_dict.get(file_path)
for idx in selected_indexes:
try:
if idx == 0:
save_path = os.path.join(output_dir, f"{base_filename}_exported.csv")
if cha is not None:
cha.to_csv(save_path)
success_count += 1
elif idx == 1:
# SPARKS Export
save_path = os.path.join(output_dir, f"{base_filename}_sparks.csv")
if haemo_obj is not None:
raw = haemo_obj
data, times = raw.get_data(return_times=True)
ann_col = np.full(times.shape, "", dtype=object)
if raw.annotations is not None and len(raw.annotations) > 0:
for onset, duration, desc in zip(
raw.annotations.onset,
raw.annotations.duration,
raw.annotations.description
):
mask = (times >= onset) & (times < onset + duration)
ann_col[mask] = desc
df = pd.DataFrame(data.T, columns=raw.ch_names)
df.insert(0, "annotation", ann_col)
df.insert(0, "time", times)
df.to_csv(save_path, index=False)
success_count += 1
else:
print(f"No method defined for index {idx}")
except Exception as e:
print(f"Failed to export {file_path} (Type {idx}): {e}")
# 4. Final Notification
if success_count > 0:
QMessageBox.information(self, "Export Complete", f"Successfully saved {success_count} CSV files to:\n{output_dir}")
# # If SPARKS export was included, show the Event Window once at the end
# if 1 in selected_indexes:
# win = UpdateEventsWindow(
# parent=self,
# mode=EventUpdateMode.WRITE_JSON,
# caller="Video Alignment Tool"
# )
# win.show()
+196
View File
@@ -0,0 +1,196 @@
"""
Filename: exporttocsv.py
Description: Logic for the Export To CSV analysis window
Note: Compliant with pylance strict type checking
Author: Tyler de Zeeuw
License: GPL-3.0
"""
# Built-in imports
import os
from pathlib import Path
from typing import Any
# External library imports
from pandas import DataFrame
from mne.io.base import BaseRaw
from PySide6.QtWidgets import QFileDialog, QMessageBox
from flares import sparks_csv_export
from src.shared.flaresbasewidget import CSVUIMixin, FlaresBaseWidget
from src.shared.shareddata import APP_NAME
class ExportToCSVWidget(CSVUIMixin, FlaresBaseWidget):
def __init__(
self,
haemo_dict: dict[str | Path, BaseRaw],
cha_dict: dict[str, DataFrame],
df_ind_dict: dict[str, DataFrame],
design_matrix_dict: dict[str, DataFrame],
contrast_results_dict: dict[str, dict[str, Any]],
group_dict: dict[str, str],
config_dict: dict[str, str],
) -> None:
super().__init__("ExportToCSV")
self.setWindowTitle(f"Export To CSV Viewer - {APP_NAME.upper()}")
self.haemo_dict = haemo_dict
self.cha_dict = cha_dict
# self.df_ind = df_ind_dict
# 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)", "2 (Export Configuration to CSV)", "3 (Paragraph of Configuration)"])
def process_request(self):
selected_display_names = self._get_checked_items(self.participant_dropdown)
selected_file_paths: list[str] = []
for display_name in selected_display_names:
for fp, short_label in self.participant_map.items():
expected_display = f"{short_label} ({os.path.basename(fp)})"
if display_name == expected_display:
selected_file_paths.append(fp)
break
selected_indexes = [
int(s.split(" ")[0]) for s in self._get_checked_items(self.image_index_dropdown)
]
if not selected_file_paths or not selected_indexes:
QMessageBox.warning(self, "Selection Missing", "Please select at least one participant and one export type.")
return
output_dir = QFileDialog.getExistingDirectory(self, "Select Output Folder for CSV Exports")
if not output_dir:
print("Export cancelled: No folder selected.")
return
success_count = 0
# Pass the necessary arguments to each method
for file_path in selected_file_paths:
base_filename = os.path.splitext(os.path.basename(file_path))[0]
haemo_obj = self.haemo_dict.get(file_path)
if haemo_obj is None:
continue
cha = self.cha_dict.get(file_path)
for idx in selected_indexes:
try:
if idx == 0:
save_path = os.path.join(output_dir, f"{base_filename}_exported.csv")
if cha is not None:
cha.to_csv(save_path)
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
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}")
except Exception as e:
print(f"Failed to export {file_path} (Type {idx}): {e}")
# 4. Final Notification
if success_count > 0:
QMessageBox.information(self, "Export Complete", f"Successfully saved {success_count} CSV files to:\n{output_dir}")
# # If SPARKS export was included, show the Event Window once at the end
# if 1 in selected_indexes:
# win = UpdateEventsWindow(
# parent=self,
# mode=EventUpdateMode.WRITE_JSON,
# 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
-306
View File
@@ -1,306 +0,0 @@
"""
Filename: group.py
Description: Group analysis window for FLARES
Author: Tyler de Zeeuw
License: GPL-3.0
"""
import os
import pandas as pd
from PySide6.QtWidgets import QComboBox, QDialog, QGridLayout, QHBoxLayout, QPushButton, QScrollArea, QWidget, QVBoxLayout, QLabel
from PySide6.QtCore import QSize
from src.shared.flaresbasewidget import FlaresBaseWidget, ParameterInputDialog
from src.shared.shareddata import APP_NAME
class GroupViewerWidget(FlaresBaseWidget):
def __init__(self, haemo_dict, cha, df_ind, design_matrix, contrast_results, group):
super().__init__("GroupViewer")
self.setWindowTitle(f"Group Viewer - {APP_NAME.upper()}")
self.haemo_dict = haemo_dict
self.cha = cha
self.df_ind = df_ind
self.design_matrix = design_matrix
self.contrast_results = contrast_results
self.group = group
self.show_all_events = True
self._updating_checkstates = False
# Create mappings: file_path -> participant label and dropdown display text
self.participant_map = {} # file_path -> "Participant 1"
self.participant_dropdown_items = [] # "Participant 1 (filename)"
for i, file_path in enumerate(self.haemo_dict.keys(), start=1):
short_label = f"Participant {i}"
display_label = f"{short_label} ({os.path.basename(file_path)})"
self.participant_map[file_path] = short_label
self.participant_dropdown_items.append(display_label)
self.layout = QVBoxLayout(self)
self.top_bar = QHBoxLayout()
self.layout.addLayout(self.top_bar)
self.group_to_paths = {}
for file_path, group_name in self.group.items():
self.group_to_paths.setdefault(group_name, []).append(file_path)
self.group_names = sorted(self.group_to_paths.keys())
self.group_dropdown = QComboBox()
self.group_dropdown.addItem("<None Selected>")
self.group_dropdown.addItems(self.group_names)
self.group_dropdown.setCurrentIndex(0)
self.group_dropdown.currentIndexChanged.connect(self.update_participant_list_for_group)
self.participant_dropdown = self._create_multiselect_dropdown(self.participant_dropdown_items)
self.participant_dropdown.currentIndexChanged.connect(self.update_participant_dropdown_label)
self.participant_dropdown.setEnabled(False)
self.event_dropdown = QComboBox()
self.event_dropdown.addItem("<None Selected>")
self.index_texts = [
"0 (GLM Results)",
"1 (Significance)",
"2 (Brain Activity Visualization)",
# "3 (fourth image)",
]
self.image_index_dropdown = self._create_multiselect_dropdown(self.index_texts)
self.image_index_dropdown.currentIndexChanged.connect(self.update_image_index_dropdown_label)
self.submit_button = QPushButton("Submit")
self.submit_button.clicked.connect(self.show_brain_images)
self.top_bar.addWidget(QLabel("Group:"))
self.top_bar.addWidget(self.group_dropdown)
self.top_bar.addWidget(QLabel("Participants:"))
self.top_bar.addWidget(self.participant_dropdown)
self.top_bar.addWidget(QLabel("Event:"))
self.top_bar.addWidget(self.event_dropdown)
self.top_bar.addWidget(QLabel("Image Indexes:"))
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.thumb_size = QSize(280, 180)
self.showMaximized()
def show_brain_images(self):
import flares as flares
selected_event = self.event_dropdown.currentText()
if selected_event == "<None Selected>":
selected_event = None
selected_display_names = self._get_checked_items(self.participant_dropdown)
selected_file_paths = []
for display_name in selected_display_names:
for fp, short_label in self.participant_map.items():
expected_display = f"{short_label} ({os.path.basename(fp)})"
if display_name == expected_display:
selected_file_paths.append(fp)
break
if selected_event:
valid_paths = []
for fp in selected_file_paths:
raw = self.haemo_dict.get(fp)
# Check if this participant actually has the event in their annotations
if raw is not None and hasattr(raw, "annotations"):
if selected_event in raw.annotations.description:
valid_paths.append(fp)
selected_file_paths = valid_paths
selected_indexes = [
int(s.split(" ")[0]) for s in self._get_checked_items(self.image_index_dropdown)
]
if not selected_file_paths:
print("No participants selected.")
return
# Only keep indexes 0 and 1 that need parameters
parameterized_indexes = {
0: [
{
"key": "lower_bound",
"label": "Lower bound + <description>",
"default": "-0.3",
"type": float, # specify int here
},
{
"key": "upper_bound",
"label": "Upper bound + <description>",
"default": "0.8",
"type": float, # specify int here
}
],
1: [
{
"key": "p_value",
"label": "Significance threshold P-value (e.g. 0.05)",
"default": "0.05",
"type": float,
},
{
"key": "graph_bounds",
"label": "Graph Upper/Lower Limit",
"default": "3.0",
"type": float,
}
],
2: [
{
"key": "show_optodes",
"label": "Determine what is rendered above the brain. Valid values are 'sensors', 'labels', 'none', 'all'.",
"default": "all",
"type": str,
},
{
"key": "t_or_theta",
"label": "Specify if t values or theta values should be plotted. Valid values are 't', 'theta'",
"default": "theta",
"type": str,
},
{
"key": "show_text",
"label": "Display informative text on the top left corner. THIS DOES NOT WORK AND SHOULD BE LEFT AT FALSE",
"default": "False",
"type": bool,
},
{
"key": "brain_bounds",
"label": "Graph Upper/Lower Limit",
"default": "1.0",
"type": float,
}
],
}
# Inject full_text from index_texts
for idx, params_list in parameterized_indexes.items():
full_text = self.index_texts[idx] if idx < len(self.index_texts) else f"{idx} (No label found)"
for param_info in params_list:
param_info["full_text"] = full_text
indexes_needing_params = {idx: parameterized_indexes[idx] for idx in selected_indexes if idx in parameterized_indexes}
param_values = {}
if indexes_needing_params:
dialog = ParameterInputDialog(indexes_needing_params, parent=self)
if dialog.exec_() == QDialog.Accepted:
param_values = dialog.get_values()
if param_values is None:
return
else:
return
all_cha = pd.DataFrame()
for file_path in selected_file_paths:
haemo_obj = self.haemo_dict.get(file_path)
if selected_event:
participant_events = set(haemo_obj.annotations.description)
if selected_event not in participant_events:
print(f"Skipping {self.participant_map[file_path]}: Event '{selected_event}' not found.")
continue
if haemo_obj is None:
continue
cha_df = self.cha.get(file_path)
if cha_df is not None:
all_cha = pd.concat([all_cha, cha_df], ignore_index=True)
# Pass the necessary arguments to each method
file_path = selected_file_paths[0]
p_haemo = self.haemo_dict.get(file_path)
p_design_matrix = self.design_matrix.get(file_path)
df_group = pd.DataFrame()
if selected_file_paths:
for file_path in selected_file_paths:
df = self.df_ind.get(file_path)
if df is not None:
df_group = pd.concat([df_group, df], ignore_index=True)
for idx in selected_indexes:
if idx == 0:
params = param_values.get(idx, {})
lower_bound = params.get("lower_bound", None)
upper_bound = params.get("upper_bound", None)
if lower_bound is None or upper_bound is None:
print(f"Missing parameters for index {idx}, skipping.")
continue
flares.plot_fir_model_results(df_group, p_haemo, p_design_matrix, selected_event, lower_bound, upper_bound)
elif idx == 1:
params = param_values.get(idx, {})
p_val = params.get("p_value", None)
graph_bounds = params.get("graph_bounds", None)
if p_val is None or graph_bounds is None:
print(f"Missing parameters for index {idx}, skipping.")
continue
all_contrasts = []
for fp in selected_file_paths:
condition_dfs = self.contrast_results.get(fp, {})
if selected_event in condition_dfs:
df = condition_dfs[selected_event].copy()
df["ID"] = fp
all_contrasts.append(df)
if not all_contrasts:
print("No contrast data found for selected participants and event.")
return
df_contrasts = pd.concat(all_contrasts, ignore_index=True)
flares.run_second_level_analysis(df_contrasts, p_haemo, p_val, graph_bounds)
elif idx == 2:
params = param_values.get(idx, {})
show_optodes = params.get("show_optodes", None)
t_or_theta = params.get("t_or_theta", None)
show_text = params.get("show_text", None)
brain_bounds = params.get("brain_bounds", None)
if show_optodes is None or t_or_theta is None or show_text is None or brain_bounds is None:
print(f"Missing parameters for index {idx}, skipping.")
continue
raw_list = [self.haemo_dict.get(fp) for fp in selected_file_paths]
if len(selected_file_paths) > 1:
print(f"Aggregating geometry for {len(selected_file_paths)} participants...")
processed_raw = flares.aggregate_fnirs_group_geometry(raw_list)
else:
processed_raw = raw_list[0].copy().pick(picks="hbo")
flares.brain_3d_visualization(processed_raw, all_cha, selected_event, t_or_theta=t_or_theta, show_optodes=show_optodes, show_text=show_text, brain_bounds=brain_bounds)
elif idx == 3:
pass
else:
print(f"No method defined for index {idx}")
-311
View File
@@ -1,311 +0,0 @@
"""
Filename: groupbrain.py
Description: Group brain analysis window for FLARES
Author: Tyler de Zeeuw
License: GPL-3.0
"""
import os
import pandas as pd
from PySide6.QtWidgets import QComboBox, QDialog, QGridLayout, QHBoxLayout, QPushButton, QScrollArea, QWidget, QVBoxLayout, QLabel
from PySide6.QtCore import QSize
from src.shared.flaresbasewidget import FlaresBaseWidget, ParameterInputDialog
from src.shared.shareddata import APP_NAME
class GroupBrainViewerWidget(FlaresBaseWidget):
def __init__(self, haemo_dict, df_ind, design_matrix, group, contrast_results_dict):
super().__init__("GroupBrainViewer")
self.setWindowTitle(f"Group Brain Viewer - {APP_NAME.upper()}")
self.haemo_dict = haemo_dict
self.df_ind = df_ind
self.design_matrix = design_matrix
self.group = group
self.contrast_results_dict = contrast_results_dict
self.group_to_paths = {}
for file_path, group_name in self.group.items():
self.group_to_paths.setdefault(group_name, []).append(file_path)
self.group_names = sorted(self.group_to_paths.keys())
self.layout = QVBoxLayout(self)
self.top_bar = QHBoxLayout()
self.layout.addLayout(self.top_bar)
self.group_a_dropdown = QComboBox()
self.group_a_dropdown.addItem("<None Selected>")
self.group_a_dropdown.addItems(self.group_names)
self.group_a_dropdown.currentIndexChanged.connect(self._update_group_a_options)
self.group_b_dropdown = QComboBox()
self.group_b_dropdown.addItem("<None Selected>")
self.group_b_dropdown.addItems(self.group_names)
self.group_b_dropdown.currentIndexChanged.connect(self._update_group_b_options)
self.event_dropdown = QComboBox()
self.event_dropdown.addItem("<None Selected>")
self.participant_dropdown_a = self._create_multiselect_dropdown([])
self.participant_dropdown_a.lineEdit().setPlaceholderText("Select participants (Group A)")
self.participant_dropdown_a.model().itemChanged.connect(self._on_participants_changed)
self.participant_dropdown_b = self._create_multiselect_dropdown([])
self.participant_dropdown_b.lineEdit().setPlaceholderText("Select participants (Group B)")
self.participant_dropdown_b.model().itemChanged.connect(self._on_participants_changed)
self.index_texts = [
"0 (Contrast Image)",
# "1 (3D Brain Contrast)",
# "2 (third image)",
# "3 (fourth image)",
]
self.image_index_dropdown = self._create_multiselect_dropdown(self.index_texts)
self.image_index_dropdown.currentIndexChanged.connect(self.update_image_index_dropdown_label)
self.submit_button = QPushButton("Submit")
self.submit_button.clicked.connect(self.show_brain_images)
self.top_bar.addWidget(QLabel("Group A:"))
self.top_bar.addWidget(self.group_a_dropdown)
self.top_bar.addWidget(QLabel("Participants (Group A):"))
self.top_bar.addWidget(self.participant_dropdown_a)
self.top_bar.addWidget(QLabel("Group B:"))
self.top_bar.addWidget(self.group_b_dropdown)
self.top_bar.addWidget(QLabel("Participants (Group B):"))
self.top_bar.addWidget(self.participant_dropdown_b)
self.top_bar.addWidget(QLabel("Event:"))
self.top_bar.addWidget(self.event_dropdown)
self.top_bar.addWidget(QLabel("Image Indexes:"))
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.thumb_size = QSize(280, 180)
self.showMaximized()
def _update_group_b_options(self):
"""Triggered when Group B changes: Update Group A to exclude B's choice"""
selected_b = self.group_b_dropdown.currentText()
# Refresh Group A and exclude what was just picked in Group B
self._refresh_group_dropdown(self.group_a_dropdown, exclude=selected_b)
# Update the participants for Group B
self.update_participant_list_for_group(selected_b, self.participant_dropdown_b)
self._update_event_dropdown()
def _update_group_a_options(self):
"""Triggered when Group A changes: Update Group B to exclude A's choice"""
selected_a = self.group_a_dropdown.currentText()
# Refresh Group B and exclude what was just picked in Group A
self._refresh_group_dropdown(self.group_b_dropdown, exclude=selected_a)
# Update the participants for Group A
self.update_participant_list_for_group(selected_a, self.participant_dropdown_a)
self._update_event_dropdown()
def _on_participants_changed(self, item=None):
self._update_event_dropdown()
def _refresh_group_dropdown(self, dropdown, exclude):
current = dropdown.currentText()
dropdown.blockSignals(True)
dropdown.clear()
dropdown.addItem("<None Selected>")
for group in self.group_names:
if group != exclude:
dropdown.addItem(group)
# Restore previous selection if still valid
if current != "<None Selected>" and current != exclude and dropdown.findText(current) != -1:
dropdown.setCurrentText(current)
else:
dropdown.setCurrentIndex(0) # Reset to "<None Selected>"
dropdown.blockSignals(False)
def _get_file_paths_from_labels(self, labels, group_name):
file_paths = []
if group_name == self.group_a_dropdown.currentText():
participant_map = self.participant_map_a
elif group_name == self.group_b_dropdown.currentText():
participant_map = self.participant_map_b
else:
return []
# Reverse map: display label -> file path
reverse_map = {
f"{label} ({os.path.basename(fp)})": fp
for fp, label in participant_map.items()
}
for label in labels:
file_path = reverse_map.get(label)
if file_path:
file_paths.append(file_path)
return file_paths
def show_brain_images(self):
import flares as flares
selected_event = self.event_dropdown.currentText()
if selected_event == "<None Selected>":
selected_event = None
# Group A
participants_a = self._get_checked_items(self.participant_dropdown_a)
file_paths_a = self._get_file_paths_from_labels(participants_a, self.group_a_dropdown.currentText())
# Group B
participants_b = self._get_checked_items(self.participant_dropdown_b)
file_paths_b = self._get_file_paths_from_labels(participants_b, self.group_b_dropdown.currentText())
selected_indexes = [
int(s.split(" ")[0]) for s in self._get_checked_items(self.image_index_dropdown)
]
all_selected_paths = list(set(file_paths_a + file_paths_b))
if not all_selected_paths:
print("No participants selected.")
return
parameterized_indexes = {
0: [
{
"key": "show_optodes",
"label": "Determine what is rendered above the brain. Valid values are 'sensors', 'labels', 'none', 'all'.",
"default": "all",
"type": str,
},
{
"key": "t_or_theta",
"label": "Specify if t values or theta values should be plotted. Valid values are 't', 'theta'",
"default": "theta",
"type": str,
},
{
"key": "show_text",
"label": "Display informative text on the top left corner about the contrast.",
"default": "True",
"type": bool,
},
{
"key": "brain_bounds",
"label": "Graph Upper/Lower Limit",
"default": "1.0",
"type": float,
},
{
"key": "is_3d",
"label": "Should we display the results in a 3D interactive window?",
"default": "True",
"type": bool,
}
],
}
# Inject full_text from index_texts
for idx, params_list in parameterized_indexes.items():
full_text = self.index_texts[idx] if idx < len(self.index_texts) else f"{idx} (No label found)"
for param_info in params_list:
param_info["full_text"] = full_text
indexes_needing_params = {idx: parameterized_indexes[idx] for idx in selected_indexes if idx in parameterized_indexes}
param_values = {}
if indexes_needing_params:
dialog = ParameterInputDialog(indexes_needing_params, parent=self)
if dialog.exec_() == QDialog.Accepted:
param_values = dialog.get_values()
if param_values is None:
return
else:
return
# Build group-level contrast DataFrames
def concat_group_contrasts(file_paths: list[str], event: str | None) -> pd.DataFrame:
group_df = pd.DataFrame()
for fp in file_paths:
print(f"Looking up contrast for: {fp}")
event_con_dict = self.contrast_results_dict.get(fp, {})
print("Available events for this file:", list(event_con_dict.keys()))
if event and event in event_con_dict:
df = event_con_dict[event]
print(f"Appending contrast df for event: {event}")
group_df = pd.concat([group_df, df], ignore_index=True)
else:
print(f"Event '{event}' not found for {fp}")
return group_df
print("Selected event:", selected_event)
print("File paths A:", file_paths_a)
print("File paths B:", file_paths_b)
contrast_df_a = concat_group_contrasts(file_paths_a, selected_event)
contrast_df_b = concat_group_contrasts(file_paths_b, selected_event)
print("contrast_df_a empty?", contrast_df_a.empty)
print("contrast_df_b empty?", contrast_df_b.empty)
all_raw_objs = [self.haemo_dict.get(fp) for fp in all_selected_paths if self.haemo_dict.get(fp)]
if len(all_raw_objs) > 1:
processed_raw = flares.aggregate_fnirs_group_geometry(all_raw_objs)
else:
processed_raw = all_raw_objs[0].copy().pick(picks="hbo")
# Visualizations
for idx in selected_indexes:
if idx == 0:
params = param_values.get(idx, {})
show_optodes = params.get("show_optodes", None)
t_or_theta = params.get("t_or_theta", None)
show_text = params.get("show_text", None)
brain_bounds = params.get("brain_bounds", None)
is_3d = params.get("is_3d", None)
if show_optodes is None or t_or_theta is None or show_text is None or brain_bounds is None or is_3d is None:
print(f"Missing parameters for index {idx}, skipping.")
continue
if not contrast_df_a.empty and not contrast_df_b.empty and processed_raw:
flares.plot_2d_3d_contrasts_between_groups(
contrast_df_a,
contrast_df_b,
raw_haemo=processed_raw,
group_a_name=self.group_a_dropdown.currentText(),
group_b_name=self.group_b_dropdown.currentText(),
is_3d=is_3d,
t_or_theta=t_or_theta,
show_optodes=show_optodes,
show_text=show_text,
brain_bounds=brain_bounds
)
else:
print("no")
-188
View File
@@ -1,188 +0,0 @@
"""
Filename: groupfunctionalconnectivity.py
Description: Group functional connectivity analysis window for FLARES
Author: Tyler de Zeeuw
License: GPL-3.0
"""
import os
from PySide6.QtWidgets import QComboBox, QDialog, QGridLayout, QHBoxLayout, QPushButton, QScrollArea, QWidget, QVBoxLayout, QLabel, QMessageBox
from PySide6.QtCore import QSize
from src.shared.flaresbasewidget import FlaresBaseWidget, ParameterInputDialog
from src.shared.shareddata import APP_NAME
class GroupFunctionalConnectivityWidget(FlaresBaseWidget):
def __init__(self, haemo_dict, group, config_dict):
super().__init__("GroupFunctionalConnectivityWidget")
self.setWindowTitle(f"Group Functional Connectivity Viewer [BETA] - {APP_NAME.upper()}")
self.haemo_dict = haemo_dict
self.group = group
self.config_dict = config_dict
self.show_all_events = True
self._updating_checkstates = False
QMessageBox.warning(self, f"Warning - {APP_NAME.upper()}", f"Functional Connectivity is still in development and the results should currently be taken with a grain of salt. "
"By clicking OK, you accept that the images generated may not be factual.")
# Create mappings: file_path -> participant label and dropdown display text
self.participant_map = {} # file_path -> "Participant 1"
self.participant_dropdown_items = [] # "Participant 1 (filename)"
for i, file_path in enumerate(self.haemo_dict.keys(), start=1):
short_label = f"Participant {i}"
display_label = f"{short_label} ({os.path.basename(file_path)})"
self.participant_map[file_path] = short_label
self.participant_dropdown_items.append(display_label)
self.layout = QVBoxLayout(self)
self.top_bar = QHBoxLayout()
self.layout.addLayout(self.top_bar)
self.group_to_paths = {}
for file_path, group_name in self.group.items():
self.group_to_paths.setdefault(group_name, []).append(file_path)
self.group_names = sorted(self.group_to_paths.keys())
self.group_dropdown = QComboBox()
self.group_dropdown.addItem("<None Selected>")
self.group_dropdown.addItems(self.group_names)
self.group_dropdown.setCurrentIndex(0)
self.group_dropdown.currentIndexChanged.connect(self.update_participant_list_for_group)
self.participant_dropdown = self._create_multiselect_dropdown(self.participant_dropdown_items)
self.participant_dropdown.currentIndexChanged.connect(self.update_participant_dropdown_label)
self.participant_dropdown.setEnabled(False)
self.event_dropdown = QComboBox()
self.event_dropdown.addItem("<None Selected>")
self.index_texts = [
"0 (Betas)",
#"1 (Significance)",
#"2 (Brain Activity Visualization)",
# "3 (fourth image)",
]
self.image_index_dropdown = self._create_multiselect_dropdown(self.index_texts)
self.image_index_dropdown.currentIndexChanged.connect(self.update_image_index_dropdown_label)
self.submit_button = QPushButton("Submit")
self.submit_button.clicked.connect(self.show_brain_images)
self.top_bar.addWidget(QLabel("Group:"))
self.top_bar.addWidget(self.group_dropdown)
self.top_bar.addWidget(QLabel("Participants:"))
self.top_bar.addWidget(self.participant_dropdown)
self.top_bar.addWidget(QLabel("Event:"))
self.top_bar.addWidget(self.event_dropdown)
self.top_bar.addWidget(QLabel("Image Indexes:"))
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.thumb_size = QSize(280, 180)
self.showMaximized()
def show_brain_images(self):
import flares as flares
selected_event = self.event_dropdown.currentText()
if selected_event == "<None Selected>":
selected_event = None
selected_display_names = self._get_checked_items(self.participant_dropdown)
selected_file_paths = []
for display_name in selected_display_names:
for fp, short_label in self.participant_map.items():
expected_display = f"{short_label} ({os.path.basename(fp)})"
if display_name == expected_display:
selected_file_paths.append(fp)
break
if selected_event:
valid_paths = []
for fp in selected_file_paths:
raw = self.haemo_dict.get(fp)
# Check if this participant actually has the event in their annotations
if raw is not None and hasattr(raw, "annotations"):
if selected_event in raw.annotations.description:
valid_paths.append(fp)
selected_file_paths = valid_paths
selected_indexes = [
int(s.split(" ")[0]) for s in self._get_checked_items(self.image_index_dropdown)
]
if not selected_file_paths:
print("No participants selected.")
return
# Only keep indexes 0 and 1 that need parameters
parameterized_indexes = {
0: [
{
"key": "n_lines",
"label": "<Description>",
"default": "20",
"type": int,
},
{
"key": "vmin",
"label": "<Description>",
"default": "0.9",
"type": float,
},
],
}
# Inject full_text from index_texts
for idx, params_list in parameterized_indexes.items():
full_text = self.index_texts[idx] if idx < len(self.index_texts) else f"{idx} (No label found)"
for param_info in params_list:
param_info["full_text"] = full_text
indexes_needing_params = {idx: parameterized_indexes[idx] for idx in selected_indexes if idx in parameterized_indexes}
param_values = {}
if indexes_needing_params:
dialog = ParameterInputDialog(indexes_needing_params, parent=self)
if dialog.exec_() == QDialog.Accepted:
param_values = dialog.get_values()
if param_values is None:
return
else:
return
for idx in selected_indexes:
if idx == 0:
params = param_values.get(idx, {})
n_lines = params.get("n_lines", None)
vmin = params.get("vmin", None)
if n_lines is None or vmin is None:
print(f"Missing parameters for index {idx}, skipping.")
continue
flares.run_group_functional_connectivity(self.haemo_dict, self.config_dict, selected_file_paths, selected_event, 50, 0.5)
elif idx == 1:
pass
elif idx == 2:
pass
elif idx == 3:
pass
else:
print(f"No method defined for index {idx}")
+221
View File
@@ -0,0 +1,221 @@
"""
Filename: intergroupbrainimage.py
Description: Logic for the Inter-Group Brain & Image analysis window
Note: Compliant with pylance strict type checking
Author: Tyler de Zeeuw
License: GPL-3.0
"""
# Built-in Imports
from pathlib import Path
from typing import Any, cast
# External library imports
import pandas as pd
from pandas import DataFrame
from mne import Annotations
from mne.io.base import BaseRaw
from flares import aggregate_fnirs_group_geometry, plot_fir_model_results, brain_3d_visualization
from src.shared.flaresbasewidget import InterGroupUIMixin, FlaresBaseWidget
from src.shared.shareddata import APP_NAME
from mne.io import BaseRaw
PARAMETERIZED_INDEXES: dict[int, list[dict[str, Any]]] = {
0: [
{
"key": "lower_bound",
"label": "Lower bound + <description>",
"default": "-0.3",
"type": float, # specify int here
},
{
"key": "upper_bound",
"label": "Upper bound + <description>",
"default": "0.8",
"type": float, # specify int here
}
],
1: [
{
"key": "p_value",
"label": "Significance threshold P-value (e.g. 0.05)",
"default": "0.05",
"type": float,
},
{
"key": "graph_bounds",
"label": "Graph Upper/Lower Limit",
"default": "3.0",
"type": float,
}
],
2: [
{
"key": "show_optodes",
"label": "Determine what is rendered above the brain. Valid values are 'sensors', 'labels', 'none', 'all'.",
"default": "all",
"type": str,
},
{
"key": "t_or_theta",
"label": "Specify if t values or theta values should be plotted. Valid values are 't', 'theta'",
"default": "theta",
"type": str,
},
{
"key": "show_text",
"label": "Display informative text on the top left corner. THIS DOES NOT WORK AND SHOULD BE LEFT AT FALSE",
"default": "False",
"type": bool,
},
{
"key": "brain_bounds",
"label": "Graph Upper/Lower Limit",
"default": "1.0",
"type": float,
}
],
}
class InterGroupBrainImageWidget(InterGroupUIMixin, FlaresBaseWidget):
def __init__(
self,
haemo_dict: dict[str | Path, BaseRaw],
cha_dict: dict[str, DataFrame],
df_ind_dict: dict[str, DataFrame],
design_matrix_dict: dict[str, DataFrame],
contrast_results_dict: dict[str, dict[str, Any]],
group_dict: dict[str, str]
) -> None:
super().__init__("InterGroupBrainImage")
self.setWindowTitle(f"Inter-Group Brain & Image Viewer - {APP_NAME.upper()}")
self.haemo_dict = haemo_dict
self.cha_dict = cha_dict
self.df_ind_dict = df_ind_dict
self.design_matrix_dict = design_matrix_dict
self.contrast_results_dict = contrast_results_dict
# self.group_dict = group_dict
self.setup_inter_group_ui(["0 (GLM Results)", "1 (Significance)", "2 (Brain Activity Visualization)",])
def process_request(self):
request = self.get_common_request_data(PARAMETERIZED_INDEXES)
if request is None:
return
(selected_event, selected_file_paths, selected_indexes, raw_params) = request
param_values = cast(dict[int | str, dict[str, Any]], raw_params)
all_cha = pd.DataFrame()
for file_path in selected_file_paths:
haemo_obj = self.haemo_dict.get(file_path)
if haemo_obj is None:
continue
if selected_event:
raw_annotations = getattr(haemo_obj, "annotations", None)
if raw_annotations is not None:
annotations = cast(Annotations, raw_annotations)
descriptions = cast(list[str], list(annotations.description))
participant_events: set[str] = set(descriptions)
else:
participant_events: set[str] = set()
if selected_event not in participant_events:
print(f"Skipping {self.participant_map[file_path]}: Event '{selected_event}' not found.")
continue
cha_df = self.cha_dict.get(file_path)
if cha_df is not None:
all_cha = pd.concat([all_cha, cha_df], ignore_index=True)
# Pass the necessary arguments to each method
file_path = selected_file_paths[0]
p_haemo = self.haemo_dict.get(file_path)
p_design_matrix = self.design_matrix_dict.get(file_path)
df_group = pd.DataFrame()
if selected_file_paths:
for file_path in selected_file_paths:
df = self.df_ind_dict.get(file_path)
if df is not None:
df_group = pd.concat([df_group, df], ignore_index=True)
for idx in selected_indexes:
if idx == 0:
params = param_values.get(idx, {})
lower_bound = params.get("lower_bound", None)
upper_bound = params.get("upper_bound", None)
if lower_bound is None or upper_bound is None:
print(f"Missing parameters for index {idx}, skipping.")
continue
plot_fir_model_results(df_group, p_haemo, p_design_matrix, selected_event, lower_bound, upper_bound)
elif idx == 1:
params = param_values.get(idx, {})
p_val = params.get("p_value", None)
graph_bounds = params.get("graph_bounds", None)
if p_val is None or graph_bounds is None:
print(f"Missing parameters for index {idx}, skipping.")
continue
all_contrasts: list[DataFrame] = []
for fp in selected_file_paths:
condition_dfs = self.contrast_results_dict.get(fp, {})
if selected_event in condition_dfs:
df = condition_dfs[selected_event].copy()
df["ID"] = fp
all_contrasts.append(df)
if not all_contrasts:
print("No contrast data found for selected participants and event.")
return
# TODO: look at intergroupstats and figure out what to do
_ = pd.concat(all_contrasts, ignore_index=True)
#flares.run_second_level_analysis(df_contrasts, p_haemo, p_val, graph_bounds)
elif idx == 2:
params = param_values.get(idx, {})
show_optodes = params.get("show_optodes", None)
t_or_theta = params.get("t_or_theta", None)
show_text = params.get("show_text", None)
brain_bounds = params.get("brain_bounds", None)
if show_optodes is None or t_or_theta is None or show_text is None or brain_bounds is None:
print(f"Missing parameters for index {idx}, skipping.")
continue
all_raw_objs = [self.haemo_dict.get(fp) for fp in selected_file_paths if self.haemo_dict.get(fp)]
if len(all_raw_objs) > 1:
processed_raw = aggregate_fnirs_group_geometry(all_raw_objs)
elif len(all_raw_objs) == 1 and all_raw_objs[0] is not None:
processed_raw = all_raw_objs[0].copy()
processed_raw.pick(picks="hbo") # type: ignore
else:
processed_raw = None
brain_3d_visualization(processed_raw, all_cha, selected_event, t_or_theta=t_or_theta, show_optodes=show_optodes, show_text=show_text, brain_bounds=brain_bounds)
elif idx == 3:
pass
else:
print(f"No method defined for index {idx}")
@@ -0,0 +1,84 @@
"""
Filename: intergroupfunctionalconnectivity.py
Description: Logic for the Inter-Group Functional Connectivity analysis window
Note: Compliant with pylance strict type checking
Author: Tyler de Zeeuw
License: GPL-3.0
"""
# Built-in imports
from pathlib import Path
from typing import Any, cast
# External library imports
from PySide6.QtWidgets import QMessageBox
from mne.io.base import BaseRaw
from flares import run_group_functional_connectivity
from src.shared.flaresbasewidget import InterGroupUIMixin, FlaresBaseWidget
from src.shared.shareddata import APP_NAME
PARAMETERIZED_INDEXES: dict[int, list[dict[str, Any]]] = {
0: [
{
"key": "n_lines",
"label": "<Description>",
"default": "20",
"type": int,
},
{
"key": "vmin",
"label": "<Description>",
"default": "0.9",
"type": float,
},
],
}
class InterGroupFunctionalConnectivityWidget(InterGroupUIMixin, FlaresBaseWidget):
def __init__(
self,
haemo_dict: dict[str | Path, BaseRaw],
group_dict: dict[str, str],
config_dict: dict[str, str]
) -> None:
super().__init__("InterGroupFunctionalConnectivity")
self.setWindowTitle(f"Inter-Group Functional Connectivity Viewer [BETA] - {APP_NAME.upper()}")
self.haemo_dict = haemo_dict
#self.group_dict = group_dict
self.config_dict = config_dict
QMessageBox.warning(self, f"Warning - {APP_NAME.upper()}", f"Functional Connectivity is still in development and the results should currently be taken with a grain of salt. "
"By clicking OK, you accept that the images generated may not be factual.")
self.setup_inter_group_ui(["0 (Betas)",])
def process_request(self):
request = self.get_common_request_data(PARAMETERIZED_INDEXES)
if request is None:
return
(selected_event, selected_file_paths, selected_indexes, raw_params) = request
param_values = cast(dict[int | str, dict[str, Any]], raw_params)
for idx in selected_indexes:
if idx == 0:
params = param_values.get(idx, {})
n_lines = params.get("n_lines", None)
vmin = params.get("vmin", None)
if n_lines is None or vmin is None:
print(f"Missing parameters for index {idx}, skipping.")
continue
run_group_functional_connectivity(self.haemo_dict, self.config_dict, selected_file_paths, selected_event, 50, 0.5)
else:
print(f"No method defined for index {idx}")
+396
View File
@@ -0,0 +1,396 @@
"""
Filename: intergroupstats.py
Description: Logic for the Inter-Group Stats analysis window
Note: Compliant with pylance strict type checking
Author: Tyler de Zeeuw
License: GPL-3.0
"""
# Built-in imports
from pathlib import Path
from typing import Any, cast
# External library imports
import pandas as pd
from pandas import DataFrame
from mne import Annotations
from mne.io.base import BaseRaw
from flares import run_roi_paired_contrast_analysis, run_roi_second_level_analysis, aggregate_channel_contrasts_to_roi
from src.shared.flaresbasewidget import InterGroupUIMixin, FlaresBaseWidget
from src.shared.shareddata import APP_NAME
PARAMETERIZED_INDEXES: dict[int, list[dict[str, Any]]] = {
0: [
{
"key": "p_threshold",
"label": "Significance threshold P-value (e.g. 0.05)",
"default": "0.05",
"type": float,
},
{
"key": "min_subjects",
"label": "Minimum number of participants to process",
"default": "5",
"type": int,
},
{
"key": "correction_method",
"label": "Correction method to utilize. Valid values are 'fdr_bh', 'None'",
"default": "fdr_bh",
"type": str,
},
{
"key": "target_chroma",
"label": "Which chroma to target. Valid values are 'hbo', 'hbr'",
"default": "hbo",
"type": str,
},
{
"key": "graph_bounds",
"label": "Graph Upper/Lower Limit",
"default": "0.0",
"type": float,
}
],
1: [
{
"key": "p_threshold",
"label": "Significance threshold P-value (e.g. 0.05)",
"default": "0.05",
"type": float,
},
{
"key": "min_subjects",
"label": "Minimum number of participants to process",
"default": "5",
"type": int,
},
{
"key": "correction_method",
"label": "Correction method to utilize. Valid values are 'fdr_bh', 'None'",
"default": "None",
"type": str,
},
{
"key": "target_chroma",
"label": "Which chroma to target. Valid values are 'hbo', 'hbr'",
"default": "hbo",
"type": str,
},
{
"key": "roi_a",
"label": "ROI A (e.g. contralateral region name from regions.json)",
"default": [],
"type": list,
},
{
"key": "roi_b",
"label": "ROI B (e.g. ipsilateral region name from regions.json)",
"default": [],
"type": list,
}
],
2: [
{
"key": "p_value",
"label": "Significance threshold P-value (e.g. 0.05)",
"default": "0.05",
"type": float,
},
{
"key": "min_subjects",
"label": "Minimum number of participants to process",
"default": "5",
"type": int,
},
{
"key": "correction_method",
"label": "Correction method to utilize. Valid values are 'fdr_bh', 'None'",
"default": "fdr_bh",
"type": str,
},
{
"key": "target_chroma",
"label": "Which chroma to target. Valid values are 'hbo', 'hbr'",
"default": "hbo",
"type": str,
},
{
"key": "contrast_name",
"label": "Name of the contrast to use",
"default": [],
"type": list,
},
{
"key": "weighted",
"label": "Use inverse-variance weighting to minimize noisy channels",
"default": True,
"type": bool,
},
{
"key": "graph_bounds",
"label": "Graph Upper/Lower Limit",
"default": "0.0",
"type": float,
},
],
}
DESCRIPTION = """0. ROI vs. Zero (run_roi_second_level_analysis)
\nTests whether one ROI's response during one condition reliably differs from zero across subjects - a one-sample t-test on each subject's ROI-averaged theta. A significant result means the region's signal during this condition is consistently non-zero across your sample, not just noise. It does not tell you whether the response is localized/specific to this region, or whether it reflects real neural activity versus systemic physiology (blood pressure, arousal) shared across the whole head during any active task - a single-condition "vs. zero" test can't distinguish those two explanations on its own.
\nIf you expected significance here and didn't get it, likely causes include: the sample size is simply small relative to between-subject variability in true response magnitude or HRF shape (individual differences in timing/amplitude inflate the variance a t-test divides by); the ROI's channel composition differs slightly across subjects (missing channels get down-weighted or excluded from the inverse-variance average, diluting a real signal); FDR correction across many ROIs is suppressing a modest true effect that would clear an uncorrected threshold; or the condition itself may not reliably engage this region the way you assumed (worth checking the single-subject/individual-level results for this ROI before concluding the group effect isn't there).
\n\n1. Paired ROI Contrast (run_roi_paired_contrast_analysis)
\nFor one condition, subtracts each subject's ROI_A response from their ROI_B response, then tests whether that per-subject difference is reliably non-zero. A significant result is a genuine spatial contrast - the two regions respond differently from each other during this specific condition, with shared systemic noise partially cancelling in the subtraction. It says nothing about whether the condition produced meaningful activity at all (only a relative difference between two places), and its power depends entirely on ROI_A and ROI_B varying together across subjects - an assumption that isn't guaranteed.
\nIf this test underperforms a plain ROI-vs-zero result, which can occur, the most likely explanation is that ROI_A and ROI_B's noise isn't well-correlated across your subjects. The math is variance(A - B) = variance(A) + variance(B) - 2·covariance(A,B): subtraction only helps when the shared/systemic component is large relative to independent noise in each region. If the two regions are picking up largely independent noise sources (motion artifact affecting one side more, different channel quality, etc.), subtracting adds variance rather than removing it, and can turn a detectable single-ROI effect into an underpowered paired one. Small sample size makes this worse, since the covariance itself is poorly estimated with few subjects.
\n\n2. Joint Contrast, ROI-Aggregated (aggregate_channel_contrasts_to_roi + one-sample test)
\nUses a contrast fit jointly within each subject's GLM (Condition A minus Condition B, estimated together), then aggregates that per-channel contrast to ROI level using inverse-variance weighting, and tests it against zero across subjects. A significant result means the two conditions produce reliably different responses at this ROI, with systemic noise largely cancelled at the model-fitting stage itself - the most statistically efficient of the three within-group methods, since the correlation between conditions is handled natively rather than inferred afterward. It does not tell you where the difference is localized on its own - for that, compare the sign/pattern across multiple ROIs: opposite signs across regions indicates a real, spatially-specific effect, while the same sign everywhere suggests diffuse/systemic noise rather than localized activity (as seen when comparing a real task-vs-task contrast against a task-vs-inert-marker contrast).
\nIf this comes back non-significant despite expecting an effect, first check whether the two conditions are actually similar enough in their neural engagement of this ROI that a small or genuinely near-zero contrast is the correct answer - not every ROI should differentiate every pair of tasks, and a null result here can be the right result. Beyond that: FDR correction across every ROI in your regions file can suppress a real but modest contrast; the inverse-variance weighting can be destabilized if a few channels within the ROI have very noisy or near-zero t-statistics (their standard error estimate becomes huge or unstable); and - as always - small subject counts limit the achievable degrees of freedom regardless of how clean the underlying per-channel estimates are.
\n\n
\nWhy channels needed to be aggregated into ROIs: Testing every channel independently means paying a steep multiple-comparisons tax - with dozens of channels, FDR/Bonferroni correction demands very large effect sizes to call anything significant, and at small subject counts (n=5) essentially nothing survives even when a real, consistent effect exists. Collapsing channels into a handful of anatomically meaningful ROIs cuts the number of independent tests from a minimum of ~40 down to 2-8, which lets a genuinely present effect actually clear correction. It also matches the scientific question better: you have a hypothesis about regions (contralateral motor cortex, prefrontal cortex), not about individual source-detector pairs, so testing at the ROI level is testing the thing you actually believe in, using inverse-variance weighting so noisier channels contribute less to the region's combined estimate rather than diluting it equally.
\nWhy some analyses needed contrasts instead of raw values: A single condition's GLM beta is only ever measured relative to the model's implicit intercept, and that intercept absorbs whatever's happening for the rest of the recording - including systemic physiology (blood pressure, arousal, general vascular reactivity) that rises during almost any active task, not just the one you care about. Testing a raw "vs. zero" value can't tell a real, localized neural response apart from that shared full-head noise. A contrast - either a within-subject spatial subtraction (ROI A minus ROI B) or a jointly-fit task contrast (Condition A minus Condition B, estimated together in one GLM) cancels out whatever's common to both halves of the subtraction, leaving something closer to the actual differential signal.
\nWhy a minimum subject count is enforced: Every one of these tests is a t-test, and a t-test's ability to detect a real effect (its power) depends heavily on degrees of freedom - at n=5 (df=4), even a fairly large true effect can produce a middling p-value, and at n=2 (df=1) the test is barely meaningful at all regardless of the underlying data. The min_subjects floor exists to stop a channel or ROI from being silently tested (and potentially reported as significant or non-significant) on a sample too small for the resulting p-value to mean anything reliable - it's better to explicitly skip and flag an underpowered channel than to quietly produce a number that looks statistically legitimate but isn't backed by enough independent observations to trust."""
class InterGroupStatsWidget(InterGroupUIMixin, FlaresBaseWidget):
def __init__(
self,
haemo_dict: dict[str | Path, BaseRaw],
cha_dict: dict[str, DataFrame],
df_ind_dict: dict[str, DataFrame],
design_matrix_dict: dict[str, DataFrame],
contrast_results_dict: dict[str, dict[str, Any]],
group_dict: dict[str, str],
json_location: str | Path
) -> None:
super().__init__("InterGroupStats")
self.setWindowTitle(f"Inter-Group Stats Viewer - {APP_NAME.upper()}")
self.haemo_dict = haemo_dict
self.cha_dict = cha_dict
self.df_ind_dict = df_ind_dict
self.design_matrix_dict = design_matrix_dict
self.contrast_results_dict = contrast_results_dict
self.group_dict = group_dict
self.json_location = json_location
self.setup_inter_group_ui(["0 (ROI vs. Zero)", "1 (Paired ROI Contrast)", "2 (Joint Contrast, ROI-Aggregated)"], placeholder_text=DESCRIPTION)
def process_request(self):
request = self.get_common_request_data(PARAMETERIZED_INDEXES, self.json_location, self.contrast_results_dict)
if request is None:
return
(selected_event, selected_file_paths, selected_indexes, raw_params) = request
param_values = cast(dict[int | str, dict[str, Any]], raw_params)
all_cha = DataFrame()
for file_path in selected_file_paths:
haemo_obj = self.haemo_dict.get(file_path)
if haemo_obj is None:
continue
if selected_event:
raw_annotations = getattr(haemo_obj, "annotations", None)
if raw_annotations is not None:
annotations = cast(Annotations, raw_annotations)
descriptions = cast(list[str], list(annotations.description))
participant_events: set[str] = set(descriptions)
else:
participant_events: set[str] = set()
if selected_event not in participant_events:
print(f"Skipping {self.participant_map[file_path]}: Event '{selected_event}' not found.")
continue
cha_df = self.cha_dict.get(file_path)
if cha_df is not None:
all_cha = pd.concat([all_cha, cha_df], ignore_index=True)
file_path = selected_file_paths[0]
p_haemo = self.haemo_dict.get(file_path)
# Concatenate individual ROI stats (df_ind) for all chosen subjects
df_group = DataFrame()
if selected_file_paths:
for file_path in selected_file_paths:
df = self.df_ind_dict.get(file_path)
if df is not None:
df_group = pd.concat([df_group, df], ignore_index=True)
for idx in selected_indexes:
if idx == 0:
params = param_values.get(idx, {})
p_threshold = params.get("p_threshold", 0.05)
min_subjects = params.get("min_subjects", 5)
correction_method = params.get("correction_method", "fdr_bh")
target_chroma = params.get("target_chroma", "hbo")
graph_bounds = params.get("graph_bounds", 0.0)
if correction_method == "None":
correction_method = None
if df_group.empty:
print("No ROI data (df_ind) found for selected participants.")
continue
# Filter down to the selected experimental event/condition
if selected_event:
if 'Condition' in df_group.columns:
df_filtered = df_group[df_group['Condition'] == selected_event]
else:
print("Warning: 'Condition' column not found in ROI data.")
df_filtered = df_group
else:
df_filtered = df_group
if df_filtered.empty:
print(f"No ROI data matches the condition '{selected_event}'.")
continue
all_cha_filtered = DataFrame()
if not all_cha.empty:
if selected_event and 'Condition' in all_cha.columns:
all_cha_filtered = all_cha[all_cha['Condition'] == selected_event]
else:
all_cha_filtered = all_cha
run_roi_second_level_analysis(
df_roi_all=df_filtered,
df_cha_all=all_cha_filtered,
raw_haemo=p_haemo,
p_threshold=p_threshold,
min_subjects=min_subjects,
correction_method=correction_method,
target_chroma=target_chroma,
graph_bounds=graph_bounds if graph_bounds > 0.0 else None,
roi_config=self.json_location
)
elif idx == 1:
params = param_values.get(idx, {})
p_threshold = params.get("p_threshold", 0.05)
min_subjects = params.get("min_subjects", 5)
correction_method = params.get("correction_method", "None")
target_chroma = params.get("target_chroma", "hbo")
roi_a = params.get("roi_a", "").strip()
roi_b = params.get("roi_b", "").strip()
if not selected_event:
print("Paired ROI contrast requires a specific event/condition "
"to be selected - pick one from the Event dropdown first.")
continue
if df_group.empty:
print("No ROI data (df_ind) found for selected participants.")
continue
if correction_method == "None":
correction_method = None
if not roi_a or not roi_b:
print("Both ROI A and ROI B must be specified.")
continue
print(min_subjects)
run_roi_paired_contrast_analysis(
df_roi_all=df_group,
roi_pairs=(roi_a, roi_b),
condition=selected_event,
target_chroma=target_chroma,
min_subjects=min_subjects,
p_threshold=p_threshold,
correction_method=correction_method,
roi_a_label=roi_a,
roi_b_label=roi_b,
)
elif idx == 2:
params = param_values.get(idx, {})
p_threshold = params.get("p_threshold", 0.05)
min_subjects = params.get("min_subjects", 5)
correction_method = params.get("correction_method", "fdr_bh")
target_chroma = params.get("target_chroma", "hbo")
contrast_name = params.get("contrast_name", "")
weighted = params.get("weighted", True)
graph_bounds = params.get("graph_bounds", 0.0)
if not selected_event:
print("Joint contrast ROI analysis requires a specific contrast "
"to be selected from the Event dropdown first.")
continue
if not contrast_name:
print("Contrast name must be specified.")
continue
all_contrasts: list[DataFrame] = []
for fp in selected_file_paths:
condition_dfs = self.contrast_results_dict.get(fp)
if condition_dfs is None:
print(f" [MISSING] '{fp}' not found in contrast_results.")
continue
if contrast_name in condition_dfs:
df = condition_dfs[contrast_name].copy()
df["ID"] = fp
df["contrast_name"] = contrast_name
all_contrasts.append(df)
else:
print(f" [MISSING CONTRAST] '{contrast_name}' not "
f"available for {self.participant_map.get(fp, fp)}.")
if not all_contrasts:
print(f"No contrast data found for '{contrast_name}' "
f"across selected participants.")
continue
df_contrasts = pd.concat(all_contrasts, ignore_index=True)
try:
roi_theta = aggregate_channel_contrasts_to_roi(
df_contrasts,
roi_json_path=self.json_location,
weighted=weighted,
)
except Exception as e:
print(f"Failed to aggregate contrasts to ROI: {e}")
continue
if roi_theta.empty:
print("No ROI-level contrast values could be computed "
"(check regions.json channel names against this montage).")
continue
# TODO: Come back to this
# df_cha_all intentionally omitted (None): the topography
# section of run_roi_second_level_analysis expects
# single-condition Condition values in df_cha_all, which
# doesn't semantically match a contrast name - skip it here
# rather than pass mismatched data.
run_roi_second_level_analysis(
df_roi_all=roi_theta,
df_cha_all=None,
raw_haemo=p_haemo,
p_threshold=p_threshold,
min_subjects=min_subjects,
correction_method=correction_method,
target_chroma=target_chroma,
graph_bounds=graph_bounds if graph_bounds > 0.0 else None,
)
else:
print(f"No method defined for index {idx}")
+50 -108
View File
@@ -1,102 +1,28 @@
"""
Filename: participantbrain.py
Description: Participant brain analysis window for FLARES
Description: Logic for the Participant Brain analysis window
Note: Compliant with pylance strict type checking
Author: Tyler de Zeeuw
License: GPL-3.0
"""
import os
# Built-in imports
from pathlib import Path
from typing import Any, cast
from PySide6.QtWidgets import QComboBox, QDialog, QGridLayout, QHBoxLayout, QPushButton, QScrollArea, QWidget, QVBoxLayout, QLabel
from PySide6.QtCore import QSize
# External library imports
from mne import Annotations
from pandas import DataFrame
from src.shared.flaresbasewidget import FlaresBaseWidget, ParameterInputDialog
from mne.io.base import BaseRaw
from flares import brain_3d_visualization, brain_landmarks_3d
from src.shared.flaresbasewidget import ParticipantUIMixin, FlaresBaseWidget
from src.shared.shareddata import APP_NAME
class ParticipantBrainViewerWidget(FlaresBaseWidget):
def __init__(self, haemo_dict, cha_dict):
super().__init__("ParticipantBrainViewer")
self.setWindowTitle(f"Participant Brain Viewer - {APP_NAME.upper()}")
self.haemo_dict = haemo_dict
self.cha_dict = cha_dict
# Create mappings: file_path -> participant label and dropdown display text
self.participant_map = {} # file_path -> "Participant 1"
self.participant_dropdown_items = [] # "Participant 1 (filename)"
for i, file_path in enumerate(self.haemo_dict.keys(), start=1):
short_label = f"Participant {i}"
display_label = f"{short_label} ({os.path.basename(file_path)})"
self.participant_map[file_path] = short_label
self.participant_dropdown_items.append(display_label)
self.layout = QVBoxLayout(self)
self.top_bar = QHBoxLayout()
self.layout.addLayout(self.top_bar)
self.participant_dropdown = self._create_multiselect_dropdown(self.participant_dropdown_items)
self.participant_dropdown.currentIndexChanged.connect(self.update_participant_dropdown_label)
self.event_dropdown = QComboBox()
self.event_dropdown.addItem("<None Selected>")
self.index_texts = [
"0 (Brain Landmarks)",
"1 (Brain Activity Visualization)",
# "2 (third image)",
# "3 (fourth image)",
]
self.image_index_dropdown = self._create_multiselect_dropdown(self.index_texts)
self.image_index_dropdown.currentIndexChanged.connect(self.update_image_index_dropdown_label)
self.submit_button = QPushButton("Submit")
self.submit_button.clicked.connect(self.show_brain_images)
self.top_bar.addWidget(QLabel("Participants:"))
self.top_bar.addWidget(self.participant_dropdown)
self.top_bar.addWidget(QLabel("Event:"))
self.top_bar.addWidget(self.event_dropdown)
self.top_bar.addWidget(QLabel("Image Indexes:"))
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.thumb_size = QSize(280, 180)
self.showMaximized()
def show_brain_images(self):
import flares as flares
selected_event = self.event_dropdown.currentText()
if selected_event == "<None Selected>":
selected_event = None
selected_display_names = self._get_checked_items(self.participant_dropdown)
selected_file_paths = []
for display_name in selected_display_names:
for fp, short_label in self.participant_map.items():
expected_display = f"{short_label} ({os.path.basename(fp)})"
if display_name == expected_display:
selected_file_paths.append(fp)
break
selected_indexes = [
int(s.split(" ")[0]) for s in self._get_checked_items(self.image_index_dropdown)
]
parameterized_indexes = {
PARAMETERIZED_INDEXES: dict[int, list[dict[str, Any]]] = {
0: [
{
"key": "show_optodes",
@@ -137,39 +63,55 @@ class ParticipantBrainViewerWidget(FlaresBaseWidget):
"type": float,
}
],
}
}
# Inject full_text from index_texts
for idx, params_list in parameterized_indexes.items():
full_text = self.index_texts[idx] if idx < len(self.index_texts) else f"{idx} (No label found)"
for param_info in params_list:
param_info["full_text"] = full_text
indexes_needing_params = {idx: parameterized_indexes[idx] for idx in selected_indexes if idx in parameterized_indexes}
class ParticipantBrainViewerWidget(ParticipantUIMixin, FlaresBaseWidget):
def __init__(
self,
haemo_dict: dict[str | Path, BaseRaw],
cha_dict: dict[str, DataFrame],
) -> None:
param_values = {}
if indexes_needing_params:
dialog = ParameterInputDialog(indexes_needing_params, parent=self)
if dialog.exec_() == QDialog.Accepted:
param_values = dialog.get_values()
if param_values is None:
return
else:
super().__init__("ParticipantBrain")
self.setWindowTitle(f"Participant Brain Viewer - {APP_NAME.upper()}")
self.haemo_dict = haemo_dict
self.cha_dict = cha_dict
self.setup_participant_ui(["0 (Brain Landmarks)", "1 (Brain Activity Visualization)",])
def process_request(self):
request = self.get_common_request_data(PARAMETERIZED_INDEXES)
if request is None:
return
(selected_event, selected_file_paths, selected_indexes, raw_params) = request
param_values = cast(dict[int | str, dict[str, Any]], raw_params)
# Pass the necessary arguments to each method
for file_path in selected_file_paths:
haemo_obj = self.haemo_dict.get(file_path)
if haemo_obj is None:
continue
if selected_event:
participant_events = set(haemo_obj.annotations.description)
raw_annotations = getattr(haemo_obj, "annotations", None)
if raw_annotations is not None:
annotations = cast(Annotations, raw_annotations)
descriptions = cast(list[str], list(annotations.description))
participant_events: set[str] = set(descriptions)
else:
participant_events: set[str] = set()
if selected_event not in participant_events:
print(f"Skipping {self.participant_map[file_path]}: Event '{selected_event}' not found.")
continue
if haemo_obj is None:
raise Exception("How did we get here?")
cha = self.cha_dict.get(file_path)
for idx in selected_indexes:
@@ -183,7 +125,7 @@ class ParticipantBrainViewerWidget(FlaresBaseWidget):
print(f"Missing parameters for index {idx}, skipping.")
continue
flares.brain_landmarks_3d(haemo_obj, show_optodes, show_brodmann)
brain_landmarks_3d(haemo_obj, show_optodes, show_brodmann)
elif idx == 1:
params = param_values.get(idx, {})
@@ -196,7 +138,7 @@ class ParticipantBrainViewerWidget(FlaresBaseWidget):
print(f"Missing parameters for index {idx}, skipping.")
continue
flares.brain_3d_visualization(haemo_obj, cha, selected_event, t_or_theta=t_or_theta, show_optodes=show_optodes, show_text=show_text, brain_bounds=brain_bounds)
brain_3d_visualization(haemo_obj, cha, selected_event, t_or_theta=t_or_theta, show_optodes=show_optodes, show_text=show_text, brain_bounds=brain_bounds)
else:
print(f"No method defined for index {idx}")
+6 -6
View File
@@ -1,6 +1,6 @@
"""
Filename: participantfoldchannels.py
Description: Participant fOLD channels analysis window for FLARES
Description: Logic for the Participant fOLD Channels analysis window
Author: Tyler de Zeeuw
License: GPL-3.0
@@ -69,9 +69,9 @@ def single_participant_worker(file_path, raw_data, result_queue, progress_queue)
""" Runs inside its own dedicated process """
p_name = os.path.basename(file_path)
try:
import flares as flares
from flares import fold_channels
# Perform the heavy fold_channels logic
channel_results = flares.fold_channels(raw_data, p_name, progress_queue)
channel_results = fold_channels(raw=raw_data, p_name=p_name, progress_queue=progress_queue)
# Hand back results and signal completion
result_queue.put({file_path: channel_results})
@@ -736,13 +736,13 @@ class ProcessOrchestrator(QObject):
def run(self):
try:
# 🟢 [Delay 1 Fix] Instantiate Manager completely off the main thread
# Instantiate Manager completely off the main thread
manager = Manager()
result_queue = manager.Queue()
progress_queue = manager.Queue()
active_processes = []
# 🟢 [Delay 2 Fix] Perform heavy pickling loop safely in the background
# Perform heavy pickling loop safely in the background
for file_path in self.selected_files:
p = Process(
target=self.worker_func,
@@ -896,7 +896,7 @@ class ParticipantFoldChannelsWidget(FlaresBaseWidget):
self.progress_queue = progress_queue
self.active_processes = active_processes
# 🟢 Safely initialize and trigger your polling listener
# Safely initialize and trigger the polling listener
self.completed_count = 0
self.result_timer = QTimer()
self.result_timer.timeout.connect(self.check_parallel_results)
+58 -113
View File
@@ -1,105 +1,30 @@
"""
Filename: participantfunctionalconnectivity.py
Description: Participant functional connectivity analysis window for FLARES
Description: Logic for the Participant Functional Connectivity analysis window
Note: Compliant with pylance strict type checking
Author: Tyler de Zeeuw
License: GPL-3.0
"""
import os
# Built-in Imports
from pathlib import Path
from typing import Any, cast
from PySide6.QtWidgets import QComboBox, QDialog, QGridLayout, QHBoxLayout, QPushButton, QScrollArea, QWidget, QVBoxLayout, QLabel, QMessageBox
from PySide6.QtCore import QSize
# External library imports
from PySide6.QtWidgets import QMessageBox
from src.shared.flaresbasewidget import FlaresBaseWidget, ParameterInputDialog
from pandas import DataFrame
from mne import Annotations
from mne.io.base import BaseRaw
from flares import functional_connectivity_betas, functional_connectivity_envelope, functional_connectivity_spectral_epochs, functional_connectivity_spectral_time
from src.shared.flaresbasewidget import ParticipantUIMixin, FlaresBaseWidget
from src.shared.shareddata import APP_NAME
class ParticipantFunctionalConnectivityWidget(FlaresBaseWidget):
def __init__(self, haemo_dict, epochs_dict):
super().__init__("FunctionalConnectivityWidget")
self.setWindowTitle(f"Functional Connectivity Viewer [BETA] - {APP_NAME.upper()}")
self.haemo_dict = haemo_dict
self.epochs_dict = epochs_dict
QMessageBox.warning(self, f"Warning - {APP_NAME.upper()}", f"Functional Connectivity is still in development and the results should currently be taken with a grain of salt. "
"By clicking OK, you accept that the images generated may not be factual.")
# Create mappings: file_path -> participant label and dropdown display text
self.participant_map = {} # file_path -> "Participant 1"
self.participant_dropdown_items = [] # "Participant 1 (filename)"
for i, file_path in enumerate(self.haemo_dict.keys(), start=1):
short_label = f"Participant {i}"
display_label = f"{short_label} ({os.path.basename(file_path)})"
self.participant_map[file_path] = short_label
self.participant_dropdown_items.append(display_label)
self.layout = QVBoxLayout(self)
self.top_bar = QHBoxLayout()
self.layout.addLayout(self.top_bar)
self.participant_dropdown = self._create_multiselect_dropdown(self.participant_dropdown_items)
self.participant_dropdown.currentIndexChanged.connect(self.update_participant_dropdown_label)
self.event_dropdown = QComboBox()
self.event_dropdown.addItem("<None Selected>")
self.index_texts = [
"0 (Spectral Connectivity Epochs)",
"1 (Envelope Correlation)",
"2 (Betas)",
"3 (Spectral Connectivity Epochs)",
]
self.image_index_dropdown = self._create_multiselect_dropdown(self.index_texts)
self.image_index_dropdown.currentIndexChanged.connect(self.update_image_index_dropdown_label)
self.submit_button = QPushButton("Submit")
self.submit_button.clicked.connect(self.show_brain_images)
self.top_bar.addWidget(QLabel("Participants:"))
self.top_bar.addWidget(self.participant_dropdown)
self.top_bar.addWidget(QLabel("Event:"))
self.top_bar.addWidget(self.event_dropdown)
self.top_bar.addWidget(QLabel("Image Indexes:"))
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.thumb_size = QSize(280, 180)
self.showMaximized()
def show_brain_images(self):
import flares as flares
selected_event = self.event_dropdown.currentText()
if selected_event == "<None Selected>":
selected_event = None
selected_display_names = self._get_checked_items(self.participant_dropdown)
selected_file_paths = []
for display_name in selected_display_names:
for fp, short_label in self.participant_map.items():
expected_display = f"{short_label} ({os.path.basename(fp)})"
if display_name == expected_display:
selected_file_paths.append(fp)
break
selected_indexes = [
int(s.split(" ")[0]) for s in self._get_checked_items(self.image_index_dropdown)
]
parameterized_indexes = {
PARAMETERIZED_INDEXES: dict[int, list[dict[str, Any]]] = {
0: [
{
"key": "n_lines",
@@ -159,40 +84,60 @@ class ParticipantFunctionalConnectivityWidget(FlaresBaseWidget):
},
],
}
}
# Inject full_text from index_texts
for idx, params_list in parameterized_indexes.items():
full_text = self.index_texts[idx] if idx < len(self.index_texts) else f"{idx} (No label found)"
for param_info in params_list:
param_info["full_text"] = full_text
indexes_needing_params = {idx: parameterized_indexes[idx] for idx in selected_indexes if idx in parameterized_indexes}
param_values = {}
if indexes_needing_params:
dialog = ParameterInputDialog(indexes_needing_params, parent=self)
if dialog.exec_() == QDialog.Accepted:
param_values = dialog.get_values()
if param_values is None:
return
else:
class ParticipantFunctionalConnectivityWidget(ParticipantUIMixin, FlaresBaseWidget):
def __init__(
self,
haemo_dict: dict[str | Path, BaseRaw],
epochs_dict: dict[str, DataFrame],
) -> None:
super().__init__("ParticipantFunctionalConnectivity")
self.setWindowTitle(f"Participant Functional Connectivity Viewer [BETA] - {APP_NAME.upper()}")
self.haemo_dict = haemo_dict
self.epochs_dict = epochs_dict
QMessageBox.warning(self, f"Warning - {APP_NAME.upper()}", f"Functional Connectivity is still in development and the results should currently be taken with a grain of salt. "
"By clicking OK, you accept that the images generated may not be factual.")
self.setup_participant_ui(["0 (Spectral Connectivity Epochs)", "1 (Envelope Correlation)", "2 (Betas)", "3 (Spectral Connectivity Epochs)",])
def process_request(self):
request = self.get_common_request_data(PARAMETERIZED_INDEXES)
if request is None:
return
(selected_event, selected_file_paths, selected_indexes, raw_params) = request
param_values = cast(dict[int | str, dict[str, Any]], raw_params)
# Pass the necessary arguments to each method
for file_path in selected_file_paths:
haemo_obj = self.haemo_dict.get(file_path)
epochs_obj = self.epochs_dict.get(file_path)
if haemo_obj is None:
continue
if selected_event:
participant_events = set(haemo_obj.annotations.description)
raw_annotations = getattr(haemo_obj, "annotations", None)
if raw_annotations is not None:
annotations = cast(Annotations, raw_annotations)
descriptions = cast(list[str], list(annotations.description))
participant_events: set[str] = set(descriptions)
else:
participant_events: set[str] = set()
if selected_event not in participant_events:
print(f"Skipping {self.participant_map[file_path]}: Event '{selected_event}' not found.")
continue
if haemo_obj is None:
raise Exception("How did we get here?")
for idx in selected_indexes:
if idx == 0:
@@ -204,7 +149,7 @@ class ParticipantFunctionalConnectivityWidget(FlaresBaseWidget):
if n_lines is None or vmin is None:
print(f"Missing parameters for index {idx}, skipping.")
continue
flares.functional_connectivity_spectral_epochs(epochs_obj, n_lines, vmin)
functional_connectivity_spectral_epochs(epochs_obj, n_lines, vmin)
elif idx == 1:
params = param_values.get(idx, {})
@@ -214,7 +159,7 @@ class ParticipantFunctionalConnectivityWidget(FlaresBaseWidget):
if n_lines is None or vmin is None:
print(f"Missing parameters for index {idx}, skipping.")
continue
flares.functional_connectivity_envelope(epochs_obj, n_lines, vmin)
functional_connectivity_envelope(epochs_obj, n_lines, vmin)
elif idx == 2:
params = param_values.get(idx, {})
@@ -224,7 +169,7 @@ class ParticipantFunctionalConnectivityWidget(FlaresBaseWidget):
if n_lines is None or vmin is None:
print(f"Missing parameters for index {idx}, skipping.")
continue
flares.functional_connectivity_betas(haemo_obj, n_lines, vmin, selected_event)
functional_connectivity_betas(haemo_obj, n_lines, vmin, selected_event)
elif idx == 3:
params = param_values.get(idx, {})
@@ -234,7 +179,7 @@ class ParticipantFunctionalConnectivityWidget(FlaresBaseWidget):
if n_lines is None or vmin is None:
print(f"Missing parameters for index {idx}, skipping.")
continue
flares.functional_connectivity_spectral_time(epochs_obj, n_lines, vmin)
functional_connectivity_spectral_time(epochs_obj, n_lines, vmin)
else:
print(f"No method defined for index {idx}")
@@ -1,15 +1,20 @@
"""
Filename: participant.py
Description: Participant analysis window for FLARES
Filename: participantimage.py
Description: Logic for the Participant Image analysis window
Note: Compliant with pylance strict type checking
Author: Tyler de Zeeuw
License: GPL-3.0
"""
import os
# Built-in Imports
import os.path as op
from pathlib import Path
from datetime import datetime
# External library imports
from mne.io.base import BaseRaw
from PySide6.QtWidgets import QGridLayout, QHBoxLayout, QMessageBox, QPushButton, QScrollArea, QWidget, QVBoxLayout, QLabel
from PySide6.QtCore import Qt, QSize
from PySide6.QtGui import QPixmap
@@ -18,27 +23,33 @@ from src.shared.flaresbasewidget import ClickableLabel, FlaresBaseWidget
from src.shared.shareddata import APP_NAME
class ParticipantViewerWidget(FlaresBaseWidget):
def __init__(self, haemo_dict, fig_bytes_dict):
super().__init__("ParticipantViewer")
class ParticipantImageViewerWidget(FlaresBaseWidget):
def __init__(
self,
haemo_dict: dict[str, BaseRaw],
fig_bytes_dict: dict[str, dict[str, bytes]]
) -> None:
super().__init__("ParticipantImage")
self.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose)
self.setWindowTitle(f"Participant Viewer - {APP_NAME.upper()}")
self.setWindowTitle(f"Participant Image Viewer - {APP_NAME.upper()}")
self.haemo_dict = haemo_dict
self.fig_bytes_dict = fig_bytes_dict
# Create mappings: file_path -> participant label and dropdown display text
self.participant_map = {} # file_path -> "Participant 1"
self.participant_dropdown_items = [] # "Participant 1 (filename)"
self.participant_map: dict[str, str] = {}
self.participant_dropdown_items: list[str] = []
for i, file_path in enumerate(self.haemo_dict.keys(), start=1):
short_label = f"Participant {i}"
display_label = f"{short_label} ({os.path.basename(file_path)})"
display_label = f"{short_label} ({op.basename(file_path)})"
self.participant_map[file_path] = short_label
self.participant_dropdown_items.append(display_label)
self.layout = QVBoxLayout(self)
self.main_layout = QVBoxLayout(self)
self.top_bar = QHBoxLayout()
self.layout.addLayout(self.top_bar)
self.main_layout.addLayout(self.top_bar)
self.participant_dropdown = self._create_multiselect_dropdown(self.participant_dropdown_items)
self.participant_dropdown.currentIndexChanged.connect(self.update_participant_dropdown_label)
@@ -58,12 +69,12 @@ class ParticipantViewerWidget(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_area = QScrollArea()
self.scroll_area.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.setWidget(self.scroll_content)
self.main_layout.addWidget(self.scroll_area)
self.thumb_size = QSize(280, 180)
@@ -85,23 +96,24 @@ class ParticipantViewerWidget(FlaresBaseWidget):
selected_display_names = self._get_checked_items(self.participant_dropdown)
# Map from display names back to file paths
selected_file_paths = []
selected_file_paths: list[str] = []
for display_name in selected_display_names:
# Find file_path by matching display name
for fp, short_label in self.participant_map.items():
expected_display = f"{short_label} ({os.path.basename(fp)})"
expected_display = f"{short_label} ({Path(fp).name})"
if display_name == expected_display:
selected_file_paths.append(fp)
selected_file_paths.append(str(fp))
break
selected_labels = self._get_checked_items(self.image_index_dropdown)
row, col = 0, 0
for file_path in selected_file_paths:
fig_list = self.fig_bytes_dict.get(file_path, [])
participant_label = self.participant_map[file_path]
fig_map: dict[str, bytes] = self.fig_bytes_dict.get(file_path, {})
participant_label: str = self.participant_map.get(file_path, "Unknown")
for label in selected_labels:
fig_bytes = fig_list.get(label)
fig_bytes: bytes | None = fig_map.get(label)
if not fig_bytes:
continue
@@ -147,7 +159,7 @@ class ParticipantViewerWidget(FlaresBaseWidget):
for display_name in selected_display_names:
# Match display name to file path
for file_path, short_label in self.participant_map.items():
expected_display = f"{short_label} ({os.path.basename(file_path)})"
expected_display = f"{short_label} ({op.basename(file_path)})"
if display_name == expected_display:
fig_dict = self.fig_bytes_dict.get(file_path, {})
for label in selected_image_labels:
@@ -155,7 +167,7 @@ class ParticipantViewerWidget(FlaresBaseWidget):
continue
fig_bytes = fig_dict[label]
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
filename = f"{os.path.basename(file_path)}_{label}_{timestamp}.png"
filename = f"{op.basename(file_path)}_{label}_{timestamp}.png"
output_path = save_dir / filename
with open(output_path, "wb") as f:
f.write(fig_bytes)
File diff suppressed because it is too large Load Diff
+16 -15
View File
@@ -1,22 +1,27 @@
"""
Filename: shareddata.py
Description: Shared constants and methods for FLARES
Description: Shared constants and methods other files depend on
Note: Compliant with pylance strict type checking
Author: Tyler de Zeeuw
License: GPL-3.0
"""
import sys
# Built-in imports
import os
import sys
import platform
CURRENT_VERSION = "1.5.0"
CURRENT_VERSION = "1.5.2"
APP_NAME = "flares"
APP_NAME_EXPANDED = "fNIRS Lightweight Analysis, Research, & Evaluation Suite"
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()
CHANGELOG_URL = "https://git.research.dezeeuw.ca/tyler/flares/raw/branch/main/changelog_major.md"
WIKI_URL = "https://git.research.dezeeuw.ca/tyler/flares/wiki"
CHANGELOG_URL = f"https://git.research.dezeeuw.ca/tyler/{APP_NAME}/raw/branch/main/changelog_major.md"
WIKI_URL = f"https://git.research.dezeeuw.ca/tyler/{APP_NAME}/wiki"
PIPELINE_STAGES = [
"Preprocessing",
@@ -43,21 +48,17 @@ PIPELINE_STAGES = [
"Design Matrix",
"General Linear Model",
"Generate GLM Results",
"Generate Channel Significance",
"Generate Channel, Region of Interest, and Contrast Results",
"Compute Contrast Results",
"Generate Channel Results",
"Generate Region of Interest Results",
"Generate Contrast Results",
"Finishing Up"
]
def resource_path(relative_path):
def resource_path(relative_path: str) -> str:
"""
Get absolute path to resource regardless of running directly or packaged using PyInstaller
"""
if hasattr(sys, '_MEIPASS'):
# PyInstaller bundle path
base_path = sys._MEIPASS
else:
base_path = os.path.abspath(".")
base_path = getattr(sys, "_MEIPASS", os.path.abspath("."))
return os.path.join(base_path, relative_path)
+5 -4
View File
@@ -1,6 +1,7 @@
"""
Filename: about.py
Description: About window for FLARES
Description: About window
Note: Compliant with pylance strict type checking
Author: Tyler de Zeeuw
License: GPL-3.0
@@ -9,7 +10,7 @@ License: GPL-3.0
from PySide6.QtWidgets import QWidget, QVBoxLayout, QLabel
from PySide6.QtCore import Qt
from src.shared.shareddata import APP_NAME, CURRENT_VERSION
from src.shared.shareddata import APP_NAME, APP_NAME_EXPANDED, CURRENT_VERSION
class AboutWindow(QWidget):
"""
@@ -19,14 +20,14 @@ class AboutWindow(QWidget):
parent (QWidget, optional): Parent widget of this window. Defaults to None.
"""
def __init__(self, parent=None):
def __init__(self, parent: QWidget | None = None) -> None:
super().__init__(parent, Qt.WindowType.Window)
self.setWindowTitle(f"About {APP_NAME.upper()}")
self.resize(250, 100)
layout = QVBoxLayout()
label = QLabel(f"About {APP_NAME.upper()}", self)
label2 = QLabel("fNIRS Lightweight Analysis, Research, & Evaluation Suite", self)
label2 = QLabel(f"{APP_NAME_EXPANDED}", self)
label3 = QLabel(f"{APP_NAME.upper()} is licensed under the GPL-3.0 licence. For more information, visit https://www.gnu.org/licenses/gpl-3.0.en.html", self)
label4 = QLabel(f"Version v{CURRENT_VERSION}")
+45 -9
View File
@@ -1,22 +1,27 @@
"""
Filename: terminal.py
Description: Terminal window for FLARES
Description: Terminal window
Note: Compliant with pylance strict type checking
Author: Tyler de Zeeuw
License: GPL-3.0
"""
from typing import Any, Callable
from PySide6.QtWidgets import QWidget, QVBoxLayout, QTextEdit, QLineEdit
from PySide6.QtCore import Qt
from src.shared.shareddata import APP_NAME, CURRENT_VERSION
from src.shared.shareddata import API_URL, API_URL_SECONDARY, APP_NAME, CURRENT_VERSION, PLATFORM_NAME
from src.window.about import AboutWindow
from updater import UpdateManager
class TerminalWindow(QWidget):
def __init__(self, parent=None):
def __init__(self, parent: QWidget | None = None) -> None:
super().__init__(parent, Qt.WindowType.Window)
self.setWindowTitle(f"Terminal - {APP_NAME.upper()}")
self.resize(320, 180)
self.output_area = QTextEdit()
self.output_area.setReadOnly(True)
@@ -28,12 +33,20 @@ class TerminalWindow(QWidget):
layout.addWidget(self.input_line)
self.setLayout(layout)
self.commands = {
self.commands: dict[str, Callable[..., Any]] = {
"hello": self.cmd_hello,
"help": self.cmd_help,
"version": self.cmd_version,
"about": self.cmd_about,
"update": self.cmd_update,
}
self.output_area.append(f"Welcome to {APP_NAME.upper()}. You are running version {CURRENT_VERSION}.")
self.output_area.append("Type 'help' for a list of available commands.\n")
self.input_line.setFocus()
def handle_command(self):
command_text = self.input_line.text()
self.input_line.clear()
@@ -58,11 +71,34 @@ class TerminalWindow(QWidget):
self.output_area.append(f"[Unknown command] '{command_name}'")
def cmd_hello(self, *args):
def cmd_hello(self, *args: Any) -> str:
return "Hello from the terminal!"
def cmd_help(self, *args):
def cmd_help(self, *args: Any) -> str:
return f"Available commands: {', '.join(self.commands.keys())}"
def cmd_version(self, *args):
return f"{CURRENT_VERSION}"
def cmd_version(self, *args: Any) -> str:
return f"{APP_NAME.upper()} is running version {CURRENT_VERSION}."
def cmd_about(self, *args: Any) -> None:
self.about = AboutWindow(self)
self.about.show()
def cmd_update(self, *args: Any) -> str:
main_win = self.parent()
if not isinstance(main_win, QWidget):
return "[Error] Main window context not found."
self.updater = UpdateManager(
main_window=main_win,
api_url=API_URL,
api_url_sec=API_URL_SECONDARY,
current_version=CURRENT_VERSION,
platform_name=PLATFORM_NAME,
platform_suffix="-" + PLATFORM_NAME,
app_name=APP_NAME
)
self.output_area.append("Checking for updates...")
self.updater.manual_check_for_updates()
return "See status bar for update information."
+3 -3
View File
@@ -15,9 +15,9 @@ import numpy as np
from PySide6.QtWidgets import QWidget, QVBoxLayout, QLabel, QHBoxLayout, QMessageBox, QLineEdit, QPushButton, QFileDialog
from PySide6.QtCore import Qt
from mne.io import read_raw_snirf
from mne_nirs.io import write_raw_snirf
from mne.channels import make_dig_montage
from mne.io import read_raw_snirf #type: ignore
from mne_nirs.io import write_raw_snirf #type: ignore
from mne.channels import make_dig_montage #type: ignore
from src.shared.shareddata import APP_NAME
+1 -1
View File
@@ -20,7 +20,7 @@ class UserGuideWindow(QWidget):
parent (QWidget, optional): Parent widget of this window. Defaults to None.
"""
def __init__(self, parent=None):
def __init__(self, parent: QWidget | None = None) -> None:
super().__init__(parent, Qt.WindowType.Window)
self.setWindowTitle(f"User Guide - {APP_NAME.upper()}")
self.resize(250, 100)
+37 -84
View File
@@ -1,19 +1,22 @@
"""
Filename: viewerlauncher.py
Description: Analysis options launcher for FLARES
Description: Viewer launcher window
Author: Tyler de Zeeuw
License: GPL-3.0
"""
# External library imports
from PySide6.QtWidgets import QPushButton, QWidget, QVBoxLayout
from PySide6.QtCore import QTimer
from src.analysis.exportcsv import ExportDataAsCSVViewerWidget
from src.analysis.group import GroupViewerWidget
from src.analysis.groupbrain import GroupBrainViewerWidget
from src.analysis.groupfunctionalconnectivity import GroupFunctionalConnectivityWidget
from src.analysis.participant import ParticipantViewerWidget
from src.analysis.exporttocsv import ExportToCSVWidget
from src.analysis.intergroupbrainimage import InterGroupBrainImageWidget
from src.analysis.crossgroupbrainimage import CrossGroupBrainImageWidget
from src.analysis.intergroupfunctionalconnectivity import InterGroupFunctionalConnectivityWidget
from src.analysis.intergroupstats import InterGroupStatsWidget
from src.analysis.crossgroupstats import CrossGroupStatsWidget
from src.analysis.participantimage import ParticipantImageViewerWidget
from src.analysis.participantbrain import ParticipantBrainViewerWidget
from src.analysis.participantfoldchannels import ParticipantFoldChannelsWidget
from src.analysis.participantfunctionalconnectivity import ParticipantFunctionalConnectivityWidget
@@ -21,92 +24,42 @@ from src.shared.shareddata import APP_NAME
class ViewerLauncherWidget(QWidget):
def __init__(self, haemo_dict, config_dict, fig_bytes_dict, cha_dict, contrast_results_dict, df_ind, design_matrix, epochs_dict, folding_bypass):
def __init__(self, haemo_dict, epochs_dict, cha_dict, df_ind_dict, design_matrix_dict, config_dict, fig_bytes_dict, contrast_results_dict, folding_bypass, json_location):
super().__init__()
self.setWindowTitle(f"Viewer Launcher - {APP_NAME.upper()}")
group_dict = {
file_path: config.get("GROUP", "Unknown")
for file_path, config in config_dict.items()
}
group_dict = {f: c.get("GROUP", "Unknown") for f, c in config_dict.items()}
def launch(func, btn, *args):
func(*args)
self._trigger_success(btn)
btn_data = [
("Participant Image Viewer", ParticipantImageViewerWidget, [haemo_dict, fig_bytes_dict], True),
("Participant Brain Viewer", ParticipantBrainViewerWidget, [haemo_dict, cha_dict], True),
("Participant Fold Channels Viewer", ParticipantFoldChannelsWidget, [haemo_dict, cha_dict], False),
("Participant Functional Connectivity Viewer [BETA]", ParticipantFunctionalConnectivityWidget, [haemo_dict, epochs_dict], True),
("Inter-Group Functional Connectivity Viewer [BETA]", InterGroupFunctionalConnectivityWidget, [haemo_dict, group_dict, config_dict], True),
("Inter-Group Stats Viewer", InterGroupStatsWidget, [haemo_dict, cha_dict, df_ind_dict, design_matrix_dict, contrast_results_dict, group_dict, json_location], True),
("Cross-Group Stats Viewer", CrossGroupStatsWidget, [haemo_dict, cha_dict, df_ind_dict, design_matrix_dict, contrast_results_dict, group_dict, 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, config_dict], True)
]
layout = QVBoxLayout(self)
for label, widget_class, args, requires_bypass in btn_data:
btn = QPushButton(f"Open {label}")
# Connect directly to the generic opener
btn.clicked.connect(lambda _, c=widget_class, b=btn, a=args: self._open_viewer(c, b, *a))
btn.setEnabled(not (requires_bypass and folding_bypass))
layout.addWidget(btn)
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)
def _open_viewer(self, widget_class, btn, *args):
# Instantiate and show dynamically
self.active_viewer = widget_class(*args)
self.active_viewer.show()
self._trigger_success(btn)
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)
layout.addWidget(btn3)
layout.addWidget(btn7)
layout.addWidget(btn8)
layout.addWidget(btn4)
layout.addWidget(btn5)
layout.addWidget(btn6)
def open_participant_viewer(self, haemo_dict, fig_bytes_dict):
self.participant_viewer = ParticipantViewerWidget(haemo_dict, fig_bytes_dict)
self.participant_viewer.show()
def open_participant_brain_viewer(self, haemo_dict, cha_dict):
self.participant_brain_viewer = ParticipantBrainViewerWidget(haemo_dict, cha_dict)
self.participant_brain_viewer.show()
def open_participant_fold_channels_viewer(self, haemo_dict, cha_dict):
self.participant_fold_channels_viewer = ParticipantFoldChannelsWidget(haemo_dict, cha_dict)
self.participant_fold_channels_viewer.show()
def open_participant_functional_connectivity_viewer(self, haemo_dict, epochs_dict):
self.participant_brain_viewer = ParticipantFunctionalConnectivityWidget(haemo_dict, epochs_dict)
self.participant_brain_viewer.show()
def open_group_functional_connectivity_viewer(self, haemo_dict, group, config_dict):
self.participant_brain_viewer = GroupFunctionalConnectivityWidget(haemo_dict, group, config_dict)
self.participant_brain_viewer.show()
def open_group_viewer(self, haemo_dict, cha_dict, df_ind, design_matrix, contrast_results_dict, group):
self.participant_brain_viewer = GroupViewerWidget(haemo_dict, cha_dict, df_ind, design_matrix, contrast_results_dict, group)
self.participant_brain_viewer.show()
def open_group_brain_viewer(self, haemo_dict, df_ind, design_matrix, group, contrast_results_dict):
self.participant_brain_viewer = GroupBrainViewerWidget(haemo_dict, df_ind, design_matrix, group, contrast_results_dict)
self.participant_brain_viewer.show()
def open_export_data_as_csv_viewer(self, haemo_dict, cha_dict, df_ind, design_matrix, group, contrast_results_dict):
self.export_data_as_csv_viewer = ExportDataAsCSVViewerWidget(haemo_dict, cha_dict, df_ind, design_matrix, group, contrast_results_dict)
self.export_data_as_csv_viewer.show()
def _launch(self, func, btn, *args):
func(*args)
self._trigger_success(btn)
def _trigger_success(self, button):
"""Temporarily adds a green checkmark to the button text."""
+5 -4
View File
@@ -1,6 +1,7 @@
"""
Filename: welcome.py
Description: Welcome dialog for FLARES
Note: Compliant with pylance strict type checking
Author: Tyler de Zeeuw
License: GPL-3.0
@@ -9,13 +10,13 @@ License: GPL-3.0
from PySide6.QtWidgets import QTextBrowser, QVBoxLayout, QLabel, QDialog, QHBoxLayout, QPushButton
from PySide6.QtGui import QDesktopServices, QIcon
from PySide6.QtCore import QUrl
from PySide6.QtNetwork import QNetworkAccessManager, QNetworkRequest
from PySide6.QtNetwork import QNetworkAccessManager, QNetworkRequest, QNetworkReply
from src.shared.shareddata import APP_NAME, CURRENT_VERSION, CHANGELOG_URL, resource_path
class WelcomeDialog(QDialog):
def __init__(self, parent=None, direct=True, first=False):
def __init__(self, parent: QDialog | None = None, direct: bool = True, first: bool = False):
super().__init__(parent)
self.setWindowTitle(f"What's New - {APP_NAME.upper()}")
self.setMinimumSize(550, 450)
@@ -64,10 +65,10 @@ class WelcomeDialog(QDialog):
self.network_manager.get(QNetworkRequest(QUrl(CHANGELOG_URL)))
def _on_download_complete(self, reply):
def _on_download_complete(self, reply: QNetworkReply) -> None:
"""Processes the downloaded markdown and drops it into the view frame."""
if reply.error() == reply.NetworkError.NoError:
raw_bytes = reply.readAll()
raw_bytes = reply.readAll().data()
# Convert raw bytes to standard text string
markdown_text = str(raw_bytes, encoding='utf-8')
+1
View File
@@ -0,0 +1 @@
update