Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
afde775b17 | ||
|
|
2708c78f8e | ||
|
|
b5de8709de | ||
|
|
ddb30d98f2 | ||
|
|
f10eb64332 | ||
|
|
617daa0e9b | ||
|
|
766bf75dd7 | ||
|
|
57b7082564 | ||
|
|
e1c7e1a0e6 | ||
|
|
69f3df8921 | ||
|
|
d74be5cdec | ||
|
|
0f6434121f | ||
|
|
8d922ecae9 | ||
|
|
8655115cca | ||
|
|
74ce2eeb2e | ||
|
|
70c4c1e779 | ||
|
|
94be2fb921 | ||
|
|
7ef07678ae | ||
|
|
f2c236bec5 | ||
|
|
46255e6aa3 | ||
|
|
3a0eaef30d | ||
|
|
868a67b44b | ||
|
|
9a32c8e301 | ||
|
|
3afcb54b77 | ||
|
|
9fe4a4dc30 | ||
|
|
8d893f846e | ||
|
|
540793d150 | ||
|
|
4922405138 | ||
|
|
1b23f2f81a | ||
|
|
3f1161a03a | ||
|
|
1b5c31e4ef | ||
|
|
276e1b1876 | ||
|
|
3cfc6f01a2 | ||
|
|
91fb9b38d4 | ||
|
|
1e346f8ef2 | ||
|
|
8c207b17ad | ||
|
|
f794b98d18 | ||
|
|
06972bee28 | ||
|
|
c279f269da |
@@ -178,4 +178,7 @@ cython_debug/
|
|||||||
*.xlsx
|
*.xlsx
|
||||||
*.csv
|
*.csv
|
||||||
*.snirf
|
*.snirf
|
||||||
*.json
|
*.json
|
||||||
|
flares-*
|
||||||
|
*.flare
|
||||||
|
*.cfg
|
||||||
@@ -27,7 +27,7 @@ This only applies for the first time you attempt to run FLARES. Subsequent times
|
|||||||
|
|
||||||
Due to the cost of a code signing certificate, the application is not digitally signed. Once the application is extracted and attempted to be launched for the first time you will get a popup stating:
|
Due to the cost of a code signing certificate, the application is not digitally signed. Once the application is extracted and attempted to be launched for the first time you will get a popup stating:
|
||||||
|
|
||||||
"Windows protected your PC - Microsoft Defender SmartScreen prevented an unrecognized app from starting. Running this app might put your PC at risk.", with the options of" More info" or "Don't run".
|
"Windows protected your PC - Microsoft Defender SmartScreen prevented an unrecognized app from starting. Running this app might put your PC at risk.", with the options of "More info" or "Don't run".
|
||||||
|
|
||||||
The solution around this is to click "More info" and then select "Run anyway".
|
The solution around this is to click "More info" and then select "Run anyway".
|
||||||
|
|
||||||
@@ -41,4 +41,4 @@ There are no conditions for Linux users at this time.
|
|||||||
|
|
||||||
FLARES is distributed under the GPL-3.0 license.
|
FLARES is distributed under the GPL-3.0 license.
|
||||||
|
|
||||||
Copyright (C) 2025 Tyler de Zeeuw
|
Copyright (C) 2025-2026 Tyler de Zeeuw
|
||||||
@@ -1,3 +1,102 @@
|
|||||||
|
# Version 1.5.0
|
||||||
|
|
||||||
|
- This release introduces a new configuration file that may break existing installs. If your application does not update correctly, please download fresh from [this link.](https://git.research.dezeeuw.ca/tyler/flares/releases/)
|
||||||
|
- This release features an almost complete rewrite of backend files. If you encounter any problems, please raise an issue at [this link.](https://git.research.dezeeuw.ca/tyler/flares/issues/new)
|
||||||
|
- New configuration file has been added! Now your choices of preferences will be saved when the application is closed and re-opened. If the configuration file is missing, a new one will be generated
|
||||||
|
- Recent files and recent projects are now saved and appear under the File menu for quick resuming
|
||||||
|
- The new option "Reset to Default Configuration" under the Options menu will reset the configuration file back to it's default values and remove any recent files
|
||||||
|
- A welcome dialog will now display the changelog on first startup and after every update. This popup will only appear once but can be reopened under the Options menu through the button "Show Update Changelog"
|
||||||
|
- Changed the hotkey for "Update optodes in snirf file..." to be F9 instead of F6
|
||||||
|
- Revamped the fOLD channels window. Images containing the pie charts are now interactable! Click whitespace to expand the whole image and click a chart to expand it.
|
||||||
|
- fOLD progress bar when processing now updates the percentages live. Fixes [Issue 76](https://git.research.dezeeuw.ca/tyler/flares/issues/76)
|
||||||
|
- Overall pie charts on an individal and global basis are now genereted. Fixes [Issue 78](https://git.research.dezeeuw.ca/tyler/flares/issues/78)
|
||||||
|
- 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)
|
||||||
|
|
||||||
|
|
||||||
|
# Version 1.4.3
|
||||||
|
|
||||||
|
- Fixed an issue where the fOLD files could not be located
|
||||||
|
- Added better support for updating events from external software
|
||||||
|
|
||||||
|
|
||||||
|
# Version 1.4.2
|
||||||
|
|
||||||
|
- Fixed AGE, GENDER, GROUP not visually appearing on a bubble after the metadata has been set. Fixes [Issue 42](https://git.research.dezeeuw.ca/tyler/flares/issues/42)
|
||||||
|
- Fixed first stage of progress bar going yellow after loading from an unprocessed save
|
||||||
|
- Fixed AGE, GENDER, GROUP not visually appearing on a bubble when loading from a save
|
||||||
|
- Group images involving an optode template will now be the average of all participants selected in the group and not the last processed participant. Fixes [Issue 62](https://git.research.dezeeuw.ca/tyler/flares/issues/62)
|
||||||
|
- Group images will no longer crash if being made with participants that have a different number of channels
|
||||||
|
- Changed CSV files to now save to the same folder rather than asking each time for each file. Fixes [Issue 39](https://git.research.dezeeuw.ca/tyler/flares/issues/39)
|
||||||
|
|
||||||
|
|
||||||
|
# Version 1.4.1
|
||||||
|
|
||||||
|
- Hotfix to fix a recursive child loop that would cause the MacOS version to not open
|
||||||
|
|
||||||
|
|
||||||
|
# Version 1.4.0
|
||||||
|
|
||||||
|
- This is potentially a save-changing release due to changes in how file paths and parameters are stored. 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
|
||||||
|
- AGE, GENDER, GROUP, loaded files, and all the parameters on the right side of the screen can now be saved before any data has been processed
|
||||||
|
- If processing has not been completed, the process button will be visible. If processing has completed, the analysis button will be visible
|
||||||
|
- If the values fail to load, they will fallback to the previous logic of retreiving these values after processing has occured. Fixes [Issue 66](https://git.research.dezeeuw.ca/tyler/flares/issues/66)
|
||||||
|
- Added new parameters to the right side of the screen: MAX_SHIFT, T_MIN, T_MAX, MAX_BAD_CHANNELS. Fixes [Issue 69](https://git.research.dezeeuw.ca/tyler/flares/issues/69)
|
||||||
|
- Participants that are bad on channels will no longer continue to the GLM stage. Fixes [Issue 18](https://git.research.dezeeuw.ca/tyler/flares/issues/18)
|
||||||
|
- Added new sections to the right side of the screen to mark bad channels: Cross Validation, Median Absolute Deviation, PSD Noise, & Channel Variance
|
||||||
|
- These sections come with the new parameters CV, CV_THRESHOLD, MAD, MAD_THRESHOLD, PSD_NOISE, TARGET_FREQ_DIV, DB_LIMIT, CHANNEL_VAR, & CHANNEL_THRESH
|
||||||
|
- Changed number of rectangles in the progress bar to 28 to account for the new options and updated the User Guide with the new stages
|
||||||
|
- 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 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)
|
||||||
|
- Fixed an issue where the significance image would be empty in the Inter-Group Viewer if only one participant was selected. Fixes [Issue 32](https://git.research.dezeeuw.ca/tyler/flares/issues/32)
|
||||||
|
- Fixed an issue where pressing the 'Clear' button after loading a save would cause the application to crash. Fixes [Issue 67](https://git.research.dezeeuw.ca/tyler/flares/issues/67)
|
||||||
|
- Fixed an issue where group dropdowns in the Cross-Group viewer would not be updated correctly based on the other groups selected value. Fixes [Issue 49](https://git.research.dezeeuw.ca/tyler/flares/issues/49)
|
||||||
|
- Fixed an issue where scrollbars were still present after clearing all data. Fixes [Issue 70](https://git.research.dezeeuw.ca/tyler/flares/issues/70)
|
||||||
|
- Fixed an issue where 'Missing Events Bypass' did not work on the Cross-Group viewer. Fixes [Issue 64](https://git.research.dezeeuw.ca/tyler/flares/issues/64)
|
||||||
|
- Fixed an issue where bubbles loaded from a save would not resize correctly. Fixes [Issue 14](https://git.research.dezeeuw.ca/tyler/flares/issues/14)
|
||||||
|
|
||||||
|
|
||||||
|
# Version 1.3.0
|
||||||
|
|
||||||
|
- This is potentially a save-changing release due to a new parameter being saved. Please update your project files to ensure compatibility
|
||||||
|
- It is still possible to load older saves by enabling 'Incompatible Save Bypass' from the Preferences menu, but your mileage may vary
|
||||||
|
- Fixed workers not releasing memory when processing multiple participants. Fixes [Issue 55](https://git.research.dezeeuw.ca/tyler/flares/issues/55)
|
||||||
|
- Fixed part of an issue where memory could increase over time despite clicking the clear button. There is still some edge cases where this can occur
|
||||||
|
- Fixed an issue when clearing a bubble, reloading the same file, and clicking it again would cause the app to crash. Fixes [Issue 57](https://git.research.dezeeuw.ca/tyler/flares/issues/57)
|
||||||
|
- Picking a .txt or .xlsx file now has both in the same file selection instead of having to select which extension was desired
|
||||||
|
- Added a new parameter to the right side of the screen: EPOCH_HANDLING. Fixes [Issue 58](https://git.research.dezeeuw.ca/tyler/flares/issues/58)
|
||||||
|
- EPOCH_HANDLING defaults to 'shift' compared to previous versions where the default would have been equivalent to 'strict'
|
||||||
|
- The label for ENHANCE_NEGATIVE_CORRELATION no longer gets cut off by its dropdown selection
|
||||||
|
- Loading in files and folders have changes to immediately show their bubbles having a respective loading symbol on each bubble
|
||||||
|
- Once the file has been completely loaded and processed, the loading symbol will change to a green checkmark and clicking will be enabled
|
||||||
|
- The metadata in the File infomation widget is now saved to prevent recalculations every time the bubble is selected
|
||||||
|
- The status bar will now say loading while the bubbles are being processed, and loaded once the processing has completed
|
||||||
|
- This new loading method will prevent the application from hanging when loading lots of files at once. Fixes [Issue 59](https://git.research.dezeeuw.ca/tyler/flares/issues/59)
|
||||||
|
- Fixed text allignment for the first paragraph when a bubble is selected in the 'File information' widget
|
||||||
|
- The three main widgets are now resizable! All of them have minimum widths to ensure they do not get too squished
|
||||||
|
- Added a new option 'Reset Window Layout' under the View menu that will resize all widgets back to their default sizes
|
||||||
|
- Added a new terminal command 'version' that will print the applications current version
|
||||||
|
|
||||||
|
|
||||||
|
# Version 1.2.2
|
||||||
|
|
||||||
|
- Added 'Update events in snirf file (BLAZES)...' and renamed 'Update events in snirf file...' to 'Update events in snirf file (BORIS)...' under the Options menu
|
||||||
|
- The BLAZES option will assign events that are exported directly from the software [BLAZES](https://git.research.dezeeuw.ca/tyler/blazes)
|
||||||
|
- Moved the updating logic to a seperate file for better reusability and generalization
|
||||||
|
- Fixed 'Toggle Status Bar' having no effect on the visibility of the status bar
|
||||||
|
- Fixed a bug when updating optode positions that would prevent .txt files from being selected. Fixes [Issue 54](https://git.research.dezeeuw.ca/tyler/flares/issues/54)
|
||||||
|
- Fixed a missing dependency in the standalone application when attempting to use an .xlsx file to update optode positions
|
||||||
|
|
||||||
|
|
||||||
# Version 1.2.1
|
# Version 1.2.1
|
||||||
|
|
||||||
- Added a requirements.txt file to ensure compatibility
|
- Added a requirements.txt file to ensure compatibility
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
# Version 1.5.0
|
||||||
|
|
||||||
|
- This release introduces a new configuration file that may break existing installs. If your application does not update correctly, please download fresh from [this link.](https://git.research.dezeeuw.ca/tyler/flares/releases/)
|
||||||
|
- This release features an almost complete rewrite of backend files. If you encounter any problems, please raise an issue at [this link.](https://git.research.dezeeuw.ca/tyler/flares/issues/new)
|
||||||
|
- New configuration file has been added! Now your choices of preferences will be saved when the application is closed and re-opened. If the configuration file is missing, a new one will be generated
|
||||||
|
- Recent files and recent projects are now saved and appear under the File menu for quick resuming
|
||||||
|
- The new option "Reset to Default Configuration" under the Options menu will reset the configuration file back to it's default values and remove any recent files
|
||||||
|
- A welcome dialog will now display the changelog on first startup and after every update. This popup will only appear once but can be reopened under the Options menu through the button "Show Update Changelog"
|
||||||
|
- Changed the hotkey for "Update optodes in snirf file..." to be F9 instead of F6
|
||||||
|
- Revamped the fOLD channels window. Images containing the pie charts are now interactable! Click whitespace to expand the whole image and click a chart to expand it.
|
||||||
|
- fOLD progress bar when processing now updates the percentages live. Fixes [Issue 76](https://git.research.dezeeuw.ca/tyler/flares/issues/76)
|
||||||
|
- Overall pie charts on an individal and global basis are now genereted. Fixes [Issue 78](https://git.research.dezeeuw.ca/tyler/flares/issues/78)
|
||||||
|
- 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)
|
||||||
@@ -18,11 +18,12 @@ import subprocess
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
PLATFORM_NAME = platform.system().lower()
|
PLATFORM_NAME = platform.system().lower()
|
||||||
|
APP_NAME = "flares"
|
||||||
|
|
||||||
if PLATFORM_NAME == 'darwin':
|
if PLATFORM_NAME == 'darwin':
|
||||||
LOG_FILE = os.path.join(os.path.dirname(sys.executable), "../../../flares_updater.log")
|
LOG_FILE = os.path.join(os.path.dirname(sys.executable), f"../../../{APP_NAME}_updater.log")
|
||||||
else:
|
else:
|
||||||
LOG_FILE = os.path.join(os.getcwd(), "flares_updater.log")
|
LOG_FILE = os.path.join(os.getcwd(), f"{APP_NAME}_updater.log")
|
||||||
|
|
||||||
|
|
||||||
def log(msg):
|
def log(msg):
|
||||||
@@ -147,7 +148,7 @@ def copy_update_files_darwin(src_folder, dest_folder, updater_name):
|
|||||||
|
|
||||||
def remove_quarantine(app_path):
|
def remove_quarantine(app_path):
|
||||||
script = f'''
|
script = f'''
|
||||||
do shell script "xattr -d -r com.apple.quarantine {shlex.quote(app_path)}" with administrator privileges with prompt "FLARES needs privileges to finish the update. (1/2)"
|
do shell script "xattr -d -r com.apple.quarantine {shlex.quote(app_path)}" with administrator privileges with prompt "{APP_NAME} needs privileges to finish the update. (1/2)"
|
||||||
'''
|
'''
|
||||||
try:
|
try:
|
||||||
subprocess.run(['osascript', '-e', script], check=True)
|
subprocess.run(['osascript', '-e', script], check=True)
|
||||||
@@ -162,7 +163,7 @@ def main():
|
|||||||
log(f"[Updater] sys.argv: {sys.argv}")
|
log(f"[Updater] sys.argv: {sys.argv}")
|
||||||
|
|
||||||
if len(sys.argv) != 3:
|
if len(sys.argv) != 3:
|
||||||
log("Invalid arguments. Usage: flares_updater <update_folder> <main_app_executable>")
|
log(f"Invalid arguments. Usage: {APP_NAME}_updater <update_folder> <main_app_executable>")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
update_folder = sys.argv[1]
|
update_folder = sys.argv[1]
|
||||||
@@ -215,7 +216,7 @@ def main():
|
|||||||
if PLATFORM_NAME == 'darwin':
|
if PLATFORM_NAME == 'darwin':
|
||||||
log(f'Attempting to delete {ppparent_dir}')
|
log(f'Attempting to delete {ppparent_dir}')
|
||||||
delete_path(ppparent_dir)
|
delete_path(ppparent_dir)
|
||||||
update_folder = os.path.join(sys.argv[1], "flares-darwin")
|
update_folder = os.path.join(sys.argv[1], f"{APP_NAME}-darwin")
|
||||||
copy_update_files_darwin(update_folder, pppparent_dir, updater_name)
|
copy_update_files_darwin(update_folder, pppparent_dir, updater_name)
|
||||||
|
|
||||||
else:
|
else:
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="#1f1f1f"><path d="M400-280h160v-80H400v80Zm0-160h280v-80H400v80ZM280-600h400v-80H280v80Zm200 120ZM265-80q-79 0-134.5-55.5T75-270q0-57 29.5-102t77.5-68H80v-80h240v240h-80v-97q-37 8-61 38t-24 69q0 46 32.5 78t77.5 32v80Zm135-40v-80h360v-560H200v160h-80v-160q0-33 23.5-56.5T200-840h560q33 0 56.5 23.5T840-760v560q0 33-23.5 56.5T760-120H400Z"/></svg>
|
||||||
|
After Width: | Height: | Size: 443 B |
@@ -1,2 +0,0 @@
|
|||||||
[LocalizedFileNames]
|
|
||||||
updater.png=@updater.png,0
|
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="#1f1f1f"><path d="M200-120q-33 0-56.5-23.5T120-200v-560q0-33 23.5-56.5T200-840h560q33 0 56.5 23.5T840-760v560q0 33-23.5 56.5T760-120H200Zm0-80h320v-560H200v560Zm560 0v-560H600v560h160Z"/></svg>
|
||||||
|
After Width: | Height: | Size: 291 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="#1f1f1f"><path d="M480-120q-138 0-240.5-91.5T122-440h82q14 104 92.5 172T480-200q117 0 198.5-81.5T760-480q0-117-81.5-198.5T480-760q-69 0-129 32t-101 88h110v80H120v-240h80v94q51-64 124.5-99T480-840q75 0 140.5 28.5t114 77q48.5 48.5 77 114T840-480q0 75-28.5 140.5t-77 114q-48.5 48.5-114 77T480-120Zm112-192L440-464v-216h80v184l128 128-56 56Z"/></svg>
|
||||||
|
After Width: | Height: | Size: 444 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="#1f1f1f"><path d="M480-80q-155 0-269-103T82-440h81q15 121 105.5 200.5T480-160q134 0 227-93t93-227q0-134-93-227t-227-93q-86 0-159.5 42.5T204-640h116v80H88q29-140 139-230t253-90q83 0 156 31.5T763-763q54 54 85.5 127T880-480q0 83-31.5 156T763-197q-54 54-127 85.5T480-80Zm112-232L440-464v-216h80v184l128 128-56 56Z"/></svg>
|
||||||
|
After Width: | Height: | Size: 416 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="#1f1f1f"><path d="M520-330v-60h160v60H520Zm60 210v-50h-60v-60h60v-50h60v160h-60Zm100-50v-60h160v60H680Zm40-110v-160h60v50h60v60h-60v50h-60Zm111-280h-83q-26-88-99-144t-169-56q-117 0-198.5 81.5T200-480q0 72 32.5 132t87.5 98v-110h80v240H160v-80h94q-62-50-98-122.5T120-480q0-75 28.5-140.5t77-114q48.5-48.5 114-77T480-840q129 0 226.5 79.5T831-560Z"/></svg>
|
||||||
|
After Width: | Height: | Size: 449 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="#1f1f1f"><path d="M451.5-251.5Q440-263 440-280t11.5-28.5Q463-320 480-320t28.5 11.5Q520-297 520-280t-11.5 28.5Q497-240 480-240t-28.5-11.5ZM440-360v-161l80 80v81h-80Zm433 158L655-419 480-720l-47 80-58-58 105-182 393 678Zm-695 2h469L350-497 178-200ZM819-28l-92-92H40l252-435L27-820l57-57L876-85l-57 57ZM499-348Zm45-181Z"/></svg>
|
||||||
|
After Width: | Height: | Size: 423 B |
|
After Width: | Height: | Size: 55 KiB |
@@ -0,0 +1,166 @@
|
|||||||
|
"""
|
||||||
|
Filename: exportcsv.py
|
||||||
|
Description: Export data as csv analysis window for FLARES
|
||||||
|
|
||||||
|
Author: Tyler de Zeeuw
|
||||||
|
License: GPL-3.0
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
from PySide6.QtWidgets import QFileDialog, QGridLayout, QHBoxLayout, QMessageBox, QPushButton, QScrollArea, QWidget, QVBoxLayout, QLabel
|
||||||
|
from PySide6.QtCore import QSize
|
||||||
|
|
||||||
|
from src.shared.flaresbasewidget import FlaresBaseWidget
|
||||||
|
from src.shared.shareddata import APP_NAME
|
||||||
|
|
||||||
|
|
||||||
|
class ExportDataAsCSVViewerWidget(FlaresBaseWidget):
|
||||||
|
def __init__(self, haemo_dict, cha_dict, df_ind, design_matrix, group, contrast_results_dict):
|
||||||
|
super().__init__("ExportDataAsCSVViewer")
|
||||||
|
self.setWindowTitle(f"Export Data As CSV Viewer - {APP_NAME.upper()}")
|
||||||
|
self.haemo_dict = haemo_dict
|
||||||
|
self.cha_dict = cha_dict
|
||||||
|
self.df_ind = df_ind
|
||||||
|
self.design_matrix = design_matrix
|
||||||
|
self.group = group
|
||||||
|
self.contrast_results_dict = contrast_results_dict
|
||||||
|
|
||||||
|
# Create mappings: file_path -> participant label and dropdown display text
|
||||||
|
self.participant_map = {} # file_path -> "Participant 1"
|
||||||
|
self.participant_dropdown_items = [] # "Participant 1 (filename)"
|
||||||
|
|
||||||
|
for i, file_path in enumerate(self.haemo_dict.keys(), start=1):
|
||||||
|
short_label = f"Participant {i}"
|
||||||
|
display_label = f"{short_label} ({os.path.basename(file_path)})"
|
||||||
|
self.participant_map[file_path] = short_label
|
||||||
|
self.participant_dropdown_items.append(display_label)
|
||||||
|
|
||||||
|
self.layout = QVBoxLayout(self)
|
||||||
|
self.top_bar = QHBoxLayout()
|
||||||
|
self.layout.addLayout(self.top_bar)
|
||||||
|
|
||||||
|
self.participant_dropdown = self._create_multiselect_dropdown(self.participant_dropdown_items)
|
||||||
|
self.participant_dropdown.currentIndexChanged.connect(self.update_participant_dropdown_label)
|
||||||
|
|
||||||
|
self.index_texts = [
|
||||||
|
"0 (Export Data to CSV)",
|
||||||
|
"1 (CSV for SPARKS)",
|
||||||
|
# "2 (third image)",
|
||||||
|
# "3 (fourth image)",
|
||||||
|
]
|
||||||
|
|
||||||
|
self.image_index_dropdown = self._create_multiselect_dropdown(self.index_texts)
|
||||||
|
self.image_index_dropdown.currentIndexChanged.connect(self.update_image_index_dropdown_label)
|
||||||
|
|
||||||
|
self.submit_button = QPushButton("Submit")
|
||||||
|
self.submit_button.clicked.connect(self.generate_and_save_csv)
|
||||||
|
|
||||||
|
self.top_bar.addWidget(QLabel("Participants:"))
|
||||||
|
self.top_bar.addWidget(self.participant_dropdown)
|
||||||
|
self.top_bar.addWidget(QLabel("Export Type:"))
|
||||||
|
self.top_bar.addWidget(self.image_index_dropdown)
|
||||||
|
self.top_bar.addWidget(self.submit_button)
|
||||||
|
|
||||||
|
self.scroll = QScrollArea()
|
||||||
|
self.scroll.setWidgetResizable(True)
|
||||||
|
self.scroll_content = QWidget()
|
||||||
|
self.grid_layout = QGridLayout(self.scroll_content)
|
||||||
|
self.scroll.setWidget(self.scroll_content)
|
||||||
|
self.layout.addWidget(self.scroll)
|
||||||
|
|
||||||
|
self.thumb_size = QSize(280, 180)
|
||||||
|
self.showMaximized()
|
||||||
|
|
||||||
|
|
||||||
|
def generate_and_save_csv(self):
|
||||||
|
|
||||||
|
selected_display_names = self._get_checked_items(self.participant_dropdown)
|
||||||
|
selected_file_paths = []
|
||||||
|
for display_name in selected_display_names:
|
||||||
|
for fp, short_label in self.participant_map.items():
|
||||||
|
expected_display = f"{short_label} ({os.path.basename(fp)})"
|
||||||
|
if display_name == expected_display:
|
||||||
|
selected_file_paths.append(fp)
|
||||||
|
break
|
||||||
|
|
||||||
|
selected_indexes = [
|
||||||
|
int(s.split(" ")[0]) for s in self._get_checked_items(self.image_index_dropdown)
|
||||||
|
]
|
||||||
|
|
||||||
|
if not selected_file_paths or not selected_indexes:
|
||||||
|
QMessageBox.warning(self, "Selection Missing", "Please select at least one participant and one export type.")
|
||||||
|
return
|
||||||
|
|
||||||
|
# 2. ASK ONCE: Select Output Directory
|
||||||
|
output_dir = QFileDialog.getExistingDirectory(self, "Select Output Folder for CSV Exports")
|
||||||
|
|
||||||
|
if not output_dir:
|
||||||
|
print("Export cancelled: No folder selected.")
|
||||||
|
return
|
||||||
|
|
||||||
|
success_count = 0
|
||||||
|
|
||||||
|
# Pass the necessary arguments to each method
|
||||||
|
for file_path in selected_file_paths:
|
||||||
|
base_filename = os.path.splitext(os.path.basename(file_path))[0]
|
||||||
|
haemo_obj = self.haemo_dict.get(file_path)
|
||||||
|
if haemo_obj is None:
|
||||||
|
continue
|
||||||
|
|
||||||
|
cha = self.cha_dict.get(file_path)
|
||||||
|
|
||||||
|
for idx in selected_indexes:
|
||||||
|
try:
|
||||||
|
if idx == 0:
|
||||||
|
save_path = os.path.join(output_dir, f"{base_filename}_exported.csv")
|
||||||
|
if cha is not None:
|
||||||
|
cha.to_csv(save_path)
|
||||||
|
success_count += 1
|
||||||
|
|
||||||
|
|
||||||
|
elif idx == 1:
|
||||||
|
# SPARKS Export
|
||||||
|
save_path = os.path.join(output_dir, f"{base_filename}_sparks.csv")
|
||||||
|
if haemo_obj is not None:
|
||||||
|
raw = haemo_obj
|
||||||
|
data, times = raw.get_data(return_times=True)
|
||||||
|
ann_col = np.full(times.shape, "", dtype=object)
|
||||||
|
|
||||||
|
if raw.annotations is not None and len(raw.annotations) > 0:
|
||||||
|
for onset, duration, desc in zip(
|
||||||
|
raw.annotations.onset,
|
||||||
|
raw.annotations.duration,
|
||||||
|
raw.annotations.description
|
||||||
|
):
|
||||||
|
mask = (times >= onset) & (times < onset + duration)
|
||||||
|
ann_col[mask] = desc
|
||||||
|
|
||||||
|
df = pd.DataFrame(data.T, columns=raw.ch_names)
|
||||||
|
df.insert(0, "annotation", ann_col)
|
||||||
|
df.insert(0, "time", times)
|
||||||
|
df.to_csv(save_path, index=False)
|
||||||
|
success_count += 1
|
||||||
|
|
||||||
|
else:
|
||||||
|
print(f"No method defined for index {idx}")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Failed to export {file_path} (Type {idx}): {e}")
|
||||||
|
|
||||||
|
# 4. Final Notification
|
||||||
|
if success_count > 0:
|
||||||
|
QMessageBox.information(self, "Export Complete", f"Successfully saved {success_count} CSV files to:\n{output_dir}")
|
||||||
|
|
||||||
|
# # If SPARKS export was included, show the Event Window once at the end
|
||||||
|
# if 1 in selected_indexes:
|
||||||
|
# win = UpdateEventsWindow(
|
||||||
|
# parent=self,
|
||||||
|
# mode=EventUpdateMode.WRITE_JSON,
|
||||||
|
# caller="Video Alignment Tool"
|
||||||
|
# )
|
||||||
|
# win.show()
|
||||||
|
|
||||||
@@ -0,0 +1,306 @@
|
|||||||
|
"""
|
||||||
|
Filename: group.py
|
||||||
|
Description: Group analysis window for FLARES
|
||||||
|
|
||||||
|
Author: Tyler de Zeeuw
|
||||||
|
License: GPL-3.0
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
from PySide6.QtWidgets import QComboBox, QDialog, QGridLayout, QHBoxLayout, QPushButton, QScrollArea, QWidget, QVBoxLayout, QLabel
|
||||||
|
from PySide6.QtCore import QSize
|
||||||
|
|
||||||
|
from src.shared.flaresbasewidget import FlaresBaseWidget, ParameterInputDialog
|
||||||
|
from src.shared.shareddata import APP_NAME
|
||||||
|
|
||||||
|
|
||||||
|
class GroupViewerWidget(FlaresBaseWidget):
|
||||||
|
def __init__(self, haemo_dict, cha, df_ind, design_matrix, contrast_results, group):
|
||||||
|
super().__init__("GroupViewer")
|
||||||
|
self.setWindowTitle(f"Group Viewer - {APP_NAME.upper()}")
|
||||||
|
self.haemo_dict = haemo_dict
|
||||||
|
self.cha = cha
|
||||||
|
self.df_ind = df_ind
|
||||||
|
self.design_matrix = design_matrix
|
||||||
|
self.contrast_results = contrast_results
|
||||||
|
self.group = group
|
||||||
|
self.show_all_events = True
|
||||||
|
self._updating_checkstates = False
|
||||||
|
|
||||||
|
# Create mappings: file_path -> participant label and dropdown display text
|
||||||
|
self.participant_map = {} # file_path -> "Participant 1"
|
||||||
|
self.participant_dropdown_items = [] # "Participant 1 (filename)"
|
||||||
|
|
||||||
|
for i, file_path in enumerate(self.haemo_dict.keys(), start=1):
|
||||||
|
short_label = f"Participant {i}"
|
||||||
|
display_label = f"{short_label} ({os.path.basename(file_path)})"
|
||||||
|
self.participant_map[file_path] = short_label
|
||||||
|
self.participant_dropdown_items.append(display_label)
|
||||||
|
|
||||||
|
self.layout = QVBoxLayout(self)
|
||||||
|
self.top_bar = QHBoxLayout()
|
||||||
|
self.layout.addLayout(self.top_bar)
|
||||||
|
|
||||||
|
self.group_to_paths = {}
|
||||||
|
for file_path, group_name in self.group.items():
|
||||||
|
self.group_to_paths.setdefault(group_name, []).append(file_path)
|
||||||
|
|
||||||
|
self.group_names = sorted(self.group_to_paths.keys())
|
||||||
|
|
||||||
|
self.group_dropdown = QComboBox()
|
||||||
|
self.group_dropdown.addItem("<None Selected>")
|
||||||
|
self.group_dropdown.addItems(self.group_names)
|
||||||
|
self.group_dropdown.setCurrentIndex(0)
|
||||||
|
self.group_dropdown.currentIndexChanged.connect(self.update_participant_list_for_group)
|
||||||
|
|
||||||
|
self.participant_dropdown = self._create_multiselect_dropdown(self.participant_dropdown_items)
|
||||||
|
self.participant_dropdown.currentIndexChanged.connect(self.update_participant_dropdown_label)
|
||||||
|
self.participant_dropdown.setEnabled(False)
|
||||||
|
|
||||||
|
self.event_dropdown = QComboBox()
|
||||||
|
self.event_dropdown.addItem("<None Selected>")
|
||||||
|
|
||||||
|
self.index_texts = [
|
||||||
|
"0 (GLM Results)",
|
||||||
|
"1 (Significance)",
|
||||||
|
"2 (Brain Activity Visualization)",
|
||||||
|
# "3 (fourth image)",
|
||||||
|
]
|
||||||
|
|
||||||
|
self.image_index_dropdown = self._create_multiselect_dropdown(self.index_texts)
|
||||||
|
self.image_index_dropdown.currentIndexChanged.connect(self.update_image_index_dropdown_label)
|
||||||
|
|
||||||
|
self.submit_button = QPushButton("Submit")
|
||||||
|
self.submit_button.clicked.connect(self.show_brain_images)
|
||||||
|
|
||||||
|
self.top_bar.addWidget(QLabel("Group:"))
|
||||||
|
self.top_bar.addWidget(self.group_dropdown)
|
||||||
|
self.top_bar.addWidget(QLabel("Participants:"))
|
||||||
|
self.top_bar.addWidget(self.participant_dropdown)
|
||||||
|
self.top_bar.addWidget(QLabel("Event:"))
|
||||||
|
self.top_bar.addWidget(self.event_dropdown)
|
||||||
|
self.top_bar.addWidget(QLabel("Image Indexes:"))
|
||||||
|
self.top_bar.addWidget(self.image_index_dropdown)
|
||||||
|
self.top_bar.addWidget(self.submit_button)
|
||||||
|
|
||||||
|
self.scroll = QScrollArea()
|
||||||
|
self.scroll.setWidgetResizable(True)
|
||||||
|
self.scroll_content = QWidget()
|
||||||
|
self.grid_layout = QGridLayout(self.scroll_content)
|
||||||
|
self.scroll.setWidget(self.scroll_content)
|
||||||
|
self.layout.addWidget(self.scroll)
|
||||||
|
|
||||||
|
self.thumb_size = QSize(280, 180)
|
||||||
|
self.showMaximized()
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def show_brain_images(self):
|
||||||
|
import flares as flares
|
||||||
|
|
||||||
|
selected_event = self.event_dropdown.currentText()
|
||||||
|
if selected_event == "<None Selected>":
|
||||||
|
selected_event = None
|
||||||
|
|
||||||
|
selected_display_names = self._get_checked_items(self.participant_dropdown)
|
||||||
|
selected_file_paths = []
|
||||||
|
for display_name in selected_display_names:
|
||||||
|
for fp, short_label in self.participant_map.items():
|
||||||
|
expected_display = f"{short_label} ({os.path.basename(fp)})"
|
||||||
|
if display_name == expected_display:
|
||||||
|
selected_file_paths.append(fp)
|
||||||
|
break
|
||||||
|
|
||||||
|
if selected_event:
|
||||||
|
valid_paths = []
|
||||||
|
for fp in selected_file_paths:
|
||||||
|
raw = self.haemo_dict.get(fp)
|
||||||
|
# Check if this participant actually has the event in their annotations
|
||||||
|
if raw is not None and hasattr(raw, "annotations"):
|
||||||
|
if selected_event in raw.annotations.description:
|
||||||
|
valid_paths.append(fp)
|
||||||
|
|
||||||
|
selected_file_paths = valid_paths
|
||||||
|
|
||||||
|
selected_indexes = [
|
||||||
|
int(s.split(" ")[0]) for s in self._get_checked_items(self.image_index_dropdown)
|
||||||
|
]
|
||||||
|
|
||||||
|
if not selected_file_paths:
|
||||||
|
print("No participants selected.")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Only keep indexes 0 and 1 that need parameters
|
||||||
|
parameterized_indexes = {
|
||||||
|
0: [
|
||||||
|
{
|
||||||
|
"key": "lower_bound",
|
||||||
|
"label": "Lower bound + <description>",
|
||||||
|
"default": "-0.3",
|
||||||
|
"type": float, # specify int here
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "upper_bound",
|
||||||
|
"label": "Upper bound + <description>",
|
||||||
|
"default": "0.8",
|
||||||
|
"type": float, # specify int here
|
||||||
|
}
|
||||||
|
],
|
||||||
|
1: [
|
||||||
|
{
|
||||||
|
"key": "p_value",
|
||||||
|
"label": "Significance threshold P-value (e.g. 0.05)",
|
||||||
|
"default": "0.05",
|
||||||
|
"type": float,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "graph_bounds",
|
||||||
|
"label": "Graph Upper/Lower Limit",
|
||||||
|
"default": "3.0",
|
||||||
|
"type": float,
|
||||||
|
}
|
||||||
|
],
|
||||||
|
2: [
|
||||||
|
{
|
||||||
|
"key": "show_optodes",
|
||||||
|
"label": "Determine what is rendered above the brain. Valid values are 'sensors', 'labels', 'none', 'all'.",
|
||||||
|
"default": "all",
|
||||||
|
"type": str,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "t_or_theta",
|
||||||
|
"label": "Specify if t values or theta values should be plotted. Valid values are 't', 'theta'",
|
||||||
|
"default": "theta",
|
||||||
|
"type": str,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "show_text",
|
||||||
|
"label": "Display informative text on the top left corner. THIS DOES NOT WORK AND SHOULD BE LEFT AT FALSE",
|
||||||
|
"default": "False",
|
||||||
|
"type": bool,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "brain_bounds",
|
||||||
|
"label": "Graph Upper/Lower Limit",
|
||||||
|
"default": "1.0",
|
||||||
|
"type": float,
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
# Inject full_text from index_texts
|
||||||
|
for idx, params_list in parameterized_indexes.items():
|
||||||
|
full_text = self.index_texts[idx] if idx < len(self.index_texts) else f"{idx} (No label found)"
|
||||||
|
for param_info in params_list:
|
||||||
|
param_info["full_text"] = full_text
|
||||||
|
|
||||||
|
indexes_needing_params = {idx: parameterized_indexes[idx] for idx in selected_indexes if idx in parameterized_indexes}
|
||||||
|
|
||||||
|
param_values = {}
|
||||||
|
if indexes_needing_params:
|
||||||
|
dialog = ParameterInputDialog(indexes_needing_params, parent=self)
|
||||||
|
if dialog.exec_() == QDialog.Accepted:
|
||||||
|
param_values = dialog.get_values()
|
||||||
|
if param_values is None:
|
||||||
|
return
|
||||||
|
else:
|
||||||
|
return
|
||||||
|
|
||||||
|
|
||||||
|
all_cha = pd.DataFrame()
|
||||||
|
for file_path in selected_file_paths:
|
||||||
|
haemo_obj = self.haemo_dict.get(file_path)
|
||||||
|
|
||||||
|
if selected_event:
|
||||||
|
participant_events = set(haemo_obj.annotations.description)
|
||||||
|
if selected_event not in participant_events:
|
||||||
|
print(f"Skipping {self.participant_map[file_path]}: Event '{selected_event}' not found.")
|
||||||
|
continue
|
||||||
|
|
||||||
|
if haemo_obj is None:
|
||||||
|
continue
|
||||||
|
|
||||||
|
cha_df = self.cha.get(file_path)
|
||||||
|
if cha_df is not None:
|
||||||
|
all_cha = pd.concat([all_cha, cha_df], ignore_index=True)
|
||||||
|
|
||||||
|
# Pass the necessary arguments to each method
|
||||||
|
file_path = selected_file_paths[0]
|
||||||
|
p_haemo = self.haemo_dict.get(file_path)
|
||||||
|
p_design_matrix = self.design_matrix.get(file_path)
|
||||||
|
|
||||||
|
df_group = pd.DataFrame()
|
||||||
|
|
||||||
|
if selected_file_paths:
|
||||||
|
for file_path in selected_file_paths:
|
||||||
|
df = self.df_ind.get(file_path)
|
||||||
|
if df is not None:
|
||||||
|
df_group = pd.concat([df_group, df], ignore_index=True)
|
||||||
|
|
||||||
|
|
||||||
|
for idx in selected_indexes:
|
||||||
|
if idx == 0:
|
||||||
|
params = param_values.get(idx, {})
|
||||||
|
lower_bound = params.get("lower_bound", None)
|
||||||
|
upper_bound = params.get("upper_bound", None)
|
||||||
|
|
||||||
|
if lower_bound is None or upper_bound is None:
|
||||||
|
print(f"Missing parameters for index {idx}, skipping.")
|
||||||
|
continue
|
||||||
|
|
||||||
|
|
||||||
|
flares.plot_fir_model_results(df_group, p_haemo, p_design_matrix, selected_event, lower_bound, upper_bound)
|
||||||
|
|
||||||
|
elif idx == 1:
|
||||||
|
params = param_values.get(idx, {})
|
||||||
|
p_val = params.get("p_value", None)
|
||||||
|
graph_bounds = params.get("graph_bounds", None)
|
||||||
|
|
||||||
|
if p_val is None or graph_bounds is None:
|
||||||
|
print(f"Missing parameters for index {idx}, skipping.")
|
||||||
|
continue
|
||||||
|
|
||||||
|
all_contrasts = []
|
||||||
|
for fp in selected_file_paths:
|
||||||
|
condition_dfs = self.contrast_results.get(fp, {})
|
||||||
|
if selected_event in condition_dfs:
|
||||||
|
df = condition_dfs[selected_event].copy()
|
||||||
|
df["ID"] = fp
|
||||||
|
all_contrasts.append(df)
|
||||||
|
|
||||||
|
if not all_contrasts:
|
||||||
|
print("No contrast data found for selected participants and event.")
|
||||||
|
return
|
||||||
|
|
||||||
|
df_contrasts = pd.concat(all_contrasts, ignore_index=True)
|
||||||
|
flares.run_second_level_analysis(df_contrasts, p_haemo, p_val, graph_bounds)
|
||||||
|
|
||||||
|
elif idx == 2:
|
||||||
|
params = param_values.get(idx, {})
|
||||||
|
show_optodes = params.get("show_optodes", None)
|
||||||
|
t_or_theta = params.get("t_or_theta", None)
|
||||||
|
show_text = params.get("show_text", None)
|
||||||
|
brain_bounds = params.get("brain_bounds", None)
|
||||||
|
|
||||||
|
if show_optodes is None or t_or_theta is None or show_text is None or brain_bounds is None:
|
||||||
|
print(f"Missing parameters for index {idx}, skipping.")
|
||||||
|
continue
|
||||||
|
|
||||||
|
raw_list = [self.haemo_dict.get(fp) for fp in selected_file_paths]
|
||||||
|
|
||||||
|
if len(selected_file_paths) > 1:
|
||||||
|
print(f"Aggregating geometry for {len(selected_file_paths)} participants...")
|
||||||
|
processed_raw = flares.aggregate_fnirs_group_geometry(raw_list)
|
||||||
|
else:
|
||||||
|
processed_raw = raw_list[0].copy().pick(picks="hbo")
|
||||||
|
|
||||||
|
flares.brain_3d_visualization(processed_raw, all_cha, selected_event, t_or_theta=t_or_theta, show_optodes=show_optodes, show_text=show_text, brain_bounds=brain_bounds)
|
||||||
|
|
||||||
|
elif idx == 3:
|
||||||
|
pass
|
||||||
|
|
||||||
|
else:
|
||||||
|
print(f"No method defined for index {idx}")
|
||||||
@@ -0,0 +1,311 @@
|
|||||||
|
"""
|
||||||
|
Filename: groupbrain.py
|
||||||
|
Description: Group brain analysis window for FLARES
|
||||||
|
|
||||||
|
Author: Tyler de Zeeuw
|
||||||
|
License: GPL-3.0
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
from PySide6.QtWidgets import QComboBox, QDialog, QGridLayout, QHBoxLayout, QPushButton, QScrollArea, QWidget, QVBoxLayout, QLabel
|
||||||
|
from PySide6.QtCore import QSize
|
||||||
|
|
||||||
|
from src.shared.flaresbasewidget import FlaresBaseWidget, ParameterInputDialog
|
||||||
|
from src.shared.shareddata import APP_NAME
|
||||||
|
|
||||||
|
|
||||||
|
class GroupBrainViewerWidget(FlaresBaseWidget):
|
||||||
|
def __init__(self, haemo_dict, df_ind, design_matrix, group, contrast_results_dict):
|
||||||
|
super().__init__("GroupBrainViewer")
|
||||||
|
self.setWindowTitle(f"Group Brain Viewer - {APP_NAME.upper()}")
|
||||||
|
self.haemo_dict = haemo_dict
|
||||||
|
self.df_ind = df_ind
|
||||||
|
self.design_matrix = design_matrix
|
||||||
|
self.group = group
|
||||||
|
self.contrast_results_dict = contrast_results_dict
|
||||||
|
|
||||||
|
self.group_to_paths = {}
|
||||||
|
for file_path, group_name in self.group.items():
|
||||||
|
self.group_to_paths.setdefault(group_name, []).append(file_path)
|
||||||
|
|
||||||
|
self.group_names = sorted(self.group_to_paths.keys())
|
||||||
|
|
||||||
|
self.layout = QVBoxLayout(self)
|
||||||
|
self.top_bar = QHBoxLayout()
|
||||||
|
self.layout.addLayout(self.top_bar)
|
||||||
|
|
||||||
|
|
||||||
|
self.group_a_dropdown = QComboBox()
|
||||||
|
self.group_a_dropdown.addItem("<None Selected>")
|
||||||
|
self.group_a_dropdown.addItems(self.group_names)
|
||||||
|
self.group_a_dropdown.currentIndexChanged.connect(self._update_group_a_options)
|
||||||
|
|
||||||
|
|
||||||
|
self.group_b_dropdown = QComboBox()
|
||||||
|
self.group_b_dropdown.addItem("<None Selected>")
|
||||||
|
self.group_b_dropdown.addItems(self.group_names)
|
||||||
|
self.group_b_dropdown.currentIndexChanged.connect(self._update_group_b_options)
|
||||||
|
|
||||||
|
|
||||||
|
self.event_dropdown = QComboBox()
|
||||||
|
self.event_dropdown.addItem("<None Selected>")
|
||||||
|
|
||||||
|
self.participant_dropdown_a = self._create_multiselect_dropdown([])
|
||||||
|
self.participant_dropdown_a.lineEdit().setPlaceholderText("Select participants (Group A)")
|
||||||
|
self.participant_dropdown_a.model().itemChanged.connect(self._on_participants_changed)
|
||||||
|
|
||||||
|
|
||||||
|
self.participant_dropdown_b = self._create_multiselect_dropdown([])
|
||||||
|
self.participant_dropdown_b.lineEdit().setPlaceholderText("Select participants (Group B)")
|
||||||
|
self.participant_dropdown_b.model().itemChanged.connect(self._on_participants_changed)
|
||||||
|
|
||||||
|
|
||||||
|
self.index_texts = [
|
||||||
|
"0 (Contrast Image)",
|
||||||
|
# "1 (3D Brain Contrast)",
|
||||||
|
# "2 (third image)",
|
||||||
|
# "3 (fourth image)",
|
||||||
|
]
|
||||||
|
self.image_index_dropdown = self._create_multiselect_dropdown(self.index_texts)
|
||||||
|
self.image_index_dropdown.currentIndexChanged.connect(self.update_image_index_dropdown_label)
|
||||||
|
|
||||||
|
|
||||||
|
self.submit_button = QPushButton("Submit")
|
||||||
|
self.submit_button.clicked.connect(self.show_brain_images)
|
||||||
|
|
||||||
|
|
||||||
|
self.top_bar.addWidget(QLabel("Group A:"))
|
||||||
|
self.top_bar.addWidget(self.group_a_dropdown)
|
||||||
|
self.top_bar.addWidget(QLabel("Participants (Group A):"))
|
||||||
|
self.top_bar.addWidget(self.participant_dropdown_a)
|
||||||
|
self.top_bar.addWidget(QLabel("Group B:"))
|
||||||
|
self.top_bar.addWidget(self.group_b_dropdown)
|
||||||
|
self.top_bar.addWidget(QLabel("Participants (Group B):"))
|
||||||
|
self.top_bar.addWidget(self.participant_dropdown_b)
|
||||||
|
self.top_bar.addWidget(QLabel("Event:"))
|
||||||
|
self.top_bar.addWidget(self.event_dropdown)
|
||||||
|
self.top_bar.addWidget(QLabel("Image Indexes:"))
|
||||||
|
self.top_bar.addWidget(self.image_index_dropdown)
|
||||||
|
self.top_bar.addWidget(self.submit_button)
|
||||||
|
|
||||||
|
self.scroll = QScrollArea()
|
||||||
|
self.scroll.setWidgetResizable(True)
|
||||||
|
self.scroll_content = QWidget()
|
||||||
|
self.grid_layout = QGridLayout(self.scroll_content)
|
||||||
|
self.scroll.setWidget(self.scroll_content)
|
||||||
|
self.layout.addWidget(self.scroll)
|
||||||
|
|
||||||
|
self.thumb_size = QSize(280, 180)
|
||||||
|
self.showMaximized()
|
||||||
|
|
||||||
|
def _update_group_b_options(self):
|
||||||
|
"""Triggered when Group B changes: Update Group A to exclude B's choice"""
|
||||||
|
selected_b = self.group_b_dropdown.currentText()
|
||||||
|
|
||||||
|
# Refresh Group A and exclude what was just picked in Group B
|
||||||
|
self._refresh_group_dropdown(self.group_a_dropdown, exclude=selected_b)
|
||||||
|
|
||||||
|
# Update the participants for Group B
|
||||||
|
self.update_participant_list_for_group(selected_b, self.participant_dropdown_b)
|
||||||
|
self._update_event_dropdown()
|
||||||
|
|
||||||
|
def _update_group_a_options(self):
|
||||||
|
"""Triggered when Group A changes: Update Group B to exclude A's choice"""
|
||||||
|
selected_a = self.group_a_dropdown.currentText()
|
||||||
|
|
||||||
|
# Refresh Group B and exclude what was just picked in Group A
|
||||||
|
self._refresh_group_dropdown(self.group_b_dropdown, exclude=selected_a)
|
||||||
|
|
||||||
|
# Update the participants for Group A
|
||||||
|
self.update_participant_list_for_group(selected_a, self.participant_dropdown_a)
|
||||||
|
self._update_event_dropdown()
|
||||||
|
|
||||||
|
def _on_participants_changed(self, item=None):
|
||||||
|
self._update_event_dropdown()
|
||||||
|
|
||||||
|
|
||||||
|
def _refresh_group_dropdown(self, dropdown, exclude):
|
||||||
|
current = dropdown.currentText()
|
||||||
|
dropdown.blockSignals(True)
|
||||||
|
dropdown.clear()
|
||||||
|
dropdown.addItem("<None Selected>")
|
||||||
|
for group in self.group_names:
|
||||||
|
if group != exclude:
|
||||||
|
dropdown.addItem(group)
|
||||||
|
# Restore previous selection if still valid
|
||||||
|
if current != "<None Selected>" and current != exclude and dropdown.findText(current) != -1:
|
||||||
|
dropdown.setCurrentText(current)
|
||||||
|
else:
|
||||||
|
dropdown.setCurrentIndex(0) # Reset to "<None Selected>"
|
||||||
|
dropdown.blockSignals(False)
|
||||||
|
|
||||||
|
|
||||||
|
def _get_file_paths_from_labels(self, labels, group_name):
|
||||||
|
file_paths = []
|
||||||
|
|
||||||
|
if group_name == self.group_a_dropdown.currentText():
|
||||||
|
participant_map = self.participant_map_a
|
||||||
|
elif group_name == self.group_b_dropdown.currentText():
|
||||||
|
participant_map = self.participant_map_b
|
||||||
|
else:
|
||||||
|
return []
|
||||||
|
|
||||||
|
# Reverse map: display label -> file path
|
||||||
|
reverse_map = {
|
||||||
|
f"{label} ({os.path.basename(fp)})": fp
|
||||||
|
for fp, label in participant_map.items()
|
||||||
|
}
|
||||||
|
|
||||||
|
for label in labels:
|
||||||
|
file_path = reverse_map.get(label)
|
||||||
|
if file_path:
|
||||||
|
file_paths.append(file_path)
|
||||||
|
|
||||||
|
return file_paths
|
||||||
|
|
||||||
|
def show_brain_images(self):
|
||||||
|
import flares as flares
|
||||||
|
|
||||||
|
selected_event = self.event_dropdown.currentText()
|
||||||
|
if selected_event == "<None Selected>":
|
||||||
|
selected_event = None
|
||||||
|
|
||||||
|
# Group A
|
||||||
|
participants_a = self._get_checked_items(self.participant_dropdown_a)
|
||||||
|
file_paths_a = self._get_file_paths_from_labels(participants_a, self.group_a_dropdown.currentText())
|
||||||
|
|
||||||
|
# Group B
|
||||||
|
participants_b = self._get_checked_items(self.participant_dropdown_b)
|
||||||
|
file_paths_b = self._get_file_paths_from_labels(participants_b, self.group_b_dropdown.currentText())
|
||||||
|
|
||||||
|
selected_indexes = [
|
||||||
|
int(s.split(" ")[0]) for s in self._get_checked_items(self.image_index_dropdown)
|
||||||
|
]
|
||||||
|
|
||||||
|
all_selected_paths = list(set(file_paths_a + file_paths_b))
|
||||||
|
|
||||||
|
if not all_selected_paths:
|
||||||
|
print("No participants selected.")
|
||||||
|
return
|
||||||
|
|
||||||
|
parameterized_indexes = {
|
||||||
|
0: [
|
||||||
|
{
|
||||||
|
"key": "show_optodes",
|
||||||
|
"label": "Determine what is rendered above the brain. Valid values are 'sensors', 'labels', 'none', 'all'.",
|
||||||
|
"default": "all",
|
||||||
|
"type": str,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "t_or_theta",
|
||||||
|
"label": "Specify if t values or theta values should be plotted. Valid values are 't', 'theta'",
|
||||||
|
"default": "theta",
|
||||||
|
"type": str,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "show_text",
|
||||||
|
"label": "Display informative text on the top left corner about the contrast.",
|
||||||
|
"default": "True",
|
||||||
|
"type": bool,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "brain_bounds",
|
||||||
|
"label": "Graph Upper/Lower Limit",
|
||||||
|
"default": "1.0",
|
||||||
|
"type": float,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "is_3d",
|
||||||
|
"label": "Should we display the results in a 3D interactive window?",
|
||||||
|
"default": "True",
|
||||||
|
"type": bool,
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# Inject full_text from index_texts
|
||||||
|
for idx, params_list in parameterized_indexes.items():
|
||||||
|
full_text = self.index_texts[idx] if idx < len(self.index_texts) else f"{idx} (No label found)"
|
||||||
|
for param_info in params_list:
|
||||||
|
param_info["full_text"] = full_text
|
||||||
|
|
||||||
|
indexes_needing_params = {idx: parameterized_indexes[idx] for idx in selected_indexes if idx in parameterized_indexes}
|
||||||
|
|
||||||
|
param_values = {}
|
||||||
|
if indexes_needing_params:
|
||||||
|
dialog = ParameterInputDialog(indexes_needing_params, parent=self)
|
||||||
|
if dialog.exec_() == QDialog.Accepted:
|
||||||
|
param_values = dialog.get_values()
|
||||||
|
if param_values is None:
|
||||||
|
return
|
||||||
|
else:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Build group-level contrast DataFrames
|
||||||
|
def concat_group_contrasts(file_paths: list[str], event: str | None) -> pd.DataFrame:
|
||||||
|
group_df = pd.DataFrame()
|
||||||
|
for fp in file_paths:
|
||||||
|
print(f"Looking up contrast for: {fp}")
|
||||||
|
event_con_dict = self.contrast_results_dict.get(fp, {})
|
||||||
|
print("Available events for this file:", list(event_con_dict.keys()))
|
||||||
|
if event and event in event_con_dict:
|
||||||
|
df = event_con_dict[event]
|
||||||
|
print(f"Appending contrast df for event: {event}")
|
||||||
|
group_df = pd.concat([group_df, df], ignore_index=True)
|
||||||
|
else:
|
||||||
|
print(f"Event '{event}' not found for {fp}")
|
||||||
|
return group_df
|
||||||
|
|
||||||
|
print("Selected event:", selected_event)
|
||||||
|
print("File paths A:", file_paths_a)
|
||||||
|
print("File paths B:", file_paths_b)
|
||||||
|
|
||||||
|
contrast_df_a = concat_group_contrasts(file_paths_a, selected_event)
|
||||||
|
contrast_df_b = concat_group_contrasts(file_paths_b, selected_event)
|
||||||
|
|
||||||
|
print("contrast_df_a empty?", contrast_df_a.empty)
|
||||||
|
print("contrast_df_b empty?", contrast_df_b.empty)
|
||||||
|
|
||||||
|
all_raw_objs = [self.haemo_dict.get(fp) for fp in all_selected_paths if self.haemo_dict.get(fp)]
|
||||||
|
|
||||||
|
if len(all_raw_objs) > 1:
|
||||||
|
processed_raw = flares.aggregate_fnirs_group_geometry(all_raw_objs)
|
||||||
|
else:
|
||||||
|
processed_raw = all_raw_objs[0].copy().pick(picks="hbo")
|
||||||
|
|
||||||
|
# Visualizations
|
||||||
|
for idx in selected_indexes:
|
||||||
|
if idx == 0:
|
||||||
|
params = param_values.get(idx, {})
|
||||||
|
show_optodes = params.get("show_optodes", None)
|
||||||
|
t_or_theta = params.get("t_or_theta", None)
|
||||||
|
show_text = params.get("show_text", None)
|
||||||
|
brain_bounds = params.get("brain_bounds", None)
|
||||||
|
is_3d = params.get("is_3d", None)
|
||||||
|
|
||||||
|
if show_optodes is None or t_or_theta is None or show_text is None or brain_bounds is None or is_3d is None:
|
||||||
|
print(f"Missing parameters for index {idx}, skipping.")
|
||||||
|
continue
|
||||||
|
|
||||||
|
if not contrast_df_a.empty and not contrast_df_b.empty and processed_raw:
|
||||||
|
|
||||||
|
flares.plot_2d_3d_contrasts_between_groups(
|
||||||
|
contrast_df_a,
|
||||||
|
contrast_df_b,
|
||||||
|
raw_haemo=processed_raw,
|
||||||
|
group_a_name=self.group_a_dropdown.currentText(),
|
||||||
|
group_b_name=self.group_b_dropdown.currentText(),
|
||||||
|
is_3d=is_3d,
|
||||||
|
t_or_theta=t_or_theta,
|
||||||
|
show_optodes=show_optodes,
|
||||||
|
show_text=show_text,
|
||||||
|
brain_bounds=brain_bounds
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
print("no")
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,188 @@
|
|||||||
|
"""
|
||||||
|
Filename: groupfunctionalconnectivity.py
|
||||||
|
Description: Group functional connectivity analysis window for FLARES
|
||||||
|
|
||||||
|
Author: Tyler de Zeeuw
|
||||||
|
License: GPL-3.0
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
from PySide6.QtWidgets import QComboBox, QDialog, QGridLayout, QHBoxLayout, QPushButton, QScrollArea, QWidget, QVBoxLayout, QLabel, QMessageBox
|
||||||
|
from PySide6.QtCore import QSize
|
||||||
|
|
||||||
|
from src.shared.flaresbasewidget import FlaresBaseWidget, ParameterInputDialog
|
||||||
|
from src.shared.shareddata import APP_NAME
|
||||||
|
|
||||||
|
|
||||||
|
class GroupFunctionalConnectivityWidget(FlaresBaseWidget):
|
||||||
|
def __init__(self, haemo_dict, group, config_dict):
|
||||||
|
super().__init__("GroupFunctionalConnectivityWidget")
|
||||||
|
self.setWindowTitle(f"Group Functional Connectivity Viewer [BETA] - {APP_NAME.upper()}")
|
||||||
|
self.haemo_dict = haemo_dict
|
||||||
|
self.group = group
|
||||||
|
self.config_dict = config_dict
|
||||||
|
self.show_all_events = True
|
||||||
|
self._updating_checkstates = False
|
||||||
|
|
||||||
|
QMessageBox.warning(self, f"Warning - {APP_NAME.upper()}", f"Functional Connectivity is still in development and the results should currently be taken with a grain of salt. "
|
||||||
|
"By clicking OK, you accept that the images generated may not be factual.")
|
||||||
|
|
||||||
|
|
||||||
|
# Create mappings: file_path -> participant label and dropdown display text
|
||||||
|
self.participant_map = {} # file_path -> "Participant 1"
|
||||||
|
self.participant_dropdown_items = [] # "Participant 1 (filename)"
|
||||||
|
|
||||||
|
for i, file_path in enumerate(self.haemo_dict.keys(), start=1):
|
||||||
|
short_label = f"Participant {i}"
|
||||||
|
display_label = f"{short_label} ({os.path.basename(file_path)})"
|
||||||
|
self.participant_map[file_path] = short_label
|
||||||
|
self.participant_dropdown_items.append(display_label)
|
||||||
|
|
||||||
|
self.layout = QVBoxLayout(self)
|
||||||
|
self.top_bar = QHBoxLayout()
|
||||||
|
self.layout.addLayout(self.top_bar)
|
||||||
|
|
||||||
|
self.group_to_paths = {}
|
||||||
|
for file_path, group_name in self.group.items():
|
||||||
|
self.group_to_paths.setdefault(group_name, []).append(file_path)
|
||||||
|
|
||||||
|
self.group_names = sorted(self.group_to_paths.keys())
|
||||||
|
|
||||||
|
self.group_dropdown = QComboBox()
|
||||||
|
self.group_dropdown.addItem("<None Selected>")
|
||||||
|
self.group_dropdown.addItems(self.group_names)
|
||||||
|
self.group_dropdown.setCurrentIndex(0)
|
||||||
|
self.group_dropdown.currentIndexChanged.connect(self.update_participant_list_for_group)
|
||||||
|
|
||||||
|
self.participant_dropdown = self._create_multiselect_dropdown(self.participant_dropdown_items)
|
||||||
|
self.participant_dropdown.currentIndexChanged.connect(self.update_participant_dropdown_label)
|
||||||
|
self.participant_dropdown.setEnabled(False)
|
||||||
|
|
||||||
|
self.event_dropdown = QComboBox()
|
||||||
|
self.event_dropdown.addItem("<None Selected>")
|
||||||
|
|
||||||
|
self.index_texts = [
|
||||||
|
"0 (Betas)",
|
||||||
|
#"1 (Significance)",
|
||||||
|
#"2 (Brain Activity Visualization)",
|
||||||
|
# "3 (fourth image)",
|
||||||
|
]
|
||||||
|
|
||||||
|
self.image_index_dropdown = self._create_multiselect_dropdown(self.index_texts)
|
||||||
|
self.image_index_dropdown.currentIndexChanged.connect(self.update_image_index_dropdown_label)
|
||||||
|
|
||||||
|
self.submit_button = QPushButton("Submit")
|
||||||
|
self.submit_button.clicked.connect(self.show_brain_images)
|
||||||
|
|
||||||
|
self.top_bar.addWidget(QLabel("Group:"))
|
||||||
|
self.top_bar.addWidget(self.group_dropdown)
|
||||||
|
self.top_bar.addWidget(QLabel("Participants:"))
|
||||||
|
self.top_bar.addWidget(self.participant_dropdown)
|
||||||
|
self.top_bar.addWidget(QLabel("Event:"))
|
||||||
|
self.top_bar.addWidget(self.event_dropdown)
|
||||||
|
self.top_bar.addWidget(QLabel("Image Indexes:"))
|
||||||
|
self.top_bar.addWidget(self.image_index_dropdown)
|
||||||
|
self.top_bar.addWidget(self.submit_button)
|
||||||
|
|
||||||
|
self.scroll = QScrollArea()
|
||||||
|
self.scroll.setWidgetResizable(True)
|
||||||
|
self.scroll_content = QWidget()
|
||||||
|
self.grid_layout = QGridLayout(self.scroll_content)
|
||||||
|
self.scroll.setWidget(self.scroll_content)
|
||||||
|
self.layout.addWidget(self.scroll)
|
||||||
|
|
||||||
|
self.thumb_size = QSize(280, 180)
|
||||||
|
self.showMaximized()
|
||||||
|
|
||||||
|
|
||||||
|
def show_brain_images(self):
|
||||||
|
import flares as flares
|
||||||
|
|
||||||
|
selected_event = self.event_dropdown.currentText()
|
||||||
|
if selected_event == "<None Selected>":
|
||||||
|
selected_event = None
|
||||||
|
|
||||||
|
selected_display_names = self._get_checked_items(self.participant_dropdown)
|
||||||
|
selected_file_paths = []
|
||||||
|
for display_name in selected_display_names:
|
||||||
|
for fp, short_label in self.participant_map.items():
|
||||||
|
expected_display = f"{short_label} ({os.path.basename(fp)})"
|
||||||
|
if display_name == expected_display:
|
||||||
|
selected_file_paths.append(fp)
|
||||||
|
break
|
||||||
|
|
||||||
|
if selected_event:
|
||||||
|
valid_paths = []
|
||||||
|
for fp in selected_file_paths:
|
||||||
|
raw = self.haemo_dict.get(fp)
|
||||||
|
# Check if this participant actually has the event in their annotations
|
||||||
|
if raw is not None and hasattr(raw, "annotations"):
|
||||||
|
if selected_event in raw.annotations.description:
|
||||||
|
valid_paths.append(fp)
|
||||||
|
|
||||||
|
selected_file_paths = valid_paths
|
||||||
|
|
||||||
|
selected_indexes = [
|
||||||
|
int(s.split(" ")[0]) for s in self._get_checked_items(self.image_index_dropdown)
|
||||||
|
]
|
||||||
|
|
||||||
|
if not selected_file_paths:
|
||||||
|
print("No participants selected.")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Only keep indexes 0 and 1 that need parameters
|
||||||
|
parameterized_indexes = {
|
||||||
|
0: [
|
||||||
|
{
|
||||||
|
"key": "n_lines",
|
||||||
|
"label": "<Description>",
|
||||||
|
"default": "20",
|
||||||
|
"type": int,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "vmin",
|
||||||
|
"label": "<Description>",
|
||||||
|
"default": "0.9",
|
||||||
|
"type": float,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
# Inject full_text from index_texts
|
||||||
|
for idx, params_list in parameterized_indexes.items():
|
||||||
|
full_text = self.index_texts[idx] if idx < len(self.index_texts) else f"{idx} (No label found)"
|
||||||
|
for param_info in params_list:
|
||||||
|
param_info["full_text"] = full_text
|
||||||
|
|
||||||
|
indexes_needing_params = {idx: parameterized_indexes[idx] for idx in selected_indexes if idx in parameterized_indexes}
|
||||||
|
|
||||||
|
param_values = {}
|
||||||
|
if indexes_needing_params:
|
||||||
|
dialog = ParameterInputDialog(indexes_needing_params, parent=self)
|
||||||
|
if dialog.exec_() == QDialog.Accepted:
|
||||||
|
param_values = dialog.get_values()
|
||||||
|
if param_values is None:
|
||||||
|
return
|
||||||
|
else:
|
||||||
|
return
|
||||||
|
|
||||||
|
for idx in selected_indexes:
|
||||||
|
if idx == 0:
|
||||||
|
params = param_values.get(idx, {})
|
||||||
|
n_lines = params.get("n_lines", None)
|
||||||
|
vmin = params.get("vmin", None)
|
||||||
|
|
||||||
|
if n_lines is None or vmin is None:
|
||||||
|
print(f"Missing parameters for index {idx}, skipping.")
|
||||||
|
continue
|
||||||
|
flares.run_group_functional_connectivity(self.haemo_dict, self.config_dict, selected_file_paths, selected_event, 50, 0.5)
|
||||||
|
elif idx == 1:
|
||||||
|
pass
|
||||||
|
elif idx == 2:
|
||||||
|
pass
|
||||||
|
elif idx == 3:
|
||||||
|
pass
|
||||||
|
|
||||||
|
else:
|
||||||
|
print(f"No method defined for index {idx}")
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
"""
|
||||||
|
Filename: participant.py
|
||||||
|
Description: Participant analysis window for FLARES
|
||||||
|
|
||||||
|
Author: Tyler de Zeeuw
|
||||||
|
License: GPL-3.0
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from PySide6.QtWidgets import QGridLayout, QHBoxLayout, QMessageBox, QPushButton, QScrollArea, QWidget, QVBoxLayout, QLabel
|
||||||
|
from PySide6.QtCore import Qt, QSize
|
||||||
|
from PySide6.QtGui import QPixmap
|
||||||
|
|
||||||
|
from src.shared.flaresbasewidget import ClickableLabel, FlaresBaseWidget
|
||||||
|
from src.shared.shareddata import APP_NAME
|
||||||
|
|
||||||
|
|
||||||
|
class ParticipantViewerWidget(FlaresBaseWidget):
|
||||||
|
def __init__(self, haemo_dict, fig_bytes_dict):
|
||||||
|
super().__init__("ParticipantViewer")
|
||||||
|
self.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose)
|
||||||
|
self.setWindowTitle(f"Participant Viewer - {APP_NAME.upper()}")
|
||||||
|
self.haemo_dict = haemo_dict
|
||||||
|
self.fig_bytes_dict = fig_bytes_dict
|
||||||
|
|
||||||
|
# Create mappings: file_path -> participant label and dropdown display text
|
||||||
|
self.participant_map = {} # file_path -> "Participant 1"
|
||||||
|
self.participant_dropdown_items = [] # "Participant 1 (filename)"
|
||||||
|
|
||||||
|
for i, file_path in enumerate(self.haemo_dict.keys(), start=1):
|
||||||
|
short_label = f"Participant {i}"
|
||||||
|
display_label = f"{short_label} ({os.path.basename(file_path)})"
|
||||||
|
self.participant_map[file_path] = short_label
|
||||||
|
self.participant_dropdown_items.append(display_label)
|
||||||
|
|
||||||
|
self.layout = QVBoxLayout(self)
|
||||||
|
self.top_bar = QHBoxLayout()
|
||||||
|
self.layout.addLayout(self.top_bar)
|
||||||
|
|
||||||
|
self.participant_dropdown = self._create_multiselect_dropdown(self.participant_dropdown_items)
|
||||||
|
self.participant_dropdown.currentIndexChanged.connect(self.update_participant_dropdown_label)
|
||||||
|
|
||||||
|
first_fig_dict = next(iter(self.fig_bytes_dict.values()))
|
||||||
|
image_label_items = list(first_fig_dict.keys())
|
||||||
|
|
||||||
|
self.image_index_dropdown = self._create_multiselect_dropdown(image_label_items)
|
||||||
|
self.image_index_dropdown.currentIndexChanged.connect(self.update_image_index_dropdown_label)
|
||||||
|
|
||||||
|
self.submit_button = QPushButton("Submit")
|
||||||
|
self.submit_button.clicked.connect(self.show_selected_images)
|
||||||
|
|
||||||
|
self.top_bar.addWidget(QLabel("Participants:"))
|
||||||
|
self.top_bar.addWidget(self.participant_dropdown)
|
||||||
|
self.top_bar.addWidget(QLabel("Image Indexes:"))
|
||||||
|
self.top_bar.addWidget(self.image_index_dropdown)
|
||||||
|
self.top_bar.addWidget(self.submit_button)
|
||||||
|
|
||||||
|
self.scroll = QScrollArea()
|
||||||
|
self.scroll.setWidgetResizable(True)
|
||||||
|
self.scroll_content = QWidget()
|
||||||
|
self.grid_layout = QGridLayout(self.scroll_content)
|
||||||
|
self.scroll.setWidget(self.scroll_content)
|
||||||
|
self.layout.addWidget(self.scroll)
|
||||||
|
|
||||||
|
self.thumb_size = QSize(280, 180)
|
||||||
|
|
||||||
|
self.save_button = QPushButton("Save Displayed Images")
|
||||||
|
self.save_button.clicked.connect(self.save_displayed_images)
|
||||||
|
self.top_bar.addWidget(self.save_button)
|
||||||
|
|
||||||
|
self.showMaximized()
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def show_selected_images(self):
|
||||||
|
# Clear previous images
|
||||||
|
while self.grid_layout.count():
|
||||||
|
item = self.grid_layout.takeAt(0)
|
||||||
|
widget = item.widget()
|
||||||
|
if widget:
|
||||||
|
widget.deleteLater()
|
||||||
|
|
||||||
|
selected_display_names = self._get_checked_items(self.participant_dropdown)
|
||||||
|
# Map from display names back to file paths
|
||||||
|
selected_file_paths = []
|
||||||
|
for display_name in selected_display_names:
|
||||||
|
# Find file_path by matching display name
|
||||||
|
for fp, short_label in self.participant_map.items():
|
||||||
|
expected_display = f"{short_label} ({os.path.basename(fp)})"
|
||||||
|
if display_name == expected_display:
|
||||||
|
selected_file_paths.append(fp)
|
||||||
|
break
|
||||||
|
|
||||||
|
selected_labels = self._get_checked_items(self.image_index_dropdown)
|
||||||
|
|
||||||
|
row, col = 0, 0
|
||||||
|
for file_path in selected_file_paths:
|
||||||
|
fig_list = self.fig_bytes_dict.get(file_path, [])
|
||||||
|
participant_label = self.participant_map[file_path]
|
||||||
|
for label in selected_labels:
|
||||||
|
fig_bytes = fig_list.get(label)
|
||||||
|
if not fig_bytes:
|
||||||
|
continue
|
||||||
|
|
||||||
|
full_pixmap = QPixmap()
|
||||||
|
full_pixmap.loadFromData(fig_bytes)
|
||||||
|
|
||||||
|
thumbnail_pixmap = full_pixmap.scaled(
|
||||||
|
self.thumb_size,
|
||||||
|
Qt.AspectRatioMode.KeepAspectRatio,
|
||||||
|
Qt.TransformationMode.SmoothTransformation
|
||||||
|
)
|
||||||
|
|
||||||
|
container = QWidget()
|
||||||
|
hlayout = QHBoxLayout(container)
|
||||||
|
hlayout.setContentsMargins(0, 0, 0, 0)
|
||||||
|
hlayout.setSpacing(0)
|
||||||
|
hlayout.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||||
|
|
||||||
|
image_label = ClickableLabel(full_pixmap, thumbnail_pixmap)
|
||||||
|
image_label.setToolTip(f"{participant_label}\n{label}")
|
||||||
|
hlayout.addWidget(image_label)
|
||||||
|
|
||||||
|
self.grid_layout.addWidget(container, row, col)
|
||||||
|
|
||||||
|
col += 1
|
||||||
|
if col >= 6:
|
||||||
|
col = 0
|
||||||
|
row += 1
|
||||||
|
|
||||||
|
# Update dropdown labels after display
|
||||||
|
self.update_participant_dropdown_label()
|
||||||
|
self.update_image_index_dropdown_label()
|
||||||
|
|
||||||
|
|
||||||
|
def save_displayed_images(self):
|
||||||
|
# Ensure the folder exists
|
||||||
|
save_dir = Path("individual_images")
|
||||||
|
save_dir.mkdir(exist_ok=True)
|
||||||
|
|
||||||
|
selected_display_names = self._get_checked_items(self.participant_dropdown)
|
||||||
|
selected_image_labels = self._get_checked_items(self.image_index_dropdown)
|
||||||
|
|
||||||
|
for display_name in selected_display_names:
|
||||||
|
# Match display name to file path
|
||||||
|
for file_path, short_label in self.participant_map.items():
|
||||||
|
expected_display = f"{short_label} ({os.path.basename(file_path)})"
|
||||||
|
if display_name == expected_display:
|
||||||
|
fig_dict = self.fig_bytes_dict.get(file_path, {})
|
||||||
|
for label in selected_image_labels:
|
||||||
|
if label not in fig_dict:
|
||||||
|
continue
|
||||||
|
fig_bytes = fig_dict[label]
|
||||||
|
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||||
|
filename = f"{os.path.basename(file_path)}_{label}_{timestamp}.png"
|
||||||
|
output_path = save_dir / filename
|
||||||
|
with open(output_path, "wb") as f:
|
||||||
|
f.write(fig_bytes)
|
||||||
|
break # file_path matched; stop loop
|
||||||
|
|
||||||
|
QMessageBox.information(self, "Save Complete", f"Images saved to {save_dir.resolve()}")
|
||||||
@@ -0,0 +1,202 @@
|
|||||||
|
"""
|
||||||
|
Filename: participantbrain.py
|
||||||
|
Description: Participant brain analysis window for FLARES
|
||||||
|
|
||||||
|
Author: Tyler de Zeeuw
|
||||||
|
License: GPL-3.0
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
from PySide6.QtWidgets import QComboBox, QDialog, QGridLayout, QHBoxLayout, QPushButton, QScrollArea, QWidget, QVBoxLayout, QLabel
|
||||||
|
from PySide6.QtCore import QSize
|
||||||
|
|
||||||
|
from src.shared.flaresbasewidget import FlaresBaseWidget, ParameterInputDialog
|
||||||
|
from src.shared.shareddata import APP_NAME
|
||||||
|
|
||||||
|
|
||||||
|
class ParticipantBrainViewerWidget(FlaresBaseWidget):
|
||||||
|
def __init__(self, haemo_dict, cha_dict):
|
||||||
|
super().__init__("ParticipantBrainViewer")
|
||||||
|
self.setWindowTitle(f"Participant Brain Viewer - {APP_NAME.upper()}")
|
||||||
|
self.haemo_dict = haemo_dict
|
||||||
|
self.cha_dict = cha_dict
|
||||||
|
|
||||||
|
# Create mappings: file_path -> participant label and dropdown display text
|
||||||
|
self.participant_map = {} # file_path -> "Participant 1"
|
||||||
|
self.participant_dropdown_items = [] # "Participant 1 (filename)"
|
||||||
|
|
||||||
|
for i, file_path in enumerate(self.haemo_dict.keys(), start=1):
|
||||||
|
short_label = f"Participant {i}"
|
||||||
|
display_label = f"{short_label} ({os.path.basename(file_path)})"
|
||||||
|
self.participant_map[file_path] = short_label
|
||||||
|
self.participant_dropdown_items.append(display_label)
|
||||||
|
|
||||||
|
self.layout = QVBoxLayout(self)
|
||||||
|
self.top_bar = QHBoxLayout()
|
||||||
|
self.layout.addLayout(self.top_bar)
|
||||||
|
|
||||||
|
self.participant_dropdown = self._create_multiselect_dropdown(self.participant_dropdown_items)
|
||||||
|
self.participant_dropdown.currentIndexChanged.connect(self.update_participant_dropdown_label)
|
||||||
|
|
||||||
|
self.event_dropdown = QComboBox()
|
||||||
|
self.event_dropdown.addItem("<None Selected>")
|
||||||
|
|
||||||
|
|
||||||
|
self.index_texts = [
|
||||||
|
"0 (Brain Landmarks)",
|
||||||
|
"1 (Brain Activity Visualization)",
|
||||||
|
# "2 (third image)",
|
||||||
|
# "3 (fourth image)",
|
||||||
|
]
|
||||||
|
|
||||||
|
self.image_index_dropdown = self._create_multiselect_dropdown(self.index_texts)
|
||||||
|
self.image_index_dropdown.currentIndexChanged.connect(self.update_image_index_dropdown_label)
|
||||||
|
|
||||||
|
self.submit_button = QPushButton("Submit")
|
||||||
|
self.submit_button.clicked.connect(self.show_brain_images)
|
||||||
|
|
||||||
|
self.top_bar.addWidget(QLabel("Participants:"))
|
||||||
|
self.top_bar.addWidget(self.participant_dropdown)
|
||||||
|
self.top_bar.addWidget(QLabel("Event:"))
|
||||||
|
self.top_bar.addWidget(self.event_dropdown)
|
||||||
|
self.top_bar.addWidget(QLabel("Image Indexes:"))
|
||||||
|
self.top_bar.addWidget(self.image_index_dropdown)
|
||||||
|
self.top_bar.addWidget(self.submit_button)
|
||||||
|
|
||||||
|
self.scroll = QScrollArea()
|
||||||
|
self.scroll.setWidgetResizable(True)
|
||||||
|
self.scroll_content = QWidget()
|
||||||
|
self.grid_layout = QGridLayout(self.scroll_content)
|
||||||
|
self.scroll.setWidget(self.scroll_content)
|
||||||
|
self.layout.addWidget(self.scroll)
|
||||||
|
|
||||||
|
self.thumb_size = QSize(280, 180)
|
||||||
|
self.showMaximized()
|
||||||
|
|
||||||
|
|
||||||
|
def show_brain_images(self):
|
||||||
|
import flares as flares
|
||||||
|
|
||||||
|
selected_event = self.event_dropdown.currentText()
|
||||||
|
if selected_event == "<None Selected>":
|
||||||
|
selected_event = None
|
||||||
|
|
||||||
|
selected_display_names = self._get_checked_items(self.participant_dropdown)
|
||||||
|
selected_file_paths = []
|
||||||
|
for display_name in selected_display_names:
|
||||||
|
for fp, short_label in self.participant_map.items():
|
||||||
|
expected_display = f"{short_label} ({os.path.basename(fp)})"
|
||||||
|
if display_name == expected_display:
|
||||||
|
selected_file_paths.append(fp)
|
||||||
|
break
|
||||||
|
|
||||||
|
selected_indexes = [
|
||||||
|
int(s.split(" ")[0]) for s in self._get_checked_items(self.image_index_dropdown)
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
parameterized_indexes = {
|
||||||
|
0: [
|
||||||
|
{
|
||||||
|
"key": "show_optodes",
|
||||||
|
"label": "Determine what is rendered above the brain. Valid values are 'sensors', 'labels', 'none', 'all'.",
|
||||||
|
"default": "all",
|
||||||
|
"type": str,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "show_brodmann",
|
||||||
|
"label": "Show common brodmann areas on the brain.",
|
||||||
|
"default": "True",
|
||||||
|
"type": bool,
|
||||||
|
}
|
||||||
|
],
|
||||||
|
1: [
|
||||||
|
{
|
||||||
|
"key": "show_optodes",
|
||||||
|
"label": "Determine what is rendered above the brain. Valid values are 'sensors', 'labels', 'none', 'all'.",
|
||||||
|
"default": "all",
|
||||||
|
"type": str,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "t_or_theta",
|
||||||
|
"label": "Specify if t values or theta values should be plotted. Valid values are 't', 'theta'",
|
||||||
|
"default": "theta",
|
||||||
|
"type": str,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "show_text",
|
||||||
|
"label": "Display informative text on the top left corner. THIS DOES NOT WORK AND SHOULD BE LEFT AT FALSE",
|
||||||
|
"default": "False",
|
||||||
|
"type": bool,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "brain_bounds",
|
||||||
|
"label": "Graph Upper/Lower Limit",
|
||||||
|
"default": "1.0",
|
||||||
|
"type": float,
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
# Inject full_text from index_texts
|
||||||
|
for idx, params_list in parameterized_indexes.items():
|
||||||
|
full_text = self.index_texts[idx] if idx < len(self.index_texts) else f"{idx} (No label found)"
|
||||||
|
for param_info in params_list:
|
||||||
|
param_info["full_text"] = full_text
|
||||||
|
|
||||||
|
indexes_needing_params = {idx: parameterized_indexes[idx] for idx in selected_indexes if idx in parameterized_indexes}
|
||||||
|
|
||||||
|
param_values = {}
|
||||||
|
if indexes_needing_params:
|
||||||
|
dialog = ParameterInputDialog(indexes_needing_params, parent=self)
|
||||||
|
if dialog.exec_() == QDialog.Accepted:
|
||||||
|
param_values = dialog.get_values()
|
||||||
|
if param_values is None:
|
||||||
|
return
|
||||||
|
else:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Pass the necessary arguments to each method
|
||||||
|
for file_path in selected_file_paths:
|
||||||
|
haemo_obj = self.haemo_dict.get(file_path)
|
||||||
|
|
||||||
|
if selected_event:
|
||||||
|
participant_events = set(haemo_obj.annotations.description)
|
||||||
|
if selected_event not in participant_events:
|
||||||
|
print(f"Skipping {self.participant_map[file_path]}: Event '{selected_event}' not found.")
|
||||||
|
continue
|
||||||
|
|
||||||
|
if haemo_obj is None:
|
||||||
|
raise Exception("How did we get here?")
|
||||||
|
|
||||||
|
cha = self.cha_dict.get(file_path)
|
||||||
|
|
||||||
|
for idx in selected_indexes:
|
||||||
|
if idx == 0:
|
||||||
|
|
||||||
|
params = param_values.get(idx, {})
|
||||||
|
show_optodes = params.get("show_optodes", None)
|
||||||
|
show_brodmann = params.get("show_brodmann", None)
|
||||||
|
|
||||||
|
if show_optodes is None or show_brodmann is None:
|
||||||
|
print(f"Missing parameters for index {idx}, skipping.")
|
||||||
|
continue
|
||||||
|
|
||||||
|
flares.brain_landmarks_3d(haemo_obj, show_optodes, show_brodmann)
|
||||||
|
|
||||||
|
elif idx == 1:
|
||||||
|
params = param_values.get(idx, {})
|
||||||
|
show_optodes = params.get("show_optodes", None)
|
||||||
|
t_or_theta = params.get("t_or_theta", None)
|
||||||
|
show_text = params.get("show_text", None)
|
||||||
|
brain_bounds = params.get("brain_bounds", None)
|
||||||
|
|
||||||
|
if show_optodes is None or t_or_theta is None or show_text is None or brain_bounds is None:
|
||||||
|
print(f"Missing parameters for index {idx}, skipping.")
|
||||||
|
continue
|
||||||
|
|
||||||
|
flares.brain_3d_visualization(haemo_obj, cha, selected_event, t_or_theta=t_or_theta, show_optodes=show_optodes, show_text=show_text, brain_bounds=brain_bounds)
|
||||||
|
|
||||||
|
else:
|
||||||
|
print(f"No method defined for index {idx}")
|
||||||
@@ -0,0 +1,240 @@
|
|||||||
|
"""
|
||||||
|
Filename: participantfunctionalconnectivity.py
|
||||||
|
Description: Participant functional connectivity analysis window for FLARES
|
||||||
|
|
||||||
|
Author: Tyler de Zeeuw
|
||||||
|
License: GPL-3.0
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
from PySide6.QtWidgets import QComboBox, QDialog, QGridLayout, QHBoxLayout, QPushButton, QScrollArea, QWidget, QVBoxLayout, QLabel, QMessageBox
|
||||||
|
from PySide6.QtCore import QSize
|
||||||
|
|
||||||
|
from src.shared.flaresbasewidget import FlaresBaseWidget, ParameterInputDialog
|
||||||
|
from src.shared.shareddata import APP_NAME
|
||||||
|
|
||||||
|
|
||||||
|
class ParticipantFunctionalConnectivityWidget(FlaresBaseWidget):
|
||||||
|
def __init__(self, haemo_dict, epochs_dict):
|
||||||
|
super().__init__("FunctionalConnectivityWidget")
|
||||||
|
self.setWindowTitle(f"Functional Connectivity Viewer [BETA] - {APP_NAME.upper()}")
|
||||||
|
self.haemo_dict = haemo_dict
|
||||||
|
self.epochs_dict = epochs_dict
|
||||||
|
|
||||||
|
QMessageBox.warning(self, f"Warning - {APP_NAME.upper()}", f"Functional Connectivity is still in development and the results should currently be taken with a grain of salt. "
|
||||||
|
"By clicking OK, you accept that the images generated may not be factual.")
|
||||||
|
|
||||||
|
# Create mappings: file_path -> participant label and dropdown display text
|
||||||
|
self.participant_map = {} # file_path -> "Participant 1"
|
||||||
|
self.participant_dropdown_items = [] # "Participant 1 (filename)"
|
||||||
|
|
||||||
|
for i, file_path in enumerate(self.haemo_dict.keys(), start=1):
|
||||||
|
short_label = f"Participant {i}"
|
||||||
|
display_label = f"{short_label} ({os.path.basename(file_path)})"
|
||||||
|
self.participant_map[file_path] = short_label
|
||||||
|
self.participant_dropdown_items.append(display_label)
|
||||||
|
|
||||||
|
self.layout = QVBoxLayout(self)
|
||||||
|
self.top_bar = QHBoxLayout()
|
||||||
|
self.layout.addLayout(self.top_bar)
|
||||||
|
|
||||||
|
self.participant_dropdown = self._create_multiselect_dropdown(self.participant_dropdown_items)
|
||||||
|
self.participant_dropdown.currentIndexChanged.connect(self.update_participant_dropdown_label)
|
||||||
|
|
||||||
|
self.event_dropdown = QComboBox()
|
||||||
|
self.event_dropdown.addItem("<None Selected>")
|
||||||
|
|
||||||
|
|
||||||
|
self.index_texts = [
|
||||||
|
"0 (Spectral Connectivity Epochs)",
|
||||||
|
"1 (Envelope Correlation)",
|
||||||
|
"2 (Betas)",
|
||||||
|
"3 (Spectral Connectivity Epochs)",
|
||||||
|
]
|
||||||
|
|
||||||
|
self.image_index_dropdown = self._create_multiselect_dropdown(self.index_texts)
|
||||||
|
self.image_index_dropdown.currentIndexChanged.connect(self.update_image_index_dropdown_label)
|
||||||
|
|
||||||
|
self.submit_button = QPushButton("Submit")
|
||||||
|
self.submit_button.clicked.connect(self.show_brain_images)
|
||||||
|
|
||||||
|
self.top_bar.addWidget(QLabel("Participants:"))
|
||||||
|
self.top_bar.addWidget(self.participant_dropdown)
|
||||||
|
self.top_bar.addWidget(QLabel("Event:"))
|
||||||
|
self.top_bar.addWidget(self.event_dropdown)
|
||||||
|
self.top_bar.addWidget(QLabel("Image Indexes:"))
|
||||||
|
self.top_bar.addWidget(self.image_index_dropdown)
|
||||||
|
self.top_bar.addWidget(self.submit_button)
|
||||||
|
|
||||||
|
self.scroll = QScrollArea()
|
||||||
|
self.scroll.setWidgetResizable(True)
|
||||||
|
self.scroll_content = QWidget()
|
||||||
|
self.grid_layout = QGridLayout(self.scroll_content)
|
||||||
|
self.scroll.setWidget(self.scroll_content)
|
||||||
|
self.layout.addWidget(self.scroll)
|
||||||
|
|
||||||
|
self.thumb_size = QSize(280, 180)
|
||||||
|
self.showMaximized()
|
||||||
|
|
||||||
|
|
||||||
|
def show_brain_images(self):
|
||||||
|
import flares as flares
|
||||||
|
|
||||||
|
selected_event = self.event_dropdown.currentText()
|
||||||
|
if selected_event == "<None Selected>":
|
||||||
|
selected_event = None
|
||||||
|
|
||||||
|
selected_display_names = self._get_checked_items(self.participant_dropdown)
|
||||||
|
selected_file_paths = []
|
||||||
|
for display_name in selected_display_names:
|
||||||
|
for fp, short_label in self.participant_map.items():
|
||||||
|
expected_display = f"{short_label} ({os.path.basename(fp)})"
|
||||||
|
if display_name == expected_display:
|
||||||
|
selected_file_paths.append(fp)
|
||||||
|
break
|
||||||
|
|
||||||
|
selected_indexes = [
|
||||||
|
int(s.split(" ")[0]) for s in self._get_checked_items(self.image_index_dropdown)
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
parameterized_indexes = {
|
||||||
|
0: [
|
||||||
|
{
|
||||||
|
"key": "n_lines",
|
||||||
|
"label": "<Description>",
|
||||||
|
"default": "20",
|
||||||
|
"type": int,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "vmin",
|
||||||
|
"label": "<Description>",
|
||||||
|
"default": "0.9",
|
||||||
|
"type": float,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
1: [
|
||||||
|
{
|
||||||
|
"key": "n_lines",
|
||||||
|
"label": "<Description>",
|
||||||
|
"default": "20",
|
||||||
|
"type": int,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "vmin",
|
||||||
|
"label": "<Description>",
|
||||||
|
"default": "0.9",
|
||||||
|
"type": float,
|
||||||
|
},
|
||||||
|
|
||||||
|
],
|
||||||
|
2: [
|
||||||
|
{
|
||||||
|
"key": "n_lines",
|
||||||
|
"label": "<Description>",
|
||||||
|
"default": "20",
|
||||||
|
"type": int,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "vmin",
|
||||||
|
"label": "<Description>",
|
||||||
|
"default": "0.9",
|
||||||
|
"type": float,
|
||||||
|
},
|
||||||
|
|
||||||
|
],
|
||||||
|
3: [
|
||||||
|
{
|
||||||
|
"key": "n_lines",
|
||||||
|
"label": "<Description>",
|
||||||
|
"default": "20",
|
||||||
|
"type": int,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "vmin",
|
||||||
|
"label": "<Description>",
|
||||||
|
"default": "0.9",
|
||||||
|
"type": float,
|
||||||
|
},
|
||||||
|
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
# Inject full_text from index_texts
|
||||||
|
for idx, params_list in parameterized_indexes.items():
|
||||||
|
full_text = self.index_texts[idx] if idx < len(self.index_texts) else f"{idx} (No label found)"
|
||||||
|
for param_info in params_list:
|
||||||
|
param_info["full_text"] = full_text
|
||||||
|
|
||||||
|
indexes_needing_params = {idx: parameterized_indexes[idx] for idx in selected_indexes if idx in parameterized_indexes}
|
||||||
|
|
||||||
|
param_values = {}
|
||||||
|
if indexes_needing_params:
|
||||||
|
dialog = ParameterInputDialog(indexes_needing_params, parent=self)
|
||||||
|
if dialog.exec_() == QDialog.Accepted:
|
||||||
|
param_values = dialog.get_values()
|
||||||
|
if param_values is None:
|
||||||
|
return
|
||||||
|
else:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Pass the necessary arguments to each method
|
||||||
|
for file_path in selected_file_paths:
|
||||||
|
haemo_obj = self.haemo_dict.get(file_path)
|
||||||
|
epochs_obj = self.epochs_dict.get(file_path)
|
||||||
|
|
||||||
|
if selected_event:
|
||||||
|
participant_events = set(haemo_obj.annotations.description)
|
||||||
|
if selected_event not in participant_events:
|
||||||
|
print(f"Skipping {self.participant_map[file_path]}: Event '{selected_event}' not found.")
|
||||||
|
continue
|
||||||
|
|
||||||
|
if haemo_obj is None:
|
||||||
|
raise Exception("How did we get here?")
|
||||||
|
|
||||||
|
|
||||||
|
for idx in selected_indexes:
|
||||||
|
if idx == 0:
|
||||||
|
|
||||||
|
params = param_values.get(idx, {})
|
||||||
|
n_lines = params.get("n_lines", None)
|
||||||
|
vmin = params.get("vmin", None)
|
||||||
|
|
||||||
|
if n_lines is None or vmin is None:
|
||||||
|
print(f"Missing parameters for index {idx}, skipping.")
|
||||||
|
continue
|
||||||
|
flares.functional_connectivity_spectral_epochs(epochs_obj, n_lines, vmin)
|
||||||
|
|
||||||
|
elif idx == 1:
|
||||||
|
params = param_values.get(idx, {})
|
||||||
|
n_lines = params.get("n_lines", None)
|
||||||
|
vmin = params.get("vmin", None)
|
||||||
|
|
||||||
|
if n_lines is None or vmin is None:
|
||||||
|
print(f"Missing parameters for index {idx}, skipping.")
|
||||||
|
continue
|
||||||
|
flares.functional_connectivity_envelope(epochs_obj, n_lines, vmin)
|
||||||
|
|
||||||
|
elif idx == 2:
|
||||||
|
params = param_values.get(idx, {})
|
||||||
|
n_lines = params.get("n_lines", None)
|
||||||
|
vmin = params.get("vmin", None)
|
||||||
|
|
||||||
|
if n_lines is None or vmin is None:
|
||||||
|
print(f"Missing parameters for index {idx}, skipping.")
|
||||||
|
continue
|
||||||
|
flares.functional_connectivity_betas(haemo_obj, n_lines, vmin, selected_event)
|
||||||
|
|
||||||
|
elif idx == 3:
|
||||||
|
params = param_values.get(idx, {})
|
||||||
|
n_lines = params.get("n_lines", None)
|
||||||
|
vmin = params.get("vmin", None)
|
||||||
|
|
||||||
|
if n_lines is None or vmin is None:
|
||||||
|
print(f"Missing parameters for index {idx}, skipping.")
|
||||||
|
continue
|
||||||
|
flares.functional_connectivity_spectral_time(epochs_obj, n_lines, vmin)
|
||||||
|
|
||||||
|
else:
|
||||||
|
print(f"No method defined for index {idx}")
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
"""
|
||||||
|
Filename: shareddata.py
|
||||||
|
Description: Shared constants and methods for FLARES
|
||||||
|
|
||||||
|
Author: Tyler de Zeeuw
|
||||||
|
License: GPL-3.0
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
import platform
|
||||||
|
|
||||||
|
CURRENT_VERSION = "1.5.0"
|
||||||
|
APP_NAME = "flares"
|
||||||
|
API_URL = f"https://git.research.dezeeuw.ca/api/v1/repos/tyler/{APP_NAME}/releases"
|
||||||
|
API_URL_SECONDARY = f"https://git.research2.dezeeuw.ca/api/v1/repos/tyler/{APP_NAME}/releases"
|
||||||
|
PLATFORM_NAME = platform.system().lower()
|
||||||
|
CHANGELOG_URL = "https://git.research.dezeeuw.ca/tyler/flares/raw/branch/main/changelog_major.md"
|
||||||
|
WIKI_URL = "https://git.research.dezeeuw.ca/tyler/flares/wiki"
|
||||||
|
|
||||||
|
PIPELINE_STAGES = [
|
||||||
|
"Preprocessing",
|
||||||
|
"Trimming",
|
||||||
|
"Verify Optode Placement",
|
||||||
|
"Short/Long Channels",
|
||||||
|
"Heart Rate",
|
||||||
|
"Scalp Coupling Index",
|
||||||
|
"Signal to Noise Ratio",
|
||||||
|
"Peak Spectral Power",
|
||||||
|
"Cross Validation",
|
||||||
|
"Median Absolute Deviation",
|
||||||
|
"Power Spectral Density Noise",
|
||||||
|
"Channel Variance",
|
||||||
|
"Bad Channels Handling",
|
||||||
|
"Optical Density",
|
||||||
|
"Temporal Derivative Distribution Repair Filtering",
|
||||||
|
"Wavelet Filtering",
|
||||||
|
"Haemoglobin Concentration",
|
||||||
|
"Enhance Negative Correlation",
|
||||||
|
"Filter",
|
||||||
|
"Extracting Events",
|
||||||
|
"Epoch Calculations",
|
||||||
|
"Design Matrix",
|
||||||
|
"General Linear Model",
|
||||||
|
"Generate GLM Results",
|
||||||
|
"Generate Channel Significance",
|
||||||
|
"Generate Channel, Region of Interest, and Contrast Results",
|
||||||
|
"Compute Contrast Results",
|
||||||
|
"Finishing Up"
|
||||||
|
]
|
||||||
|
|
||||||
|
def resource_path(relative_path):
|
||||||
|
"""
|
||||||
|
Get absolute path to resource regardless of running directly or packaged using PyInstaller
|
||||||
|
"""
|
||||||
|
|
||||||
|
if hasattr(sys, '_MEIPASS'):
|
||||||
|
# PyInstaller bundle path
|
||||||
|
base_path = sys._MEIPASS
|
||||||
|
else:
|
||||||
|
base_path = os.path.abspath(".")
|
||||||
|
|
||||||
|
return os.path.join(base_path, relative_path)
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
"""
|
||||||
|
Filename: about.py
|
||||||
|
Description: About window for FLARES
|
||||||
|
|
||||||
|
Author: Tyler de Zeeuw
|
||||||
|
License: GPL-3.0
|
||||||
|
"""
|
||||||
|
|
||||||
|
from PySide6.QtWidgets import QWidget, QVBoxLayout, QLabel
|
||||||
|
from PySide6.QtCore import Qt
|
||||||
|
|
||||||
|
from src.shared.shareddata import APP_NAME, CURRENT_VERSION
|
||||||
|
|
||||||
|
class AboutWindow(QWidget):
|
||||||
|
"""
|
||||||
|
Simple About window displaying basic application information.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
parent (QWidget, optional): Parent widget of this window. Defaults to None.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, parent=None):
|
||||||
|
super().__init__(parent, Qt.WindowType.Window)
|
||||||
|
self.setWindowTitle(f"About {APP_NAME.upper()}")
|
||||||
|
self.resize(250, 100)
|
||||||
|
|
||||||
|
layout = QVBoxLayout()
|
||||||
|
label = QLabel(f"About {APP_NAME.upper()}", self)
|
||||||
|
label2 = QLabel("fNIRS Lightweight Analysis, Research, & Evaluation Suite", self)
|
||||||
|
label3 = QLabel(f"{APP_NAME.upper()} is licensed under the GPL-3.0 licence. For more information, visit https://www.gnu.org/licenses/gpl-3.0.en.html", self)
|
||||||
|
label4 = QLabel(f"Version v{CURRENT_VERSION}")
|
||||||
|
|
||||||
|
layout.addWidget(label)
|
||||||
|
layout.addWidget(label2)
|
||||||
|
layout.addWidget(label3)
|
||||||
|
layout.addWidget(label4)
|
||||||
|
|
||||||
|
self.setLayout(layout)
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
"""
|
||||||
|
Filename: terminal.py
|
||||||
|
Description: Terminal window for FLARES
|
||||||
|
|
||||||
|
Author: Tyler de Zeeuw
|
||||||
|
License: GPL-3.0
|
||||||
|
"""
|
||||||
|
|
||||||
|
from PySide6.QtWidgets import QWidget, QVBoxLayout, QTextEdit, QLineEdit
|
||||||
|
from PySide6.QtCore import Qt
|
||||||
|
|
||||||
|
from src.shared.shareddata import APP_NAME, CURRENT_VERSION
|
||||||
|
|
||||||
|
|
||||||
|
class TerminalWindow(QWidget):
|
||||||
|
def __init__(self, parent=None):
|
||||||
|
super().__init__(parent, Qt.WindowType.Window)
|
||||||
|
self.setWindowTitle(f"Terminal - {APP_NAME.upper()}")
|
||||||
|
|
||||||
|
self.output_area = QTextEdit()
|
||||||
|
self.output_area.setReadOnly(True)
|
||||||
|
|
||||||
|
self.input_line = QLineEdit()
|
||||||
|
self.input_line.returnPressed.connect(self.handle_command)
|
||||||
|
|
||||||
|
layout = QVBoxLayout()
|
||||||
|
layout.addWidget(self.output_area)
|
||||||
|
layout.addWidget(self.input_line)
|
||||||
|
self.setLayout(layout)
|
||||||
|
|
||||||
|
self.commands = {
|
||||||
|
"hello": self.cmd_hello,
|
||||||
|
"help": self.cmd_help,
|
||||||
|
"version": self.cmd_version,
|
||||||
|
}
|
||||||
|
|
||||||
|
def handle_command(self):
|
||||||
|
command_text = self.input_line.text()
|
||||||
|
self.input_line.clear()
|
||||||
|
|
||||||
|
self.output_area.append(f"> {command_text}")
|
||||||
|
parts = command_text.strip().split()
|
||||||
|
if not parts:
|
||||||
|
return
|
||||||
|
|
||||||
|
command_name = parts[0]
|
||||||
|
args = parts[1:]
|
||||||
|
|
||||||
|
func = self.commands.get(command_name)
|
||||||
|
if func:
|
||||||
|
try:
|
||||||
|
result = func(*args)
|
||||||
|
if result:
|
||||||
|
self.output_area.append(str(result))
|
||||||
|
except Exception as e:
|
||||||
|
self.output_area.append(f"[Error] {e}")
|
||||||
|
else:
|
||||||
|
self.output_area.append(f"[Unknown command] '{command_name}'")
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_hello(self, *args):
|
||||||
|
return "Hello from the terminal!"
|
||||||
|
|
||||||
|
def cmd_help(self, *args):
|
||||||
|
return f"Available commands: {', '.join(self.commands.keys())}"
|
||||||
|
|
||||||
|
def cmd_version(self, *args):
|
||||||
|
return f"{CURRENT_VERSION}"
|
||||||
@@ -0,0 +1,855 @@
|
|||||||
|
"""
|
||||||
|
Filename: updateevents.py
|
||||||
|
Description: Methods to update snirf events for FLARES
|
||||||
|
|
||||||
|
Author: Tyler de Zeeuw
|
||||||
|
License: GPL-3.0
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import json
|
||||||
|
from enum import Enum, auto
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
from PySide6.QtWidgets import QWidget, QVBoxLayout, QLabel, QLineEdit, QPushButton, QComboBox, QHBoxLayout, QMessageBox, QFileDialog
|
||||||
|
from PySide6.QtCore import Qt
|
||||||
|
|
||||||
|
from mne import Annotations
|
||||||
|
from mne.io import read_raw_snirf
|
||||||
|
from mne_nirs.io import write_raw_snirf
|
||||||
|
|
||||||
|
from src.shared.shareddata import APP_NAME
|
||||||
|
|
||||||
|
|
||||||
|
class EventUpdateMode(Enum):
|
||||||
|
WRITE_SNIRF = auto() # destructive
|
||||||
|
WRITE_JSON = auto() # non-destructive
|
||||||
|
|
||||||
|
|
||||||
|
class UpdateEventsWindow(QWidget):
|
||||||
|
def __init__(self, parent=None, mode=EventUpdateMode.WRITE_SNIRF, caller=None):
|
||||||
|
super().__init__(parent, Qt.WindowType.Window)
|
||||||
|
|
||||||
|
self.mode = mode
|
||||||
|
self.caller = caller or self.__class__.__name__
|
||||||
|
self.setWindowTitle(f"Update event markers - {APP_NAME.upper()}")
|
||||||
|
self.resize(760, 200)
|
||||||
|
|
||||||
|
print("INIT MODE:", mode)
|
||||||
|
|
||||||
|
self.label_file_a = QLabel("SNIRF file:")
|
||||||
|
self.line_edit_file_a = QLineEdit()
|
||||||
|
self.line_edit_file_a.setReadOnly(True)
|
||||||
|
self.btn_browse_a = QPushButton("Browse .snirf")
|
||||||
|
self.btn_browse_a.clicked.connect(self.browse_file_a)
|
||||||
|
|
||||||
|
self.label_file_b = QLabel("BORIS file:")
|
||||||
|
self.line_edit_file_b = QLineEdit()
|
||||||
|
self.line_edit_file_b.setReadOnly(True)
|
||||||
|
self.btn_browse_b = QPushButton("Browse .boris")
|
||||||
|
self.btn_browse_b.clicked.connect(self.browse_file_b)
|
||||||
|
|
||||||
|
self.label_suffix = QLabel("Filename in BORIS project file:")
|
||||||
|
self.combo_suffix = QComboBox()
|
||||||
|
self.combo_suffix.setEditable(False)
|
||||||
|
self.combo_suffix.currentIndexChanged.connect(self.on_observation_selected)
|
||||||
|
|
||||||
|
self.label_events = QLabel("Events in selected observation:")
|
||||||
|
self.combo_events = QComboBox()
|
||||||
|
self.combo_events.setEnabled(False)
|
||||||
|
|
||||||
|
self.label_snirf_events = QLabel("Events in SNIRF file:")
|
||||||
|
self.combo_snirf_events = QComboBox()
|
||||||
|
self.combo_snirf_events.setEnabled(False)
|
||||||
|
|
||||||
|
self.btn_clear = QPushButton("Clear")
|
||||||
|
self.btn_go = QPushButton("Go")
|
||||||
|
self.btn_clear.clicked.connect(self.clear_files)
|
||||||
|
self.btn_go.clicked.connect(self.go_action)
|
||||||
|
|
||||||
|
# ---
|
||||||
|
layout = QVBoxLayout()
|
||||||
|
self.description = QLabel()
|
||||||
|
self.description.setTextFormat(Qt.TextFormat.RichText)
|
||||||
|
self.description.setTextInteractionFlags(Qt.TextInteractionFlag.TextBrowserInteraction)
|
||||||
|
self.description.setOpenExternalLinks(True)
|
||||||
|
|
||||||
|
self.description.setText("The events that are present in a snirf file may not be the events that are to be studied and examined.<br>"
|
||||||
|
"Utilizing different software and video recordings, it is easy enough to see when an action actually occured in a file.<br>"
|
||||||
|
"The software <a href='https://www.boris.unito.it/'>BORIS</a> is used to add these events to video files, and these events can be applied to the snirf file <br>"
|
||||||
|
"selected below by selecting the correct BORIS observation and time syncing it to an event that it shares with the snirf file.")
|
||||||
|
|
||||||
|
layout.addWidget(self.description)
|
||||||
|
|
||||||
|
help_text_a = "Select the SNIRF (.snirf) file to update with new event markers."
|
||||||
|
|
||||||
|
file_a_layout = QHBoxLayout()
|
||||||
|
|
||||||
|
# Help button on the left
|
||||||
|
help_btn_a = QPushButton("?")
|
||||||
|
help_btn_a.setFixedWidth(25)
|
||||||
|
help_btn_a.setToolTip(help_text_a)
|
||||||
|
help_btn_a.clicked.connect(lambda _, text=help_text_a: self.show_help_popup(text))
|
||||||
|
file_a_layout.addWidget(help_btn_a)
|
||||||
|
|
||||||
|
# Container for label + line_edit + browse button with tooltip
|
||||||
|
file_a_container = QWidget()
|
||||||
|
file_a_container_layout = QHBoxLayout()
|
||||||
|
file_a_container_layout.setContentsMargins(0, 0, 0, 0)
|
||||||
|
file_a_container_layout.addWidget(self.label_file_a)
|
||||||
|
file_a_container_layout.addWidget(self.line_edit_file_a)
|
||||||
|
file_a_container_layout.addWidget(self.btn_browse_a)
|
||||||
|
file_a_container.setLayout(file_a_container_layout)
|
||||||
|
file_a_container.setToolTip(help_text_a)
|
||||||
|
|
||||||
|
file_a_layout.addWidget(file_a_container)
|
||||||
|
layout.addLayout(file_a_layout)
|
||||||
|
|
||||||
|
help_text_b = "Provide a .boris project file that contains events for this participant."
|
||||||
|
|
||||||
|
file_b_layout = QHBoxLayout()
|
||||||
|
|
||||||
|
help_btn_b = QPushButton("?")
|
||||||
|
help_btn_b.setFixedWidth(25)
|
||||||
|
help_btn_b.setToolTip(help_text_b)
|
||||||
|
help_btn_b.clicked.connect(lambda _, text=help_text_b: self.show_help_popup(text))
|
||||||
|
file_b_layout.addWidget(help_btn_b)
|
||||||
|
|
||||||
|
file_b_container = QWidget()
|
||||||
|
file_b_container_layout = QHBoxLayout()
|
||||||
|
file_b_container_layout.setContentsMargins(0, 0, 0, 0)
|
||||||
|
file_b_container_layout.addWidget(self.label_file_b)
|
||||||
|
file_b_container_layout.addWidget(self.line_edit_file_b)
|
||||||
|
file_b_container_layout.addWidget(self.btn_browse_b)
|
||||||
|
file_b_container.setLayout(file_b_container_layout)
|
||||||
|
file_b_container.setToolTip(help_text_b)
|
||||||
|
|
||||||
|
file_b_layout.addWidget(file_b_container)
|
||||||
|
layout.addLayout(file_b_layout)
|
||||||
|
|
||||||
|
help_text_suffix = "This participant from the .boris project file matches the .snirf file."
|
||||||
|
|
||||||
|
suffix_layout = QHBoxLayout()
|
||||||
|
|
||||||
|
help_btn_suffix = QPushButton("?")
|
||||||
|
help_btn_suffix.setFixedWidth(25)
|
||||||
|
help_btn_suffix.setToolTip(help_text_suffix)
|
||||||
|
help_btn_suffix.clicked.connect(lambda _, text=help_text_suffix: self.show_help_popup(text))
|
||||||
|
suffix_layout.addWidget(help_btn_suffix)
|
||||||
|
|
||||||
|
suffix_container = QWidget()
|
||||||
|
suffix_container_layout = QHBoxLayout()
|
||||||
|
suffix_container_layout.setContentsMargins(0, 0, 0, 0)
|
||||||
|
suffix_container_layout.addWidget(self.label_suffix)
|
||||||
|
suffix_container_layout.addWidget(self.combo_suffix)
|
||||||
|
suffix_container.setLayout(suffix_container_layout)
|
||||||
|
suffix_container.setToolTip(help_text_suffix)
|
||||||
|
|
||||||
|
suffix_layout.addWidget(suffix_container)
|
||||||
|
layout.addLayout(suffix_layout)
|
||||||
|
|
||||||
|
help_text_suffix = "The events extracted from the BORIS project file for the selected observation."
|
||||||
|
|
||||||
|
suffix2_layout = QHBoxLayout()
|
||||||
|
|
||||||
|
help_btn_suffix = QPushButton("?")
|
||||||
|
help_btn_suffix.setFixedWidth(25)
|
||||||
|
help_btn_suffix.setToolTip(help_text_suffix)
|
||||||
|
help_btn_suffix.clicked.connect(lambda _, text=help_text_suffix: self.show_help_popup(text))
|
||||||
|
suffix2_layout.addWidget(help_btn_suffix)
|
||||||
|
|
||||||
|
suffix2_container = QWidget()
|
||||||
|
suffix2_container_layout = QHBoxLayout()
|
||||||
|
suffix2_container_layout.setContentsMargins(0, 0, 0, 0)
|
||||||
|
suffix2_container_layout.addWidget(self.label_events)
|
||||||
|
suffix2_container_layout.addWidget(self.combo_events)
|
||||||
|
suffix2_container.setLayout(suffix2_container_layout)
|
||||||
|
suffix2_container.setToolTip(help_text_suffix)
|
||||||
|
|
||||||
|
suffix2_layout.addWidget(suffix2_container)
|
||||||
|
layout.addLayout(suffix2_layout)
|
||||||
|
|
||||||
|
snirf_events_layout = QHBoxLayout()
|
||||||
|
|
||||||
|
help_text_snirf_events = "The event markers extracted from the SNIRF file."
|
||||||
|
help_btn_snirf_events = QPushButton("?")
|
||||||
|
help_btn_snirf_events.setFixedWidth(25)
|
||||||
|
help_btn_snirf_events.setToolTip(help_text_snirf_events)
|
||||||
|
help_btn_snirf_events.clicked.connect(lambda _, text=help_text_snirf_events: self.show_help_popup(text))
|
||||||
|
snirf_events_layout.addWidget(help_btn_snirf_events)
|
||||||
|
|
||||||
|
snirf_events_container = QWidget()
|
||||||
|
snirf_events_container_layout = QHBoxLayout()
|
||||||
|
snirf_events_container_layout.setContentsMargins(0, 0, 0, 0)
|
||||||
|
snirf_events_container_layout.addWidget(self.label_snirf_events)
|
||||||
|
snirf_events_container_layout.addWidget(self.combo_snirf_events)
|
||||||
|
snirf_events_container.setLayout(snirf_events_container_layout)
|
||||||
|
snirf_events_container.setToolTip(help_text_snirf_events)
|
||||||
|
|
||||||
|
snirf_events_layout.addWidget(snirf_events_container)
|
||||||
|
layout.addLayout(snirf_events_layout)
|
||||||
|
|
||||||
|
buttons_layout = QHBoxLayout()
|
||||||
|
buttons_layout.addStretch()
|
||||||
|
buttons_layout.addWidget(self.btn_clear)
|
||||||
|
buttons_layout.addWidget(self.btn_go)
|
||||||
|
layout.addLayout(buttons_layout)
|
||||||
|
|
||||||
|
self.setLayout(layout)
|
||||||
|
|
||||||
|
def show_help_popup(self, text):
|
||||||
|
msg = QMessageBox(self)
|
||||||
|
msg.setWindowTitle(f"Parameter Info - {APP_NAME.upper()}")
|
||||||
|
msg.setText(text)
|
||||||
|
msg.exec()
|
||||||
|
|
||||||
|
def browse_file_a(self):
|
||||||
|
file_path, _ = QFileDialog.getOpenFileName(self, "Select SNIRF File", "", "SNIRF Files (*.snirf)")
|
||||||
|
if file_path:
|
||||||
|
self.line_edit_file_a.setText(file_path)
|
||||||
|
try:
|
||||||
|
# TODO: Bad! read_raw_snirf doesnt release memory properly! Should be spawned in a seperate process and killed once completed
|
||||||
|
raw = read_raw_snirf(file_path, preload=False)
|
||||||
|
annotations = raw.annotations
|
||||||
|
|
||||||
|
# Build individual event entries
|
||||||
|
event_entries = []
|
||||||
|
for onset, description in zip(annotations.onset, annotations.description):
|
||||||
|
event_str = f"{description} @ {onset:.3f}s"
|
||||||
|
event_entries.append(event_str)
|
||||||
|
|
||||||
|
if not event_entries:
|
||||||
|
QMessageBox.information(self, "No Events", "No events found in SNIRF file.")
|
||||||
|
self.combo_snirf_events.clear()
|
||||||
|
self.combo_snirf_events.setEnabled(False)
|
||||||
|
return
|
||||||
|
|
||||||
|
self.combo_snirf_events.clear()
|
||||||
|
self.combo_snirf_events.addItems(event_entries)
|
||||||
|
self.combo_snirf_events.setEnabled(True)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
QMessageBox.warning(self, "Error", f"Could not read SNIRF file with MNE:\n{str(e)}")
|
||||||
|
self.combo_snirf_events.clear()
|
||||||
|
self.combo_snirf_events.setEnabled(False)
|
||||||
|
|
||||||
|
def browse_file_b(self):
|
||||||
|
file_path, _ = QFileDialog.getOpenFileName(self, "Select BORIS File", "", "BORIS project Files (*.boris)")
|
||||||
|
if file_path:
|
||||||
|
self.line_edit_file_b.setText(file_path)
|
||||||
|
|
||||||
|
try:
|
||||||
|
with open(file_path, 'r', encoding='utf-8') as f:
|
||||||
|
data = json.load(f)
|
||||||
|
self.boris_data = data
|
||||||
|
|
||||||
|
observation_keys = self.extract_boris_observation_keys(data)
|
||||||
|
self.combo_suffix.clear()
|
||||||
|
self.combo_suffix.addItems(observation_keys)
|
||||||
|
|
||||||
|
except (json.JSONDecodeError, FileNotFoundError, KeyError) as e:
|
||||||
|
QMessageBox.warning(self, "Error", f"Failed to parse BORIS file:\n{e}")
|
||||||
|
|
||||||
|
def extract_boris_observation_keys(self, data):
|
||||||
|
if "observations" not in data:
|
||||||
|
raise KeyError("Missing 'observations' key in BORIS file.")
|
||||||
|
|
||||||
|
observations = data["observations"]
|
||||||
|
if not isinstance(observations, dict):
|
||||||
|
raise TypeError("'observations' must be a dictionary.")
|
||||||
|
|
||||||
|
return list(observations.keys())
|
||||||
|
|
||||||
|
def on_observation_selected(self):
|
||||||
|
selected_obs = self.combo_suffix.currentText()
|
||||||
|
if not selected_obs or not hasattr(self, 'boris_data'):
|
||||||
|
self.combo_events.clear()
|
||||||
|
self.combo_events.setEnabled(False)
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
events = self.boris_data["observations"][selected_obs]["events"]
|
||||||
|
except (KeyError, TypeError):
|
||||||
|
self.combo_events.clear()
|
||||||
|
self.combo_events.setEnabled(False)
|
||||||
|
return
|
||||||
|
|
||||||
|
event_entries = []
|
||||||
|
for event in events:
|
||||||
|
if isinstance(event, list) and len(event) >= 3:
|
||||||
|
timestamp = event[0]
|
||||||
|
label = event[2]
|
||||||
|
display = f"{label} @ {timestamp:.3f}"
|
||||||
|
event_entries.append(display)
|
||||||
|
|
||||||
|
self.combo_events.clear()
|
||||||
|
self.combo_events.addItems(event_entries)
|
||||||
|
self.combo_events.setEnabled(bool(event_entries))
|
||||||
|
|
||||||
|
def clear_files(self):
|
||||||
|
self.line_edit_file_a.clear()
|
||||||
|
self.line_edit_file_b.clear()
|
||||||
|
|
||||||
|
def go_action(self):
|
||||||
|
|
||||||
|
file_a = self.line_edit_file_a.text()
|
||||||
|
suffix = "flare"
|
||||||
|
|
||||||
|
if not hasattr(self, "boris_data") or self.combo_events.count() == 0 or self.combo_snirf_events.count() == 0:
|
||||||
|
QMessageBox.warning(self, "Missing data", "Please make sure a BORIS and SNIRF event are selected.")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Extract BORIS anchor
|
||||||
|
try:
|
||||||
|
boris_label, boris_time_str = self.combo_events.currentText().split(" @ ")
|
||||||
|
boris_anchor_time = float(boris_time_str.replace("s", "").strip())
|
||||||
|
except Exception as e:
|
||||||
|
QMessageBox.critical(self, "BORIS Event Error", f"Could not parse BORIS anchor event:\n{e}")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Extract SNIRF anchor
|
||||||
|
try:
|
||||||
|
snirf_label, snirf_time_str = self.combo_snirf_events.currentText().split(" @ ")
|
||||||
|
snirf_anchor_time = float(snirf_time_str.replace("s", "").strip())
|
||||||
|
except Exception as e:
|
||||||
|
QMessageBox.critical(self, "SNIRF Event Error", f"Could not parse SNIRF anchor event:\n{e}")
|
||||||
|
return
|
||||||
|
|
||||||
|
time_shift = snirf_anchor_time - boris_anchor_time
|
||||||
|
|
||||||
|
selected_obs = self.combo_suffix.currentText()
|
||||||
|
if not selected_obs or selected_obs not in self.boris_data["observations"]:
|
||||||
|
QMessageBox.warning(self, "Invalid selection", "Selected observation not found in BORIS file.")
|
||||||
|
return
|
||||||
|
|
||||||
|
boris_events = self.boris_data["observations"][selected_obs].get("events", [])
|
||||||
|
if not boris_events:
|
||||||
|
QMessageBox.warning(self, "No BORIS events", "No events found in selected BORIS observation.")
|
||||||
|
return
|
||||||
|
|
||||||
|
snirf_path = self.line_edit_file_a.text()
|
||||||
|
if not snirf_path:
|
||||||
|
QMessageBox.warning(self, "No SNIRF file", "Please select a SNIRF file.")
|
||||||
|
return
|
||||||
|
|
||||||
|
boris_obs = self.boris_data["observations"][selected_obs]
|
||||||
|
|
||||||
|
# --- Extract videos + delays ---
|
||||||
|
files = boris_obs.get("file", {})
|
||||||
|
offsets = boris_obs.get("media_info", {}).get("offset", {})
|
||||||
|
|
||||||
|
videos = {}
|
||||||
|
for key, path in files.items():
|
||||||
|
if path: # only include videos that exist
|
||||||
|
delay = offsets.get(key, 0.0) # default 0 if missing
|
||||||
|
videos[key] = {"file": path, "delay": delay}
|
||||||
|
|
||||||
|
base_name = os.path.splitext(os.path.basename(file_a))[0]
|
||||||
|
|
||||||
|
if self.mode == EventUpdateMode.WRITE_SNIRF:
|
||||||
|
# Open save dialog for SNIRF
|
||||||
|
base_name = os.path.splitext(os.path.basename(file_a))[0]
|
||||||
|
suggested_name = f"{base_name}_{suffix}.snirf"
|
||||||
|
save_path, _ = QFileDialog.getSaveFileName(
|
||||||
|
self,
|
||||||
|
"Save SNIRF File As",
|
||||||
|
suggested_name,
|
||||||
|
"SNIRF Files (*.snirf)"
|
||||||
|
)
|
||||||
|
if not save_path:
|
||||||
|
print("SNIRF save cancelled.")
|
||||||
|
return
|
||||||
|
if not save_path.lower().endswith(".snirf"):
|
||||||
|
save_path += ".snirf"
|
||||||
|
|
||||||
|
try:
|
||||||
|
raw = read_raw_snirf(file_a, preload=True)
|
||||||
|
|
||||||
|
# --- Align BORIS events to SNIRF ---
|
||||||
|
boris_events = boris_obs.get("events", [])
|
||||||
|
onsets, durations, descriptions = [], [], []
|
||||||
|
open_events = {} # label -> list of start times
|
||||||
|
label_counts = {}
|
||||||
|
used_times = set()
|
||||||
|
sfreq = raw.info['sfreq']
|
||||||
|
min_shift = 1.0 / sfreq
|
||||||
|
max_attempts = 10
|
||||||
|
|
||||||
|
for event in boris_events:
|
||||||
|
if not isinstance(event, list) or len(event) < 3:
|
||||||
|
continue
|
||||||
|
event_time = event[0]
|
||||||
|
label = event[2]
|
||||||
|
count = label_counts.get(label, 0) + 1
|
||||||
|
label_counts[label] = count
|
||||||
|
|
||||||
|
if label not in open_events:
|
||||||
|
open_events[label] = []
|
||||||
|
|
||||||
|
if count % 2 == 1:
|
||||||
|
open_events[label].append(event_time)
|
||||||
|
else:
|
||||||
|
if open_events[label]:
|
||||||
|
start_time = open_events[label].pop(0)
|
||||||
|
duration = event_time - start_time
|
||||||
|
if duration <= 0:
|
||||||
|
continue
|
||||||
|
|
||||||
|
adjusted_time = start_time + time_shift
|
||||||
|
attempts = 0
|
||||||
|
while round(adjusted_time, 6) in used_times and attempts < max_attempts:
|
||||||
|
adjusted_time += min_shift
|
||||||
|
attempts += 1
|
||||||
|
if attempts == max_attempts:
|
||||||
|
continue
|
||||||
|
|
||||||
|
adjusted_time = round(adjusted_time, 6)
|
||||||
|
used_times.add(adjusted_time)
|
||||||
|
onsets.append(adjusted_time)
|
||||||
|
durations.append(duration)
|
||||||
|
descriptions.append(label)
|
||||||
|
# Handle unmatched starts
|
||||||
|
for label, starts in open_events.items():
|
||||||
|
for start_time in starts:
|
||||||
|
adjusted_time = start_time + time_shift
|
||||||
|
attempts = 0
|
||||||
|
while round(adjusted_time, 6) in used_times and attempts < max_attempts:
|
||||||
|
adjusted_time += min_shift
|
||||||
|
attempts += 1
|
||||||
|
if attempts == max_attempts:
|
||||||
|
continue
|
||||||
|
adjusted_time = round(adjusted_time, 6)
|
||||||
|
used_times.add(adjusted_time)
|
||||||
|
onsets.append(adjusted_time)
|
||||||
|
durations.append(0.0)
|
||||||
|
descriptions.append(label)
|
||||||
|
|
||||||
|
new_annotations = Annotations(onset=onsets, duration=durations, description=descriptions)
|
||||||
|
raw.set_annotations(new_annotations)
|
||||||
|
write_raw_snirf(raw, save_path)
|
||||||
|
QMessageBox.information(self, "Success", "SNIRF file updated with aligned BORIS events.")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
QMessageBox.critical(self, "Error", f"Failed to update SNIRF file:\n{e}")
|
||||||
|
|
||||||
|
elif self.mode == EventUpdateMode.WRITE_JSON:
|
||||||
|
# Open save dialog for JSON
|
||||||
|
base_name = os.path.splitext(os.path.basename(file_a))[0]
|
||||||
|
suggested_name = f"{base_name}_{suffix}_alignment.json"
|
||||||
|
save_path, _ = QFileDialog.getSaveFileName(
|
||||||
|
self,
|
||||||
|
"Save Event Alignment JSON As",
|
||||||
|
suggested_name,
|
||||||
|
"JSON Files (*.json)"
|
||||||
|
)
|
||||||
|
if not save_path:
|
||||||
|
print("JSON save cancelled.")
|
||||||
|
return
|
||||||
|
if not save_path.lower().endswith(".json"):
|
||||||
|
save_path += ".json"
|
||||||
|
|
||||||
|
# Build JSON dict
|
||||||
|
json_data = {
|
||||||
|
"observation": selected_obs,
|
||||||
|
"snirf_anchor": {"label": snirf_label, "time": snirf_anchor_time},
|
||||||
|
"boris_anchor": {"label": boris_label, "time": boris_anchor_time},
|
||||||
|
"time_shift": time_shift,
|
||||||
|
"videos": videos
|
||||||
|
}
|
||||||
|
|
||||||
|
# Write JSON
|
||||||
|
try:
|
||||||
|
with open(save_path, "w", encoding="utf-8") as f:
|
||||||
|
json.dump(json_data, f, indent=4)
|
||||||
|
QMessageBox.information(self, "Success", f"Event alignment saved to:\n{save_path}")
|
||||||
|
except Exception as e:
|
||||||
|
QMessageBox.critical(self, "Error", f"Failed to write JSON:\n{e}")
|
||||||
|
|
||||||
|
|
||||||
|
def update_optode_positions(self, file_a, file_b, save_path):
|
||||||
|
|
||||||
|
fiducials = {}
|
||||||
|
ch_positions = {}
|
||||||
|
|
||||||
|
# Read the lines from the optode file
|
||||||
|
with open(file_b, 'r') as f:
|
||||||
|
for line in f:
|
||||||
|
if line.strip():
|
||||||
|
# Split by the semicolon and convert to meters
|
||||||
|
ch_name, coords_str = line.split(":")
|
||||||
|
coords = np.array(list(map(float, coords_str.strip().split()))) * 0.001
|
||||||
|
|
||||||
|
# The key we have is a fiducial
|
||||||
|
if ch_name.lower() in ['lpa', 'nz', 'rpa']:
|
||||||
|
fiducials[ch_name.lower()] = coords
|
||||||
|
|
||||||
|
# The key we have is a source or detector
|
||||||
|
else:
|
||||||
|
ch_positions[ch_name.upper()] = coords
|
||||||
|
|
||||||
|
# Create montage with updated coords in head space
|
||||||
|
initial_montage = make_dig_montage(ch_pos=ch_positions, nasion=fiducials.get('nz'), lpa=fiducials.get('lpa'), rpa=fiducials.get('rpa'), coord_frame='head') # type: ignore
|
||||||
|
|
||||||
|
# Read the SNIRF file, set the montage, and write it back
|
||||||
|
# TODO: Bad! read_raw_snirf doesnt release memory properly! Should be spawned in a seperate process and killed once completed
|
||||||
|
raw = read_raw_snirf(file_a, preload=True)
|
||||||
|
raw.set_montage(initial_montage)
|
||||||
|
write_raw_snirf(raw, save_path)
|
||||||
|
|
||||||
|
|
||||||
|
def _apply_events_to_snirf(self, raw, new_annotations, save_path):
|
||||||
|
raw.set_annotations(new_annotations)
|
||||||
|
write_raw_snirf(raw, save_path)
|
||||||
|
|
||||||
|
def _write_event_mapping_json(
|
||||||
|
self,
|
||||||
|
file_a,
|
||||||
|
file_b,
|
||||||
|
selected_obs,
|
||||||
|
snirf_anchor,
|
||||||
|
boris_anchor,
|
||||||
|
time_shift,
|
||||||
|
mapped_events,
|
||||||
|
save_path
|
||||||
|
):
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"source": {
|
||||||
|
"called_from": self.caller,
|
||||||
|
"snirf_file": os.path.basename(file_a),
|
||||||
|
"boris_file": os.path.basename(file_b),
|
||||||
|
"observation": selected_obs
|
||||||
|
},
|
||||||
|
"alignment": {
|
||||||
|
"snirf_anchor": snirf_anchor,
|
||||||
|
"boris_anchor": boris_anchor,
|
||||||
|
"time_shift_seconds": time_shift
|
||||||
|
},
|
||||||
|
"events": mapped_events,
|
||||||
|
"created_at": datetime.utcnow().isoformat() + "Z"
|
||||||
|
}
|
||||||
|
|
||||||
|
with open(save_path, "w", encoding="utf-8") as f:
|
||||||
|
json.dump(payload, f, indent=2)
|
||||||
|
|
||||||
|
return save_path
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
class UpdateEventsBlazesWindow(QWidget):
|
||||||
|
|
||||||
|
def __init__(self, parent=None, mode=EventUpdateMode.WRITE_SNIRF, caller=None):
|
||||||
|
super().__init__(parent, Qt.WindowType.Window)
|
||||||
|
|
||||||
|
self.mode = mode
|
||||||
|
self.caller = caller or self.__class__.__name__
|
||||||
|
self.setWindowTitle("Update event markers (BLAZES)")
|
||||||
|
self.resize(760, 200)
|
||||||
|
|
||||||
|
self.label_file_a = QLabel("SNIRF file:")
|
||||||
|
self.line_edit_file_a = QLineEdit()
|
||||||
|
self.line_edit_file_a.setReadOnly(True)
|
||||||
|
self.btn_browse_a = QPushButton("Browse .snirf")
|
||||||
|
self.btn_browse_a.clicked.connect(self.browse_file_a)
|
||||||
|
|
||||||
|
self.label_file_b = QLabel("BLAZES file:")
|
||||||
|
self.line_edit_file_b = QLineEdit()
|
||||||
|
self.line_edit_file_b.setReadOnly(True)
|
||||||
|
self.btn_browse_b = QPushButton("Browse .blaze")
|
||||||
|
self.btn_browse_b.clicked.connect(self.browse_file_b)
|
||||||
|
|
||||||
|
self.label_events = QLabel("Events in selected blazes file:")
|
||||||
|
self.combo_events = QComboBox()
|
||||||
|
self.combo_events.setEnabled(False)
|
||||||
|
|
||||||
|
self.label_snirf_events = QLabel("Events in SNIRF file:")
|
||||||
|
self.combo_snirf_events = QComboBox()
|
||||||
|
self.combo_snirf_events.setEnabled(False)
|
||||||
|
|
||||||
|
self.btn_clear = QPushButton("Clear")
|
||||||
|
self.btn_go = QPushButton("Go")
|
||||||
|
self.btn_clear.clicked.connect(self.clear_files)
|
||||||
|
self.btn_go.clicked.connect(self.go_action)
|
||||||
|
|
||||||
|
# ---
|
||||||
|
layout = QVBoxLayout()
|
||||||
|
self.description = QLabel()
|
||||||
|
self.description.setTextFormat(Qt.TextFormat.RichText)
|
||||||
|
self.description.setTextInteractionFlags(Qt.TextInteractionFlag.TextBrowserInteraction)
|
||||||
|
self.description.setOpenExternalLinks(True)
|
||||||
|
|
||||||
|
self.description.setText("The events that are present in a snirf file may not be the events that are to be studied and examined.<br>"
|
||||||
|
"Utilizing different software and video recordings, it is easy enough to see when an action actually occured in a file.<br>"
|
||||||
|
"The software <a href='https://git.research.dezeeuw.ca/tyler/blazes/'>BLAZES</a> is used to create these events in video files, and these events can be applied to the snirf file <br>"
|
||||||
|
"selected below by time syncing it to an event that it shares with the snirf file.")
|
||||||
|
|
||||||
|
layout.addWidget(self.description)
|
||||||
|
|
||||||
|
help_text_a = "Select the SNIRF (.snirf) file to update with new event markers."
|
||||||
|
|
||||||
|
file_a_layout = QHBoxLayout()
|
||||||
|
|
||||||
|
# Help button on the left
|
||||||
|
help_btn_a = QPushButton("?")
|
||||||
|
help_btn_a.setFixedWidth(25)
|
||||||
|
help_btn_a.setToolTip(help_text_a)
|
||||||
|
help_btn_a.clicked.connect(lambda _, text=help_text_a: self.show_help_popup(text))
|
||||||
|
file_a_layout.addWidget(help_btn_a)
|
||||||
|
|
||||||
|
# Container for label + line_edit + browse button with tooltip
|
||||||
|
file_a_container = QWidget()
|
||||||
|
file_a_container_layout = QHBoxLayout()
|
||||||
|
file_a_container_layout.setContentsMargins(0, 0, 0, 0)
|
||||||
|
file_a_container_layout.addWidget(self.label_file_a)
|
||||||
|
file_a_container_layout.addWidget(self.line_edit_file_a)
|
||||||
|
file_a_container_layout.addWidget(self.btn_browse_a)
|
||||||
|
file_a_container.setLayout(file_a_container_layout)
|
||||||
|
file_a_container.setToolTip(help_text_a)
|
||||||
|
|
||||||
|
file_a_layout.addWidget(file_a_container)
|
||||||
|
layout.addLayout(file_a_layout)
|
||||||
|
|
||||||
|
help_text_b = "Provide a .blaze output file that contains events for this participant."
|
||||||
|
|
||||||
|
file_b_layout = QHBoxLayout()
|
||||||
|
|
||||||
|
help_btn_b = QPushButton("?")
|
||||||
|
help_btn_b.setFixedWidth(25)
|
||||||
|
help_btn_b.setToolTip(help_text_b)
|
||||||
|
help_btn_b.clicked.connect(lambda _, text=help_text_b: self.show_help_popup(text))
|
||||||
|
file_b_layout.addWidget(help_btn_b)
|
||||||
|
|
||||||
|
file_b_container = QWidget()
|
||||||
|
file_b_container_layout = QHBoxLayout()
|
||||||
|
file_b_container_layout.setContentsMargins(0, 0, 0, 0)
|
||||||
|
file_b_container_layout.addWidget(self.label_file_b)
|
||||||
|
file_b_container_layout.addWidget(self.line_edit_file_b)
|
||||||
|
file_b_container_layout.addWidget(self.btn_browse_b)
|
||||||
|
file_b_container.setLayout(file_b_container_layout)
|
||||||
|
file_b_container.setToolTip(help_text_b)
|
||||||
|
|
||||||
|
file_b_layout.addWidget(file_b_container)
|
||||||
|
layout.addLayout(file_b_layout)
|
||||||
|
|
||||||
|
help_text_suffix = "The events extracted from the blaze file."
|
||||||
|
|
||||||
|
suffix2_layout = QHBoxLayout()
|
||||||
|
|
||||||
|
help_btn_suffix = QPushButton("?")
|
||||||
|
help_btn_suffix.setFixedWidth(25)
|
||||||
|
help_btn_suffix.setToolTip(help_text_suffix)
|
||||||
|
help_btn_suffix.clicked.connect(lambda _, text=help_text_suffix: self.show_help_popup(text))
|
||||||
|
suffix2_layout.addWidget(help_btn_suffix)
|
||||||
|
|
||||||
|
suffix2_container = QWidget()
|
||||||
|
suffix2_container_layout = QHBoxLayout()
|
||||||
|
suffix2_container_layout.setContentsMargins(0, 0, 0, 0)
|
||||||
|
suffix2_container_layout.addWidget(self.label_events)
|
||||||
|
suffix2_container_layout.addWidget(self.combo_events)
|
||||||
|
suffix2_container.setLayout(suffix2_container_layout)
|
||||||
|
suffix2_container.setToolTip(help_text_suffix)
|
||||||
|
|
||||||
|
suffix2_layout.addWidget(suffix2_container)
|
||||||
|
layout.addLayout(suffix2_layout)
|
||||||
|
|
||||||
|
snirf_events_layout = QHBoxLayout()
|
||||||
|
|
||||||
|
help_text_snirf_events = "The event markers extracted from the SNIRF file."
|
||||||
|
help_btn_snirf_events = QPushButton("?")
|
||||||
|
help_btn_snirf_events.setFixedWidth(25)
|
||||||
|
help_btn_snirf_events.setToolTip(help_text_snirf_events)
|
||||||
|
help_btn_snirf_events.clicked.connect(lambda _, text=help_text_snirf_events: self.show_help_popup(text))
|
||||||
|
snirf_events_layout.addWidget(help_btn_snirf_events)
|
||||||
|
|
||||||
|
snirf_events_container = QWidget()
|
||||||
|
snirf_events_container_layout = QHBoxLayout()
|
||||||
|
snirf_events_container_layout.setContentsMargins(0, 0, 0, 0)
|
||||||
|
snirf_events_container_layout.addWidget(self.label_snirf_events)
|
||||||
|
snirf_events_container_layout.addWidget(self.combo_snirf_events)
|
||||||
|
snirf_events_container.setLayout(snirf_events_container_layout)
|
||||||
|
snirf_events_container.setToolTip(help_text_snirf_events)
|
||||||
|
|
||||||
|
snirf_events_layout.addWidget(snirf_events_container)
|
||||||
|
layout.addLayout(snirf_events_layout)
|
||||||
|
|
||||||
|
buttons_layout = QHBoxLayout()
|
||||||
|
buttons_layout.addStretch()
|
||||||
|
buttons_layout.addWidget(self.btn_clear)
|
||||||
|
buttons_layout.addWidget(self.btn_go)
|
||||||
|
layout.addLayout(buttons_layout)
|
||||||
|
|
||||||
|
self.setLayout(layout)
|
||||||
|
|
||||||
|
|
||||||
|
def show_help_popup(self, text):
|
||||||
|
msg = QMessageBox(self)
|
||||||
|
msg.setWindowTitle(f"Parameter Info - {APP_NAME.upper()}")
|
||||||
|
msg.setText(text)
|
||||||
|
msg.exec()
|
||||||
|
|
||||||
|
def browse_file_a(self):
|
||||||
|
file_path, _ = QFileDialog.getOpenFileName(self, "Select SNIRF File", "", "SNIRF Files (*.snirf)")
|
||||||
|
if file_path:
|
||||||
|
self.line_edit_file_a.setText(file_path)
|
||||||
|
try:
|
||||||
|
# TODO: Bad! read_raw_snirf doesnt release memory properly! Should be spawned in a seperate process and killed once completed
|
||||||
|
raw = read_raw_snirf(file_path, preload=False)
|
||||||
|
annotations = raw.annotations
|
||||||
|
|
||||||
|
# Build individual event entries
|
||||||
|
event_entries = []
|
||||||
|
for onset, description in zip(annotations.onset, annotations.description):
|
||||||
|
event_str = f"{description} @ {onset:.3f}s"
|
||||||
|
event_entries.append(event_str)
|
||||||
|
|
||||||
|
if not event_entries:
|
||||||
|
QMessageBox.information(self, "No Events", "No events found in SNIRF file.")
|
||||||
|
self.combo_snirf_events.clear()
|
||||||
|
self.combo_snirf_events.setEnabled(False)
|
||||||
|
return
|
||||||
|
|
||||||
|
self.combo_snirf_events.clear()
|
||||||
|
self.combo_snirf_events.addItems(event_entries)
|
||||||
|
self.combo_snirf_events.setEnabled(True)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
QMessageBox.warning(self, "Error", f"Could not read SNIRF file with MNE:\n{str(e)}")
|
||||||
|
self.combo_snirf_events.clear()
|
||||||
|
self.combo_snirf_events.setEnabled(False)
|
||||||
|
|
||||||
|
def browse_file_b(self):
|
||||||
|
file_path, _ = QFileDialog.getOpenFileName(self, "Select JSON Timeline File", "", "JSON Files (*.json)")
|
||||||
|
if file_path:
|
||||||
|
self.line_edit_file_b.setText(file_path)
|
||||||
|
|
||||||
|
try:
|
||||||
|
with open(file_path, 'r', encoding='utf-8') as f:
|
||||||
|
data = json.load(f)
|
||||||
|
self.json_data = data
|
||||||
|
|
||||||
|
obs_keys = self.extract_json_observation_strings(data)
|
||||||
|
self.combo_events.clear()
|
||||||
|
if obs_keys:
|
||||||
|
self.combo_events.addItems(obs_keys)
|
||||||
|
self.combo_events.setEnabled(True)
|
||||||
|
else:
|
||||||
|
QMessageBox.information(self, "No Events", "No events found in JSON file.")
|
||||||
|
self.combo_events.setEnabled(False)
|
||||||
|
|
||||||
|
except (json.JSONDecodeError, FileNotFoundError, KeyError, TypeError) as e:
|
||||||
|
QMessageBox.warning(self, "Error", f"Failed to parse JSON file:\n{e}")
|
||||||
|
self.combo_events.clear()
|
||||||
|
self.combo_events.setEnabled(False)
|
||||||
|
|
||||||
|
|
||||||
|
def extract_json_observation_strings(self, data):
|
||||||
|
if "events" not in data:
|
||||||
|
raise KeyError("Missing 'events' key in JSON file.")
|
||||||
|
|
||||||
|
event_strings = []
|
||||||
|
|
||||||
|
# The new format is a flat list chronologically ordered
|
||||||
|
for event in data["events"]:
|
||||||
|
track_name = event.get("track_name", "Unknown")
|
||||||
|
onset = event.get("start_sec", 0.0)
|
||||||
|
|
||||||
|
# Formatting to match your SNIRF style: "Event Name @ 0.000s"
|
||||||
|
display_str = f"{track_name} @ {onset:.3f}s"
|
||||||
|
event_strings.append(display_str)
|
||||||
|
|
||||||
|
return event_strings
|
||||||
|
|
||||||
|
|
||||||
|
def clear_files(self):
|
||||||
|
self.line_edit_file_a.clear()
|
||||||
|
self.line_edit_file_b.clear()
|
||||||
|
|
||||||
|
|
||||||
|
def go_action(self):
|
||||||
|
file_a = self.line_edit_file_a.text()
|
||||||
|
file_b = self.line_edit_file_b.text()
|
||||||
|
suffix = APP_NAME
|
||||||
|
|
||||||
|
if not hasattr(self, "json_data") or self.combo_events.count() == 0 or self.combo_snirf_events.count() == 0:
|
||||||
|
QMessageBox.warning(self, "Missing data", "Please make sure a JSON and SNIRF event are selected.")
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
json_text = self.combo_events.currentText()
|
||||||
|
_, json_time_str = json_text.split(" @ ")
|
||||||
|
json_anchor_time = float(json_time_str.replace("s", "").strip())
|
||||||
|
except Exception as e:
|
||||||
|
QMessageBox.critical(self, "JSON Event Error", f"Could not parse JSON anchor:\n{e}")
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
snirf_text = self.combo_snirf_events.currentText()
|
||||||
|
_, snirf_time_str = snirf_text.split(" @ ")
|
||||||
|
snirf_anchor_time = float(snirf_time_str.replace("s", "").strip())
|
||||||
|
except Exception as e:
|
||||||
|
QMessageBox.critical(self, "SNIRF Event Error", f"Could not parse SNIRF anchor:\n{e}")
|
||||||
|
return
|
||||||
|
|
||||||
|
time_shift = snirf_anchor_time - json_anchor_time
|
||||||
|
|
||||||
|
onsets, durations, descriptions = [], [], []
|
||||||
|
skipped_count = 0
|
||||||
|
|
||||||
|
try:
|
||||||
|
events_list = self.json_data.get("events", [])
|
||||||
|
|
||||||
|
for event in events_list:
|
||||||
|
track_name = event.get("track_name", "Unknown")
|
||||||
|
clean_name = track_name.replace("AI: ", "").strip()
|
||||||
|
|
||||||
|
original_start = event.get("start_sec", 0.0)
|
||||||
|
original_end = event.get("end_sec", original_start)
|
||||||
|
duration = original_end - original_start
|
||||||
|
|
||||||
|
# FILTER: Minimum 0.1s duration
|
||||||
|
if duration < 0.1:
|
||||||
|
skipped_count += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Apply shift
|
||||||
|
adjusted_onset = original_start + time_shift
|
||||||
|
|
||||||
|
onsets.append(round(adjusted_onset, 6))
|
||||||
|
durations.append(round(duration, 6))
|
||||||
|
descriptions.append(clean_name)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
QMessageBox.critical(self, "Track Error", f"Failed to process tracks: {e}")
|
||||||
|
return
|
||||||
|
|
||||||
|
if not onsets:
|
||||||
|
QMessageBox.warning(self, "No Data", f"No events met the 0.1s threshold. (Skipped {skipped_count})")
|
||||||
|
return
|
||||||
|
|
||||||
|
if self.mode == EventUpdateMode.WRITE_SNIRF:
|
||||||
|
suggested_name = f"{os.path.splitext(os.path.basename(file_a))[0]}_{suffix}.snirf"
|
||||||
|
save_path, _ = QFileDialog.getSaveFileName(self, "Save SNIRF", suggested_name, "SNIRF Files (*.snirf)")
|
||||||
|
|
||||||
|
if not save_path: return
|
||||||
|
if not save_path.lower().endswith(".snirf"): save_path += ".snirf"
|
||||||
|
|
||||||
|
try:
|
||||||
|
raw = read_raw_snirf(file_a, preload=True)
|
||||||
|
|
||||||
|
# Create annotations
|
||||||
|
new_annotations = Annotations(
|
||||||
|
onset=onsets,
|
||||||
|
duration=durations,
|
||||||
|
description=descriptions
|
||||||
|
)
|
||||||
|
|
||||||
|
# Replace existing annotations with the new aligned JSON tracks
|
||||||
|
raw.set_annotations(new_annotations)
|
||||||
|
|
||||||
|
write_raw_snirf(raw, save_path)
|
||||||
|
QMessageBox.information(self, "Success",
|
||||||
|
f"Aligned {len(onsets)} events.\n(Filtered out {skipped_count} short events)")
|
||||||
|
except Exception as e:
|
||||||
|
QMessageBox.critical(self, "Error", f"Failed to update SNIRF file:\n{e}")
|
||||||
@@ -0,0 +1,296 @@
|
|||||||
|
"""
|
||||||
|
Filename: updateoptodes.py
|
||||||
|
Description: Methods to update optode locations for FLARES
|
||||||
|
|
||||||
|
Author: Tyler de Zeeuw
|
||||||
|
License: GPL-3.0
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pandas as pd
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
from PySide6.QtWidgets import QWidget, QVBoxLayout, QLabel, QHBoxLayout, QMessageBox, QLineEdit, QPushButton, QFileDialog
|
||||||
|
from PySide6.QtCore import Qt
|
||||||
|
|
||||||
|
from mne.io import read_raw_snirf
|
||||||
|
from mne_nirs.io import write_raw_snirf
|
||||||
|
from mne.channels import make_dig_montage
|
||||||
|
|
||||||
|
from src.shared.shareddata import APP_NAME
|
||||||
|
|
||||||
|
|
||||||
|
class UpdateOptodesWindow(QWidget):
|
||||||
|
|
||||||
|
def __init__(self, parent=None):
|
||||||
|
super().__init__(parent, Qt.WindowType.Window)
|
||||||
|
self.setWindowTitle(f"Update optode positions - {APP_NAME.upper()}")
|
||||||
|
self.resize(760, 200)
|
||||||
|
|
||||||
|
self.label_file_a = QLabel("SNIRF file:")
|
||||||
|
self.line_edit_file_a = QLineEdit()
|
||||||
|
self.line_edit_file_a.setReadOnly(True)
|
||||||
|
self.btn_browse_a = QPushButton("Browse .snirf")
|
||||||
|
self.btn_browse_a.clicked.connect(self.browse_file_a)
|
||||||
|
|
||||||
|
self.label_file_b = QLabel("Text file:")
|
||||||
|
self.line_edit_file_b = QLineEdit()
|
||||||
|
self.line_edit_file_b.setReadOnly(True)
|
||||||
|
self.btn_browse_b = QPushButton("Browse .txt/.xlsx")
|
||||||
|
self.btn_browse_b.clicked.connect(self.browse_file_b)
|
||||||
|
|
||||||
|
self.label_suffix = QLabel("Suffix to append to filename:")
|
||||||
|
self.line_edit_suffix = QLineEdit()
|
||||||
|
self.line_edit_suffix.setText("flare")
|
||||||
|
|
||||||
|
self.btn_clear = QPushButton("Clear")
|
||||||
|
self.btn_go = QPushButton("Go")
|
||||||
|
self.btn_clear.clicked.connect(self.clear_files)
|
||||||
|
self.btn_go.clicked.connect(self.go_action)
|
||||||
|
|
||||||
|
# ---
|
||||||
|
layout = QVBoxLayout()
|
||||||
|
self.description = QLabel()
|
||||||
|
self.description.setTextFormat(Qt.TextFormat.RichText)
|
||||||
|
self.description.setTextInteractionFlags(Qt.TextInteractionFlag.TextBrowserInteraction)
|
||||||
|
self.description.setOpenExternalLinks(False) # Handle the click internally
|
||||||
|
|
||||||
|
self.description.setText("Some software when creating snirf files will insert a template of optode positions as the correct position of the optodes for the participant.<br>"
|
||||||
|
"This is rarely correct as each head differs slightly in shape or size, and a lot of calculations require the optodes to be in the correct location.<br>"
|
||||||
|
"Using a .txt or .xlsx file, we can update the positions in the snirf file to match those of a digitization system such as one from Polhemus or elsewhere.<br>"
|
||||||
|
"The .txt file should have the fiducials, detectors, and sources clearly labeled, followed by the x, y, and z coordinates seperated by a space.<br>"
|
||||||
|
"An example format of what a digitization text file should look like can be found <a href='custom_link'>by clicking here</a>. Currently only .xlsx files directly exported from a<br>"
|
||||||
|
"Polhemus system are supported.")
|
||||||
|
|
||||||
|
self.description.linkActivated.connect(self.handle_link_click)
|
||||||
|
layout.addWidget(self.description)
|
||||||
|
|
||||||
|
help_text_a = "Select the SNIRF (.snirf) file to update with new optode positions."
|
||||||
|
|
||||||
|
file_a_layout = QHBoxLayout()
|
||||||
|
|
||||||
|
# Help button on the left
|
||||||
|
help_btn_a = QPushButton("?")
|
||||||
|
help_btn_a.setFixedWidth(25)
|
||||||
|
help_btn_a.setToolTip(help_text_a)
|
||||||
|
help_btn_a.clicked.connect(lambda _, text=help_text_a: self.show_help_popup(text))
|
||||||
|
file_a_layout.addWidget(help_btn_a)
|
||||||
|
|
||||||
|
# Container for label + line_edit + browse button with tooltip
|
||||||
|
file_a_container = QWidget()
|
||||||
|
file_a_container_layout = QHBoxLayout()
|
||||||
|
file_a_container_layout.setContentsMargins(0, 0, 0, 0)
|
||||||
|
file_a_container_layout.addWidget(self.label_file_a)
|
||||||
|
file_a_container_layout.addWidget(self.line_edit_file_a)
|
||||||
|
file_a_container_layout.addWidget(self.btn_browse_a)
|
||||||
|
file_a_container.setLayout(file_a_container_layout)
|
||||||
|
file_a_container.setToolTip(help_text_a)
|
||||||
|
|
||||||
|
file_a_layout.addWidget(file_a_container)
|
||||||
|
layout.addLayout(file_a_layout)
|
||||||
|
|
||||||
|
help_text_b = "Provide a .txt file with labeled optodes (e.g., nz, rpa, lpa, d1, s1) and their x, y, z coordinates, or a .xlsx file from a Polhemius system."
|
||||||
|
|
||||||
|
file_b_layout = QHBoxLayout()
|
||||||
|
|
||||||
|
help_btn_b = QPushButton("?")
|
||||||
|
help_btn_b.setFixedWidth(25)
|
||||||
|
help_btn_b.setToolTip(help_text_b)
|
||||||
|
help_btn_b.clicked.connect(lambda _, text=help_text_b: self.show_help_popup(text))
|
||||||
|
file_b_layout.addWidget(help_btn_b)
|
||||||
|
|
||||||
|
file_b_container = QWidget()
|
||||||
|
file_b_container_layout = QHBoxLayout()
|
||||||
|
file_b_container_layout.setContentsMargins(0, 0, 0, 0)
|
||||||
|
file_b_container_layout.addWidget(self.label_file_b)
|
||||||
|
file_b_container_layout.addWidget(self.line_edit_file_b)
|
||||||
|
file_b_container_layout.addWidget(self.btn_browse_b)
|
||||||
|
file_b_container.setLayout(file_b_container_layout)
|
||||||
|
file_b_container.setToolTip(help_text_b)
|
||||||
|
|
||||||
|
file_b_layout.addWidget(file_b_container)
|
||||||
|
layout.addLayout(file_b_layout)
|
||||||
|
|
||||||
|
|
||||||
|
help_text_suffix = "This text will be appended to the original filename when saving. Default is 'flare'."
|
||||||
|
|
||||||
|
suffix_layout = QHBoxLayout()
|
||||||
|
|
||||||
|
help_btn_suffix = QPushButton("?")
|
||||||
|
help_btn_suffix.setFixedWidth(25)
|
||||||
|
help_btn_suffix.setToolTip(help_text_suffix)
|
||||||
|
help_btn_suffix.clicked.connect(lambda _, text=help_text_suffix: self.show_help_popup(text))
|
||||||
|
suffix_layout.addWidget(help_btn_suffix)
|
||||||
|
|
||||||
|
suffix_container = QWidget()
|
||||||
|
suffix_container_layout = QHBoxLayout()
|
||||||
|
suffix_container_layout.setContentsMargins(0, 0, 0, 0)
|
||||||
|
suffix_container_layout.addWidget(self.label_suffix)
|
||||||
|
suffix_container_layout.addWidget(self.line_edit_suffix)
|
||||||
|
suffix_container.setLayout(suffix_container_layout)
|
||||||
|
suffix_container.setToolTip(help_text_suffix)
|
||||||
|
|
||||||
|
suffix_layout.addWidget(suffix_container)
|
||||||
|
layout.addLayout(suffix_layout)
|
||||||
|
|
||||||
|
buttons_layout = QHBoxLayout()
|
||||||
|
buttons_layout.addStretch()
|
||||||
|
buttons_layout.addWidget(self.btn_clear)
|
||||||
|
buttons_layout.addWidget(self.btn_go)
|
||||||
|
layout.addLayout(buttons_layout)
|
||||||
|
|
||||||
|
self.setLayout(layout)
|
||||||
|
|
||||||
|
def show_help_popup(self, text):
|
||||||
|
msg = QMessageBox(self)
|
||||||
|
msg.setWindowTitle(f"Parameter Info - {APP_NAME.upper()}")
|
||||||
|
msg.setText(text)
|
||||||
|
msg.exec()
|
||||||
|
|
||||||
|
def handle_link_click(self, link):
|
||||||
|
if link == "custom_link":
|
||||||
|
msg = QMessageBox(self)
|
||||||
|
msg.setWindowTitle("Example Digitization File")
|
||||||
|
|
||||||
|
text = "nz: -1.91 85.175 -31.1525\n" \
|
||||||
|
"rpa: 80.3825 -17.1925 -57.2775\n" \
|
||||||
|
"lpa: -81.815 -17.1925 -57.965\n" \
|
||||||
|
"d1: 0.01 -97.5175 62.5875\n" \
|
||||||
|
"d2: 25.125 -103.415 45.045\n" \
|
||||||
|
"d3: 49.095 -97.9025 30.2075\n" \
|
||||||
|
"s1: 0.01 -112.43 32.595\n" \
|
||||||
|
"s2: 30.325 -84.3125 71.8975\n" \
|
||||||
|
"s3: 0.01 -70.6875 89.0925\n"
|
||||||
|
msg.setText(text)
|
||||||
|
msg.exec()
|
||||||
|
|
||||||
|
def browse_file_a(self):
|
||||||
|
file_path, _ = QFileDialog.getOpenFileName(self, "Select SNIRF File", "", "SNIRF Files (*.snirf)")
|
||||||
|
if file_path:
|
||||||
|
self.line_edit_file_a.setText(file_path)
|
||||||
|
|
||||||
|
def browse_file_b(self):
|
||||||
|
file_path, _ = QFileDialog.getOpenFileName(self, "Select File", "", "Supported Files (*.txt *.xlsx)")
|
||||||
|
if file_path:
|
||||||
|
self.line_edit_file_b.setText(file_path)
|
||||||
|
|
||||||
|
def clear_files(self):
|
||||||
|
self.line_edit_file_a.clear()
|
||||||
|
self.line_edit_file_b.clear()
|
||||||
|
|
||||||
|
def go_action(self):
|
||||||
|
file_a = self.line_edit_file_a.text()
|
||||||
|
file_b = self.line_edit_file_b.text()
|
||||||
|
suffix = self.line_edit_suffix.text().strip() or "flare"
|
||||||
|
|
||||||
|
if not file_a:
|
||||||
|
QMessageBox.critical(self, "Missing File", "Please select a SNIRF file.")
|
||||||
|
return
|
||||||
|
if not file_b:
|
||||||
|
QMessageBox.critical(self, "Missing File", "Please select a TXT file.")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Get original filename without extension
|
||||||
|
base_name = os.path.splitext(os.path.basename(file_a))[0]
|
||||||
|
suggested_name = f"{base_name}_{suffix}.snirf"
|
||||||
|
|
||||||
|
# Open save dialog with default name
|
||||||
|
save_path, _ = QFileDialog.getSaveFileName(
|
||||||
|
self,
|
||||||
|
"Save SNIRF File As",
|
||||||
|
suggested_name,
|
||||||
|
"SNIRF Files (*.snirf)"
|
||||||
|
)
|
||||||
|
|
||||||
|
if not save_path:
|
||||||
|
print("Save cancelled.")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Ensure .snirf extension
|
||||||
|
if not save_path.lower().endswith(".snirf"):
|
||||||
|
save_path += ".snirf"
|
||||||
|
|
||||||
|
try:
|
||||||
|
self.update_optode_positions(file_a=file_a, file_b=file_b, save_path=save_path)
|
||||||
|
except Exception as e:
|
||||||
|
QMessageBox.critical(self, "Error", f"Failed to write file:\n{e}")
|
||||||
|
return
|
||||||
|
|
||||||
|
QMessageBox.information(self, "File Saved", f"File was saved to:\n{save_path}")
|
||||||
|
|
||||||
|
def update_optode_positions(self, file_a, file_b, save_path):
|
||||||
|
|
||||||
|
fiducials = {}
|
||||||
|
ch_positions = {}
|
||||||
|
|
||||||
|
extension = Path(file_b).suffix
|
||||||
|
|
||||||
|
# Read the lines from the optode file
|
||||||
|
if extension == '.txt':
|
||||||
|
with open(file_b, 'r') as f:
|
||||||
|
for line in f:
|
||||||
|
if line.strip():
|
||||||
|
# Split by the semicolon and convert to meters
|
||||||
|
ch_name, coords_str = line.split(":")
|
||||||
|
coords = np.array(list(map(float, coords_str.strip().split()))) * 0.001
|
||||||
|
|
||||||
|
# The key we have is a fiducial
|
||||||
|
if ch_name.lower() in ['lpa', 'nz', 'rpa']:
|
||||||
|
fiducials[ch_name.lower()] = coords
|
||||||
|
|
||||||
|
# The key we have is a source or detector
|
||||||
|
else:
|
||||||
|
ch_positions[ch_name.upper()] = coords
|
||||||
|
|
||||||
|
elif extension == '.xlsx':
|
||||||
|
|
||||||
|
# TODO: Bad! Why assume sheet1 has the contents?
|
||||||
|
df = pd.read_excel(file_b, sheet_name='Sheet1')
|
||||||
|
|
||||||
|
def _get_block_data(df, block_id, row_mapping, scale=0.001):
|
||||||
|
"""Isolates a block, cleans numeric data, and returns a scaled dictionary."""
|
||||||
|
# 1. Isolate and clean
|
||||||
|
block = df[df['block_id'] == block_id].iloc[:, [1, 2, 3]].copy()
|
||||||
|
block = block.apply(pd.to_numeric, errors='coerce')
|
||||||
|
|
||||||
|
# 2. Extract into dictionary based on mapping
|
||||||
|
result = {}
|
||||||
|
|
||||||
|
# If row_mapping is a dict (like {0: 'nz'}), use it directly
|
||||||
|
if isinstance(row_mapping, dict):
|
||||||
|
for row_idx, key in row_mapping.items():
|
||||||
|
if row_idx < len(block):
|
||||||
|
result[key] = block.iloc[row_idx].to_numpy(dtype=float) * scale
|
||||||
|
|
||||||
|
# If row_mapping is a string prefix (like 'D' or 'S'), auto-generate keys
|
||||||
|
elif isinstance(row_mapping, str):
|
||||||
|
for i in range(len(block)):
|
||||||
|
result[f"{row_mapping}{i+1}"] = block.iloc[i].to_numpy(dtype=float) * scale
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
# Identify blocks
|
||||||
|
is_empty = df.isnull().all(axis=1)
|
||||||
|
df['block_id'] = is_empty.cumsum()
|
||||||
|
clean_df = df[~is_empty].copy()
|
||||||
|
|
||||||
|
# Process Block 2: Landmarks
|
||||||
|
fiducials = _get_block_data(clean_df, 2, {0: 'nz', 2: 'rpa', 3: 'lpa'})
|
||||||
|
|
||||||
|
# Process Block 3: D-Points
|
||||||
|
d_points = _get_block_data(clean_df, 3, 'D')
|
||||||
|
|
||||||
|
# Process Block 4: S-Points
|
||||||
|
s_points = _get_block_data(clean_df, 4, 'S')
|
||||||
|
|
||||||
|
ch_positions = {**d_points, **s_points}
|
||||||
|
|
||||||
|
# Create montage with updated coords in head space
|
||||||
|
initial_montage = make_dig_montage(ch_pos=ch_positions, nasion=fiducials.get('nz'), lpa=fiducials.get('lpa'), rpa=fiducials.get('rpa'), coord_frame='head') # type: ignore
|
||||||
|
|
||||||
|
# Read the SNIRF file, set the montage, and write it back
|
||||||
|
raw = read_raw_snirf(file_a, preload=True)
|
||||||
|
raw.set_montage(initial_montage)
|
||||||
|
write_raw_snirf(raw, save_path)
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
"""
|
||||||
|
Filename: userguide.py
|
||||||
|
Description: User guide for FLARES
|
||||||
|
|
||||||
|
Author: Tyler de Zeeuw
|
||||||
|
License: GPL-3.0
|
||||||
|
"""
|
||||||
|
|
||||||
|
from PySide6.QtWidgets import QWidget, QVBoxLayout, QLabel
|
||||||
|
from PySide6.QtCore import Qt
|
||||||
|
|
||||||
|
from src.shared.shareddata import APP_NAME, PIPELINE_STAGES, WIKI_URL
|
||||||
|
|
||||||
|
|
||||||
|
class UserGuideWindow(QWidget):
|
||||||
|
"""
|
||||||
|
Simple User Guide window displaying basic information on how to use the software.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
parent (QWidget, optional): Parent widget of this window. Defaults to None.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, parent=None):
|
||||||
|
super().__init__(parent, Qt.WindowType.Window)
|
||||||
|
self.setWindowTitle(f"User Guide - {APP_NAME.upper()}")
|
||||||
|
self.resize(250, 100)
|
||||||
|
|
||||||
|
layout = QVBoxLayout()
|
||||||
|
label = QLabel("Progress Bar Stages:", self)
|
||||||
|
label2_text = "\n".join(f"Stage {idx + 1}: {name}" for idx, name in enumerate(PIPELINE_STAGES)) + "\n"
|
||||||
|
label2 = QLabel(label2_text, self)
|
||||||
|
|
||||||
|
label3 = QLabel(f"For more information, visit the Git wiki page <a href='{WIKI_URL}'>here</a>.", self)
|
||||||
|
label3.setTextFormat(Qt.TextFormat.RichText)
|
||||||
|
label3.setTextInteractionFlags(Qt.TextInteractionFlag.TextBrowserInteraction)
|
||||||
|
label3.setOpenExternalLinks(True)
|
||||||
|
layout.addWidget(label)
|
||||||
|
layout.addWidget(label2)
|
||||||
|
layout.addWidget(label3)
|
||||||
|
|
||||||
|
self.setLayout(layout)
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
"""
|
||||||
|
Filename: viewerlauncher.py
|
||||||
|
Description: Analysis options launcher for FLARES
|
||||||
|
|
||||||
|
Author: Tyler de Zeeuw
|
||||||
|
License: GPL-3.0
|
||||||
|
"""
|
||||||
|
|
||||||
|
from PySide6.QtWidgets import QPushButton, QWidget, QVBoxLayout
|
||||||
|
from PySide6.QtCore import QTimer
|
||||||
|
|
||||||
|
from src.analysis.exportcsv import ExportDataAsCSVViewerWidget
|
||||||
|
from src.analysis.group import GroupViewerWidget
|
||||||
|
from src.analysis.groupbrain import GroupBrainViewerWidget
|
||||||
|
from src.analysis.groupfunctionalconnectivity import GroupFunctionalConnectivityWidget
|
||||||
|
from src.analysis.participant import ParticipantViewerWidget
|
||||||
|
from src.analysis.participantbrain import ParticipantBrainViewerWidget
|
||||||
|
from src.analysis.participantfoldchannels import ParticipantFoldChannelsWidget
|
||||||
|
from src.analysis.participantfunctionalconnectivity import ParticipantFunctionalConnectivityWidget
|
||||||
|
from src.shared.shareddata import APP_NAME
|
||||||
|
|
||||||
|
|
||||||
|
class ViewerLauncherWidget(QWidget):
|
||||||
|
def __init__(self, haemo_dict, config_dict, fig_bytes_dict, cha_dict, contrast_results_dict, df_ind, design_matrix, epochs_dict, folding_bypass):
|
||||||
|
super().__init__()
|
||||||
|
self.setWindowTitle(f"Viewer Launcher - {APP_NAME.upper()}")
|
||||||
|
|
||||||
|
group_dict = {
|
||||||
|
file_path: config.get("GROUP", "Unknown")
|
||||||
|
for file_path, config in config_dict.items()
|
||||||
|
}
|
||||||
|
|
||||||
|
def launch(func, btn, *args):
|
||||||
|
func(*args)
|
||||||
|
self._trigger_success(btn)
|
||||||
|
|
||||||
|
layout = QVBoxLayout(self)
|
||||||
|
|
||||||
|
btn1 = QPushButton("Open Participant Viewer")
|
||||||
|
btn1.clicked.connect(lambda: launch(self.open_participant_viewer, btn1, haemo_dict, fig_bytes_dict))
|
||||||
|
btn1.setEnabled(not folding_bypass)
|
||||||
|
|
||||||
|
btn2 = QPushButton("Open Participant Brain Viewer")
|
||||||
|
btn2.clicked.connect(lambda: launch(self.open_participant_brain_viewer, btn2, haemo_dict, cha_dict))
|
||||||
|
btn2.setEnabled(not folding_bypass)
|
||||||
|
|
||||||
|
btn3 = QPushButton("Open Participant Fold Channels Viewer")
|
||||||
|
btn3.clicked.connect(lambda: launch(self.open_participant_fold_channels_viewer, btn3, haemo_dict, cha_dict))
|
||||||
|
|
||||||
|
btn7 = QPushButton("Open Functional Connectivity Viewer [BETA]")
|
||||||
|
btn7.clicked.connect(lambda: launch(self.open_participant_functional_connectivity_viewer, btn7, haemo_dict, epochs_dict))
|
||||||
|
btn7.setEnabled(not folding_bypass)
|
||||||
|
|
||||||
|
btn8 = QPushButton("Open Group Functional Connectivity Viewer [BETA]")
|
||||||
|
btn8.clicked.connect(lambda: launch(self.open_group_functional_connectivity_viewer, btn8, haemo_dict, group_dict, config_dict))
|
||||||
|
btn8.setEnabled(not folding_bypass)
|
||||||
|
|
||||||
|
btn4 = QPushButton("Open Inter-Group Viewer")
|
||||||
|
btn4.clicked.connect(lambda: launch(self.open_group_viewer, btn4, haemo_dict, cha_dict, df_ind, design_matrix, contrast_results_dict, group_dict))
|
||||||
|
btn4.setEnabled(not folding_bypass)
|
||||||
|
|
||||||
|
btn5 = QPushButton("Open Cross Group Brain Viewer")
|
||||||
|
btn5.clicked.connect(lambda: launch(self.open_group_brain_viewer, btn5, haemo_dict, df_ind, design_matrix, group_dict, contrast_results_dict))
|
||||||
|
btn5.setEnabled(not folding_bypass)
|
||||||
|
|
||||||
|
btn6 = QPushButton("Open Export Data As CSV Viewer")
|
||||||
|
btn6.clicked.connect(lambda: launch(self.open_export_data_as_csv_viewer, btn6, haemo_dict, cha_dict, df_ind, design_matrix, group_dict, contrast_results_dict))
|
||||||
|
btn6.setEnabled(not folding_bypass)
|
||||||
|
|
||||||
|
layout.addWidget(btn1)
|
||||||
|
layout.addWidget(btn2)
|
||||||
|
layout.addWidget(btn3)
|
||||||
|
layout.addWidget(btn7)
|
||||||
|
layout.addWidget(btn8)
|
||||||
|
layout.addWidget(btn4)
|
||||||
|
layout.addWidget(btn5)
|
||||||
|
layout.addWidget(btn6)
|
||||||
|
|
||||||
|
def open_participant_viewer(self, haemo_dict, fig_bytes_dict):
|
||||||
|
self.participant_viewer = ParticipantViewerWidget(haemo_dict, fig_bytes_dict)
|
||||||
|
self.participant_viewer.show()
|
||||||
|
|
||||||
|
def open_participant_brain_viewer(self, haemo_dict, cha_dict):
|
||||||
|
self.participant_brain_viewer = ParticipantBrainViewerWidget(haemo_dict, cha_dict)
|
||||||
|
self.participant_brain_viewer.show()
|
||||||
|
|
||||||
|
def open_participant_fold_channels_viewer(self, haemo_dict, cha_dict):
|
||||||
|
self.participant_fold_channels_viewer = ParticipantFoldChannelsWidget(haemo_dict, cha_dict)
|
||||||
|
self.participant_fold_channels_viewer.show()
|
||||||
|
|
||||||
|
def open_participant_functional_connectivity_viewer(self, haemo_dict, epochs_dict):
|
||||||
|
self.participant_brain_viewer = ParticipantFunctionalConnectivityWidget(haemo_dict, epochs_dict)
|
||||||
|
self.participant_brain_viewer.show()
|
||||||
|
|
||||||
|
def open_group_functional_connectivity_viewer(self, haemo_dict, group, config_dict):
|
||||||
|
self.participant_brain_viewer = GroupFunctionalConnectivityWidget(haemo_dict, group, config_dict)
|
||||||
|
self.participant_brain_viewer.show()
|
||||||
|
|
||||||
|
def open_group_viewer(self, haemo_dict, cha_dict, df_ind, design_matrix, contrast_results_dict, group):
|
||||||
|
self.participant_brain_viewer = GroupViewerWidget(haemo_dict, cha_dict, df_ind, design_matrix, contrast_results_dict, group)
|
||||||
|
self.participant_brain_viewer.show()
|
||||||
|
|
||||||
|
def open_group_brain_viewer(self, haemo_dict, df_ind, design_matrix, group, contrast_results_dict):
|
||||||
|
self.participant_brain_viewer = GroupBrainViewerWidget(haemo_dict, df_ind, design_matrix, group, contrast_results_dict)
|
||||||
|
self.participant_brain_viewer.show()
|
||||||
|
|
||||||
|
def open_export_data_as_csv_viewer(self, haemo_dict, cha_dict, df_ind, design_matrix, group, contrast_results_dict):
|
||||||
|
self.export_data_as_csv_viewer = ExportDataAsCSVViewerWidget(haemo_dict, cha_dict, df_ind, design_matrix, group, contrast_results_dict)
|
||||||
|
self.export_data_as_csv_viewer.show()
|
||||||
|
|
||||||
|
def _trigger_success(self, button):
|
||||||
|
"""Temporarily adds a green checkmark to the button text."""
|
||||||
|
original_text = button.text()
|
||||||
|
button.setText(f"{original_text} ✔")
|
||||||
|
button.setStyleSheet("color: green; font-weight: bold;")
|
||||||
|
|
||||||
|
# Revert after 1 second
|
||||||
|
QTimer.singleShot(1000, lambda: self._revert_button(button, original_text))
|
||||||
|
|
||||||
|
def _revert_button(self, button, original_text):
|
||||||
|
button.setText(original_text)
|
||||||
|
button.setStyleSheet("")
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
"""
|
||||||
|
Filename: welcome.py
|
||||||
|
Description: Welcome dialog for FLARES
|
||||||
|
|
||||||
|
Author: Tyler de Zeeuw
|
||||||
|
License: GPL-3.0
|
||||||
|
"""
|
||||||
|
|
||||||
|
from PySide6.QtWidgets import QTextBrowser, QVBoxLayout, QLabel, QDialog, QHBoxLayout, QPushButton
|
||||||
|
from PySide6.QtGui import QDesktopServices, QIcon
|
||||||
|
from PySide6.QtCore import QUrl
|
||||||
|
from PySide6.QtNetwork import QNetworkAccessManager, QNetworkRequest
|
||||||
|
|
||||||
|
from src.shared.shareddata import APP_NAME, CURRENT_VERSION, CHANGELOG_URL, resource_path
|
||||||
|
|
||||||
|
|
||||||
|
class WelcomeDialog(QDialog):
|
||||||
|
def __init__(self, parent=None, direct=True, first=False):
|
||||||
|
super().__init__(parent)
|
||||||
|
self.setWindowTitle(f"What's New - {APP_NAME.upper()}")
|
||||||
|
self.setMinimumSize(550, 450)
|
||||||
|
self.resize(800, 500)
|
||||||
|
|
||||||
|
layout = QVBoxLayout(self)
|
||||||
|
|
||||||
|
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))
|
||||||
|
if first:
|
||||||
|
title_label = QLabel(f"<h2>Welcome to {APP_NAME.upper()}!</h2>", self)
|
||||||
|
elif direct:
|
||||||
|
title_label = QLabel(f"<h2>{APP_NAME.upper()} has been sucessfully updated to version {CURRENT_VERSION}!</h2>", self)
|
||||||
|
else:
|
||||||
|
title_label = QLabel(f"<h2>{APP_NAME.upper()} is currently running version {CURRENT_VERSION}.</h2>", self)
|
||||||
|
|
||||||
|
header_layout.addWidget(logo_label)
|
||||||
|
header_layout.addWidget(title_label)
|
||||||
|
header_layout.addStretch()
|
||||||
|
layout.addLayout(header_layout)
|
||||||
|
|
||||||
|
self.text_browser = QTextBrowser(self)
|
||||||
|
self.text_browser.setHtml("<p style='color: gray;'>Loading latest updates from server...</p>")
|
||||||
|
|
||||||
|
# Ensure links open in the default web browser and not in this window
|
||||||
|
self.text_browser.setOpenLinks(False)
|
||||||
|
self.text_browser.anchorClicked.connect(QDesktopServices.openUrl)
|
||||||
|
layout.addWidget(self.text_browser)
|
||||||
|
|
||||||
|
footer_layout = QHBoxLayout()
|
||||||
|
|
||||||
|
ok_button = QPushButton("OK", self)
|
||||||
|
ok_button.setDefault(True)
|
||||||
|
ok_button.clicked.connect(self.accept)
|
||||||
|
|
||||||
|
footer_layout.addStretch()
|
||||||
|
footer_layout.addWidget(ok_button)
|
||||||
|
layout.addLayout(footer_layout)
|
||||||
|
|
||||||
|
# Fetch markdown from the web asynchronously
|
||||||
|
self.network_manager = QNetworkAccessManager(self)
|
||||||
|
self.network_manager.finished.connect(self._on_download_complete)
|
||||||
|
self.network_manager.get(QNetworkRequest(QUrl(CHANGELOG_URL)))
|
||||||
|
|
||||||
|
|
||||||
|
def _on_download_complete(self, reply):
|
||||||
|
"""Processes the downloaded markdown and drops it into the view frame."""
|
||||||
|
if reply.error() == reply.NetworkError.NoError:
|
||||||
|
raw_bytes = reply.readAll()
|
||||||
|
|
||||||
|
# Convert raw bytes to standard text string
|
||||||
|
markdown_text = str(raw_bytes, encoding='utf-8')
|
||||||
|
self.text_browser.setMarkdown(markdown_text)
|
||||||
|
else:
|
||||||
|
self.text_browser.setHtml(
|
||||||
|
f"<p style='color: red;'><b>Failed to load content.</b><br>Error: {reply.errorString()}</p>"
|
||||||
|
)
|
||||||
|
reply.deleteLater()
|
||||||
@@ -0,0 +1,550 @@
|
|||||||
|
"""
|
||||||
|
Filename: updater.py
|
||||||
|
Description: Generic updater file
|
||||||
|
|
||||||
|
Author: Tyler de Zeeuw
|
||||||
|
License: GPL-3.0
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Built-in imports
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
import shlex
|
||||||
|
import shutil
|
||||||
|
import zipfile
|
||||||
|
import traceback
|
||||||
|
import subprocess
|
||||||
|
import configparser
|
||||||
|
|
||||||
|
# External library imports
|
||||||
|
import psutil
|
||||||
|
import requests
|
||||||
|
|
||||||
|
from PySide6.QtWidgets import QMessageBox
|
||||||
|
from PySide6.QtCore import QThread, Signal, QObject
|
||||||
|
|
||||||
|
|
||||||
|
class UpdateDownloadThread(QThread):
|
||||||
|
"""
|
||||||
|
Thread that downloads and extracts an update package and emits a signal on completion or error.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
download_url (str): URL of the update zip file to download.
|
||||||
|
latest_version (str): Version string of the latest update.
|
||||||
|
"""
|
||||||
|
|
||||||
|
update_ready = Signal(str, str)
|
||||||
|
error_occurred = Signal(str)
|
||||||
|
|
||||||
|
def __init__(self, download_url, latest_version, platform_name, app_name):
|
||||||
|
super().__init__()
|
||||||
|
self.download_url = download_url
|
||||||
|
self.latest_version = latest_version
|
||||||
|
self.platform_name = platform_name
|
||||||
|
self.app_name = app_name
|
||||||
|
|
||||||
|
def run(self):
|
||||||
|
try:
|
||||||
|
local_filename = os.path.basename(self.download_url)
|
||||||
|
|
||||||
|
if self.platform_name == 'darwin':
|
||||||
|
tmp_dir = f'/tmp/{self.app_name}tempupdate'
|
||||||
|
os.makedirs(tmp_dir, exist_ok=True)
|
||||||
|
local_path = os.path.join(tmp_dir, local_filename)
|
||||||
|
else:
|
||||||
|
local_path = os.path.join(os.getcwd(), local_filename)
|
||||||
|
|
||||||
|
# Download the file
|
||||||
|
with requests.get(self.download_url, stream=True, timeout=15) as r:
|
||||||
|
r.raise_for_status()
|
||||||
|
with open(local_path, 'wb') as f:
|
||||||
|
for chunk in r.iter_content(chunk_size=8192):
|
||||||
|
if chunk:
|
||||||
|
f.write(chunk)
|
||||||
|
|
||||||
|
# Extract folder name (remove .zip)
|
||||||
|
if self.platform_name == 'darwin':
|
||||||
|
extract_folder = os.path.splitext(local_filename)[0]
|
||||||
|
extract_path = os.path.join(tmp_dir, extract_folder)
|
||||||
|
|
||||||
|
else:
|
||||||
|
extract_folder = os.path.splitext(local_filename)[0]
|
||||||
|
extract_path = os.path.join(os.getcwd(), extract_folder)
|
||||||
|
|
||||||
|
# Create the folder if not exists
|
||||||
|
os.makedirs(extract_path, exist_ok=True)
|
||||||
|
|
||||||
|
# Extract the zip file contents
|
||||||
|
if self.platform_name == 'darwin':
|
||||||
|
subprocess.run(['ditto', '-xk', local_path, extract_path], check=True)
|
||||||
|
else:
|
||||||
|
with zipfile.ZipFile(local_path, 'r') as zip_ref:
|
||||||
|
zip_ref.extractall(extract_path)
|
||||||
|
|
||||||
|
# Remove the zip once extracted and emit a signal
|
||||||
|
os.remove(local_path)
|
||||||
|
self.update_ready.emit(self.latest_version, extract_path)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
# Emit a signal signifying failure
|
||||||
|
self.error_occurred.emit(str(e))
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
class UpdateCheckThread(QThread):
|
||||||
|
"""
|
||||||
|
Thread that checks for updates by querying the API and emits a signal based on the result.
|
||||||
|
|
||||||
|
Signals:
|
||||||
|
download_requested(str, str): Emitted with (download_url, latest_version) when an update is available.
|
||||||
|
no_update_available(): Emitted when no update is found or current version is up to date.
|
||||||
|
error_occurred(str): Emitted with an error message if the update check fails.
|
||||||
|
"""
|
||||||
|
|
||||||
|
download_requested = Signal(str, str)
|
||||||
|
no_update_available = Signal()
|
||||||
|
error_occurred = Signal(str)
|
||||||
|
|
||||||
|
def __init__(self, api_url, api_url_sec, current_version, platform_name, app_name):
|
||||||
|
super().__init__()
|
||||||
|
self.api_url = api_url
|
||||||
|
self.api_url_sec = api_url_sec
|
||||||
|
self.current_version = current_version
|
||||||
|
self.platform_name = platform_name
|
||||||
|
self.app_name = app_name
|
||||||
|
|
||||||
|
def run(self):
|
||||||
|
# if not getattr(sys, 'frozen', False):
|
||||||
|
# self.error_occurred.emit("Application is not frozen (Development mode).")
|
||||||
|
# return
|
||||||
|
try:
|
||||||
|
latest_version, download_url = self.get_latest_release_for_platform()
|
||||||
|
if not latest_version:
|
||||||
|
self.no_update_available.emit()
|
||||||
|
return
|
||||||
|
|
||||||
|
if not download_url:
|
||||||
|
self.error_occurred.emit(f"No download available for platform '{self.platform_name}'")
|
||||||
|
return
|
||||||
|
|
||||||
|
if self.version_compare(latest_version, self.current_version) > 0:
|
||||||
|
self.download_requested.emit(download_url, latest_version)
|
||||||
|
else:
|
||||||
|
self.no_update_available.emit()
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
self.error_occurred.emit(f"Update check failed: {e}")
|
||||||
|
|
||||||
|
def version_compare(self, v1, v2):
|
||||||
|
def normalize(v): return [int(x) for x in v.split(".")]
|
||||||
|
return (normalize(v1) > normalize(v2)) - (normalize(v1) < normalize(v2))
|
||||||
|
|
||||||
|
def get_latest_release_for_platform(self):
|
||||||
|
urls = [self.api_url, self.api_url_sec]
|
||||||
|
for url in urls:
|
||||||
|
try:
|
||||||
|
|
||||||
|
response = requests.get(url, timeout=5)
|
||||||
|
response.raise_for_status()
|
||||||
|
releases = response.json()
|
||||||
|
|
||||||
|
if not releases:
|
||||||
|
continue
|
||||||
|
|
||||||
|
latest = next((r for r in releases if not r.get("prerelease") and not r.get("draft")), None)
|
||||||
|
|
||||||
|
if not latest:
|
||||||
|
continue
|
||||||
|
|
||||||
|
tag = latest["tag_name"].lstrip("v")
|
||||||
|
|
||||||
|
for asset in latest.get("assets", []):
|
||||||
|
if self.platform_name in asset["name"].lower():
|
||||||
|
return tag, asset["browser_download_url"]
|
||||||
|
|
||||||
|
return tag, None
|
||||||
|
except (requests.RequestException, ValueError) as e:
|
||||||
|
continue
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
|
||||||
|
class LocalPendingUpdateCheckThread(QThread):
|
||||||
|
"""
|
||||||
|
Thread that checks for locally pending updates by scanning the download directory and emits a signal accordingly.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
current_version (str): Current application version.
|
||||||
|
platform_suffix (str): Platform-specific suffix to identify update folders.
|
||||||
|
"""
|
||||||
|
|
||||||
|
pending_update_found = Signal(str, str)
|
||||||
|
no_pending_update = Signal()
|
||||||
|
|
||||||
|
def __init__(self, current_version, platform_suffix, platform_name, app_name):
|
||||||
|
super().__init__()
|
||||||
|
self.current_version = current_version
|
||||||
|
self.platform_suffix = platform_suffix
|
||||||
|
self.platform_name = platform_name
|
||||||
|
self.app_name = app_name
|
||||||
|
|
||||||
|
def version_compare(self, v1, v2):
|
||||||
|
def normalize(v): return [int(x) for x in v.split(".")]
|
||||||
|
return (normalize(v1) > normalize(v2)) - (normalize(v1) < normalize(v2))
|
||||||
|
|
||||||
|
def run(self):
|
||||||
|
if self.platform_name == 'darwin':
|
||||||
|
cwd = f'/tmp/{self.app_name}tempupdate'
|
||||||
|
else:
|
||||||
|
cwd = os.getcwd()
|
||||||
|
|
||||||
|
pattern = re.compile(r".*-(\d+\.\d+\.\d+)" + re.escape(self.platform_suffix) + r"$")
|
||||||
|
found = False
|
||||||
|
|
||||||
|
try:
|
||||||
|
for item in os.listdir(cwd):
|
||||||
|
folder_path = os.path.join(cwd, item)
|
||||||
|
if os.path.isdir(folder_path) and item.endswith(self.platform_suffix):
|
||||||
|
match = pattern.match(item)
|
||||||
|
if match:
|
||||||
|
folder_version = match.group(1)
|
||||||
|
if self.version_compare(folder_version, self.current_version) > 0:
|
||||||
|
self.pending_update_found.emit(folder_version, folder_path)
|
||||||
|
found = True
|
||||||
|
break
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if not found:
|
||||||
|
self.no_pending_update.emit()
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
class UpdateManager(QObject):
|
||||||
|
"""
|
||||||
|
Orchestrates the update process.
|
||||||
|
Main apps should instantiate this and call check_for_updates().
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, main_window, api_url, api_url_sec, current_version, platform_name, platform_suffix, app_name):
|
||||||
|
super().__init__()
|
||||||
|
self.parent = main_window
|
||||||
|
self.api_url = api_url
|
||||||
|
self.api_url_sec = api_url_sec
|
||||||
|
self.current_version = current_version
|
||||||
|
self.platform_name = platform_name
|
||||||
|
self.platform_suffix = platform_suffix
|
||||||
|
self.app_name = app_name
|
||||||
|
|
||||||
|
self.pending_update_version = None
|
||||||
|
self.pending_update_path = None
|
||||||
|
|
||||||
|
|
||||||
|
def manual_check_for_updates(self):
|
||||||
|
self.local_check_thread = LocalPendingUpdateCheckThread(self.current_version, self.platform_suffix, self.platform_name, self.app_name)
|
||||||
|
self.local_check_thread.pending_update_found.connect(self.on_pending_update_found)
|
||||||
|
self.local_check_thread.no_pending_update.connect(self.on_no_pending_update)
|
||||||
|
self.local_check_thread.start()
|
||||||
|
|
||||||
|
def on_pending_update_found(self, version, folder_path):
|
||||||
|
self.parent.statusBar().showMessage(f"Pending update found: version {version}")
|
||||||
|
self.pending_update_version = version
|
||||||
|
self.pending_update_path = folder_path
|
||||||
|
self.show_pending_update_popup()
|
||||||
|
|
||||||
|
def on_no_pending_update(self):
|
||||||
|
# No pending update found locally, start server check directly
|
||||||
|
self.parent.statusBar().showMessage("No pending local update found. Checking server...")
|
||||||
|
self.start_update_check_thread()
|
||||||
|
|
||||||
|
def show_pending_update_popup(self):
|
||||||
|
msg_box = QMessageBox(self.parent)
|
||||||
|
msg_box.setWindowTitle("Pending Update Found")
|
||||||
|
msg_box.setText(f"A previously downloaded update for {self.app_name.upper()} (version {self.pending_update_version}) is available at:\n{self.pending_update_path}\nWould you like to install it now?")
|
||||||
|
install_now_button = msg_box.addButton("Install Now", QMessageBox.ButtonRole.AcceptRole)
|
||||||
|
install_later_button = msg_box.addButton("Install Later", QMessageBox.ButtonRole.RejectRole)
|
||||||
|
msg_box.exec()
|
||||||
|
|
||||||
|
if msg_box.clickedButton() == install_now_button:
|
||||||
|
self.install_update(self.pending_update_path)
|
||||||
|
else:
|
||||||
|
self.parent.statusBar().showMessage("Pending update available. Install later.")
|
||||||
|
# After user dismisses, still check the server for new updates
|
||||||
|
self.start_update_check_thread()
|
||||||
|
|
||||||
|
def start_update_check_thread(self):
|
||||||
|
self.check_thread = UpdateCheckThread(self.api_url, self.api_url_sec, self.current_version, self.platform_name, self.app_name)
|
||||||
|
self.check_thread.download_requested.connect(self.on_server_update_requested)
|
||||||
|
self.check_thread.no_update_available.connect(self.on_server_no_update)
|
||||||
|
self.check_thread.error_occurred.connect(self.on_error)
|
||||||
|
self.check_thread.start()
|
||||||
|
|
||||||
|
def on_server_no_update(self):
|
||||||
|
self.parent.statusBar().showMessage("No new updates found on server.", 5000)
|
||||||
|
|
||||||
|
def on_server_update_requested(self, download_url, latest_version):
|
||||||
|
if self.pending_update_version:
|
||||||
|
cmp = self.version_compare(latest_version, self.pending_update_version)
|
||||||
|
if cmp > 0:
|
||||||
|
# Server version is newer than pending update
|
||||||
|
self.parent.statusBar().showMessage(f"Newer version {latest_version} available on server. Removing old pending update...")
|
||||||
|
try:
|
||||||
|
shutil.rmtree(self.pending_update_path)
|
||||||
|
self.parent.statusBar().showMessage(f"Deleted old update folder: {self.pending_update_path}")
|
||||||
|
except Exception as e:
|
||||||
|
self.parent.statusBar().showMessage(f"Failed to delete old update folder: {e}")
|
||||||
|
|
||||||
|
# Clear pending update info so new download proceeds
|
||||||
|
self.pending_update_version = None
|
||||||
|
self.pending_update_path = None
|
||||||
|
|
||||||
|
# Download the new update
|
||||||
|
self.download_update(download_url, latest_version)
|
||||||
|
elif cmp == 0:
|
||||||
|
# Versions equal, no download needed
|
||||||
|
self.parent.statusBar().showMessage(f"Pending update version {self.pending_update_version} is already latest. No download needed.")
|
||||||
|
else:
|
||||||
|
# Server version older than pending? Unlikely but just keep pending update
|
||||||
|
self.parent.statusBar().showMessage(f"Pending update version {self.pending_update_version} is newer than server version. No action.")
|
||||||
|
else:
|
||||||
|
# No pending update, just download
|
||||||
|
self.download_update(download_url, latest_version)
|
||||||
|
|
||||||
|
def download_update(self, download_url, latest_version):
|
||||||
|
self.parent.statusBar().showMessage("Downloading update...")
|
||||||
|
self.download_thread = UpdateDownloadThread(download_url, latest_version, self.platform_name, self.app_name)
|
||||||
|
self.download_thread.update_ready.connect(self.on_update_ready)
|
||||||
|
self.download_thread.error_occurred.connect(self.on_error)
|
||||||
|
self.download_thread.start()
|
||||||
|
|
||||||
|
def on_update_ready(self, latest_version, extract_folder):
|
||||||
|
self.parent.statusBar().showMessage("Update downloaded and extracted.")
|
||||||
|
|
||||||
|
msg_box = QMessageBox(self.parent)
|
||||||
|
msg_box.setWindowTitle("Update Ready")
|
||||||
|
msg_box.setText(f"Version {latest_version} has been downloaded and extracted to:\n{extract_folder}\nWould you like to install it now?")
|
||||||
|
install_now_button = msg_box.addButton("Install Now", QMessageBox.ButtonRole.AcceptRole)
|
||||||
|
install_later_button = msg_box.addButton("Install Later", QMessageBox.ButtonRole.RejectRole)
|
||||||
|
|
||||||
|
msg_box.exec()
|
||||||
|
|
||||||
|
if msg_box.clickedButton() == install_now_button:
|
||||||
|
self.install_update(extract_folder)
|
||||||
|
else:
|
||||||
|
self.parent.statusBar().showMessage("Update ready. Install later.")
|
||||||
|
|
||||||
|
|
||||||
|
def install_update(self, extract_folder):
|
||||||
|
# Path to updater executable
|
||||||
|
|
||||||
|
if self.platform_name == 'windows':
|
||||||
|
updater_path = os.path.join(os.getcwd(), f"{self.app_name}_updater.exe")
|
||||||
|
elif self.platform_name == 'darwin':
|
||||||
|
if getattr(sys, 'frozen', False):
|
||||||
|
updater_path = os.path.join(os.path.dirname(sys.executable), f"../../../{self.app_name}_updater.app")
|
||||||
|
else:
|
||||||
|
updater_path = os.path.join(os.getcwd(), f"../{self.app_name}_updater.app")
|
||||||
|
|
||||||
|
elif self.platform_name == 'linux':
|
||||||
|
updater_path = os.path.join(os.getcwd(), f"{self.app_name}_updater")
|
||||||
|
else:
|
||||||
|
updater_path = os.getcwd()
|
||||||
|
|
||||||
|
if not os.path.exists(updater_path):
|
||||||
|
QMessageBox.critical(self.parent, "Error", f"Updater not found at:\n{updater_path}. The absolute path was {os.path.abspath(updater_path)}")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Launch updater with extracted folder path as argument
|
||||||
|
try:
|
||||||
|
# Pass current app's executable path for updater to relaunch
|
||||||
|
main_app_executable = os.path.abspath(sys.argv[0])
|
||||||
|
|
||||||
|
print(f'Launching updater with: "{updater_path}" "{extract_folder}" "{main_app_executable}"')
|
||||||
|
|
||||||
|
if self.platform_name == 'darwin':
|
||||||
|
subprocess.Popen(['open', updater_path, '--args', extract_folder, main_app_executable])
|
||||||
|
else:
|
||||||
|
subprocess.Popen([updater_path, f'{extract_folder}', f'{main_app_executable}'], cwd=os.path.dirname(updater_path))
|
||||||
|
|
||||||
|
# Close the current app so updater can replace files
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
QMessageBox.critical(self.parent, "Error", f"[Updater Launch Failed]\n{str(e)}\n{traceback.format_exc()}")
|
||||||
|
|
||||||
|
def on_error(self, message):
|
||||||
|
# print(f"Error: {message}")
|
||||||
|
self.parent.statusBar().showMessage(f"Error occurred during update process. {message}")
|
||||||
|
|
||||||
|
def version_compare(self, v1, v2):
|
||||||
|
def normalize(v): return [int(x) for x in v.split(".")]
|
||||||
|
return (normalize(v1) > normalize(v2)) - (normalize(v1) < normalize(v2))
|
||||||
|
|
||||||
|
|
||||||
|
def wait_for_process_to_exit(process_name, timeout=10):
|
||||||
|
"""
|
||||||
|
Waits for a process with the specified name to exit within a timeout period.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
process_name (str): Name (or part of the name) of the process to wait for.
|
||||||
|
timeout (int, optional): Maximum time to wait in seconds. Defaults to 10.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
bool: True if the process exited before the timeout, False otherwise.
|
||||||
|
"""
|
||||||
|
|
||||||
|
print(f"Waiting for {process_name} to exit...")
|
||||||
|
deadline = time.time() + timeout
|
||||||
|
while time.time() < deadline:
|
||||||
|
still_running = False
|
||||||
|
for proc in psutil.process_iter(['name']):
|
||||||
|
try:
|
||||||
|
if proc.info['name'] and process_name.lower() in proc.info['name'].lower():
|
||||||
|
still_running = True
|
||||||
|
print(f"Still running: {proc.info['name']} (PID: {proc.pid})")
|
||||||
|
break
|
||||||
|
except (psutil.NoSuchProcess, psutil.AccessDenied):
|
||||||
|
continue
|
||||||
|
if not still_running:
|
||||||
|
print(f"{process_name} has exited.")
|
||||||
|
return True
|
||||||
|
time.sleep(0.5)
|
||||||
|
print(f"{process_name} did not exit in time.")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def finish_update_if_needed(platform_name, app_name, cfg_path):
|
||||||
|
"""
|
||||||
|
Completes a pending application update if '--finish-update' is present in the command-line arguments.
|
||||||
|
"""
|
||||||
|
|
||||||
|
if "--finish-update" in sys.argv:
|
||||||
|
print("Finishing update...")
|
||||||
|
|
||||||
|
update_cfg = configparser.ConfigParser()
|
||||||
|
try:
|
||||||
|
update_cfg.read(cfg_path)
|
||||||
|
update_cfg.set("Options", "show_welcome_dialog", "true")
|
||||||
|
|
||||||
|
with open(cfg_path, "w") as f:
|
||||||
|
update_cfg.write(f)
|
||||||
|
print("Welcome dialog flag successfully reset to 'true' for next run.")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Warning: Could not update welcome dialog preference flag: {e}")
|
||||||
|
|
||||||
|
if platform_name == 'darwin':
|
||||||
|
app_dir = f'/tmp/{app_name}tempupdate'
|
||||||
|
else:
|
||||||
|
app_dir = os.getcwd()
|
||||||
|
|
||||||
|
# 1. Find update folder
|
||||||
|
update_folder = None
|
||||||
|
for entry in os.listdir(app_dir):
|
||||||
|
entry_path = os.path.join(app_dir, entry)
|
||||||
|
if os.path.isdir(entry_path) and entry.startswith(f"{app_name}-") and entry.endswith("-" + platform_name):
|
||||||
|
update_folder = os.path.join(app_dir, entry)
|
||||||
|
break
|
||||||
|
|
||||||
|
if update_folder is None:
|
||||||
|
print("No update folder found. Skipping update steps.")
|
||||||
|
return
|
||||||
|
|
||||||
|
if platform_name == 'darwin':
|
||||||
|
update_folder = os.path.join(update_folder, f"{app_name}-darwin")
|
||||||
|
|
||||||
|
# 2. Wait for updater to exit
|
||||||
|
print(f"Waiting for {app_name}_updater to exit...")
|
||||||
|
for proc in psutil.process_iter(['pid', 'name']):
|
||||||
|
if proc.info['name'] and f"{app_name}_updater" in proc.info['name'].lower():
|
||||||
|
try:
|
||||||
|
proc.wait(timeout=5)
|
||||||
|
except psutil.TimeoutExpired:
|
||||||
|
print(f"Force killing lingering {app_name}_updater")
|
||||||
|
proc.kill()
|
||||||
|
|
||||||
|
# 3. Replace the updater
|
||||||
|
if platform_name == 'windows':
|
||||||
|
new_updater = os.path.join(update_folder, f"{app_name}_updater.exe")
|
||||||
|
dest_updater = os.path.join(app_dir, f"{app_name}_updater.exe")
|
||||||
|
|
||||||
|
elif platform_name == 'darwin':
|
||||||
|
new_updater = os.path.join(update_folder, f"{app_name}_updater.app")
|
||||||
|
dest_updater = os.path.abspath(os.path.join(sys.executable, f"../../../../{app_name}_updater.app"))
|
||||||
|
|
||||||
|
elif platform_name == 'linux':
|
||||||
|
new_updater = os.path.join(update_folder, f"{app_name}_updater")
|
||||||
|
dest_updater = os.path.join(app_dir, f"{app_name}_updater")
|
||||||
|
|
||||||
|
else:
|
||||||
|
print("Unknown Platform")
|
||||||
|
new_updater = os.getcwd()
|
||||||
|
dest_updater = os.getcwd()
|
||||||
|
|
||||||
|
print(f"New updater is {new_updater}")
|
||||||
|
print(f"Dest updater is {dest_updater}")
|
||||||
|
|
||||||
|
print("Writable?", os.access(dest_updater, os.W_OK))
|
||||||
|
print("Executable path:", sys.executable)
|
||||||
|
print("Trying to copy:", new_updater, "->", dest_updater)
|
||||||
|
|
||||||
|
if os.path.exists(new_updater):
|
||||||
|
try:
|
||||||
|
if os.path.exists(dest_updater):
|
||||||
|
if platform_name == 'darwin':
|
||||||
|
try:
|
||||||
|
if os.path.isdir(dest_updater):
|
||||||
|
shutil.rmtree(dest_updater)
|
||||||
|
print(f"Deleted directory: {dest_updater}")
|
||||||
|
else:
|
||||||
|
os.remove(dest_updater)
|
||||||
|
print(f"Deleted file: {dest_updater}")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error deleting {dest_updater}: {e}")
|
||||||
|
else:
|
||||||
|
os.remove(dest_updater)
|
||||||
|
|
||||||
|
if platform_name == 'darwin':
|
||||||
|
wait_for_process_to_exit(f"{app_name}_updater", timeout=10)
|
||||||
|
subprocess.check_call(["ditto", new_updater, dest_updater])
|
||||||
|
else:
|
||||||
|
shutil.copy2(new_updater, dest_updater)
|
||||||
|
|
||||||
|
if platform_name in ('linux', 'darwin'):
|
||||||
|
os.chmod(dest_updater, 0o755)
|
||||||
|
|
||||||
|
if platform_name == 'darwin':
|
||||||
|
remove_quarantine(dest_updater, app_name)
|
||||||
|
|
||||||
|
print(f"{app_name}_updater replaced.")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Failed to replace {app_name}_updater: {e}")
|
||||||
|
|
||||||
|
# 4. Delete the update folder
|
||||||
|
try:
|
||||||
|
if platform_name == 'darwin':
|
||||||
|
shutil.rmtree(app_dir)
|
||||||
|
else:
|
||||||
|
shutil.rmtree(update_folder)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Failed to delete update folder: {e}")
|
||||||
|
|
||||||
|
sys.argv.remove("--finish-update")
|
||||||
|
|
||||||
|
|
||||||
|
def remove_quarantine(app_path, app_name):
|
||||||
|
"""
|
||||||
|
Removes the macOS quarantine attribute from the specified application path.
|
||||||
|
"""
|
||||||
|
|
||||||
|
script = f'''
|
||||||
|
do shell script "xattr -d -r com.apple.quarantine {shlex.quote(app_path)}" with administrator privileges with prompt "{app_name.upper()} needs privileges to finish the update. (2/2)"
|
||||||
|
'''
|
||||||
|
try:
|
||||||
|
subprocess.run(['osascript', '-e', script], check=True)
|
||||||
|
print("✅ Quarantine attribute removed.")
|
||||||
|
except subprocess.CalledProcessError as e:
|
||||||
|
print("❌ Failed to remove quarantine attribute.")
|
||||||
|
print(e)
|
||||||