Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d2a595d0d0 | ||
|
|
1e7fca49f2 | ||
|
|
074a0681b9 | ||
|
|
68e34484ee | ||
|
|
a0199737f5 | ||
|
|
0b52d2b0bc | ||
|
|
7c456e31e7 | ||
|
|
61a9ea2f34 |
@@ -3,13 +3,13 @@ FLARES (fNIRS Lightweight Analysis, Research, & Evaluation Suite)
|
||||
|
||||
FLARES is a lightweight standalone application to extract meaningful data out of .snirf files.
|
||||
|
||||
FLARES is free and open-source software that runs on Windows, MacOS, and Linux. Please read the information regarding each operating system below.
|
||||
FLARES is free and open-source software that runs on Windows, macOS, and Linux. Please read the information regarding each operating system below.
|
||||
|
||||
Visit the official [FLARES web site](https://research.dezeeuw.ca/flares).
|
||||
|
||||
[](https://www.python.org)
|
||||
|
||||
# For MacOS Users
|
||||
# For macOS Users
|
||||
|
||||
Due to the cost of an Apple Developer account, the application is not certified by Apple. Once the application is extracted and attempted to be launched for the first time you will get a popup stating:
|
||||
|
||||
|
||||
+44
-7
@@ -1,3 +1,40 @@
|
||||
# Version 1.6.0
|
||||
|
||||
- This is potentially a save-changing release due to adding more data into the save file, renaming existing data, and changing what part of the code saves data. 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
|
||||
- Optimized calculations being performed when calculating the Heart Rate to speed up step 5 by up to ~35% on a per-file basis
|
||||
- Optimized calculations being performed when running the General Linear Model to speed up step 5 by up to ~35% on a per-file basis
|
||||
- The Group Stats Viewer windows will now properly load the Right/Left or Front/Back fallback ROIs if JSON_LOCATION is not set
|
||||
- Changed the warning message for lots of short channels and changed when it shows to be percentage based (35%) instead of numerical based (6)
|
||||
- Renames GENDER to SEX to better represent the values that the field expects and to be more BIDS compliant
|
||||
- Changed the Assign Groups by AGE window to now be the Assign Groups by Metadata window and added support for grouping by other metadata values
|
||||
- Added a new metadata value of HAND to allow for grouping participants based on their handiness
|
||||
- Updated the layout of the participant metadata area to better accommodate current and future metadata values
|
||||
- Updated the message displayed when "Why are these useful?" is clicked to better represent what the values are used for and to accommodate the new metadata values
|
||||
- Changed the application title to now show what project you are working on. If you are not working on a project, it will display 'Untitled'
|
||||
- Tracking of current save states is now present and an asterisk will now appear in the title bar when a project has not been saved
|
||||
- Currently tracking is only supported on loaded files, parameters on the right side of the screen, and when processing has completed. Per-file metadata or processing stages are not currently implemented
|
||||
- Clicking close on the Main Window will now properly close all sub windows when closing the application and closing the application with unsaved changes will now prompt to save a project or discard changes. Fixes [Issue 87](https://git.research.dezeeuw.ca/tyler/flares/issues/87)
|
||||
- The Save button under the File menu no longer asks for a destination if a saved project was already loaded. Fixes [Issue 72](https://git.research.dezeeuw.ca/tyler/flares/issues/72)
|
||||
- Added an icon to the 'Toggle Status Bar' view option
|
||||
- Status bar visibility and window layout sizes are now stored between application sessions. Fixes [Issue 88](https://git.research.dezeeuw.ca/tyler/flares/issues/88)
|
||||
- The popup that appeared when a project was saved has now been moved to a message displayed from the status bar
|
||||
- Moved all loading of files, loading of folders, loading of projects, and saving of projects to a new file for easier future development
|
||||
- Changed the working of some messages on the status bar to better reflect what has occurred
|
||||
- Added a new terminal command 'assoc' to associate .flare files to the application. This is undergoing testing and may not function correctly.
|
||||
- Removed the debug flag from the application package, which may speed up the application. Fixes [Issue 11](https://git.research.dezeeuw.ca/tyler/flares/issues/11)
|
||||
- Fixed an issue where the two Group Stats Viewer windows would crash the application once attempted to be opened if JSON_LOCATION was not set
|
||||
- Fixed an issue where participants could be skipped when processing multiple participants at once which could prevent overall processing from completing
|
||||
- Fixed an issue where when loading a save file some list dropdowns could go blue and bold even if the value was default
|
||||
- Fixed an issue where the Analysis button would not be clickable if the user had previously pushed Clear. Fixes [Issue 83](https://git.research.dezeeuw.ca/tyler/flares/issues/83)
|
||||
- Fixed an issue where removing all files via their right click feature would cause the Process button to still be visible. Fixes [Issue 91](https://git.research.dezeeuw.ca/tyler/flares/issues/91)
|
||||
- Fixed an issue where some popup windows would not properly display the application name when they would open
|
||||
- Fixed an issue where the welcome dialog image would not display correctly on macOS
|
||||
- Fixed an issue where the Linux version would fail at 0 seconds when attempting to process files
|
||||
- Fixed an issue where fOLDing channels could cause the same channels to be repeated misaligning the labels
|
||||
- Fixed all instances of the word MacOS to now read macOS to match Apple branding
|
||||
|
||||
|
||||
# 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
|
||||
@@ -12,9 +49,9 @@
|
||||
- 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%
|
||||
- Optimized some of the calculations in Scalp Coupling Index to speed up Step 6 by up to ~25% on a per-file basis
|
||||
- Removed duplicate/redundant calculations in Peak Spectral Power to speed up Step 8 by up to ~50% on a per-file basis
|
||||
- Changed how the figures are generated when processing to speed up Step 28 by up to ~85% on a per-file basis
|
||||
- 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
|
||||
@@ -45,7 +82,7 @@
|
||||
- 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)
|
||||
- Reset to Default Configuration will now properly reset all of the parameters to their default values. Fixes [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"
|
||||
@@ -64,7 +101,7 @@
|
||||
- Fixed a crucial bug where short channels were not being processed and filtered the same way as long channels before being used as regressors
|
||||
- Fixed a crucial bug where short channels were being presented to the design matrix as normal long channels
|
||||
- Fixed a crucial bug where long channels could be interpolated from short channels. Short channels are still potentially interpolated from long channels. See [this link](https://git.research.dezeeuw.ca/tyler/flares/issues/80) for more information regarding this issue.
|
||||
- Decreased unnecessary processing time when fOLDing channels by an order of magnitude
|
||||
- Decreased unnecessary processing time when fOLDing channels by an order of magnitude. Fixes [Issue 84](https://git.research.dezeeuw.ca/tyler/flares/issues/84)
|
||||
- Added a welcome message when the terminal is opened, resized the terminal, and added more commands
|
||||
|
||||
|
||||
@@ -105,7 +142,7 @@
|
||||
|
||||
# Version 1.4.1
|
||||
|
||||
- Hotfix to fix a recursive child loop that would cause the MacOS version to not open
|
||||
- Hotfix to fix a recursive child loop that would cause the macOS version to not open
|
||||
|
||||
|
||||
# Version 1.4.0
|
||||
@@ -123,7 +160,7 @@
|
||||
- Added feedback when clicking an analysis option that opens up a new window. Fixes [Issue 20](https://git.research.dezeeuw.ca/tyler/flares/issues/20)
|
||||
- Fixed an issue where projects can not be saved to a different drive letter on windows. Fixes [Issue 71](https://git.research.dezeeuw.ca/tyler/flares/issues/71)
|
||||
- Fixed an issue where the fOLD files were not included in the Windows version. Fixes [Issue 60](https://git.research.dezeeuw.ca/tyler/flares/issues/60)
|
||||
- Fixed an issue where the MacOS version would fail to perform some analysis options. Fixes [Issue 63](https://git.research.dezeeuw.ca/tyler/flares/issues/63)
|
||||
- Fixed an issue where the macOS version would fail to perform some analysis options. Fixes [Issue 63](https://git.research.dezeeuw.ca/tyler/flares/issues/63)
|
||||
- Fixed an issue where processing too many participants would cause the analysis button to not appear. Fixes [Issue 61](https://git.research.dezeeuw.ca/tyler/flares/issues/61)
|
||||
- Fixed an issue where the error message when a participant fails would not appear. Fixes [Issue 68](https://git.research.dezeeuw.ca/tyler/flares/issues/68)
|
||||
- Fixed an issue where changes would not be saved if a project was originally loaded from a save. Fixes [Issue 44](https://git.research.dezeeuw.ca/tyler/flares/issues/44)
|
||||
|
||||
+34
-86
@@ -1,87 +1,35 @@
|
||||
# Version 1.5.2
|
||||
# Version 1.6.0
|
||||
|
||||
- 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 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 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)
|
||||
- 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)
|
||||
- This is potentially a save-changing release due to adding more data into the save file, renaming existing data, and changing what part of the code saves data. 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
|
||||
- Optimized calculations being performed when calculating the Heart Rate to speed up step 5 by up to ~35% on a per-file basis
|
||||
- Optimized calculations being performed when running the General Linear Model to speed up step 5 by up to ~35% on a per-file basis
|
||||
- The Group Stats Viewer windows will now properly load the Right/Left or Front/Back fallback ROIs if JSON_LOCATION is not set
|
||||
- Changed the warning message for lots of short channels and changed when it shows to be percentage based (35%) instead of numerical based (6)
|
||||
- Renames GENDER to SEX to better represent the values that the field expects and to be more BIDS compliant
|
||||
- Changed the Assign Groups by AGE window to now be the Assign Groups by Metadata window and added support for grouping by other metadata values
|
||||
- Added a new metadata value of HAND to allow for grouping participants based on their handiness
|
||||
- Updated the layout of the participant metadata area to better accommodate current and future metadata values
|
||||
- Updated the message displayed when "Why are these useful?" is clicked to better represent what the values are used for and to accommodate the new metadata values
|
||||
- Changed the application title to now show what project you are working on. If you are not working on a project, it will display 'Untitled'
|
||||
- Tracking of current save states is now present and an asterisk will now appear in the title bar when a project has not been saved
|
||||
- Currently tracking is only supported on loaded files, parameters on the right side of the screen, and when processing has completed. Per-file metadata or processing stages are not currently implemented
|
||||
- Clicking close on the Main Window will now properly close all sub windows when closing the application and closing the application with unsaved changes will now prompt to save a project or discard changes. Fixes [Issue 87](https://git.research.dezeeuw.ca/tyler/flares/issues/87)
|
||||
- The Save button under the File menu no longer asks for a destination if a saved project was already loaded. Fixes [Issue 72](https://git.research.dezeeuw.ca/tyler/flares/issues/72)
|
||||
- Added an icon to the 'Toggle Status Bar' view option
|
||||
- Status bar visibility and window layout sizes are now stored between application sessions. Fixes [Issue 88](https://git.research.dezeeuw.ca/tyler/flares/issues/88)
|
||||
- The popup that appeared when a project was saved has now been moved to a message displayed from the status bar
|
||||
- Moved all loading of files, loading of folders, loading of projects, and saving of projects to a new file for easier future development
|
||||
- Changed the working of some messages on the status bar to better reflect what has occurred
|
||||
- Added a new terminal command 'assoc' to associate .flare files to the application. This is undergoing testing and may not function correctly.
|
||||
- Removed the debug flag from the application package, which may speed up the application. Fixes [Issue 11](https://git.research.dezeeuw.ca/tyler/flares/issues/11)
|
||||
- Fixed an issue where the two Group Stats Viewer windows would crash the application once attempted to be opened if JSON_LOCATION was not set
|
||||
- Fixed an issue where participants could be skipped when processing multiple participants at once which could prevent overall processing from completing
|
||||
- Fixed an issue where when loading a save file some list dropdowns could go blue and bold even if the value was default
|
||||
- Fixed an issue where the Analysis button would not be clickable if the user had previously pushed Clear. Fixes [Issue 83](https://git.research.dezeeuw.ca/tyler/flares/issues/83)
|
||||
- Fixed an issue where removing all files via their right click feature would cause the Process button to still be visible. Fixes [Issue 91](https://git.research.dezeeuw.ca/tyler/flares/issues/91)
|
||||
- Fixed an issue where some popup windows would not properly display the application name when they would open
|
||||
- Fixed an issue where the welcome dialog image would not display correctly on macOS
|
||||
- Fixed an issue where the Linux version would fail at 0 seconds when attempting to process files
|
||||
- Fixed an issue where fOLDing channels could cause the same channels to be repeated misaligning the labels
|
||||
- Fixed all instances of the word MacOS to now read macOS to match Apple branding
|
||||
@@ -0,0 +1,393 @@
|
||||
"""
|
||||
Filename: file_ext_registration.py
|
||||
Description: Registers the extension of project files with the application
|
||||
Note: Compliant with pylance strict type checking
|
||||
|
||||
Author: Tyler de Zeeuw
|
||||
License: GPL-3.0
|
||||
"""
|
||||
|
||||
# Built-in imports
|
||||
import os
|
||||
import sys
|
||||
import plistlib
|
||||
import subprocess
|
||||
from typing import Optional, Tuple
|
||||
|
||||
# External library imports
|
||||
from src.shared.shareddata import APP_NAME, PLATFORM_NAME
|
||||
|
||||
ELEVATION_FLAG = "--register_file_association_elevated"
|
||||
|
||||
|
||||
|
||||
def register_file_association(ext: Optional[str] = None,
|
||||
prog_id: Optional[str] = None,
|
||||
app_name: Optional[str] = None,
|
||||
bundle_id: Optional[str] = None,
|
||||
force_admin: bool = False,
|
||||
) -> Tuple[bool, str]:
|
||||
|
||||
"""
|
||||
Registers a custom file extension across Windows, Linux, and macOS.
|
||||
Handles non-admin Windows users by falling back to local user registry.
|
||||
"""
|
||||
|
||||
clean_app = APP_NAME.replace(" ", "").lower()[:-1]
|
||||
if ext is None:
|
||||
ext = f".{clean_app}"
|
||||
if prog_id is None:
|
||||
prog_id = f"{APP_NAME.upper().replace(' ', '')}.ProjectFile"
|
||||
if app_name is None:
|
||||
app_name = APP_NAME
|
||||
|
||||
# Ensure extension starts with dot and contains no spaces
|
||||
ext = f".{ext.lstrip('.').replace(' ', '').lower()}"
|
||||
|
||||
if PLATFORM_NAME == "windows":
|
||||
return _register_windows(ext, prog_id, app_name, force_admin=force_admin)
|
||||
elif PLATFORM_NAME == "linux":
|
||||
return _register_linux(ext, prog_id, app_name)
|
||||
elif PLATFORM_NAME == "darwin": # macOS
|
||||
if bundle_id is None:
|
||||
bundle_id = f"com.{clean_app}.app"
|
||||
return _register_macos(ext, app_name, bundle_id)
|
||||
else:
|
||||
return False, f"Unsupported OS: {PLATFORM_NAME}"
|
||||
|
||||
|
||||
|
||||
def _dev_windowless_executable() -> str:
|
||||
"""
|
||||
Windows dev-mode only: returns pythonw.exe alongside the current
|
||||
interpreter if it exists, otherwise falls back to sys.executable.
|
||||
"""
|
||||
|
||||
if getattr(sys, 'frozen', False):
|
||||
return sys.executable
|
||||
exe_dir = os.path.dirname(sys.executable)
|
||||
windowless = os.path.join(exe_dir, "pythonw.exe")
|
||||
return windowless if os.path.exists(windowless) else sys.executable
|
||||
|
||||
|
||||
|
||||
def is_windows_admin() -> bool:
|
||||
"""
|
||||
Returns True if currently running elevated on Windows. Always False on
|
||||
macOS/Linux, and False (rather than raising) if the check itself fails.
|
||||
"""
|
||||
|
||||
if PLATFORM_NAME != "windows":
|
||||
return False
|
||||
import ctypes
|
||||
try:
|
||||
return ctypes.windll.shell32.IsUserAnAdmin() != 0
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
|
||||
def _relaunch_elevated(ext: str, prog_id: str, app_name: str) -> Optional[int]:
|
||||
"""
|
||||
Triggers a UAC prompt and re-launches this process elevated with the
|
||||
registration args.
|
||||
"""
|
||||
|
||||
import ctypes
|
||||
from ctypes import wintypes
|
||||
|
||||
if getattr(sys, 'frozen', False):
|
||||
exe = sys.executable
|
||||
params = [ELEVATION_FLAG, ext, prog_id, app_name]
|
||||
else:
|
||||
exe = _dev_windowless_executable()
|
||||
script_path = os.path.abspath(sys.argv[0])
|
||||
params = [script_path, ELEVATION_FLAG, ext, prog_id, app_name]
|
||||
|
||||
param_str = " ".join(f'"{p}"' for p in params)
|
||||
|
||||
SEE_MASK_NOCLOSEPROCESS = 0x00000040
|
||||
SW_SHOWNORMAL = 1
|
||||
|
||||
class SHELLEXECUTEINFO(ctypes.Structure):
|
||||
_fields_ = [
|
||||
("cbSize", wintypes.DWORD),
|
||||
("fMask", ctypes.c_ulong),
|
||||
("hwnd", wintypes.HWND),
|
||||
("lpVerb", wintypes.LPCWSTR),
|
||||
("lpFile", wintypes.LPCWSTR),
|
||||
("lpParameters", wintypes.LPCWSTR),
|
||||
("lpDirectory", wintypes.LPCWSTR),
|
||||
("nShow", ctypes.c_int),
|
||||
("hInstApp", wintypes.HINSTANCE),
|
||||
("lpIDList", ctypes.c_void_p),
|
||||
("lpClass", wintypes.LPCWSTR),
|
||||
("hKeyClass", wintypes.HKEY),
|
||||
("dwHotKey", wintypes.DWORD),
|
||||
("hIconOrMonitor", wintypes.HANDLE),
|
||||
("hProcess", wintypes.HANDLE),
|
||||
]
|
||||
|
||||
sei = SHELLEXECUTEINFO()
|
||||
sei.cbSize = ctypes.sizeof(sei)
|
||||
sei.fMask = SEE_MASK_NOCLOSEPROCESS
|
||||
sei.lpVerb = "runas"
|
||||
sei.lpFile = exe
|
||||
sei.lpParameters = param_str
|
||||
sei.nShow = SW_SHOWNORMAL
|
||||
|
||||
if not ctypes.windll.shell32.ShellExecuteExW(ctypes.byref(sei)):
|
||||
# User clicked "No" on the UAC prompt, or elevation failed outright.
|
||||
return None
|
||||
|
||||
WAIT_INFINITE = 0xFFFFFFFF
|
||||
ctypes.windll.kernel32.WaitForSingleObject(sei.hProcess, WAIT_INFINITE)
|
||||
|
||||
exit_code = wintypes.DWORD()
|
||||
ctypes.windll.kernel32.GetExitCodeProcess(sei.hProcess, ctypes.byref(exit_code))
|
||||
ctypes.windll.kernel32.CloseHandle(sei.hProcess)
|
||||
|
||||
return exit_code.value
|
||||
|
||||
|
||||
|
||||
def _delete_key_recursive(root_key: int, path: str) -> None:
|
||||
"""Recursively deletes a registry key and all its subkeys, if present."""
|
||||
|
||||
import winreg
|
||||
try:
|
||||
with winreg.OpenKey(root_key, path, 0, winreg.KEY_ALL_ACCESS) as key:
|
||||
while True:
|
||||
try:
|
||||
subkey_name = winreg.EnumKey(key, 0)
|
||||
except OSError:
|
||||
break
|
||||
_delete_key_recursive(root_key, f"{path}\\{subkey_name}")
|
||||
winreg.DeleteKey(root_key, path)
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
|
||||
|
||||
|
||||
def _cleanup_stale_hkcu_entries(ext: str, prog_id: str) -> None:
|
||||
"""
|
||||
Removes any leftover per-user (HKCU) association for this ext/prog_id.
|
||||
Windows prefers HKCU\\Software\\Classes over HKEY_CLASSES_ROOT for a
|
||||
given user, so a stale non-admin dev-time registration can silently
|
||||
keep shadowing a correct system-wide (admin) registration.
|
||||
"""
|
||||
|
||||
import winreg
|
||||
_delete_key_recursive(winreg.HKEY_CURRENT_USER, f"Software\\Classes\\{ext}")
|
||||
_delete_key_recursive(winreg.HKEY_CURRENT_USER, f"Software\\Classes\\{prog_id}")
|
||||
|
||||
|
||||
|
||||
def _register_windows(ext: str, prog_id: str, app_name: str, force_admin: bool = False) -> Tuple[bool, str]:
|
||||
"""
|
||||
Registers the file extension with the application on Windows.
|
||||
"""
|
||||
import winreg
|
||||
import ctypes
|
||||
|
||||
is_admin = is_windows_admin()
|
||||
|
||||
if force_admin and not is_admin:
|
||||
exit_code = _relaunch_elevated(ext, prog_id, app_name)
|
||||
if exit_code is None:
|
||||
return False, "Elevation was cancelled or the UAC prompt could not be shown."
|
||||
if exit_code != 0:
|
||||
return False, f"Elevated registration process exited with code {exit_code}."
|
||||
return True, f"Successfully registered {ext} on Windows (System-wide, via elevated relaunch)!"
|
||||
|
||||
# Windows is the one platform where unfrozen invocation is supported
|
||||
if getattr(sys, 'frozen', False):
|
||||
command_str = f'"{sys.executable}" "%1"'
|
||||
else:
|
||||
script_path = os.path.abspath(sys.argv[0])
|
||||
command_str = f'"{_dev_windowless_executable()}" "{script_path}" "%1"'
|
||||
|
||||
root_key = winreg.HKEY_CLASSES_ROOT if is_admin else winreg.HKEY_CURRENT_USER
|
||||
base_path = "" if is_admin else "Software\\Classes\\"
|
||||
|
||||
try:
|
||||
# 1. Map extension -> ProgID
|
||||
ext_path = f"{base_path}{ext}"
|
||||
with winreg.CreateKey(root_key, ext_path) as key:
|
||||
winreg.SetValue(key, "", winreg.REG_SZ, prog_id)
|
||||
|
||||
# 2. Add to OpenWithProgids (Forces Windows 10/11 to show in 'Open With')
|
||||
open_with_path = f"{base_path}{ext}\\OpenWithProgids"
|
||||
with winreg.CreateKey(root_key, open_with_path) as key:
|
||||
winreg.SetValueEx(key, prog_id, 0, winreg.REG_SZ, "")
|
||||
|
||||
# 3. Set friendly type description (shown as "Type" in File Explorer)
|
||||
type_description = f"{app_name.upper()} Project File"
|
||||
prog_path = f"{base_path}{prog_id}"
|
||||
with winreg.CreateKey(root_key, prog_path) as key:
|
||||
winreg.SetValue(key, "", winreg.REG_SZ, type_description)
|
||||
|
||||
# 4. Set launch command
|
||||
cmd_path = f"{base_path}{prog_id}\\shell\\open\\command"
|
||||
with winreg.CreateKey(root_key, cmd_path) as key:
|
||||
winreg.SetValue(key, "", winreg.REG_SZ, command_str)
|
||||
|
||||
# 5. Notify Shell (SHCNE_ASSOCCHANGED = 0x08000000, SHCNF_IDLIST = 0x0000)
|
||||
try:
|
||||
ctypes.windll.shell32.SHChangeNotify(0x08000000, 0x0000, 0, 0)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 6. If we just wrote a system-wide entry, clean up any stale per-user entry that would otherwise shadow it.
|
||||
if is_admin:
|
||||
try:
|
||||
_cleanup_stale_hkcu_entries(ext, prog_id)
|
||||
except Exception:
|
||||
# Non-fatal: the system-wide write above already succeeded.
|
||||
pass
|
||||
|
||||
scope = "System-wide" if is_admin else "Local User (Non-Admin)"
|
||||
return True, f"Successfully registered {ext} on Windows ({scope})!"
|
||||
|
||||
except Exception as e:
|
||||
return False, f"Windows Registration failed: {str(e)}"
|
||||
|
||||
|
||||
# TODO: Validate
|
||||
def _register_linux(ext: str, prog_id: str, app_name: str) -> Tuple[bool, str]:
|
||||
"""
|
||||
Registers the file extension with the application on Linux based platforms.
|
||||
"""
|
||||
clean_ext = ext.lstrip('.')
|
||||
mime_type = f"application/x-{clean_ext}"
|
||||
desktop_file_name = f"{prog_id.lower()}.desktop"
|
||||
|
||||
# Linux build is always a frozen PyInstaller executable
|
||||
exe_path = f'"{sys.executable}"'
|
||||
|
||||
apps_dir = os.path.expanduser("~/.local/share/applications")
|
||||
mime_dir = os.path.expanduser("~/.local/share/mime/packages")
|
||||
os.makedirs(apps_dir, exist_ok=True)
|
||||
os.makedirs(mime_dir, exist_ok=True)
|
||||
|
||||
try:
|
||||
# 1. Create XML MIME definition
|
||||
mime_xml_path = os.path.join(mime_dir, f"{prog_id.lower()}.xml")
|
||||
xml_content = f"""<?xml version="1.0" encoding="UTF-8"?>
|
||||
<mime-info xmlns="http://www.freedesktop.org/standards/shared-mime-info">
|
||||
<mime-type type="{mime_type}">
|
||||
<comment>{app_name} File</comment>
|
||||
<glob pattern="*.{clean_ext}"/>
|
||||
</mime-type>
|
||||
</mime-info>"""
|
||||
with open(mime_xml_path, "w") as f:
|
||||
f.write(xml_content)
|
||||
|
||||
# 2. Create .desktop file
|
||||
desktop_path = os.path.join(apps_dir, desktop_file_name)
|
||||
desktop_content = f"""[Desktop Entry]
|
||||
Name={app_name}
|
||||
Exec={exe_path} %f
|
||||
Type=Application
|
||||
MimeType={mime_type};
|
||||
Terminal=false
|
||||
"""
|
||||
with open(desktop_path, "w") as f:
|
||||
f.write(desktop_content)
|
||||
|
||||
# 3. Update the shared-mime-info database and check it actually worked
|
||||
mime_result = subprocess.run(
|
||||
["update-mime-database", os.path.expanduser("~/.local/share/mime")],
|
||||
capture_output=True, text=True,
|
||||
)
|
||||
if mime_result.returncode != 0:
|
||||
return False, f"update-mime-database failed: {mime_result.stderr.strip()}"
|
||||
|
||||
# 4. Set as default handler and check it actually worked
|
||||
xdg_result = subprocess.run(
|
||||
["xdg-mime", "default", desktop_file_name, mime_type],
|
||||
capture_output=True, text=True,
|
||||
)
|
||||
if xdg_result.returncode != 0:
|
||||
return False, f"xdg-mime default failed: {xdg_result.stderr.strip()}"
|
||||
|
||||
return True, f"Successfully registered {ext} on Linux (Local User)!"
|
||||
|
||||
except FileNotFoundError as e:
|
||||
return False, (
|
||||
f"Required tool not found ({str(e)}). Is shared-mime-info / "
|
||||
f"xdg-utils installed on this system?"
|
||||
)
|
||||
except Exception as e:
|
||||
return False, f"Linux Registration failed: {str(e)}"
|
||||
|
||||
|
||||
# TODO: Validate
|
||||
def _register_macos(ext: str, app_name: str, bundle_id: str) -> Tuple[bool, str]:
|
||||
"""
|
||||
Registers the file extension with the application on macOS.
|
||||
"""
|
||||
|
||||
if not getattr(sys, 'frozen', False):
|
||||
return False, "macOS file association requires app to be packaged as a .app bundle."
|
||||
|
||||
exe_path = sys.executable
|
||||
app_bundle_path = os.path.abspath(os.path.join(exe_path, "../../../"))
|
||||
|
||||
if not app_bundle_path.endswith(".app"):
|
||||
return False, "Could not locate outer .app bundle."
|
||||
|
||||
info_plist_path = os.path.join(app_bundle_path, "Contents", "Info.plist")
|
||||
if not os.path.exists(info_plist_path):
|
||||
return False, f"Info.plist not found at {info_plist_path}"
|
||||
|
||||
clean_ext = ext.lstrip('.')
|
||||
uti = f"{bundle_id}.{clean_ext}"
|
||||
|
||||
try:
|
||||
with open(info_plist_path, "rb") as f:
|
||||
plist = plistlib.load(f)
|
||||
|
||||
# Tell macOS this app can open files with our UTI
|
||||
doc_types = plist.get("CFBundleDocumentTypes", [])
|
||||
if not any(uti in dt.get("LSItemContentTypes", []) for dt in doc_types):
|
||||
doc_types.append({
|
||||
"CFBundleTypeName": f"{app_name} Project File",
|
||||
"CFBundleTypeRole": "Editor",
|
||||
"LSHandlerRank": "Owner",
|
||||
"LSItemContentTypes": [uti],
|
||||
})
|
||||
plist["CFBundleDocumentTypes"] = doc_types
|
||||
|
||||
# Declare the UTI itself (required — without this the type is unknown to LS)
|
||||
exported_types = plist.get("UTExportedTypeDeclarations", [])
|
||||
if not any(t.get("UTTypeIdentifier") == uti for t in exported_types):
|
||||
exported_types.append({
|
||||
"UTTypeIdentifier": uti,
|
||||
"UTTypeDescription": f"{app_name} File",
|
||||
"UTTypeConformsTo": ["public.data"],
|
||||
"UTTypeTagSpecification": {"public.filename-extension": [clean_ext]},
|
||||
})
|
||||
plist["UTExportedTypeDeclarations"] = exported_types
|
||||
|
||||
with open(info_plist_path, "wb") as f:
|
||||
plistlib.dump(plist, f)
|
||||
|
||||
except Exception as e:
|
||||
return False, f"Failed to update Info.plist: {str(e)}"
|
||||
|
||||
try:
|
||||
lsregister_path = (
|
||||
"/System/Library/Frameworks/CoreServices.framework/Frameworks/"
|
||||
"LaunchServices.framework/Support/lsregister"
|
||||
)
|
||||
subprocess.run(
|
||||
[lsregister_path, "-f", app_bundle_path],
|
||||
check=True, capture_output=True,
|
||||
)
|
||||
return True, f"Registered {ext} with {app_bundle_path} via macOS Launch Services!"
|
||||
except subprocess.CalledProcessError as e:
|
||||
stderr = e.stderr.decode(errors="ignore") if e.stderr else str(e)
|
||||
return False, f"macOS Launch Services registration failed: {stderr}"
|
||||
except Exception as e:
|
||||
return False, f"macOS Registration failed: {str(e)}"
|
||||
@@ -54,7 +54,7 @@ from statsmodels.tools.sm_exceptions import ConvergenceWarning
|
||||
|
||||
from scipy.spatial.distance import cdist
|
||||
from scipy.signal import welch, butter, filtfilt, periodogram # type: ignore
|
||||
from scipy.stats import pearsonr, zscore, ttest_1samp, ttest_ind, sem
|
||||
from scipy.stats import pearsonr, zscore, ttest_1samp, ttest_ind, sem, t as t_dist
|
||||
|
||||
import pywt # type: ignore
|
||||
import neurokit2 as nk # type: ignore
|
||||
@@ -104,9 +104,6 @@ from src.shared.shareddata import PLATFORM_NAME, resource_path
|
||||
|
||||
|
||||
|
||||
# Needs to be set for mne
|
||||
os.environ["SUBJECTS_DIR"] = str(data_path()) + "/subjects" # type: ignore
|
||||
|
||||
PRIMARY_COLORS = {
|
||||
"SCI only": "skyblue", # Scalp Coupling Index (Standard MNE)
|
||||
"SNR only": "lightgreen", # Signal-to-Noise Ratio (Your original)
|
||||
@@ -1246,7 +1243,7 @@ def mark_bads(raw, bad_sci, bad_snr, bad_psp, bad_coeff_var, bad_range, bad_noi
|
||||
fig_dropped.tight_layout()
|
||||
|
||||
|
||||
raw_before = deepcopy(raw)
|
||||
raw_before = raw.copy()
|
||||
bads_channels = [ch for ch in raw.ch_names if ch in raw.info['bads']]
|
||||
print(bads_channels)
|
||||
if bads_channels:
|
||||
@@ -1359,8 +1356,7 @@ def safe_create_epochs(raw, events, event_dict, tmin, tmax, baseline, max_shift,
|
||||
|
||||
|
||||
|
||||
def epochs_calculations(raw_haemo, events, event_dict, epoch_handling, max_shift, t_min, t_max, baseline, reject_epochs, reject_hbo_threshold):
|
||||
fig_epochs = [] # List to store figures
|
||||
def epochs_calculations(raw_haemo, events, event_dict, epoch_handling, max_shift, t_min, t_max, baseline, reject_epochs, reject_hbo_threshold, png_queue):
|
||||
|
||||
if epoch_handling == 'shift':
|
||||
epochs = safe_create_epochs(raw=raw_haemo, events=events, event_dict=event_dict, tmin=t_min, tmax=t_max, baseline=baseline, max_shift=max_shift, reject_epochs=reject_epochs, reject_hbo_threshold=reject_hbo_threshold)
|
||||
@@ -1377,12 +1373,18 @@ def epochs_calculations(raw_haemo, events, event_dict, epoch_handling, max_shift
|
||||
# Plot drop log
|
||||
# TODO: Why show this if we never use epochs2?
|
||||
fig_epochs_dropped = epochs2.plot_drop_log(show=False)
|
||||
fig_epochs.append(("fig_epochs_dropped", fig_epochs_dropped))
|
||||
_enqueue("epochs_fig_epochs_dropped", fig_epochs_dropped, png_queue)
|
||||
|
||||
conditions = list(epochs.event_id.keys())
|
||||
evoked_cache = {}
|
||||
|
||||
# Plot for each condition
|
||||
for idx, condition in enumerate(epochs.event_id.keys()):
|
||||
logger.info(condition)
|
||||
logger.info(idx)
|
||||
|
||||
epo_cond = epochs[condition]
|
||||
|
||||
# Plot images for each condition
|
||||
fig_epochs_data = epochs[condition].plot_image(
|
||||
combine="mean",
|
||||
@@ -1399,22 +1401,23 @@ def epochs_calculations(raw_haemo, events, event_dict, epoch_handling, max_shift
|
||||
ax = fig.axes[0]
|
||||
original_title = ax.get_title()
|
||||
ax.set_title(f"{condition}: {original_title}")
|
||||
fig_epochs.append((f"fig_{condition}_data_{idx}_{j}", fig)) # Store with a unique name
|
||||
_enqueue(f"epochs_fig_{condition}_data_{idx}_{j}", fig, png_queue)
|
||||
|
||||
# Evoked average figure for each condition
|
||||
evoked_avg = epochs[condition].average()
|
||||
evoked_avg = epo_cond.average()
|
||||
evoked_cache[condition] = evoked_avg
|
||||
|
||||
clims = dict(hbo=[-1, 1], hbr=[1, -1])
|
||||
condition_fig = evoked_avg.plot_image(clim=clims, show=False)
|
||||
|
||||
for ax in condition_fig.axes:
|
||||
original_title = ax.get_title()
|
||||
ax.set_title(f"{original_title} - {condition}")
|
||||
fig_epochs.append((f"evoked_avg_{condition}", condition_fig)) # Store with a unique name
|
||||
_enqueue(f"epochs_evoked_avg_{condition}", condition_fig, png_queue)
|
||||
|
||||
# Prepare evokeds and colors for topographic plot
|
||||
evokeds3 = []
|
||||
colors = []
|
||||
conditions = list(epochs.event_id.keys())
|
||||
cmap = plt.get_cmap("tab10", len(conditions))
|
||||
|
||||
for idx, cond in enumerate(conditions):
|
||||
@@ -1433,20 +1436,24 @@ def epochs_calculations(raw_haemo, events, event_dict, epoch_handling, max_shift
|
||||
lines.append(line)
|
||||
|
||||
fig.legend(lines, conditions, loc="lower right")
|
||||
fig_epochs.append(("evoked_topo", help)) # Store with a unique name
|
||||
_enqueue("epochs_evoked_topo", help, png_queue)
|
||||
|
||||
unique_annotations = set(raw_haemo.annotations.description)
|
||||
|
||||
for cond in unique_annotations:
|
||||
|
||||
# Evoked response for specific condition ("Activity")
|
||||
evoked_stim1 = epochs[cond].average()
|
||||
evoked_stim1 = evoked_cache.get(cond)
|
||||
if evoked_stim1 is None:
|
||||
# Not one of the epoch conditions already averaged above - fall back
|
||||
# to computing it directly (matches original behavior in that case).
|
||||
evoked_stim1 = epochs[cond].average()
|
||||
|
||||
fig_evoked_hbo = evoked_stim1.copy().pick(picks='hbo').plot(time_unit='s', show=False)
|
||||
fig_evoked_hbr = evoked_stim1.copy().pick(picks='hbr').plot(time_unit='s', show=False)
|
||||
|
||||
fig_epochs.append((f"fig_evoked_hbo_{cond}", fig_evoked_hbo)) # Store with a unique name
|
||||
fig_epochs.append((f"fig_evoked_hbr_{cond}", fig_evoked_hbr)) # Store with a unique name
|
||||
_enqueue(f"epochs_fig_evoked_hbo_{cond}", fig_evoked_hbo, png_queue)
|
||||
_enqueue(f"epochs_fig_evoked_hbr_{cond}", fig_evoked_hbr, png_queue)
|
||||
|
||||
print("Evoked HbO peak amplitude:", evoked_stim1.copy().pick(picks='hbo').data.max())
|
||||
|
||||
@@ -1459,11 +1466,11 @@ def epochs_calculations(raw_haemo, events, event_dict, epoch_handling, max_shift
|
||||
for condition in epochs.event_id:
|
||||
if condition not in all_evokeds:
|
||||
all_evokeds[condition] = []
|
||||
all_evokeds[condition].append(epochs[condition].average())
|
||||
all_evokeds[condition].append(evoked_cache[condition])
|
||||
|
||||
group_averages = {cond: evoked_cache[cond] for cond in conditions if cond in evoked_cache}
|
||||
group_aucs = {}
|
||||
# TODO: group averages with a single person?
|
||||
group_averages = {cond: grand_average(evokeds) for cond, evokeds in all_evokeds.items()}
|
||||
|
||||
for condition, evoked in group_averages.items():
|
||||
group_aucs[condition] = {}
|
||||
for pick in ["hbo", "hbr"]:
|
||||
@@ -1513,9 +1520,9 @@ def epochs_calculations(raw_haemo, events, event_dict, epoch_handling, max_shift
|
||||
|
||||
ax.legend(legend_labels)
|
||||
|
||||
fig_epochs.append((f"fig_{condition}_compare_evokeds", fig)) # Store with a unique name
|
||||
_enqueue(f"epochs_fig_{condition}_compare_evokeds", fig, png_queue)
|
||||
|
||||
return epochs, fig_epochs
|
||||
return epochs
|
||||
|
||||
|
||||
|
||||
@@ -1833,7 +1840,8 @@ def fold_channels(raw: BaseRaw, p_name: str, atlas: str='Brodmann', progress_que
|
||||
channel_results = {}
|
||||
step_idx = 0
|
||||
|
||||
for cidx, channel_name in enumerate(hbo_channel_names):
|
||||
for channel_name in hbo_channel_names:
|
||||
cidx = raw.ch_names.index(channel_name)
|
||||
tbl = _source_detector_fold_table(
|
||||
raw, cidx, reference_locations, fold_tbl, interpolate=True
|
||||
)
|
||||
@@ -1988,7 +1996,11 @@ def plot_3d_evoked_array(
|
||||
ea = ea.pick(picks=picks) # type: ignore
|
||||
|
||||
if subjects_dir is None:
|
||||
subjects_dir = os.environ["SUBJECTS_DIR"]
|
||||
subjects_dir = os.environ.get("SUBJECTS_DIR")
|
||||
if subjects_dir is None:
|
||||
subjects_dir = str(data_path()) + "/subjects" # type: ignore
|
||||
os.environ["SUBJECTS_DIR"] = subjects_dir
|
||||
|
||||
if src is None:
|
||||
fname_src_fs = os.path.join(
|
||||
subjects_dir, "fsaverage", "bem", "fsaverage-ico-5-src.fif"
|
||||
@@ -2168,8 +2180,14 @@ def brain_3d_visualization(
|
||||
|
||||
|
||||
|
||||
def brain_landmarks_3d(raw_haemo: BaseRaw, show_optodes: Literal['sensors', 'labels', 'none', 'all'] = 'all', show_brodmann: bool = True) -> None:
|
||||
def brain_landmarks_3d(raw_haemo: BaseRaw, show_optodes: Literal['sensors', 'labels', 'none', 'all'] = 'all', show_brodmann: bool = True, subjects_dir = None) -> None:
|
||||
|
||||
if subjects_dir is None:
|
||||
subjects_dir = os.environ.get("SUBJECTS_DIR")
|
||||
if subjects_dir is None:
|
||||
subjects_dir = str(data_path()) + "/subjects" # type: ignore
|
||||
os.environ["SUBJECTS_DIR"] = subjects_dir
|
||||
|
||||
brain = Brain("fsaverage", background="white", size=(800, 700)) # type: ignore
|
||||
|
||||
distances = source_detector_distances(raw_haemo.info)
|
||||
@@ -2639,7 +2657,6 @@ def run_roi_second_level_analysis(
|
||||
correction_method: str | None = "fdr_bh",
|
||||
target_chroma: str = "hbo",
|
||||
graph_bounds: float | None = None,
|
||||
roi_config: str | Path | None = None,
|
||||
threshold_topo: bool = False,
|
||||
) -> DataFrame:
|
||||
|
||||
@@ -2794,35 +2811,13 @@ def run_roi_second_level_analysis(
|
||||
con_model_df = statsmodels_to_results(con_model, order=raw_picked.ch_names)
|
||||
|
||||
# --- DYNAMIC ROI PARSING ---
|
||||
roi_mapping = {}
|
||||
if roi_config is not None:
|
||||
raw_json = None
|
||||
if isinstance(roi_config, str) and os.path.exists(roi_config):
|
||||
with open(roi_config, 'r') as f:
|
||||
raw_json = json.load(f)
|
||||
elif isinstance(roi_config, dict):
|
||||
raw_json = roi_config
|
||||
|
||||
if raw_json:
|
||||
if "regions_of_interest" in raw_json:
|
||||
for roi_item in raw_json["regions_of_interest"]:
|
||||
roi_name = roi_item.get("name")
|
||||
channels = roi_item.get("channels", [])
|
||||
if roi_name and channels:
|
||||
roi_mapping[roi_name] = channels
|
||||
else:
|
||||
roi_mapping = raw_json
|
||||
|
||||
if roi_mapping:
|
||||
ch_to_roi = {}
|
||||
for roi_name, channels in roi_mapping.items():
|
||||
for ch in channels:
|
||||
ch_to_roi[ch] = roi_name
|
||||
ch_to_roi[ch.split()[0]] = roi_name
|
||||
|
||||
con_summary['ROI'] = con_summary[ch_col].apply(
|
||||
lambda x: ch_to_roi.get(x, ch_to_roi.get(x.split()[0], None) if isinstance(x, str) else None)
|
||||
)
|
||||
if 'ROI' not in con_summary.columns or con_summary['ROI'].dropna().empty:
|
||||
if df_roi_all is not None and 'ROI' in df_roi_all.columns and ch_col in df_roi_all.columns:
|
||||
# Create channel -> ROI mapping from df_roi_all
|
||||
ch_to_roi = df_roi_all.dropna(subset=['ROI', ch_col]).set_index(ch_col)['ROI'].to_dict()
|
||||
con_summary['ROI'] = con_summary[ch_col].apply(
|
||||
lambda x: ch_to_roi.get(x, ch_to_roi.get(x.split()[0], None) if isinstance(x, str) else None)
|
||||
)
|
||||
|
||||
unique_rois = []
|
||||
if 'ROI' in con_summary.columns:
|
||||
@@ -2914,7 +2909,7 @@ def run_cross_group_second_level_analysis(
|
||||
target_chroma: str = "hbo",
|
||||
selected_event: str | None = None,
|
||||
graph_bounds: tuple[float, float] | list[float] | None = None,
|
||||
roi_config: Path | str | None = None,
|
||||
roi_channel_maps: dict[str, dict[str, str]] | None = None,
|
||||
threshold_topo: bool = False,
|
||||
) -> DataFrame:
|
||||
|
||||
@@ -3118,21 +3113,13 @@ def run_cross_group_second_level_analysis(
|
||||
con_model_df = pd.DataFrame(contrast_data)
|
||||
|
||||
# --- DYNAMIC ROI PARSING ---
|
||||
roi_mapping = {}
|
||||
if roi_config is not None and os.path.exists(roi_config):
|
||||
with open(roi_config, 'r') as f:
|
||||
raw_json = json.load(f)
|
||||
if "regions_of_interest" in raw_json:
|
||||
for roi_item in raw_json["regions_of_interest"]:
|
||||
roi_mapping[roi_item.get("name")] = roi_item.get("channels", [])
|
||||
if roi_channel_maps:
|
||||
def _lookup_roi(row):
|
||||
m = roi_channel_maps.get(row['clean_ID'], {})
|
||||
ch = row[ch_col]
|
||||
return m.get(ch, m.get(ch.split()[0]) if isinstance(ch, str) else None)
|
||||
|
||||
if roi_mapping:
|
||||
ch_to_roi = {}
|
||||
for roi_name, channels in roi_mapping.items():
|
||||
for ch in channels:
|
||||
ch_to_roi[ch] = roi_name
|
||||
ch_to_roi[ch.split()[0]] = roi_name
|
||||
con_summary['ROI'] = con_summary[ch_col].apply(lambda x: ch_to_roi.get(x, ch_to_roi.get(x.split()[0], None) if isinstance(x, str) else None))
|
||||
con_summary['ROI'] = con_summary.apply(_lookup_roi, axis=1)
|
||||
|
||||
unique_rois = [r for r in con_summary['ROI'].dropna().unique() if r != ""] if 'ROI' in con_summary.columns else ['All_Channels']
|
||||
|
||||
@@ -3443,7 +3430,8 @@ def run_cross_group_contrast_analysis(
|
||||
df_contrasts_a: DataFrame,
|
||||
df_contrasts_b: DataFrame,
|
||||
contrast_name: str,
|
||||
roi_json_path: str | Path | None,
|
||||
roi_channel_maps_a: dict[str, dict[str, str]],
|
||||
roi_channel_maps_b: dict[str, dict[str, str]],
|
||||
group_a_name: str = "Group A",
|
||||
group_b_name: str = "Group B",
|
||||
target_chroma: str = "hbo",
|
||||
@@ -3536,8 +3524,8 @@ def run_cross_group_contrast_analysis(
|
||||
print(f"[ERROR] Contrast '{contrast_name}' not found anywhere in {group_b_name}'s data.")
|
||||
return DataFrame()
|
||||
|
||||
roi_a = aggregate_channel_contrasts_to_roi(df_a_filt, roi_json_path, weighted=weighted)
|
||||
roi_b = aggregate_channel_contrasts_to_roi(df_b_filt, roi_json_path, weighted=weighted)
|
||||
roi_a = aggregate_channel_contrasts_to_roi(df_a_filt, roi_channel_maps_a, weighted=weighted)
|
||||
roi_b = aggregate_channel_contrasts_to_roi(df_b_filt, roi_channel_maps_b, weighted=weighted)
|
||||
|
||||
roi_a = roi_a[roi_a['Chroma'] == target_chroma]
|
||||
roi_b = roi_b[roi_b['Chroma'] == target_chroma]
|
||||
@@ -3886,7 +3874,7 @@ def run_roi_paired_contrast_analysis(
|
||||
|
||||
def aggregate_channel_contrasts_to_roi(
|
||||
df_contrasts: DataFrame,
|
||||
roi_json_path: str | Path | None,
|
||||
roi_channel_maps: dict[str, dict[str, str]],
|
||||
weighted: bool = True
|
||||
) -> DataFrame:
|
||||
"""
|
||||
@@ -3913,11 +3901,14 @@ def aggregate_channel_contrasts_to_roi(
|
||||
['ch_name', 'effect', 'stat', 'Chroma', 'contrast_name', 'ID']
|
||||
`stat` must be the t-statistic (ContrastType == 't'), since standard
|
||||
error is recovered as effect / stat.
|
||||
roi_json_path : str
|
||||
Path to the same regions.json used elsewhere in the pipeline, with
|
||||
the structure: {"regions_of_interest": [{"name": ..., "channels": [...]}]}
|
||||
`channels` entries should be bare source-detector names (e.g. "S1_D1"),
|
||||
matching the convention already used for the GLM-level ROI loading.
|
||||
roi_channel_maps : dict[str, dict[str, str]]
|
||||
Per-subject channel-to-ROI mapping, keyed by subject ID (the same
|
||||
ID values used in df_contrasts['ID']), e.g.
|
||||
{"sub-01": {"S1_D1 hbo": "Left", "S1_D1 hbr": "Left", ...}, ...}.
|
||||
This is the actual mapping generate_roi_results used for that
|
||||
subject (whichever tier produced it — JSON, geometric split, or
|
||||
per-channel fallback) — not re-derived here, so ROI assignments
|
||||
stay consistent with df_ind_dict for the same subject.
|
||||
weighted : bool, default True
|
||||
If True, combine channels within an ROI using inverse-variance
|
||||
weighting (weight = 1 / se^2), matching MNE-NIRS's own default
|
||||
@@ -3937,35 +3928,18 @@ def aggregate_channel_contrasts_to_roi(
|
||||
if not all(col in df_contrasts.columns for col in required_cols):
|
||||
raise ValueError(f"Input contrast DataFrame must include: {required_cols}")
|
||||
|
||||
# --- Load ROI definitions and build a channel-base -> ROI lookup ---
|
||||
# Channel base names (e.g. "S1_D1") map to both hbo/hbr rows via the
|
||||
# ch_name column ("S1_D1 hbo" / "S1_D1 hbr"), so we key on the base name.
|
||||
with open(roi_json_path, 'r') as f:
|
||||
roi_data = json.load(f)
|
||||
|
||||
ch_base_to_roi = {}
|
||||
for region in roi_data.get("regions_of_interest", []):
|
||||
roi_name = region["name"]
|
||||
for ch_base in region["channels"]:
|
||||
if ch_base in ch_base_to_roi:
|
||||
logger.warning(
|
||||
f"Channel '{ch_base}' assigned to multiple ROIs "
|
||||
f"('{ch_base_to_roi[ch_base]}' and '{roi_name}') — "
|
||||
f"using '{roi_name}' (last one wins)."
|
||||
)
|
||||
ch_base_to_roi[ch_base] = roi_name
|
||||
|
||||
df = df_contrasts.copy()
|
||||
df['ch_base'] = df['ch_name'].str.split().str[0] # "S1_D1 hbo" -> "S1_D1"
|
||||
df['ROI'] = df['ch_base'].map(ch_base_to_roi)
|
||||
|
||||
n_unassigned = df['ROI'].isna().sum()
|
||||
if n_unassigned:
|
||||
logger.warning(
|
||||
f"{n_unassigned} channel-rows did not match any ROI in "
|
||||
f"'{roi_json_path}' and will be excluded."
|
||||
)
|
||||
df['ch_base'] = df['ch_name'].str.split().str[0]
|
||||
|
||||
def lookup(row):
|
||||
m = roi_channel_maps.get(row['ID'], {})
|
||||
return m.get(row['ch_name'], m.get(row['ch_base']))
|
||||
|
||||
df['ROI'] = df.apply(lookup, axis=1)
|
||||
df = df.dropna(subset=['ROI'])
|
||||
|
||||
if df.empty:
|
||||
raise ValueError("No channel contrasts matched any subject's ROI mapping.")
|
||||
|
||||
# Recover standard error from the t-statistic: t = effect / se -> se = effect / t
|
||||
with np.errstate(divide='ignore', invalid='ignore'):
|
||||
@@ -3985,14 +3959,12 @@ def aggregate_channel_contrasts_to_roi(
|
||||
|
||||
group_cols = ['ROI', 'contrast_name', 'Chroma', 'ID']
|
||||
|
||||
def _weighted_mean(g):
|
||||
return np.average(g['effect'], weights=g['weight'])
|
||||
|
||||
roi_theta = (
|
||||
df.groupby(group_cols, group_keys=False)
|
||||
.apply(lambda g: pd.Series({'theta': _weighted_mean(g)}))
|
||||
.reset_index()
|
||||
df['_effect_weight'] = df['effect'] * df['weight']
|
||||
roi_theta = df.groupby(group_cols, as_index=False).agg(
|
||||
_sum_ew=('_effect_weight', 'sum'),
|
||||
_sum_w=('weight', 'sum'),
|
||||
)
|
||||
roi_theta['theta'] = roi_theta['_sum_ew'] / roi_theta['_sum_w']
|
||||
|
||||
roi_theta = roi_theta.rename(columns={'contrast_name': 'Condition'})
|
||||
return roi_theta[['ROI', 'Condition', 'Chroma', 'theta', 'ID']]
|
||||
@@ -4776,7 +4748,7 @@ def hr_calc(raw, seconds_to_strip_hr, l_freq, h_freq, search_min, search_max, ma
|
||||
|
||||
hr1, hr2 = plot_heart_rate(freq_bpm_scipy, psd_scipy, freq_range_scipy, mean_hr_scipy, hr_smooth_nk, mean_hr_nk, times_trimmed, overruled, hr_window=hr_window)
|
||||
|
||||
fig = raw.plot_psd(show=False)
|
||||
fig = raw.compute_psd().plot(show=False)
|
||||
raw_filtered = raw.copy().filter(0.5, 3, fir_design='firwin')
|
||||
sfreq = raw.info['sfreq']
|
||||
data = raw_filtered.get_data()
|
||||
@@ -4787,23 +4759,28 @@ def hr_calc(raw, seconds_to_strip_hr, l_freq, h_freq, search_min, search_max, ma
|
||||
nperseg = int(sfreq / desired_bin_hz)
|
||||
hr_range = (search_min, search_max)
|
||||
|
||||
# --- Function to find strongest local peak ---
|
||||
def find_hr_from_psd(ch_data):
|
||||
f, Pxx = welch(ch_data, sfreq, nperseg=nperseg)
|
||||
mask = (f >= hr_range[0]/60) & (f <= hr_range[1]/60)
|
||||
f_masked = f[mask]
|
||||
Pxx_masked = Pxx[mask]
|
||||
if len(Pxx_masked) < 3:
|
||||
return np.nan
|
||||
peaks = [i for i in range(1, len(Pxx_masked)-1)
|
||||
if Pxx_masked[i] > Pxx_masked[i-1] and Pxx_masked[i] > Pxx_masked[i+1]]
|
||||
if not peaks:
|
||||
return np.nan
|
||||
best_idx = peaks[np.argmax([Pxx_masked[i] for i in peaks])]
|
||||
return f_masked[best_idx] * 60 # bpm
|
||||
|
||||
# --- Compute HR across all channels ---
|
||||
hr_all_channels = np.array([find_hr_from_psd(data[i, :]) for i in range(len(channel_names))])
|
||||
f, Pxx = welch(data, fs=sfreq, nperseg=nperseg, axis=1) # data: (n_channels, n_samples)
|
||||
mask = (f >= hr_range[0] / 60) & (f <= hr_range[1] / 60)
|
||||
f_masked = f[mask]
|
||||
Pxx_masked = Pxx[:, mask] # (n_channels, n_freq_in_range)
|
||||
|
||||
hr_all_channels = np.full(Pxx_masked.shape[0], np.nan)
|
||||
if Pxx_masked.shape[1] >= 3:
|
||||
# same "strictly greater than both neighbors" local-max definition as
|
||||
# the original per-channel loop, vectorized across all channels at once
|
||||
interior = Pxx_masked[:, 1:-1]
|
||||
left = Pxx_masked[:, :-2]
|
||||
right = Pxx_masked[:, 2:]
|
||||
is_local_peak = (interior > left) & (interior > right)
|
||||
|
||||
for ch in range(Pxx_masked.shape[0]):
|
||||
peak_offsets = np.where(is_local_peak[ch])[0]
|
||||
if len(peak_offsets) == 0:
|
||||
continue
|
||||
candidate_idx = peak_offsets + 1 # shift back into Pxx_masked indexing
|
||||
best_idx = candidate_idx[np.argmax(Pxx_masked[ch, candidate_idx])]
|
||||
hr_all_channels[ch] = f_masked[best_idx] * 60 # bpm
|
||||
|
||||
hr_all_channels = hr_all_channels[~np.isnan(hr_all_channels)]
|
||||
hr_mode = np.round(np.median(hr_all_channels)) # Use median if some NaNs
|
||||
|
||||
@@ -4873,20 +4850,19 @@ def make_and_run_glm(raw_haemo, df_design_matrix, noise_model, bins, n_jobs, ver
|
||||
# Extract base task conditions (e.g., "Tapping_Left", "Tapping_Right")
|
||||
base_conditions = list(set(col.split('_delay_')[0] for col in fir_cols))
|
||||
|
||||
theta_list = glm_est.theta()
|
||||
columns = list(df_design_matrix.columns)
|
||||
|
||||
peak_conditions = []
|
||||
for cond in base_conditions:
|
||||
# Find all delays corresponding to this specific condition
|
||||
cond_delays = [col for col in fir_cols if col.startswith(f"{cond}_delay_")]
|
||||
|
||||
# Find the delay with the highest average absolute effect (theta) across channels
|
||||
|
||||
delay_impacts = {}
|
||||
for col in cond_delays:
|
||||
col_idx = list(df_design_matrix.columns).index(col)
|
||||
# glm_est.theta() returns list of theta arrays (one array per channel)
|
||||
avg_absolute_theta = np.mean(np.abs([ch_theta[col_idx] for ch_theta in glm_est.theta()]))
|
||||
col_idx = columns.index(col)
|
||||
avg_absolute_theta = np.mean(np.abs([ch_theta[col_idx] for ch_theta in theta_list]))
|
||||
delay_impacts[col] = avg_absolute_theta
|
||||
|
||||
# Pick the delay column with the absolute largest channel-wide effect
|
||||
|
||||
peak_delay_col = max(delay_impacts, key=delay_impacts.get)
|
||||
logger.info(f"Condition '{cond}' peak response identified at delay column: {peak_delay_col}")
|
||||
peak_conditions.append(peak_delay_col)
|
||||
@@ -5016,6 +4992,12 @@ def generate_roi_results(raw_haemo, df_design_matrix, glm_est, file_path, json_l
|
||||
|
||||
subject_id = sub['ID'].iloc[0] if 'ID' in sub.columns else ''
|
||||
n_conditions = sub['Condition'].nunique()
|
||||
|
||||
roi_channel_map = {
|
||||
raw_haemo.ch_names[idx]: roi_name
|
||||
for roi_name, indices in rois_formatted.items()
|
||||
for idx in indices
|
||||
}
|
||||
|
||||
sns.set_theme(style="whitegrid")
|
||||
fig, ax = plt.subplots(figsize=(max(6, 1.5 * sub['ROI'].nunique()), 5))
|
||||
@@ -5035,7 +5017,7 @@ def generate_roi_results(raw_haemo, df_design_matrix, glm_est, file_path, json_l
|
||||
plt.tight_layout()
|
||||
plt.close(fig)
|
||||
|
||||
return df_roi, fig
|
||||
return df_roi, roi_channel_map, fig
|
||||
|
||||
|
||||
|
||||
@@ -5417,7 +5399,7 @@ def process_participant(file_path, file_start, progress_callback=None):
|
||||
|
||||
# Step 21: Epoch Calculations
|
||||
if EPOCHS and EVENTS and not FOLDING_BYP:
|
||||
epochs, fig_epochs = epochs_calculations(
|
||||
epochs = epochs_calculations(
|
||||
raw_haemo,
|
||||
events,
|
||||
event_dict,
|
||||
@@ -5427,10 +5409,9 @@ def process_participant(file_path, file_start, progress_callback=None):
|
||||
t_max=T_MAX,
|
||||
baseline=(None,0), #TODO: Unhardcode this
|
||||
reject_epochs=REJECT_EPOCHS,
|
||||
reject_hbo_threshold=dict(hbo=REJECT_HBO_THRESHOLD)
|
||||
reject_hbo_threshold=dict(hbo=REJECT_HBO_THRESHOLD),
|
||||
png_queue=png_queue
|
||||
)
|
||||
for name, fig in fig_epochs:
|
||||
_enqueue(f"epochs_{name}", fig, png_queue)
|
||||
if progress_callback: progress_callback(21)
|
||||
logger.info("Step 21 Completed.")
|
||||
step_start = lap(step_start, timings, "Step 21")
|
||||
@@ -5483,7 +5464,7 @@ def process_participant(file_path, file_start, progress_callback=None):
|
||||
step_start = lap(step_start, timings, "Step 25")
|
||||
|
||||
# Step 26: Generate Region of Interest Results
|
||||
df_roi, fig_roi = generate_roi_results(raw_haemo, df_design_matrix, glm_est, file_path, json_location=JSON_LOCATION)
|
||||
df_roi, roi_channel_map, fig_roi = generate_roi_results(raw_haemo, df_design_matrix, glm_est, file_path, json_location=JSON_LOCATION)
|
||||
_enqueue("Region of Interest", fig_roi, png_queue)
|
||||
if progress_callback: progress_callback(26)
|
||||
logger.info("26")
|
||||
@@ -5511,7 +5492,7 @@ def process_participant(file_path, file_start, progress_callback=None):
|
||||
logger.info(f" {name:<25} {elapsed:7.3f}s")
|
||||
|
||||
logger.info(f"Total processing time: {sum(timings.values()):.3f}s")
|
||||
return raw_haemo, epochs, df_cha, df_roi, df_design_matrix, config_dict, fig_bytes_dict, contrast_results_dict, True
|
||||
return raw_haemo, epochs, df_cha, df_roi, df_design_matrix, config_dict, fig_bytes_dict, contrast_results_dict, roi_channel_map, True
|
||||
|
||||
|
||||
|
||||
@@ -5708,46 +5689,27 @@ def functional_connectivity_betas(
|
||||
# ------------------------------------------------------------------
|
||||
beta_series = np.zeros((n_channels, len(trial_tags)))
|
||||
|
||||
for t, tag in enumerate(trial_tags):
|
||||
idx = [
|
||||
i for i, col in enumerate(reg_names)
|
||||
if col.startswith(f"{tag}_delay")
|
||||
]
|
||||
beta_series[:, t] = np.mean(betas[:, idx], axis=1).flatten()
|
||||
|
||||
|
||||
# n_channels, n_trials = betas.shape[0], len(onsets)
|
||||
# beta_series = np.zeros((n_channels, n_trials))
|
||||
|
||||
# for t in range(n_trials):
|
||||
# trial_indices = [i for i, col in enumerate(reg_names) if col.startswith(f"trial_{t:03d}_delay")]
|
||||
# if trial_indices:
|
||||
# beta_series[:, t] = np.mean(betas[:, trial_indices], axis=1).flatten()
|
||||
|
||||
# Normalize each channel so they are on the same scale
|
||||
# Without this, everything is connected to everything. Apparently this is a big issue in fNIRS?
|
||||
beta_series = zscore(beta_series, axis=1)
|
||||
|
||||
global_signal = np.mean(beta_series, axis=0)
|
||||
global_signal = np.mean(beta_series, axis=0)
|
||||
beta_series_clean = np.zeros_like(beta_series)
|
||||
for i in range(n_channels):
|
||||
slope, _ = np.polyfit(global_signal, beta_series[i, :], 1)
|
||||
beta_series_clean[i, :] = beta_series[i, :] - (slope * global_signal)
|
||||
|
||||
# 4. Correlation & Strict Filtering
|
||||
corr_matrix = np.zeros((n_channels, n_channels))
|
||||
p_matrix = np.ones((n_channels, n_channels))
|
||||
|
||||
for i in range(n_channels):
|
||||
for j in range(i + 1, n_channels):
|
||||
r, p = pearsonr(beta_series_clean[i, :], beta_series_clean[j, :])
|
||||
corr_matrix[i, j] = corr_matrix[j, i] = r
|
||||
p_matrix[i, j] = p_matrix[j, i] = p
|
||||
|
||||
# 5. High-Bar Thresholding
|
||||
reject, _ = multipletests(p_matrix[np.triu_indices(n_channels, k=1)], method='fdr_bh', alpha=0.05)[:2]
|
||||
sig_corr_matrix = np.zeros_like(corr_matrix)
|
||||
|
||||
# --- Vectorized correlation + analytic p-values (replaces the nested
|
||||
# pearsonr loop below) ---
|
||||
n_trials = beta_series_clean.shape[1]
|
||||
corr_matrix = np.corrcoef(beta_series_clean)
|
||||
|
||||
with np.errstate(divide='ignore', invalid='ignore'):
|
||||
t_stats = corr_matrix * np.sqrt((n_trials - 2) / (1 - corr_matrix ** 2))
|
||||
p_matrix = 2 * t_dist.sf(np.abs(t_stats), df=n_trials - 2)
|
||||
np.fill_diagonal(p_matrix, 1.0) # diagonal r=1 -> nan/inf guarded explicitly
|
||||
|
||||
triu = np.triu_indices(n_channels, k=1)
|
||||
flat_p = p_matrix[triu]
|
||||
|
||||
reject, _ = multipletests(flat_p, method='fdr_bh', alpha=0.05)[:2]
|
||||
sig_corr_matrix = np.zeros_like(corr_matrix)
|
||||
|
||||
for idx, is_sig in enumerate(reject):
|
||||
r_val = corr_matrix[triu[0][idx], triu[1][idx]]
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="#1f1f1f"><path d="M160-360v-80h400v80H160Zm0-160v-80h640v80H160Z"/></svg>
|
||||
|
After Width: | Height: | Size: 171 B |
@@ -0,0 +1,826 @@
|
||||
"""
|
||||
Filename: project_manager.py
|
||||
Description: Manager file for anything project related
|
||||
|
||||
Author: Tyler de Zeeuw
|
||||
License: GPL-3.0
|
||||
"""
|
||||
|
||||
# Built-in imports
|
||||
import os
|
||||
import sys
|
||||
import copy
|
||||
import pickle
|
||||
import concurrent
|
||||
from pathlib import Path, PurePosixPath
|
||||
|
||||
# External library imports
|
||||
import pandas as pd
|
||||
|
||||
from PySide6.QtWidgets import QMessageBox, QVBoxLayout, QFileDialog, QLabel, QDialog
|
||||
from PySide6.QtCore import QThread, Signal, Qt, QTimer
|
||||
from PySide6.QtGui import QAction
|
||||
|
||||
from mne.io import read_raw_snirf
|
||||
from mne.preprocessing.nirs import source_detector_distances
|
||||
from mne_nirs.channels import get_short_channels # type: ignore
|
||||
|
||||
from src.shared.flaresbasewidget import ProgressBubble
|
||||
from src.shared.shareddata import APP_NAME, CURRENT_VERSION, PLATFORM_NAME, DATA_SCHEMA
|
||||
|
||||
|
||||
|
||||
class SaveProjectThread(QThread):
|
||||
finished_signal = Signal(str)
|
||||
error_signal = Signal(str)
|
||||
|
||||
def __init__(self, filename, project_data):
|
||||
super().__init__()
|
||||
self.filename = filename
|
||||
self.project_data = project_data
|
||||
|
||||
def run(self):
|
||||
try:
|
||||
with open(self.filename, "wb") as f:
|
||||
pickle.dump(self.project_data, f)
|
||||
self.finished_signal.emit(self.filename)
|
||||
except Exception as e:
|
||||
self.error_signal.emit(str(e))
|
||||
|
||||
|
||||
|
||||
class SavingOverlay(QDialog):
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
self.setWindowFlags(Qt.WindowType.Dialog | Qt.WindowType.FramelessWindowHint)
|
||||
self.setModal(True)
|
||||
self.setWindowModality(Qt.WindowModality.ApplicationModal)
|
||||
self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground)
|
||||
|
||||
layout = QVBoxLayout()
|
||||
layout.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
|
||||
label = QLabel("Saving Project…")
|
||||
label.setStyleSheet("font-size: 18px; color: white; background-color: rgba(0,0,0,150); padding: 20px; border-radius: 10px;")
|
||||
layout.addWidget(label)
|
||||
self.setLayout(layout)
|
||||
|
||||
|
||||
class ProjectManager:
|
||||
"""
|
||||
Central manager for all I/O operations:
|
||||
- File loading (individual files & folders)
|
||||
- Project loading & saving (Save vs Save As)
|
||||
- Relative / absolute path utilities
|
||||
- State baseline synchronization (dirty tracking)
|
||||
"""
|
||||
|
||||
def __init__(self, app, file_cfg, cfg_path):
|
||||
self.app = app
|
||||
self.file_cfg = file_cfg
|
||||
self.cfg_path = cfg_path
|
||||
|
||||
# =========================================================================
|
||||
# Path Utilities
|
||||
# =========================================================================
|
||||
def get_safe_path(self, target_path, project_dir):
|
||||
"""Converts an absolute file path to a relative path relative to project_dir."""
|
||||
try:
|
||||
target = Path(target_path).resolve()
|
||||
proj = Path(project_dir).resolve()
|
||||
return str(PurePosixPath(target.relative_to(proj)))
|
||||
except ValueError:
|
||||
# Fall back to absolute path string if on a different drive/volume
|
||||
return str(PurePosixPath(Path(target_path).resolve()))
|
||||
|
||||
# =========================================================================
|
||||
# File & Folder Opening Dialogs
|
||||
# =========================================================================
|
||||
def open_file_dialog(self):
|
||||
"""Opens dialog to pick a single .snirf file."""
|
||||
file_path, _ = QFileDialog.getOpenFileName(
|
||||
self.app, "Open File", "", "SNIRF Files (*.snirf);;All Files (*)"
|
||||
)
|
||||
if file_path:
|
||||
self._load_files_into_pipeline([os.path.normpath(file_path)])
|
||||
|
||||
def open_folder_dialog(self):
|
||||
"""Recursively finds all .snirf files in a selected directory."""
|
||||
folder_path = QFileDialog.getExistingDirectory(self.app, "Select Folder", "")
|
||||
if folder_path:
|
||||
snirf_files = [os.path.normpath(str(f)) for f in Path(folder_path).rglob("*.snirf")]
|
||||
self._load_files_into_pipeline(snirf_files)
|
||||
|
||||
def _load_files_into_pipeline(self, file_paths):
|
||||
"""Loads .snirf files into UI using chunked batches and background workers."""
|
||||
app = self.app
|
||||
if not file_paths:
|
||||
return
|
||||
|
||||
# 1. Warm up the executor if needed
|
||||
if not hasattr(app, "file_executor") or app.file_executor is None:
|
||||
app.file_executor = concurrent.futures.ProcessPoolExecutor(max_workers=1)
|
||||
|
||||
# 2. Track this session to prevent ghost updates
|
||||
if not hasattr(app, "loading_session_id"):
|
||||
app.loading_session_id = 0
|
||||
app.loading_session_id += 1
|
||||
current_session = app.loading_session_id
|
||||
|
||||
# 3. Setup internal tracking if not exists
|
||||
if not hasattr(app, "bubble_widgets"):
|
||||
app.bubble_widgets = {}
|
||||
if not hasattr(app, "selected_paths"):
|
||||
app.selected_paths = []
|
||||
if not hasattr(app, "metadata_cache"):
|
||||
app.metadata_cache = {}
|
||||
|
||||
# Filter out files already in the UI to avoid duplicates
|
||||
new_files = [p for p in file_paths if p not in app.selected_paths]
|
||||
if not new_files:
|
||||
return
|
||||
|
||||
# Update the pending count for the current load batch
|
||||
if not hasattr(app, "pending_files_count"):
|
||||
app.pending_files_count = 0
|
||||
app.pending_files_count += len(new_files)
|
||||
|
||||
app.button1.setVisible(True)
|
||||
app.statusBar().showMessage(f"Loading {len(new_files)} new file(s)...")
|
||||
|
||||
# Queue chunked widget creation
|
||||
CHUNK_SIZE = 10
|
||||
|
||||
def process_chunk(file_queue):
|
||||
chunk = file_queue[:CHUNK_SIZE]
|
||||
remaining = file_queue[CHUNK_SIZE:]
|
||||
|
||||
for path in chunk:
|
||||
app.selected_paths.append(path)
|
||||
self.add_to_recent_files(path)
|
||||
|
||||
display_name = os.path.basename(path)
|
||||
bubble = ProgressBubble(display_name, path)
|
||||
bubble.setCursor(Qt.CursorShape.WaitCursor)
|
||||
bubble.set_loading_state(True)
|
||||
|
||||
app.bubble_widgets[path] = bubble
|
||||
app.bubble_layout.addWidget(bubble)
|
||||
|
||||
# Submit background task as each bubble is constructed
|
||||
future = app.file_executor.submit(extract_metadata_worker, path)
|
||||
future.add_done_callback(
|
||||
lambda f, p=path, s=current_session: app._on_metadata_ready(f, p, s)
|
||||
)
|
||||
|
||||
app.files_are_dirty = True
|
||||
app.is_saved = False
|
||||
|
||||
if hasattr(app, "check_if_app_is_dirty"):
|
||||
app.check_if_app_is_dirty()
|
||||
elif hasattr(app, "update_window_title"):
|
||||
app.update_window_title()
|
||||
|
||||
# Schedule remaining files
|
||||
if remaining:
|
||||
QTimer.singleShot(0, lambda: process_chunk(remaining))
|
||||
|
||||
process_chunk(new_files)
|
||||
|
||||
def add_files_to_project(self, file_paths):
|
||||
"""Adds file paths to the application state, creating bubble UI items."""
|
||||
app = self.app
|
||||
normalized_paths = [os.path.normpath(p) for p in file_paths]
|
||||
|
||||
# Merge with existing selected paths avoiding duplicates
|
||||
existing_paths = getattr(app, "selected_paths", [])
|
||||
new_paths = [p for p in normalized_paths if p not in existing_paths]
|
||||
|
||||
if not new_paths:
|
||||
return
|
||||
|
||||
app.selected_paths = existing_paths + new_paths
|
||||
|
||||
# Render file bubbles in UI if method exists
|
||||
if hasattr(app, "show_files_as_bubbles_from_list"):
|
||||
progress_states = getattr(app, "progress_states", {})
|
||||
current_project = getattr(app, "current_project_path", "")
|
||||
app.show_files_as_bubbles_from_list(
|
||||
app.selected_paths, progress_states, current_project
|
||||
)
|
||||
|
||||
# Record in recent files menu
|
||||
for path in new_paths:
|
||||
self.add_to_recent_files(path)
|
||||
|
||||
# Trigger dirty check
|
||||
if hasattr(app, "check_if_app_is_dirty"):
|
||||
app.check_if_app_is_dirty()
|
||||
|
||||
# =========================================================================
|
||||
# Project Loading
|
||||
# =========================================================================
|
||||
def load_project_dialog(self):
|
||||
"""Prompts for a project file and loads it."""
|
||||
app = self.app
|
||||
filename, _ = QFileDialog.getOpenFileName(
|
||||
app, "Load Project", "", "FLARE Project (*.flare)"
|
||||
)
|
||||
if filename:
|
||||
self.load_project(filename)
|
||||
|
||||
def load_project(self, filename):
|
||||
"""Loads a .flare project file into the application."""
|
||||
app = self.app
|
||||
try:
|
||||
with open(filename, "rb") as f:
|
||||
data = pickle.load(f)
|
||||
|
||||
checks = [
|
||||
("version", "<=1.1.7"),
|
||||
("file_metadata", "<=1.2.2"),
|
||||
("file_parameters", "<=1.3.0"),
|
||||
("roi_channel_map_dict", "<=1.5.2"),
|
||||
]
|
||||
|
||||
for key, ver_str in checks:
|
||||
if key not in data:
|
||||
msg = (
|
||||
f"This project was saved in an earlier version of {APP_NAME.upper()} ({ver_str}) "
|
||||
"and is potentially not compatible with this version. "
|
||||
)
|
||||
if getattr(app, "incompatible_save_bypass", False):
|
||||
QMessageBox.warning(
|
||||
app, f"Warning - {APP_NAME.upper()}", msg + "Attempting load."
|
||||
)
|
||||
break
|
||||
else:
|
||||
QMessageBox.critical(
|
||||
app,
|
||||
f"Error - {APP_NAME.upper()}",
|
||||
msg + "Enable bypass in Preferences to load.",
|
||||
)
|
||||
return
|
||||
|
||||
# Clear existing UI bubbles
|
||||
if hasattr(app, "bubble_widgets"):
|
||||
for bubble in list(app.bubble_widgets.values()):
|
||||
bubble.setParent(None)
|
||||
bubble.deleteLater()
|
||||
app.bubble_widgets.clear()
|
||||
|
||||
app.selected_paths = []
|
||||
app.current_project_path = filename
|
||||
|
||||
# Restore Data Schema
|
||||
for item in DATA_SCHEMA:
|
||||
setattr(app, item["key"], data.get(item["key"], {}))
|
||||
|
||||
project_dir = Path(filename).parent
|
||||
saved_cache = data.get("file_metadata", {})
|
||||
raw_params = data.get("file_parameters", {})
|
||||
app.metadata_cache = {}
|
||||
app.file_metadata = {}
|
||||
|
||||
for rel_path, meta_content in saved_cache.items():
|
||||
abs_path = str((project_dir / Path(rel_path)).resolve())
|
||||
app.metadata_cache[abs_path] = meta_content
|
||||
|
||||
file_list = [
|
||||
str((project_dir / Path(rel_path)).resolve()) for rel_path in data["file_list"]
|
||||
]
|
||||
raw_progress = data.get("progress_states", {})
|
||||
progress_states = {
|
||||
str((project_dir / Path(rel_path)).resolve()): step
|
||||
for rel_path, step in raw_progress.items()
|
||||
}
|
||||
|
||||
for rel_path in data["file_list"]:
|
||||
abs_path = str((project_dir / Path(rel_path)).resolve())
|
||||
if rel_path in raw_params:
|
||||
app.file_metadata[abs_path] = raw_params[rel_path]
|
||||
elif hasattr(app, "config_dict") and abs_path in app.config_dict:
|
||||
old_cfg = app.config_dict[abs_path]
|
||||
app.file_metadata[abs_path] = {
|
||||
"AGE": str(old_cfg.get("AGE", "")),
|
||||
"SEX": str(old_cfg.get("SEX", "")),
|
||||
"HAND": str(old_cfg.get("HAND", "")),
|
||||
"GROUP": str(old_cfg.get("GROUP", "")),
|
||||
}
|
||||
else:
|
||||
app.file_metadata[abs_path] = {
|
||||
"AGE": "",
|
||||
"SEX": "",
|
||||
"HAND": "",
|
||||
"GROUP": "",
|
||||
}
|
||||
|
||||
app.show_files_as_bubbles_from_list(file_list, progress_states, filename)
|
||||
|
||||
if "current_ui_params" in data:
|
||||
app.restore_sections_from_config(data["current_ui_params"])
|
||||
elif getattr(app, "config_dict", None):
|
||||
first_file = next(iter(app.config_dict.keys()))
|
||||
app.restore_sections_from_config(app.config_dict[first_file])
|
||||
|
||||
has_data = any(len(getattr(app, item["key"], {})) > 0 for item in DATA_SCHEMA)
|
||||
if hasattr(app, "button1"):
|
||||
app.button1.setVisible(not has_data)
|
||||
if hasattr(app, "button3"):
|
||||
app.button3.setVisible(has_data)
|
||||
|
||||
|
||||
self.add_to_recent_projects(os.path.normpath(filename))
|
||||
|
||||
# Reset baselines cleanly
|
||||
self.reset_all_dirty_states()
|
||||
|
||||
QMessageBox.information(app, "Loaded", f"Project loaded from:\n{filename}")
|
||||
|
||||
except Exception as e:
|
||||
QMessageBox.critical(app, "Error", f"Failed to load project:\n{e}")
|
||||
|
||||
# =========================================================================
|
||||
# Project Saving (Save / Save As)
|
||||
# =========================================================================
|
||||
def save_project(self, onCrash=False, ask=False):
|
||||
"""
|
||||
Saves the project to disk.
|
||||
- ask=False: Quick Save to self.app.current_project_path (prompts if unsaved).
|
||||
- ask=True: Save As (always prompts for location).
|
||||
"""
|
||||
app = self.app
|
||||
|
||||
# 1. Sync active text fields into active metadata dict
|
||||
if hasattr(app, "current_file") and app.current_file and hasattr(app, "meta_fields"):
|
||||
if not hasattr(app, "file_metadata"):
|
||||
app.file_metadata = {}
|
||||
app.file_metadata[app.current_file] = {
|
||||
key: field.text().strip() for key, field in app.meta_fields.items()
|
||||
}
|
||||
|
||||
# 2. Check if saveable state exists
|
||||
has_files = len(getattr(app, "selected_paths", [])) > 0
|
||||
has_metadata = any(
|
||||
any(val for val in m.values()) for m in getattr(app, "file_metadata", {}).values()
|
||||
)
|
||||
has_param_changes = any(
|
||||
s.has_any_changes() for s in getattr(app, "param_sections", [])
|
||||
)
|
||||
has_processed_data = any(
|
||||
len(getattr(app, item["key"], {})) > 0 for item in DATA_SCHEMA
|
||||
)
|
||||
|
||||
if not (has_files or has_processed_data or has_metadata or has_param_changes):
|
||||
if not onCrash:
|
||||
QMessageBox.warning(
|
||||
app,
|
||||
"Save Project",
|
||||
"There is no data or configuration to save.",
|
||||
)
|
||||
return
|
||||
|
||||
# 3. Path Resolution
|
||||
filename = None
|
||||
if not onCrash:
|
||||
existing_path = getattr(app, "current_project_path", None)
|
||||
if ask or not existing_path:
|
||||
start_dir = existing_path if existing_path else ""
|
||||
filename, _ = QFileDialog.getSaveFileName(
|
||||
app, "Save Project", start_dir, "FLARE Project (*.flare)"
|
||||
)
|
||||
if not filename:
|
||||
return
|
||||
else:
|
||||
filename = existing_path
|
||||
else:
|
||||
if PLATFORM_NAME == "darwin":
|
||||
filename = os.path.join(
|
||||
os.path.dirname(sys.executable), "../../../flares_autosave.flare"
|
||||
)
|
||||
else:
|
||||
filename = os.path.join(os.getcwd(), "flares_autosave.flare")
|
||||
|
||||
try:
|
||||
if not filename.endswith(".flare"):
|
||||
filename += ".flare"
|
||||
|
||||
project_path = Path(filename).resolve()
|
||||
project_dir = project_path.parent
|
||||
|
||||
# 4. Convert Paths to Relative
|
||||
bubble_widgets = getattr(app, "bubble_widgets", {})
|
||||
file_list = [
|
||||
self.get_safe_path(b.file_path, project_dir) for b in bubble_widgets.values()
|
||||
]
|
||||
progress_states = {
|
||||
self.get_safe_path(b.file_path, project_dir): getattr(b, "current_step", 0)
|
||||
for b in bubble_widgets.values()
|
||||
}
|
||||
|
||||
rel_metadata = {}
|
||||
for full_path, meta in getattr(app, "metadata_cache", {}).items():
|
||||
try:
|
||||
rel_metadata[self.get_safe_path(full_path, project_dir)] = meta
|
||||
except Exception as e:
|
||||
print(f"Metadata conversion failed for {full_path}: {e}")
|
||||
|
||||
rel_file_params = {
|
||||
self.get_safe_path(f_path, project_dir): meta
|
||||
for f_path, meta in getattr(app, "file_metadata", {}).items()
|
||||
}
|
||||
|
||||
current_params = app.get_all_current_ui_params()
|
||||
if not current_params and getattr(app, "config_dict", None):
|
||||
first_file = next(iter(app.config_dict.keys()))
|
||||
current_params = app.config_dict[first_file]
|
||||
|
||||
# 5. Build Serialized Payload
|
||||
project_data = {
|
||||
item["key"]: getattr(app, item["key"], {}) for item in DATA_SCHEMA
|
||||
}
|
||||
project_data.update({
|
||||
"version": CURRENT_VERSION,
|
||||
"file_list": file_list,
|
||||
"progress_states": progress_states,
|
||||
"file_metadata": rel_metadata,
|
||||
"file_parameters": rel_file_params,
|
||||
"current_ui_params": current_params,
|
||||
})
|
||||
|
||||
def sanitize(obj):
|
||||
if isinstance(obj, Path):
|
||||
return str(PurePosixPath(obj))
|
||||
elif isinstance(obj, dict):
|
||||
return {sanitize(k): sanitize(v) for k, v in obj.items()}
|
||||
elif isinstance(obj, list):
|
||||
return [sanitize(i) for i in obj]
|
||||
return obj
|
||||
|
||||
project_data = sanitize(project_data)
|
||||
|
||||
self.add_to_recent_projects(os.path.normpath(filename))
|
||||
|
||||
# 6. Background Saving Execution
|
||||
if not onCrash:
|
||||
app.saving_overlay = SavingOverlay(app)
|
||||
app.saving_overlay.resize(app.size())
|
||||
app.saving_overlay.show()
|
||||
|
||||
app.save_thread = SaveProjectThread(filename, project_data)
|
||||
|
||||
def _on_save_success(saved_file):
|
||||
if hasattr(app, "saving_overlay"):
|
||||
app.saving_overlay.close()
|
||||
|
||||
self.add_to_recent_projects(os.path.normpath(saved_file))
|
||||
|
||||
app.current_project_path = saved_file
|
||||
self.reset_all_dirty_states()
|
||||
|
||||
if not onCrash:
|
||||
QMessageBox.information(
|
||||
app, "Success", f"Project saved to:\n{saved_file}"
|
||||
)
|
||||
|
||||
def _on_save_error(error_msg):
|
||||
if hasattr(app, "saving_overlay"):
|
||||
app.saving_overlay.close()
|
||||
if not onCrash:
|
||||
QMessageBox.critical(
|
||||
app, "Error", f"Failed to save project:\n{error_msg}"
|
||||
)
|
||||
|
||||
app.save_thread.finished_signal.connect(_on_save_success)
|
||||
app.save_thread.error_signal.connect(_on_save_error)
|
||||
app.save_thread.start()
|
||||
|
||||
except Exception as e:
|
||||
if not onCrash:
|
||||
QMessageBox.critical(app, "Error", f"Failed to save project:\n{e}")
|
||||
|
||||
|
||||
def update_recent_projects_menu(self):
|
||||
"""Clears and rebuilds the Recent Projects submenu items."""
|
||||
app = self.app
|
||||
if not hasattr(app, "recent_projects_menu"):
|
||||
return
|
||||
|
||||
app.recent_projects_menu.clear()
|
||||
|
||||
raw_projects = self.file_cfg.get("File", "recent_projects", fallback="")
|
||||
projects = [p.strip() for p in raw_projects.split(",") if p.strip()]
|
||||
|
||||
if not projects:
|
||||
no_recent = app.recent_projects_menu.addAction(
|
||||
"No Recent Projects"
|
||||
)
|
||||
no_recent.setEnabled(False)
|
||||
return
|
||||
|
||||
for i, project_path in enumerate(projects):
|
||||
action = QAction(f"{i+1}: {project_path}", app)
|
||||
action.setToolTip(project_path)
|
||||
action.triggered.connect(
|
||||
lambda checked, path=project_path: self.open_recent_project(path)
|
||||
)
|
||||
app.recent_projects_menu.addAction(action)
|
||||
|
||||
def add_to_recent_projects(self, project_path):
|
||||
"""Adds a project path, moves it to the top, and hard caps at 10."""
|
||||
raw_projects = self.file_cfg.get("File", "recent_projects", fallback="")
|
||||
projects = [p.strip() for p in raw_projects.split(",") if p.strip()]
|
||||
|
||||
if project_path in projects:
|
||||
projects.remove(project_path)
|
||||
|
||||
projects.insert(0, project_path)
|
||||
projects = projects[:10] # Hard cap of 10 items
|
||||
|
||||
self.file_cfg.set("File", "recent_projects", ",".join(projects))
|
||||
try:
|
||||
with open(self.cfg_path, "w") as f:
|
||||
self.file_cfg.write(f)
|
||||
except Exception as e:
|
||||
print(f"Warning: Could not save config history: {e}")
|
||||
|
||||
self.update_recent_projects_menu()
|
||||
|
||||
def open_recent_project(self, project_path):
|
||||
"""The slot that executes when a recent project entry is clicked."""
|
||||
if os.path.exists(project_path):
|
||||
print(f"Opening recent project: {project_path}")
|
||||
|
||||
# Route project loading through ProjectManager or app's loader
|
||||
if hasattr(self.app, "project_loader"):
|
||||
self.app.project_loader(project_path)
|
||||
else:
|
||||
self.load_project(project_path)
|
||||
|
||||
self.add_to_recent_projects(project_path)
|
||||
else:
|
||||
QMessageBox.warning(
|
||||
self.app,
|
||||
"Project Not Found",
|
||||
f"The project file could not be found:\n{project_path}",
|
||||
)
|
||||
# Clean out the broken path
|
||||
raw_projects = self.file_cfg.get("File", "recent_projects", fallback="")
|
||||
projects = [
|
||||
p.strip()
|
||||
for p in raw_projects.split(",")
|
||||
if p.strip() and p.strip() != project_path
|
||||
]
|
||||
self.file_cfg.set("File", "recent_projects", ",".join(projects))
|
||||
self.update_recent_projects_menu()
|
||||
|
||||
# =========================================================================
|
||||
# Recent Files Operations
|
||||
# =========================================================================
|
||||
def update_recent_files_menu(self):
|
||||
"""Clears and rebuilds the Recent Files submenu items."""
|
||||
app = self.app
|
||||
if not hasattr(app, "recent_files_menu"):
|
||||
return
|
||||
|
||||
app.recent_files_menu.clear()
|
||||
|
||||
raw_files = self.file_cfg.get("File", "recent_files", fallback="")
|
||||
files = [f.strip() for f in raw_files.split(",") if f.strip()]
|
||||
|
||||
if not files:
|
||||
no_recent = app.recent_files_menu.addAction("No Recent Files")
|
||||
no_recent.setEnabled(False)
|
||||
return
|
||||
|
||||
for i, file_path in enumerate(files):
|
||||
action = QAction(f"{i+1}: {file_path}", app)
|
||||
action.triggered.connect(
|
||||
lambda checked, path=file_path: self.open_recent_file(path)
|
||||
)
|
||||
app.recent_files_menu.addAction(action)
|
||||
|
||||
def add_to_recent_files(self, file_path):
|
||||
"""Adds a path, moves it to the top, and hard caps the list at 10."""
|
||||
raw_files = self.file_cfg.get("File", "recent_files", fallback="")
|
||||
files = [f.strip() for f in raw_files.split(",") if f.strip()]
|
||||
|
||||
if file_path in files:
|
||||
files.remove(file_path)
|
||||
|
||||
files.insert(0, file_path)
|
||||
files = files[:10]
|
||||
|
||||
self.file_cfg.set("File", "recent_files", ",".join(files))
|
||||
try:
|
||||
with open(self.cfg_path, "w") as f:
|
||||
self.file_cfg.write(f)
|
||||
except Exception as e:
|
||||
print(f"Warning: Could not save config history: {e}")
|
||||
|
||||
self.update_recent_files_menu()
|
||||
|
||||
def open_recent_file(self, file_path):
|
||||
"""The slot that executes when someone clicks a recent file entry."""
|
||||
if os.path.exists(file_path):
|
||||
print(f"Opening recent file: {file_path}")
|
||||
self._load_files_into_pipeline([os.path.normpath(file_path)])
|
||||
|
||||
# Refresh position to top
|
||||
self.add_to_recent_files(file_path)
|
||||
else:
|
||||
QMessageBox.warning(
|
||||
self.app,
|
||||
"File Not Found",
|
||||
f"The file could not be found:\n{file_path}",
|
||||
)
|
||||
# Clean up the broken link from history
|
||||
raw_files = self.file_cfg.get("File", "recent_files", fallback="")
|
||||
files = [
|
||||
f.strip()
|
||||
for f in raw_files.split(",")
|
||||
if f.strip() and f.strip() != file_path
|
||||
]
|
||||
self.file_cfg.set("File", "recent_files", ",".join(files))
|
||||
self.update_recent_files_menu()
|
||||
|
||||
# =========================================================================
|
||||
# Baseline Synchronization & Dirty Checks
|
||||
# =========================================================================
|
||||
def sync_metadata_baseline(self):
|
||||
"""Captures current file_metadata state as baseline."""
|
||||
self.app.saved_file_metadata = copy.deepcopy(getattr(self.app, "file_metadata", {}))
|
||||
|
||||
def is_metadata_dirty(self):
|
||||
"""Returns True if file metadata has been modified relative to saved baseline."""
|
||||
current_meta = getattr(self.app, "file_metadata", {})
|
||||
saved_meta = getattr(self.app, "saved_file_metadata", {})
|
||||
|
||||
if current_meta != saved_meta:
|
||||
return True
|
||||
|
||||
if (
|
||||
hasattr(self.app, "current_file")
|
||||
and self.app.current_file
|
||||
and hasattr(self.app, "meta_fields")
|
||||
):
|
||||
active_saved = saved_meta.get(self.app.current_file, {})
|
||||
for key, field in getattr(self.app, "meta_fields", {}).items():
|
||||
if field.text().strip() != active_saved.get(key, "").strip():
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def reset_all_dirty_states(self):
|
||||
"""Resets parameter, file, and metadata baselines after load/save."""
|
||||
# 1. Sync file list baseline
|
||||
self.app.saved_selected_paths = copy.deepcopy(getattr(self.app, "selected_paths", []))
|
||||
self.app.files_are_dirty = False
|
||||
|
||||
# 2. Sync metadata baseline
|
||||
self.sync_metadata_baseline()
|
||||
|
||||
# 3. Sync parameter baselines
|
||||
for section_widget in getattr(self.app, "param_sections", []):
|
||||
if hasattr(section_widget, "save_current_as_baseline"):
|
||||
section_widget.save_current_as_baseline()
|
||||
|
||||
# 4. Clear dirty status & update UI title
|
||||
self.app.is_saved = True
|
||||
if hasattr(self.app, "update_window_title"):
|
||||
self.app.update_window_title()
|
||||
|
||||
|
||||
|
||||
|
||||
def _get_bids_demographics(snirf_path: str) -> dict[str, str]:
|
||||
"""Traverses the path of a SNIRF file to extract age/sex/hand from BIDS TSV files.
|
||||
'hand' is only included if a value is present and isn't 'n/a' (case-insensitive) -
|
||||
many datasets leave it unset/inapplicable, so surfacing 'n/a' explicitly just adds noise.
|
||||
"""
|
||||
path = Path(snirf_path)
|
||||
fields = ["age", "sex", "hand"]
|
||||
|
||||
# Extract sub-XX and ses-YY labels from the path
|
||||
sub_id = next((part for part in path.parts if part.startswith("sub-")), None)
|
||||
ses_id = next((part for part in path.parts if part.startswith("ses-")), None)
|
||||
|
||||
if not sub_id:
|
||||
return {}
|
||||
|
||||
def _row_to_dict(row) -> dict[str, str]:
|
||||
result = {}
|
||||
for field in fields:
|
||||
if field not in row:
|
||||
continue
|
||||
val = row[field]
|
||||
if pd.isna(val):
|
||||
continue
|
||||
val_str = str(val).strip()
|
||||
if field == "hand" and val_str.lower() in ("n/a", "na", ""):
|
||||
continue
|
||||
result[field] = val_str
|
||||
return result
|
||||
|
||||
# 1. Look for sub-<id>/sub-<id>_sessions.tsv
|
||||
sub_dir = next((p for p in path.parents if p.name == sub_id), None)
|
||||
if sub_dir and ses_id:
|
||||
sessions_tsv = sub_dir / f"{sub_id}_sessions.tsv"
|
||||
if sessions_tsv.exists():
|
||||
try:
|
||||
df = pd.read_csv(sessions_tsv, sep="\t")
|
||||
matching = df[df["session_id"].astype(str).str.replace("ses-", "") == ses_id.replace("ses-", "")]
|
||||
if not matching.empty:
|
||||
result = _row_to_dict(matching.iloc[0])
|
||||
if result:
|
||||
return result
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 2. Fallback: Check dataset root participants.tsv
|
||||
bids_root = sub_dir.parent if sub_dir else None
|
||||
if bids_root:
|
||||
participants_tsv = bids_root / "participants.tsv"
|
||||
if participants_tsv.exists():
|
||||
try:
|
||||
df = pd.read_csv(participants_tsv, sep="\t")
|
||||
matching = df[df["participant_id"].astype(str).str.replace("sub-", "") == sub_id.replace("sub-", "")]
|
||||
if not matching.empty:
|
||||
result = _row_to_dict(matching.iloc[0])
|
||||
if result:
|
||||
return result
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return {}
|
||||
|
||||
|
||||
|
||||
def extract_metadata_worker(file_name):
|
||||
"""Runs in the separate worker process. Returns a clean dict."""
|
||||
|
||||
# 1. Use preload=False! We only need metadata.
|
||||
raw = None
|
||||
|
||||
try:
|
||||
raw = read_raw_snirf(file_name, preload=False, verbose="ERROR")
|
||||
snirf_info = {}
|
||||
|
||||
# 2. Measurement date
|
||||
snirf_info['Measurement Date'] = str(raw.info.get('meas_date'))
|
||||
|
||||
# 3. Short Channels
|
||||
try:
|
||||
short_chans = get_short_channels(raw, max_dist=0.015)
|
||||
names = list(short_chans.ch_names)
|
||||
snirf_info['Short Channels'] = f"Likely - {names}"
|
||||
total_chans = len(raw.ch_names)
|
||||
pct_short = (len(names) / total_chans * 100) if total_chans else 0
|
||||
if pct_short > 35:
|
||||
snirf_info['Short Channels'] += "\n There are a lot of short channels. Perhaps the optode distances are incorrect?"
|
||||
except:
|
||||
snirf_info['Short Channels'] = "Unlikely"
|
||||
|
||||
# 4. Distances
|
||||
dist_vals = source_detector_distances(raw.info)
|
||||
snirf_info['Source-Detector Distances'] = [
|
||||
f"{name}: {d:.4f} m" for name, d in zip(raw.info['ch_names'], dist_vals)
|
||||
]
|
||||
|
||||
# 5. Digitization
|
||||
dig = raw.info.get('dig', None)
|
||||
if dig is not None:
|
||||
snirf_info['Digitization Points'] = [
|
||||
f"Kind: {p['kind']}, ID: {p['ident']}, Coord: {p['r']}" for p in dig
|
||||
]
|
||||
else:
|
||||
snirf_info['Digitization Points'] = "Not found"
|
||||
|
||||
# 6. Annotations (using our copy-to-string trick)
|
||||
if raw.annotations is not None and len(raw.annotations) > 0:
|
||||
snirf_info['Annotations'] = [
|
||||
f"Onset: {o:.2f}s, Duration: {d:.2f}s, Description: {str(desc)}"
|
||||
for o, d, desc in zip(raw.annotations.onset, raw.annotations.duration, raw.annotations.description)
|
||||
]
|
||||
else:
|
||||
snirf_info['Annotations'] = "No annotations found"
|
||||
|
||||
demographics = _get_bids_demographics(file_name)
|
||||
if "age" in demographics:
|
||||
snirf_info["BIDS - Age"] = demographics["age"]
|
||||
if "sex" in demographics:
|
||||
snirf_info["BIDS - Sex"] = demographics["sex"]
|
||||
if "hand" in demographics:
|
||||
snirf_info["BIDS - Handedness"] = demographics["hand"]
|
||||
return snirf_info
|
||||
|
||||
except Exception as e:
|
||||
print(f"Worker safely caught failure on {file_name}: {str(e)}")
|
||||
return {'status': 'error', 'reason': str(e)}
|
||||
|
||||
finally:
|
||||
if raw is not None:
|
||||
try:
|
||||
raw.close()
|
||||
except:
|
||||
pass
|
||||
@@ -152,8 +152,8 @@ class CrossGroupStatsWidget(CrossGroupUIMixin, FlaresBaseWidget):
|
||||
df_ind_dict: dict[str, DataFrame],
|
||||
design_matrix_dict: dict[str, DataFrame],
|
||||
contrast_results_dict: dict[str, dict[str, Any]],
|
||||
roi_channel_map_dict: dict[str, dict[str, str]],
|
||||
group_dict: dict[str, str],
|
||||
json_location: str | Path
|
||||
) -> None:
|
||||
|
||||
super().__init__("CrossGroupStats")
|
||||
@@ -163,14 +163,14 @@ class CrossGroupStatsWidget(CrossGroupUIMixin, FlaresBaseWidget):
|
||||
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.roi_channel_map_dict = roi_channel_map_dict
|
||||
self.group_dict = group_dict
|
||||
|
||||
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)
|
||||
request = self.get_common_request_data(PARAMETERIZED_INDEXES, self.df_ind_dict, self.contrast_results_dict)
|
||||
if request is None:
|
||||
return
|
||||
|
||||
@@ -200,6 +200,12 @@ class CrossGroupStatsWidget(CrossGroupUIMixin, FlaresBaseWidget):
|
||||
target_chroma = params.get("target_chroma", "hbo")
|
||||
threshold_topo = params.get("threshold_topo", False)
|
||||
|
||||
selected_roi_maps = {
|
||||
fp: self.roi_channel_map_dict[fp]
|
||||
for fp in (file_paths_a + file_paths_b)
|
||||
if fp in self.roi_channel_map_dict
|
||||
}
|
||||
|
||||
run_cross_group_second_level_analysis(
|
||||
df_roi_all=df_ind_combined, # Individual stats dataframe
|
||||
file_paths_a=file_paths_a,
|
||||
@@ -213,7 +219,7 @@ class CrossGroupStatsWidget(CrossGroupUIMixin, FlaresBaseWidget):
|
||||
correction_method=correction_method,
|
||||
target_chroma=target_chroma,
|
||||
selected_event=selected_event,
|
||||
roi_config=self.json_location,
|
||||
roi_channel_maps=selected_roi_maps,
|
||||
threshold_topo=threshold_topo # Shows the raw difference map (Unthresholded)
|
||||
)
|
||||
elif idx == 1:
|
||||
@@ -317,12 +323,27 @@ class CrossGroupStatsWidget(CrossGroupUIMixin, FlaresBaseWidget):
|
||||
if df_contrasts_a.empty or df_contrasts_b.empty:
|
||||
print("No contrast data found for one or both groups.")
|
||||
continue
|
||||
|
||||
|
||||
roi_maps_a = {
|
||||
fp: self.roi_channel_map_dict[fp]
|
||||
for fp in file_paths_a
|
||||
if fp in self.roi_channel_map_dict
|
||||
}
|
||||
roi_maps_b = {
|
||||
fp: self.roi_channel_map_dict[fp]
|
||||
for fp in file_paths_b
|
||||
if fp in self.roi_channel_map_dict
|
||||
}
|
||||
if not roi_maps_a or not roi_maps_b:
|
||||
print("No channel-to-ROI mapping available 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,
|
||||
roi_channel_maps_a=roi_maps_a,
|
||||
roi_channel_maps_b=roi_maps_b,
|
||||
group_a_name=self.group_a_dropdown.currentText(),
|
||||
group_b_name=self.group_b_dropdown.currentText(),
|
||||
target_chroma=target_chroma,
|
||||
|
||||
@@ -21,7 +21,7 @@ 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: [
|
||||
@@ -81,7 +81,6 @@ PARAMETERIZED_INDEXES: dict[int, list[dict[str, Any]]] = {
|
||||
}
|
||||
|
||||
|
||||
|
||||
class InterGroupBrainImageWidget(InterGroupUIMixin, FlaresBaseWidget):
|
||||
def __init__(
|
||||
self,
|
||||
@@ -100,7 +99,7 @@ class InterGroupBrainImageWidget(InterGroupUIMixin, FlaresBaseWidget):
|
||||
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.group_dict = group_dict
|
||||
|
||||
self.setup_inter_group_ui(["0 (GLM Results)", "1 (Significance)", "2 (Brain Activity Visualization)",])
|
||||
|
||||
|
||||
@@ -39,7 +39,6 @@ PARAMETERIZED_INDEXES: dict[int, list[dict[str, Any]]] = {
|
||||
}
|
||||
|
||||
|
||||
|
||||
class InterGroupFunctionalConnectivityWidget(InterGroupUIMixin, FlaresBaseWidget):
|
||||
def __init__(
|
||||
self,
|
||||
|
||||
@@ -157,7 +157,6 @@ DESCRIPTION = """0. ROI vs. Zero (run_roi_second_level_analysis)
|
||||
|
||||
|
||||
class InterGroupStatsWidget(InterGroupUIMixin, FlaresBaseWidget):
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
haemo_dict: dict[str | Path, BaseRaw],
|
||||
@@ -165,8 +164,8 @@ class InterGroupStatsWidget(InterGroupUIMixin, FlaresBaseWidget):
|
||||
df_ind_dict: dict[str, DataFrame],
|
||||
design_matrix_dict: dict[str, DataFrame],
|
||||
contrast_results_dict: dict[str, dict[str, Any]],
|
||||
roi_channel_map_dict: dict[str, dict[str, str]],
|
||||
group_dict: dict[str, str],
|
||||
json_location: str | Path
|
||||
) -> None:
|
||||
|
||||
super().__init__("InterGroupStats")
|
||||
@@ -176,14 +175,14 @@ class InterGroupStatsWidget(InterGroupUIMixin, FlaresBaseWidget):
|
||||
self.df_ind_dict = df_ind_dict
|
||||
self.design_matrix_dict = design_matrix_dict
|
||||
self.contrast_results_dict = contrast_results_dict
|
||||
self.roi_channel_map_dict = roi_channel_map_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)
|
||||
request = self.get_common_request_data(PARAMETERIZED_INDEXES, self.df_ind_dict, self.contrast_results_dict)
|
||||
if request is None:
|
||||
return
|
||||
|
||||
@@ -276,7 +275,6 @@ class InterGroupStatsWidget(InterGroupUIMixin, FlaresBaseWidget):
|
||||
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:
|
||||
@@ -358,11 +356,20 @@ class InterGroupStatsWidget(InterGroupUIMixin, FlaresBaseWidget):
|
||||
continue
|
||||
|
||||
df_contrasts = pd.concat(all_contrasts, ignore_index=True)
|
||||
|
||||
|
||||
selected_roi_maps = {
|
||||
fp: self.roi_channel_map_dict[fp]
|
||||
for fp in selected_file_paths
|
||||
if fp in self.roi_channel_map_dict
|
||||
}
|
||||
if not selected_roi_maps:
|
||||
print("No channel-to-ROI mapping available for selected participants.")
|
||||
continue
|
||||
|
||||
try:
|
||||
roi_theta = aggregate_channel_contrasts_to_roi(
|
||||
df_contrasts,
|
||||
roi_json_path=self.json_location,
|
||||
roi_channel_maps=selected_roi_maps,
|
||||
weighted=weighted,
|
||||
)
|
||||
|
||||
|
||||
@@ -87,7 +87,6 @@ PARAMETERIZED_INDEXES: dict[int, list[dict[str, Any]]] = {
|
||||
}
|
||||
|
||||
|
||||
|
||||
class ParticipantFunctionalConnectivityWidget(ParticipantUIMixin, FlaresBaseWidget):
|
||||
def __init__(
|
||||
self,
|
||||
@@ -106,7 +105,6 @@ class ParticipantFunctionalConnectivityWidget(ParticipantUIMixin, FlaresBaseWidg
|
||||
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:
|
||||
@@ -138,7 +136,6 @@ class ParticipantFunctionalConnectivityWidget(ParticipantUIMixin, FlaresBaseWidg
|
||||
print(f"Skipping {self.participant_map[file_path]}: Event '{selected_event}' not found.")
|
||||
continue
|
||||
|
||||
|
||||
for idx in selected_indexes:
|
||||
if idx == 0:
|
||||
|
||||
|
||||
@@ -85,7 +85,6 @@ class ParticipantImageViewerWidget(FlaresBaseWidget):
|
||||
self.showMaximized()
|
||||
|
||||
|
||||
|
||||
def show_selected_images(self):
|
||||
# Clear previous images
|
||||
while self.grid_layout.count():
|
||||
|
||||
+447
-327
File diff suppressed because it is too large
Load Diff
@@ -13,7 +13,7 @@ import sys
|
||||
import platform
|
||||
|
||||
|
||||
CURRENT_VERSION = "1.5.2"
|
||||
CURRENT_VERSION = "1.6.0"
|
||||
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"
|
||||
@@ -55,6 +55,20 @@ PIPELINE_STAGES = [
|
||||
]
|
||||
|
||||
|
||||
DATA_SCHEMA = [
|
||||
{"key": "raw_haemo_dict", "help": "Dict[file_path, MNE RawArray]: Haemodynamic raw data"},
|
||||
{"key": "epochs_dict", "help": "Dict[file_path, MNE Epochs]: Time-locked epoch data"},
|
||||
{"key": "cha_dict", "help": "Dict[file_path, DataFrame]: Channel analysis results"},
|
||||
{"key": "df_ind_dict", "help": "Dict[file_path, DataFrame]: Individual-level data/ROI results"},
|
||||
{"key": "design_matrix_dict", "help": "Dict[file_path, DataFrame]: GLM design matrices"},
|
||||
{"key": "config_dict", "help": "Dict[file_path, dict]: Processing configuration parameters"},
|
||||
{"key": "fig_bytes_dict", "help": "Dict[file_path, dict]: Serialized figure data"},
|
||||
{"key": "contrast_results_dict", "help": "Dict[file_path, dict]: Calculated contrast statistical results"},
|
||||
{"key": "roi_channel_map_dict", "help": "Dict[file_path, dict]: Calculated contrast statistical results"},
|
||||
{"key": "valid_dict", "help": "Dict[file_path, bool]: Boolean validity status per file"}
|
||||
]
|
||||
|
||||
|
||||
def resource_path(relative_path: str) -> str:
|
||||
"""
|
||||
Get absolute path to resource regardless of running directly or packaged using PyInstaller
|
||||
|
||||
@@ -7,11 +7,13 @@ Author: Tyler de Zeeuw
|
||||
License: GPL-3.0
|
||||
"""
|
||||
|
||||
# External library imports
|
||||
from PySide6.QtWidgets import QWidget, QVBoxLayout, QLabel
|
||||
from PySide6.QtCore import Qt
|
||||
|
||||
from src.shared.shareddata import APP_NAME, APP_NAME_EXPANDED, CURRENT_VERSION
|
||||
|
||||
|
||||
class AboutWindow(QWidget):
|
||||
"""
|
||||
Simple About window displaying basic application information.
|
||||
|
||||
+64
-2
@@ -7,16 +7,38 @@ Author: Tyler de Zeeuw
|
||||
License: GPL-3.0
|
||||
"""
|
||||
|
||||
# Built-in imports
|
||||
from typing import Any, Callable
|
||||
|
||||
# External library imports
|
||||
from PySide6.QtWidgets import QWidget, QVBoxLayout, QTextEdit, QLineEdit
|
||||
from PySide6.QtCore import Qt
|
||||
from PySide6.QtCore import Qt, QThread, Signal
|
||||
|
||||
from file_ext_registration import register_file_association, is_windows_admin
|
||||
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 _AssocWorker(QThread):
|
||||
"""
|
||||
Runs register_file_association() off the UI thread. Only actually
|
||||
needed for the force_admin=True path, since that blocks on
|
||||
WaitForSingleObject while the UAC prompt is up and the elevated
|
||||
child process runs — which would otherwise freeze the terminal
|
||||
window. Used for the non-elevated path too for consistency.
|
||||
"""
|
||||
result_ready = Signal(bool, str)
|
||||
|
||||
def __init__(self, force_admin: bool, parent: QWidget | None = None) -> None:
|
||||
super().__init__(parent)
|
||||
self._force_admin = force_admin
|
||||
|
||||
def run(self) -> None:
|
||||
ok, msg = register_file_association(force_admin=self._force_admin)
|
||||
self.result_ready.emit(ok, msg)
|
||||
|
||||
|
||||
class TerminalWindow(QWidget):
|
||||
def __init__(self, parent: QWidget | None = None) -> None:
|
||||
super().__init__(parent, Qt.WindowType.Window)
|
||||
@@ -38,9 +60,13 @@ class TerminalWindow(QWidget):
|
||||
"help": self.cmd_help,
|
||||
"version": self.cmd_version,
|
||||
"about": self.cmd_about,
|
||||
"assoc": self.cmd_assoc,
|
||||
"update": self.cmd_update,
|
||||
}
|
||||
|
||||
self._pending_assoc_confirmation: bool = False
|
||||
self._assoc_worker: _AssocWorker | None = None
|
||||
|
||||
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")
|
||||
|
||||
@@ -52,6 +78,12 @@ class TerminalWindow(QWidget):
|
||||
self.input_line.clear()
|
||||
|
||||
self.output_area.append(f"> {command_text}")
|
||||
|
||||
if self._pending_assoc_confirmation:
|
||||
self._pending_assoc_confirmation = False
|
||||
self._handle_assoc_confirmation(command_text.strip().lower())
|
||||
return
|
||||
|
||||
parts = command_text.strip().split()
|
||||
if not parts:
|
||||
return
|
||||
@@ -101,4 +133,34 @@ class TerminalWindow(QWidget):
|
||||
self.output_area.append("Checking for updates...")
|
||||
|
||||
self.updater.manual_check_for_updates()
|
||||
return "See status bar for update information."
|
||||
return "See status bar for update information."
|
||||
|
||||
def cmd_assoc(self, *args: Any) -> str | None:
|
||||
# Non-Windows platforms don't have the admin/non-admin split —
|
||||
# just register directly.
|
||||
if PLATFORM_NAME != "windows" or is_windows_admin():
|
||||
self._run_assoc(force_admin=False)
|
||||
return None
|
||||
|
||||
self._pending_assoc_confirmation = True
|
||||
return "Not running as admin. Register system-wide via UAC elevation? (y/n)"
|
||||
|
||||
def _handle_assoc_confirmation(self, answer: str) -> None:
|
||||
if answer in ("y", "yes"):
|
||||
self._run_assoc(force_admin=True)
|
||||
elif answer in ("n", "no"):
|
||||
self._run_assoc(force_admin=False)
|
||||
else:
|
||||
self.output_area.append("Please answer 'y' or 'n'. Run 'assoc' again to retry.")
|
||||
|
||||
def _run_assoc(self, force_admin: bool) -> None:
|
||||
if force_admin:
|
||||
self.output_area.append("Requesting elevation. Check for a UAC prompt...")
|
||||
|
||||
self._assoc_worker = _AssocWorker(force_admin=force_admin, parent=self)
|
||||
self._assoc_worker.result_ready.connect(self._on_assoc_result)
|
||||
self._assoc_worker.start()
|
||||
|
||||
def _on_assoc_result(self, ok: bool, msg: str) -> None:
|
||||
self.output_area.append(msg)
|
||||
self._assoc_worker = None
|
||||
@@ -1,11 +1,13 @@
|
||||
"""
|
||||
Filename: userguide.py
|
||||
Description: User guide for FLARES
|
||||
Description: User guide window
|
||||
Note: Compliant with pylance strict type checking
|
||||
|
||||
Author: Tyler de Zeeuw
|
||||
License: GPL-3.0
|
||||
"""
|
||||
|
||||
# External library imports
|
||||
from PySide6.QtWidgets import QWidget, QVBoxLayout, QLabel
|
||||
from PySide6.QtCore import Qt
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ from src.shared.shareddata import APP_NAME
|
||||
|
||||
|
||||
class ViewerLauncherWidget(QWidget):
|
||||
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):
|
||||
def __init__(self, haemo_dict, epochs_dict, cha_dict, df_ind_dict, design_matrix_dict, config_dict, fig_bytes_dict, contrast_results_dict, roi_channel_map_dict, folding_bypass):
|
||||
super().__init__()
|
||||
self.setWindowTitle(f"Viewer Launcher - {APP_NAME.upper()}")
|
||||
|
||||
@@ -36,8 +36,8 @@ class ViewerLauncherWidget(QWidget):
|
||||
("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 Stats Viewer", InterGroupStatsWidget, [haemo_dict, cha_dict, df_ind_dict, design_matrix_dict, contrast_results_dict, roi_channel_map_dict, group_dict], True),
|
||||
("Cross-Group Stats Viewer", CrossGroupStatsWidget, [haemo_dict, cha_dict, df_ind_dict, design_matrix_dict, contrast_results_dict, roi_channel_map_dict, group_dict], 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)
|
||||
|
||||
@@ -1,18 +1,19 @@
|
||||
"""
|
||||
Filename: welcome.py
|
||||
Description: Welcome dialog for FLARES
|
||||
Description: Welcome dialog window
|
||||
Note: Compliant with pylance strict type checking
|
||||
|
||||
Author: Tyler de Zeeuw
|
||||
License: GPL-3.0
|
||||
"""
|
||||
|
||||
# External library imports
|
||||
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, QNetworkReply
|
||||
|
||||
from src.shared.shareddata import APP_NAME, CURRENT_VERSION, CHANGELOG_URL, resource_path
|
||||
from src.shared.shareddata import APP_NAME, PLATFORM_NAME, CURRENT_VERSION, CHANGELOG_URL, resource_path
|
||||
|
||||
|
||||
class WelcomeDialog(QDialog):
|
||||
@@ -27,8 +28,8 @@ class WelcomeDialog(QDialog):
|
||||
header_layout = QHBoxLayout()
|
||||
logo_label = QLabel(self)
|
||||
|
||||
# NOTE: might not work on mac and need the icns file
|
||||
logo_label.setPixmap(QIcon(resource_path("icons/main.ico")).pixmap(48, 48))
|
||||
icon_ext = "icns" if PLATFORM_NAME == "darwin" else "ico"
|
||||
logo_label.setPixmap(QIcon(resource_path(f"icons/main.{icon_ext}")).pixmap(48, 48))
|
||||
if first:
|
||||
title_label = QLabel(f"<h2>Welcome to {APP_NAME.upper()}!</h2>", self)
|
||||
elif direct:
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
"""
|
||||
Filename: startup_args.py
|
||||
Description: Parses the startup arguments for the application
|
||||
Note: Compliant with pylance strict type checking
|
||||
|
||||
Author: Tyler de Zeeuw
|
||||
License: GPL-3.0
|
||||
"""
|
||||
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass
|
||||
class StartupArgs:
|
||||
finish_update: bool
|
||||
initial_file: str | None
|
||||
|
||||
|
||||
def parse_startup_args(argv: list[str]) -> StartupArgs:
|
||||
"""
|
||||
Parses the command-line flags.
|
||||
|
||||
Recognizes:
|
||||
--finish-update set by the updater when relaunching post-update
|
||||
<path> a project file to open on startup — the first
|
||||
argument that doesn't start with '-', found
|
||||
anywhere in argv rather than assumed to be at
|
||||
a fixed position, so it survives regardless of
|
||||
whether --finish-update precedes it.
|
||||
"""
|
||||
|
||||
finish_update = "--finish-update" in argv[1:]
|
||||
|
||||
initial_file = None
|
||||
for arg in argv[1:]:
|
||||
if not arg.startswith("-"):
|
||||
initial_file = os.path.abspath(arg)
|
||||
break
|
||||
|
||||
return StartupArgs(finish_update=finish_update, initial_file=initial_file)
|
||||
+2
-2
@@ -416,12 +416,12 @@ def wait_for_process_to_exit(process_name, timeout=10):
|
||||
return False
|
||||
|
||||
|
||||
def finish_update_if_needed(platform_name, app_name, cfg_path):
|
||||
def finish_update_if_needed(platform_name, app_name, cfg_path, finish_update):
|
||||
"""
|
||||
Completes a pending application update if '--finish-update' is present in the command-line arguments.
|
||||
"""
|
||||
|
||||
if "--finish-update" in sys.argv:
|
||||
if finish_update:
|
||||
print("Finishing update...")
|
||||
|
||||
update_cfg = configparser.ConfigParser()
|
||||
|
||||
Reference in New Issue
Block a user