Compare commits
25
Commits
v1.6.0
..
4059a3ef93
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4059a3ef93 | ||
|
|
66242c1a5c | ||
|
|
2a5e3f5606 | ||
|
|
613d2b9103 | ||
|
|
cd0b55df34 | ||
|
|
5d46d3b3e1 | ||
|
|
05dc9203ba | ||
|
|
4d00ef86e4 | ||
|
|
e9ef78c872 | ||
|
|
ca203fcb56 | ||
|
|
815f342ead | ||
|
|
57492a479b | ||
|
|
8ef352add3 | ||
|
|
c566527f48 | ||
|
|
2fe6f42c48 | ||
|
|
71b6280cc2 | ||
|
|
f0b8266cf9 | ||
|
|
a9ea0efcb4 | ||
|
|
83ab73a05a | ||
|
|
2fa3188296 | ||
|
|
d9d5b6d940 | ||
|
|
e37275a1bb | ||
|
|
19bd3f1279 | ||
|
|
7a438b1798 | ||
|
|
2f76622e52 |
@@ -175,6 +175,7 @@ cython_debug/
|
|||||||
.pypirc
|
.pypirc
|
||||||
|
|
||||||
/individual_images
|
/individual_images
|
||||||
|
/plugins
|
||||||
*.xlsx
|
*.xlsx
|
||||||
*.csv
|
*.csv
|
||||||
*.snirf
|
*.snirf
|
||||||
@@ -182,3 +183,5 @@ cython_debug/
|
|||||||
flares-*
|
flares-*
|
||||||
*.flare
|
*.flare
|
||||||
*.cfg
|
*.cfg
|
||||||
|
tempCodeRunnerFile.py
|
||||||
|
*.pkl
|
||||||
@@ -209,7 +209,7 @@ If you develop a new program, and you want it to be of the greatest possible use
|
|||||||
To do so, attach the following notices to the program. It is safest to attach them to the start of each source file to most effectively state the exclusion of warranty; and each file should have at least the “copyright” line and a pointer to where the full notice is found.
|
To do so, attach the following notices to the program. It is safest to attach them to the start of each source file to most effectively state the exclusion of warranty; and each file should have at least the “copyright” line and a pointer to where the full notice is found.
|
||||||
|
|
||||||
flares
|
flares
|
||||||
Copyright (C) 2025 tyler
|
Copyright (C) 2025-2026 tyler
|
||||||
|
|
||||||
This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version.
|
This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version.
|
||||||
|
|
||||||
@@ -221,7 +221,7 @@ Also add information on how to contact you by electronic and paper mail.
|
|||||||
|
|
||||||
If the program does terminal interaction, make it output a short notice like this when it starts in an interactive mode:
|
If the program does terminal interaction, make it output a short notice like this when it starts in an interactive mode:
|
||||||
|
|
||||||
flares Copyright (C) 2025 tyler
|
flares Copyright (C) 2025-2026 tyler
|
||||||
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
|
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
|
||||||
This is free software, and you are welcome to redistribute it under certain conditions; type `show c' for details.
|
This is free software, and you are welcome to redistribute it under certain conditions; type `show c' for details.
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,60 @@
|
|||||||
|
# Version 1.7.2
|
||||||
|
|
||||||
|
- Fixed the Linux build not starting if shared library files were missing from the host machine
|
||||||
|
- Fixed the macOS version not loading plugins due to not having an HTTPS certificate
|
||||||
|
|
||||||
|
|
||||||
|
# Version 1.7.1
|
||||||
|
|
||||||
|
- Added support for Plugins! Plugins can be downloaded from the official repository or from custom ones. Support for creating a plugin can be found on the wiki
|
||||||
|
- Files and folders can now be dragged and dropped into the loaded files area to be loaded instead of relying on the file menu
|
||||||
|
- Fixed an issue where shadow snirf files would attempt to be loaded by the application. Fixes [Issue 92](https://git.research.dezeeuw.ca/tyler/flares/issues/92)
|
||||||
|
- Fixed an issue where the application could not automatically update between versions 1.5.1 to 1.7.1. Sorry! 1.7.1 upgrading onward should be fixed and more robust
|
||||||
|
- Fixed an issue where some log files would not generate, or would generate in an incorrect location
|
||||||
|
- Fixed an issue where updating events in a snirf file would not release memory properly
|
||||||
|
- Fixed an issue where loading multiple files in quick succession could cause some of the files to never finish loading
|
||||||
|
- Fixed an issue where loading a saved project, pushing clear, and re-opening snirf files would cause them to appear incorrectly in the loaded files area
|
||||||
|
- Fixed a crucial bug where short channels were still being sent to the donor pool to assist in interpolating long channels from. Fixes [Issue 80](https://git.research.dezeeuw.ca/tyler/flares/issues/80)
|
||||||
|
|
||||||
|
|
||||||
|
# Version 1.7.0
|
||||||
|
|
||||||
|
- This is potentially a save-changing release due to adding more data into the save file. Please update your project files to ensure compatibility
|
||||||
|
- Renamed all instances of "Inter" to properly read as "Intra" and changed "Cross" to now read as "Inter"
|
||||||
|
- Changed RESAMPLE to only apply where it is required to avoid having Functional Connectivity analysis methods running on data that has been resampled
|
||||||
|
- Added parameters that appear when attempting to generate results from the Participant and Intra-Group Functional Connectivity viewers and removed the non-functional placeholder parameters
|
||||||
|
- Modified the Participant and Intra-Group Functional Connectivity analysis options to better perform their expected tasks. This remains as a BETA feature
|
||||||
|
- Removed the existing Intra-Group Functional Connectivity option and replaced it with two new ones: Beta-Series Correlation and Spectral Coherence (epochs)
|
||||||
|
- Updated the names of the methods provided for the Participant Functional Connectivity Viewer to better match the actions they perform
|
||||||
|
- Updated the warnings for the Functional Connectivity Viewers to better represent the challenges these analysis options now face
|
||||||
|
- Added basic unit testing to hopefully prevent any accidental processing changes from occurring in the future
|
||||||
|
- Added a new parameter to the PSP section on the right side of the screen: PSP_USE_HEART_RATE_BAND. This functions similarly to the existing SCI_USE_HEART_RATE_BAND
|
||||||
|
- Added description text to the Inter-Group and Intra-Group Brain and Image Viewers, as well as the Functional Connectivity windows to explain what output can be expected
|
||||||
|
- Added a new Preference option of Theme. Allows from selecting Auto (System default), Light, or Dark. Fixes [Issue 7](https://git.research.dezeeuw.ca/tyler/flares/issues/7)
|
||||||
|
- Optode updater can now update optode locations in multiple snirf files at once
|
||||||
|
- Removed image index 1 (Significance) from the Intra-Group Brain and Image Viewer as it is now provided more in depth with the Stats viewers
|
||||||
|
- Modified the timeout when waiting for the application to close while performing updates down to a reasonable number
|
||||||
|
- Modified the help messages for parameters in the SCI and PSP areas to better reflect how the parameters are used
|
||||||
|
- Modified the heart rate calculation to be more precise and correct when dealing with good data and not messy data
|
||||||
|
- Modified the heart rate calculation to not take only one channel in the data to use, but rather an average of channels. This still prefers short channels if they are present
|
||||||
|
- Fixed an issue where loading a project from a saved project file would not allow the data to be reprocessed with new parameters
|
||||||
|
- Fixed an issue where the "Hand" BIDS metadata value would not populate on bubbles correctly
|
||||||
|
- Fixed an issue where performing PSP could ignore the bad channels that were marked by SCI and SNR
|
||||||
|
- Fixed an issue where the heart rate calculation would disregard all calculations and fall back to an extremely rudimentary calculation
|
||||||
|
- Fixed an issue that could prevent log file generation while the application was in the middle of an update
|
||||||
|
- Fixed an issue where a rare crash could occur while the application was in the middle of an update
|
||||||
|
- Fixed an issue that could have passed multiple conditions when generating an Intra-Group Stats image
|
||||||
|
- Fixed an issue that could pass NaN values when attempting to collapse channels
|
||||||
|
- Fixed an issue that was causing the OLS model to always be used for brain images with multiple participants, and not the MixedLM model
|
||||||
|
- Fixed an issue that could cause the Wavelet filtering step to crash
|
||||||
|
- Fixed an issue where file associations appeared to work as intended but would not load the project on macOS and only open the application
|
||||||
|
- Fixed an issue where file associations would refuse to associate on macOS once they have attempted to be associated
|
||||||
|
- Fixed an issue where certain parameters would not enable or disable depending on other parameters when they should've
|
||||||
|
- Fixed an issue where not all widgets would close when attempting to close the application causing the application to crash
|
||||||
|
- Fixed an issue where events were not created correctly after the data had been resampled by the design matrix
|
||||||
|
- Fixed an issue where progress bar colors would not reset if the data was reprocessed
|
||||||
|
|
||||||
|
|
||||||
# Version 1.6.0
|
# Version 1.6.0
|
||||||
|
|
||||||
- This is potentially a save-changing release due to adding more data into the save file, renaming existing data, and changing what part of the code saves data. Please update your project files to ensure compatibility
|
- This is potentially a save-changing release due to adding more data into the save file, renaming existing data, and changing what part of the code saves data. Please update your project files to ensure compatibility
|
||||||
|
|||||||
+54
-34
@@ -1,35 +1,55 @@
|
|||||||
# Version 1.6.0
|
# Version 1.7.2
|
||||||
|
|
||||||
- This is potentially a save-changing release due to adding more data into the save file, renaming existing data, and changing what part of the code saves data. Please update your project files to ensure compatibility
|
- Fixed the Linux build not starting if shared library files were missing from the host machine
|
||||||
- It is still possible to load older saves by enabling 'Incompatible Save Bypass' from the Preferences menu, but your mileage may vary
|
- Fixed the macOS version not loading plugins due to not having an HTTPS certificate
|
||||||
- Optimized calculations being performed when calculating the Heart Rate to speed up step 5 by up to ~35% on a per-file basis
|
|
||||||
- Optimized calculations being performed when running the General Linear Model to speed up step 5 by up to ~35% on a per-file basis
|
|
||||||
- The Group Stats Viewer windows will now properly load the Right/Left or Front/Back fallback ROIs if JSON_LOCATION is not set
|
# Version 1.7.1
|
||||||
- Changed the warning message for lots of short channels and changed when it shows to be percentage based (35%) instead of numerical based (6)
|
|
||||||
- Renames GENDER to SEX to better represent the values that the field expects and to be more BIDS compliant
|
- Added support for Plugins! Plugins can be downloaded from the official repository or from custom ones. Support for creating a plugin can be found on the wiki
|
||||||
- Changed the Assign Groups by AGE window to now be the Assign Groups by Metadata window and added support for grouping by other metadata values
|
- Files and folders can now be dragged and dropped into the loaded files area to be loaded instead of relying on the file menu
|
||||||
- Added a new metadata value of HAND to allow for grouping participants based on their handiness
|
- Fixed an issue where shadow snirf files would attempt to be loaded by the application. Fixes [Issue 92](https://git.research.dezeeuw.ca/tyler/flares/issues/92)
|
||||||
- Updated the layout of the participant metadata area to better accommodate current and future metadata values
|
- Fixed an issue where the application could not automatically update between versions 1.5.1 to 1.7.1. Sorry! 1.7.1 upgrading onward should be fixed and more robust
|
||||||
- Updated the message displayed when "Why are these useful?" is clicked to better represent what the values are used for and to accommodate the new metadata values
|
- Fixed an issue where some log files would not generate, or would generate in an incorrect location
|
||||||
- Changed the application title to now show what project you are working on. If you are not working on a project, it will display 'Untitled'
|
- Fixed an issue where updating events in a snirf file would not release memory properly
|
||||||
- Tracking of current save states is now present and an asterisk will now appear in the title bar when a project has not been saved
|
- Fixed an issue where loading multiple files in quick succession could cause some of the files to never finish loading
|
||||||
- Currently tracking is only supported on loaded files, parameters on the right side of the screen, and when processing has completed. Per-file metadata or processing stages are not currently implemented
|
- Fixed an issue where loading a saved project, pushing clear, and re-opening snirf files would cause them to appear incorrectly in the loaded files area
|
||||||
- Clicking close on the Main Window will now properly close all sub windows when closing the application and closing the application with unsaved changes will now prompt to save a project or discard changes. Fixes [Issue 87](https://git.research.dezeeuw.ca/tyler/flares/issues/87)
|
- Fixed a crucial bug where short channels were still being sent to the donor pool to assist in interpolating long channels from. Fixes [Issue 80](https://git.research.dezeeuw.ca/tyler/flares/issues/80)
|
||||||
- The Save button under the File menu no longer asks for a destination if a saved project was already loaded. Fixes [Issue 72](https://git.research.dezeeuw.ca/tyler/flares/issues/72)
|
|
||||||
- Added an icon to the 'Toggle Status Bar' view option
|
|
||||||
- Status bar visibility and window layout sizes are now stored between application sessions. Fixes [Issue 88](https://git.research.dezeeuw.ca/tyler/flares/issues/88)
|
# Version 1.7.0
|
||||||
- The popup that appeared when a project was saved has now been moved to a message displayed from the status bar
|
|
||||||
- Moved all loading of files, loading of folders, loading of projects, and saving of projects to a new file for easier future development
|
- This is potentially a save-changing release due to adding more data into the save file. Please update your project files to ensure compatibility
|
||||||
- Changed the working of some messages on the status bar to better reflect what has occurred
|
- Renamed all instances of "Inter" to properly read as "Intra" and changed "Cross" to now read as "Inter"
|
||||||
- Added a new terminal command 'assoc' to associate .flare files to the application. This is undergoing testing and may not function correctly.
|
- Changed RESAMPLE to only apply where it is required to avoid having Functional Connectivity analysis methods running on data that has been resampled
|
||||||
- Removed the debug flag from the application package, which may speed up the application. Fixes [Issue 11](https://git.research.dezeeuw.ca/tyler/flares/issues/11)
|
- Added parameters that appear when attempting to generate results from the Participant and Intra-Group Functional Connectivity viewers and removed the non-functional placeholder parameters
|
||||||
- Fixed an issue where the two Group Stats Viewer windows would crash the application once attempted to be opened if JSON_LOCATION was not set
|
- Modified the Participant and Intra-Group Functional Connectivity analysis options to better perform their expected tasks. This remains as a BETA feature
|
||||||
- Fixed an issue where participants could be skipped when processing multiple participants at once which could prevent overall processing from completing
|
- Removed the existing Intra-Group Functional Connectivity option and replaced it with two new ones: Beta-Series Correlation and Spectral Coherence (epochs)
|
||||||
- Fixed an issue where when loading a save file some list dropdowns could go blue and bold even if the value was default
|
- Updated the names of the methods provided for the Participant Functional Connectivity Viewer to better match the actions they perform
|
||||||
- Fixed an issue where the Analysis button would not be clickable if the user had previously pushed Clear. Fixes [Issue 83](https://git.research.dezeeuw.ca/tyler/flares/issues/83)
|
- Updated the warnings for the Functional Connectivity Viewers to better represent the challenges these analysis options now face
|
||||||
- Fixed an issue where removing all files via their right click feature would cause the Process button to still be visible. Fixes [Issue 91](https://git.research.dezeeuw.ca/tyler/flares/issues/91)
|
- Added basic unit testing to hopefully prevent any accidental processing changes from occurring in the future
|
||||||
- Fixed an issue where some popup windows would not properly display the application name when they would open
|
- Added a new parameter to the PSP section on the right side of the screen: PSP_USE_HEART_RATE_BAND. This functions similarly to the existing SCI_USE_HEART_RATE_BAND
|
||||||
- Fixed an issue where the welcome dialog image would not display correctly on macOS
|
- Added description text to the Inter-Group and Intra-Group Brain and Image Viewers, as well as the Functional Connectivity windows to explain what output can be expected
|
||||||
- Fixed an issue where the Linux version would fail at 0 seconds when attempting to process files
|
- Added a new Preference option of Theme. Allows from selecting Auto (System default), Light, or Dark. Fixes [Issue 7](https://git.research.dezeeuw.ca/tyler/flares/issues/7)
|
||||||
- Fixed an issue where fOLDing channels could cause the same channels to be repeated misaligning the labels
|
- Optode updater can now update optode locations in multiple snirf files at once
|
||||||
- Fixed all instances of the word MacOS to now read macOS to match Apple branding
|
- Removed image index 1 (Significance) from the Intra-Group Brain and Image Viewer as it is now provided more in depth with the Stats viewers
|
||||||
|
- Modified the timeout when waiting for the application to close while performing updates down to a reasonable number
|
||||||
|
- Modified the help messages for parameters in the SCI and PSP areas to better reflect how the parameters are used
|
||||||
|
- Modified the heart rate calculation to be more precise and correct when dealing with good data and not messy data
|
||||||
|
- Modified the heart rate calculation to not take only one channel in the data to use, but rather an average of channels. This still prefers short channels if they are present
|
||||||
|
- Fixed an issue where loading a project from a saved project file would not allow the data to be reprocessed with new parameters
|
||||||
|
- Fixed an issue where the "Hand" BIDS metadata value would not populate on bubbles correctly
|
||||||
|
- Fixed an issue where performing PSP could ignore the bad channels that were marked by SCI and SNR
|
||||||
|
- Fixed an issue where the heart rate calculation would disregard all calculations and fall back to an extremely rudimentary calculation
|
||||||
|
- Fixed an issue that could prevent log file generation while the application was in the middle of an update
|
||||||
|
- Fixed an issue where a rare crash could occur while the application was in the middle of an update
|
||||||
|
- Fixed an issue that could have passed multiple conditions when generating an Intra-Group Stats image
|
||||||
|
- Fixed an issue that could pass NaN values when attempting to collapse channels
|
||||||
|
- Fixed an issue that was causing the OLS model to always be used for brain images with multiple participants, and not the MixedLM model
|
||||||
|
- Fixed an issue that could cause the Wavelet filtering step to crash
|
||||||
|
- Fixed an issue where file associations appeared to work as intended but would not load the project on macOS and only open the application
|
||||||
|
- Fixed an issue where file associations would refuse to associate on macOS once they have attempted to be associated
|
||||||
|
- Fixed an issue where certain parameters would not enable or disable depending on other parameters when they should've
|
||||||
|
- Fixed an issue where not all widgets would close when attempting to close the application causing the application to crash
|
||||||
|
- Fixed an issue where events were not created correctly after the data had been resampled by the design matrix
|
||||||
|
- Fixed an issue where progress bar colors would not reset if the data was reprocessed
|
||||||
@@ -10,15 +10,14 @@ License: GPL-3.0
|
|||||||
# Built-in imports
|
# Built-in imports
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
import plistlib
|
|
||||||
import subprocess
|
import subprocess
|
||||||
from typing import Optional, Tuple
|
from typing import Optional, Tuple
|
||||||
|
|
||||||
# External library imports
|
# External library imports
|
||||||
from src.shared.shareddata import APP_NAME, PLATFORM_NAME
|
from src.shared.shareddata import APP_NAME, PLATFORM_NAME
|
||||||
|
|
||||||
ELEVATION_FLAG = "--register_file_association_elevated"
|
|
||||||
|
|
||||||
|
ELEVATION_FLAG = "--register_file_association_elevated"
|
||||||
|
|
||||||
|
|
||||||
def register_file_association(ext: Optional[str] = None,
|
def register_file_association(ext: Optional[str] = None,
|
||||||
@@ -27,7 +26,6 @@ def register_file_association(ext: Optional[str] = None,
|
|||||||
bundle_id: Optional[str] = None,
|
bundle_id: Optional[str] = None,
|
||||||
force_admin: bool = False,
|
force_admin: bool = False,
|
||||||
) -> Tuple[bool, str]:
|
) -> Tuple[bool, str]:
|
||||||
|
|
||||||
"""
|
"""
|
||||||
Registers a custom file extension across Windows, Linux, and macOS.
|
Registers a custom file extension across Windows, Linux, and macOS.
|
||||||
Handles non-admin Windows users by falling back to local user registry.
|
Handles non-admin Windows users by falling back to local user registry.
|
||||||
@@ -337,45 +335,6 @@ def _register_macos(ext: str, app_name: str, bundle_id: str) -> Tuple[bool, str]
|
|||||||
if not app_bundle_path.endswith(".app"):
|
if not app_bundle_path.endswith(".app"):
|
||||||
return False, "Could not locate outer .app bundle."
|
return False, "Could not locate outer .app bundle."
|
||||||
|
|
||||||
info_plist_path = os.path.join(app_bundle_path, "Contents", "Info.plist")
|
|
||||||
if not os.path.exists(info_plist_path):
|
|
||||||
return False, f"Info.plist not found at {info_plist_path}"
|
|
||||||
|
|
||||||
clean_ext = ext.lstrip('.')
|
|
||||||
uti = f"{bundle_id}.{clean_ext}"
|
|
||||||
|
|
||||||
try:
|
|
||||||
with open(info_plist_path, "rb") as f:
|
|
||||||
plist = plistlib.load(f)
|
|
||||||
|
|
||||||
# Tell macOS this app can open files with our UTI
|
|
||||||
doc_types = plist.get("CFBundleDocumentTypes", [])
|
|
||||||
if not any(uti in dt.get("LSItemContentTypes", []) for dt in doc_types):
|
|
||||||
doc_types.append({
|
|
||||||
"CFBundleTypeName": f"{app_name} Project File",
|
|
||||||
"CFBundleTypeRole": "Editor",
|
|
||||||
"LSHandlerRank": "Owner",
|
|
||||||
"LSItemContentTypes": [uti],
|
|
||||||
})
|
|
||||||
plist["CFBundleDocumentTypes"] = doc_types
|
|
||||||
|
|
||||||
# Declare the UTI itself (required — without this the type is unknown to LS)
|
|
||||||
exported_types = plist.get("UTExportedTypeDeclarations", [])
|
|
||||||
if not any(t.get("UTTypeIdentifier") == uti for t in exported_types):
|
|
||||||
exported_types.append({
|
|
||||||
"UTTypeIdentifier": uti,
|
|
||||||
"UTTypeDescription": f"{app_name} File",
|
|
||||||
"UTTypeConformsTo": ["public.data"],
|
|
||||||
"UTTypeTagSpecification": {"public.filename-extension": [clean_ext]},
|
|
||||||
})
|
|
||||||
plist["UTExportedTypeDeclarations"] = exported_types
|
|
||||||
|
|
||||||
with open(info_plist_path, "wb") as f:
|
|
||||||
plistlib.dump(plist, f)
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
return False, f"Failed to update Info.plist: {str(e)}"
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
lsregister_path = (
|
lsregister_path = (
|
||||||
"/System/Library/Frameworks/CoreServices.framework/Frameworks/"
|
"/System/Library/Frameworks/CoreServices.framework/Frameworks/"
|
||||||
@@ -385,9 +344,9 @@ def _register_macos(ext: str, app_name: str, bundle_id: str) -> Tuple[bool, str]
|
|||||||
[lsregister_path, "-f", app_bundle_path],
|
[lsregister_path, "-f", app_bundle_path],
|
||||||
check=True, capture_output=True,
|
check=True, capture_output=True,
|
||||||
)
|
)
|
||||||
return True, f"Registered {ext} with {app_bundle_path} via macOS Launch Services!"
|
return True, f"Refreshed Launch Services registration for {app_bundle_path}."
|
||||||
except subprocess.CalledProcessError as e:
|
except subprocess.CalledProcessError as e:
|
||||||
stderr = e.stderr.decode(errors="ignore") if e.stderr else str(e)
|
stderr = e.stderr.decode(errors="ignore") if e.stderr else str(e)
|
||||||
return False, f"macOS Launch Services registration failed: {stderr}"
|
return False, f"macOS Launch Services refresh failed: {stderr}"
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return False, f"macOS Registration failed: {str(e)}"
|
return False, f"macOS Registration failed: {str(e)}"
|
||||||
+90
-52
@@ -1,6 +1,7 @@
|
|||||||
"""
|
"""
|
||||||
Filename: flares_updater.py
|
Filename: flares_updater.py
|
||||||
Description: FLARES updater executable
|
Description: FLARES updater executable
|
||||||
|
Note: Compliant with pylance strict type checking
|
||||||
|
|
||||||
Author: Tyler de Zeeuw
|
Author: Tyler de Zeeuw
|
||||||
License: GPL-3.0
|
License: GPL-3.0
|
||||||
@@ -13,26 +14,33 @@ import time
|
|||||||
import shlex
|
import shlex
|
||||||
import psutil
|
import psutil
|
||||||
import shutil
|
import shutil
|
||||||
import platform
|
|
||||||
import subprocess
|
import subprocess
|
||||||
|
from typing import Union
|
||||||
|
from pathlib import Path
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
PLATFORM_NAME = platform.system().lower()
|
# External library imports
|
||||||
APP_NAME = "flares"
|
from src.shared.shareddata import APP_NAME, PLATFORM_NAME, get_app_dir
|
||||||
|
|
||||||
|
|
||||||
if PLATFORM_NAME == 'darwin':
|
if PLATFORM_NAME == 'darwin':
|
||||||
LOG_FILE = os.path.join(os.path.dirname(sys.executable), f"../../../{APP_NAME}_updater.log")
|
_log_path = os.path.join(os.path.dirname(sys.executable), f"../../../{APP_NAME}_updater.log")
|
||||||
else:
|
else:
|
||||||
LOG_FILE = os.path.join(os.getcwd(), f"{APP_NAME}_updater.log")
|
_log_path = os.path.join(get_app_dir(), f"{APP_NAME}_updater.log")
|
||||||
|
|
||||||
|
LOG_FILE = _log_path
|
||||||
|
|
||||||
|
|
||||||
def log(msg):
|
def log(msg: str) -> None:
|
||||||
with open(LOG_FILE, "a", encoding="utf-8") as f:
|
try:
|
||||||
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
with open(LOG_FILE, "a", encoding="utf-8") as f:
|
||||||
f.write(f"{timestamp} - {msg}\n")
|
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||||
|
f.write(f"{timestamp} - {msg}\n")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
def kill_all_processes_by_executable(exe_path):
|
def kill_all_processes_by_executable(exe_path: Union[str, Path]) -> bool:
|
||||||
terminated_any = False
|
terminated_any = False
|
||||||
exe_path = os.path.realpath(exe_path)
|
exe_path = os.path.realpath(exe_path)
|
||||||
|
|
||||||
@@ -40,22 +48,28 @@ def kill_all_processes_by_executable(exe_path):
|
|||||||
for proc in psutil.process_iter(['pid', 'exe']):
|
for proc in psutil.process_iter(['pid', 'exe']):
|
||||||
try:
|
try:
|
||||||
proc_exe = proc.info.get('exe')
|
proc_exe = proc.info.get('exe')
|
||||||
if proc_exe and os.path.samefile(os.path.realpath(proc_exe), exe_path):
|
if proc_exe and os.path.exists(proc_exe) and os.path.exists(exe_path):
|
||||||
log(f"Terminating process: PID {proc.pid}")
|
if os.path.samefile(os.path.realpath(proc_exe), exe_path):
|
||||||
_terminate_process(proc)
|
log(f"Terminating process: PID {proc.pid}")
|
||||||
terminated_any = True
|
_terminate_process(proc)
|
||||||
|
terminated_any = True
|
||||||
|
except (psutil.NoSuchProcess, psutil.AccessDenied, FileNotFoundError) as e:
|
||||||
|
continue
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
log(f"Error terminating process (Windows): {e}")
|
log(f"Error terminating process (Windows): {e}")
|
||||||
|
|
||||||
elif PLATFORM_NAME == 'linux':
|
elif PLATFORM_NAME == 'linux':
|
||||||
for proc in psutil.process_iter(['pid', 'cmdline']):
|
for proc in psutil.process_iter(['pid', 'cmdline']):
|
||||||
try:
|
try:
|
||||||
cmdline = proc.info.get('cmdline', [])
|
cmdline = proc.info.get('cmdline', [])
|
||||||
if cmdline:
|
if cmdline and os.path.exists(cmdline[0]) and os.path.exists(exe_path):
|
||||||
proc_cmd = os.path.realpath(cmdline[0])
|
proc_cmd = os.path.realpath(cmdline[0])
|
||||||
if os.path.samefile(proc_cmd, exe_path):
|
if os.path.samefile(proc_cmd, exe_path):
|
||||||
log(f"Terminating process: PID {proc.pid}")
|
log(f"Terminating process: PID {proc.pid}")
|
||||||
_terminate_process(proc)
|
_terminate_process(proc)
|
||||||
terminated_any = True
|
terminated_any = True
|
||||||
|
except (psutil.NoSuchProcess, psutil.AccessDenied, FileNotFoundError) as e:
|
||||||
|
continue
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
log(f"Error terminating process (Linux): {e}")
|
log(f"Error terminating process (Linux): {e}")
|
||||||
|
|
||||||
@@ -64,7 +78,7 @@ def kill_all_processes_by_executable(exe_path):
|
|||||||
return terminated_any
|
return terminated_any
|
||||||
|
|
||||||
|
|
||||||
def _terminate_process(proc):
|
def _terminate_process(proc: psutil.Process) -> None:
|
||||||
try:
|
try:
|
||||||
proc.terminate()
|
proc.terminate()
|
||||||
proc.wait(timeout=10)
|
proc.wait(timeout=10)
|
||||||
@@ -76,7 +90,10 @@ def _terminate_process(proc):
|
|||||||
log(f"Process {proc.pid} killed.")
|
log(f"Process {proc.pid} killed.")
|
||||||
|
|
||||||
|
|
||||||
def wait_for_unlock(path, timeout=100):
|
def wait_for_unlock(path: Union[str, Path], timeout: Union[int, float] = 100) -> None:
|
||||||
|
if not os.path.exists(path):
|
||||||
|
return
|
||||||
|
|
||||||
start_time = time.time()
|
start_time = time.time()
|
||||||
while time.time() - start_time < timeout:
|
while time.time() - start_time < timeout:
|
||||||
try:
|
try:
|
||||||
@@ -92,7 +109,7 @@ def wait_for_unlock(path, timeout=100):
|
|||||||
log(f"Failed to delete after wait: {path}")
|
log(f"Failed to delete after wait: {path}")
|
||||||
|
|
||||||
|
|
||||||
def delete_path(path):
|
def delete_path(path: Union[str, Path]) -> None:
|
||||||
if os.path.exists(path):
|
if os.path.exists(path):
|
||||||
try:
|
try:
|
||||||
if os.path.isdir(path):
|
if os.path.isdir(path):
|
||||||
@@ -105,7 +122,7 @@ def delete_path(path):
|
|||||||
log(f"Error deleting {path}: {e}")
|
log(f"Error deleting {path}: {e}")
|
||||||
|
|
||||||
|
|
||||||
def copy_update_files(src_folder, dest_folder, updater_name):
|
def copy_update_files(src_folder: Union[str, Path], dest_folder: Union[str, Path], updater_name: str) -> None:
|
||||||
for item in os.listdir(src_folder):
|
for item in os.listdir(src_folder):
|
||||||
if item.lower() == updater_name.lower():
|
if item.lower() == updater_name.lower():
|
||||||
log(f"Skipping updater executable: {item}")
|
log(f"Skipping updater executable: {item}")
|
||||||
@@ -124,9 +141,9 @@ def copy_update_files(src_folder, dest_folder, updater_name):
|
|||||||
log(f"Error copying {s} -> {d}: {e}")
|
log(f"Error copying {s} -> {d}: {e}")
|
||||||
|
|
||||||
|
|
||||||
def copy_update_files_darwin(src_folder, dest_folder, updater_name):
|
def copy_update_files_darwin(src_folder: Union[str, Path], dest_folder: Union[str, Path], updater_name: str) -> None:
|
||||||
|
if not updater_name.endswith(".app"):
|
||||||
updater_name = updater_name + ".app"
|
updater_name = updater_name + ".app"
|
||||||
|
|
||||||
for item in os.listdir(src_folder):
|
for item in os.listdir(src_folder):
|
||||||
if item.lower() == updater_name.lower():
|
if item.lower() == updater_name.lower():
|
||||||
@@ -146,19 +163,30 @@ def copy_update_files_darwin(src_folder, dest_folder, updater_name):
|
|||||||
log(f"Error copying {s} -> {d}: {e}")
|
log(f"Error copying {s} -> {d}: {e}")
|
||||||
|
|
||||||
|
|
||||||
def remove_quarantine(app_path):
|
def remove_quarantine(app_path: Union[str, Path]) -> bool:
|
||||||
|
"""Removes the macOS quarantine extended attribute from an application bundle using osascript."""
|
||||||
|
clean_path: str = str(app_path)
|
||||||
|
escaped_path: str = shlex.quote(clean_path)
|
||||||
|
|
||||||
script = f'''
|
script = f'''
|
||||||
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)"
|
do shell script "xattr -d -r com.apple.quarantine {escaped_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)
|
||||||
print("✅ Quarantine attribute removed.")
|
print("✅ Quarantine attribute removed.")
|
||||||
|
return True
|
||||||
except subprocess.CalledProcessError as e:
|
except subprocess.CalledProcessError as e:
|
||||||
print("❌ Failed to remove quarantine attribute.")
|
print("❌ Failed to remove quarantine attribute.")
|
||||||
print(e)
|
print(e)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main() -> None:
|
||||||
|
main_exe: str = ""
|
||||||
|
app_dir: Path = Path()
|
||||||
|
bundle_dir: Path = Path()
|
||||||
|
parent_bundle_dir: Path = Path()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
log(f"[Updater] sys.argv: {sys.argv}")
|
log(f"[Updater] sys.argv: {sys.argv}")
|
||||||
|
|
||||||
@@ -169,43 +197,53 @@ def main():
|
|||||||
update_folder = sys.argv[1]
|
update_folder = sys.argv[1]
|
||||||
main_exe = sys.argv[2]
|
main_exe = sys.argv[2]
|
||||||
|
|
||||||
# Interesting naming convention
|
main_exe_path = Path(main_exe).resolve()
|
||||||
parent_dir = os.path.dirname(os.path.abspath(main_exe))
|
app_dir = main_exe_path.parent
|
||||||
pparent_dir = os.path.dirname(parent_dir)
|
|
||||||
ppparent_dir = os.path.dirname(pparent_dir)
|
|
||||||
pppparent_dir = os.path.dirname(ppparent_dir)
|
|
||||||
|
|
||||||
updater_name = os.path.basename(sys.argv[0])
|
if PLATFORM_NAME == 'darwin':
|
||||||
|
# Bundle hierarchy on macOS: App.app/Contents/MacOS/executable
|
||||||
|
if len(main_exe_path.parents) >= 4:
|
||||||
|
bundle_dir = main_exe_path.parents[2]
|
||||||
|
parent_bundle_dir = main_exe_path.parents[3]
|
||||||
|
else:
|
||||||
|
bundle_dir = app_dir
|
||||||
|
parent_bundle_dir = app_dir.parent
|
||||||
|
|
||||||
|
updater_name = os.path.basename(sys.executable if getattr(sys, 'frozen', False) else sys.argv[0])
|
||||||
|
|
||||||
log("Updater started.")
|
log("Updater started.")
|
||||||
log(f"Update folder: {update_folder}")
|
log(f"Update folder: {update_folder}")
|
||||||
log(f"Main EXE: {main_exe}")
|
log(f"Main EXE: {main_exe}")
|
||||||
log(f"Updater EXE: {updater_name}")
|
log(f"Updater EXE: {updater_name}")
|
||||||
if PLATFORM_NAME == 'darwin':
|
if PLATFORM_NAME == 'darwin' and bundle_dir:
|
||||||
log(f"Main App Folder: {ppparent_dir}")
|
log(f"Main App Folder: {bundle_dir}")
|
||||||
|
|
||||||
# Kill all instances of main app
|
# Kill all instances of main app
|
||||||
kill_all_processes_by_executable(main_exe)
|
kill_all_processes_by_executable(main_exe)
|
||||||
|
|
||||||
# Wait until main_exe process is fully gone (polling)
|
# Wait until main_exe process is fully gone (polling)
|
||||||
for _ in range(20): # wait max 10 seconds
|
for _ in range(10): # wait max 10 seconds
|
||||||
running = False
|
running = False
|
||||||
for proc in psutil.process_iter(['exe', 'cmdline']):
|
for proc in psutil.process_iter(['exe', 'cmdline']):
|
||||||
try:
|
try:
|
||||||
if PLATFORM_NAME == 'windows':
|
if PLATFORM_NAME == 'windows':
|
||||||
proc_exe = proc.info.get('exe')
|
proc_exe = proc.info.get('exe')
|
||||||
if proc_exe and os.path.samefile(os.path.realpath(proc_exe), os.path.realpath(main_exe)):
|
if proc_exe and os.path.exists(proc_exe) and os.path.exists(main_exe):
|
||||||
running = True
|
if os.path.samefile(os.path.realpath(proc_exe), os.path.realpath(main_exe)):
|
||||||
break
|
running = True
|
||||||
|
break
|
||||||
elif PLATFORM_NAME == 'linux':
|
elif PLATFORM_NAME == 'linux':
|
||||||
cmdline = proc.info.get('cmdline', [])
|
cmdline = proc.info.get('cmdline', [])
|
||||||
if cmdline:
|
if cmdline and os.path.exists(cmdline[0]) and os.path.exists(main_exe):
|
||||||
proc_cmd = os.path.realpath(cmdline[0])
|
proc_cmd = os.path.realpath(cmdline[0])
|
||||||
if os.path.samefile(proc_cmd, os.path.realpath(main_exe)):
|
if os.path.samefile(proc_cmd, os.path.realpath(main_exe)):
|
||||||
running = True
|
running = True
|
||||||
break
|
break
|
||||||
|
except (psutil.NoSuchProcess, psutil.AccessDenied, FileNotFoundError):
|
||||||
|
continue
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
log(f"Polling error: {e}")
|
log(f"Polling error: {e}")
|
||||||
|
|
||||||
if not running:
|
if not running:
|
||||||
break
|
break
|
||||||
time.sleep(0.5)
|
time.sleep(0.5)
|
||||||
@@ -213,18 +251,18 @@ def main():
|
|||||||
log("Warning: main executable still running after wait timeout.")
|
log("Warning: main executable still running after wait timeout.")
|
||||||
|
|
||||||
# Delete old version files
|
# Delete old version files
|
||||||
if PLATFORM_NAME == 'darwin':
|
if PLATFORM_NAME == 'darwin' and bundle_dir and parent_bundle_dir:
|
||||||
log(f'Attempting to delete {ppparent_dir}')
|
log(f'Attempting to delete {bundle_dir}')
|
||||||
delete_path(ppparent_dir)
|
delete_path(str(bundle_dir))
|
||||||
update_folder = os.path.join(sys.argv[1], f"{APP_NAME}-darwin")
|
extracted_update_folder = os.path.join(update_folder, f"{APP_NAME}-darwin")
|
||||||
copy_update_files_darwin(update_folder, pppparent_dir, updater_name)
|
copy_update_files_darwin(extracted_update_folder, str(parent_bundle_dir), updater_name)
|
||||||
|
|
||||||
else:
|
else:
|
||||||
delete_path(main_exe)
|
delete_path(main_exe)
|
||||||
wait_for_unlock(os.path.join(parent_dir, "_internal"))
|
wait_for_unlock(os.path.join(str(app_dir), "_internal"))
|
||||||
|
|
||||||
# Copy new files excluding the updater itself
|
# Copy new files excluding the updater itself
|
||||||
copy_update_files(update_folder, parent_dir, updater_name)
|
copy_update_files(update_folder, str(app_dir), updater_name)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
log(f"Something went wrong: {e}")
|
log(f"Something went wrong: {e}")
|
||||||
@@ -235,14 +273,14 @@ def main():
|
|||||||
os.chmod(main_exe, 0o755)
|
os.chmod(main_exe, 0o755)
|
||||||
log("Added executable bit")
|
log("Added executable bit")
|
||||||
|
|
||||||
if PLATFORM_NAME == 'darwin':
|
if PLATFORM_NAME == 'darwin' and bundle_dir:
|
||||||
os.chmod(ppparent_dir, 0o755)
|
os.chmod(str(bundle_dir), 0o755)
|
||||||
log("Added executable bit")
|
log("Added executable bit")
|
||||||
remove_quarantine(ppparent_dir)
|
remove_quarantine(str(bundle_dir))
|
||||||
log(f"Removed the quarantine flag on {ppparent_dir}")
|
log(f"Removed the quarantine flag on {bundle_dir}")
|
||||||
subprocess.Popen(['open', ppparent_dir, "--args", "--finish-update"])
|
subprocess.Popen(['open', str(bundle_dir), "--args", "--finish-update"])
|
||||||
else:
|
else:
|
||||||
subprocess.Popen([main_exe, "--finish-update"], cwd=parent_dir)
|
subprocess.Popen([main_exe, "--finish-update"], cwd=str(app_dir))
|
||||||
|
|
||||||
log("Relaunched main app.")
|
log("Relaunched main app.")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
@@ -19,8 +19,13 @@ from queue import Empty
|
|||||||
from copy import deepcopy
|
from copy import deepcopy
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
from functools import partial
|
||||||
from multiprocessing import Process, current_process, freeze_support, Queue, set_start_method
|
from multiprocessing import Process, current_process, freeze_support, Queue, set_start_method
|
||||||
|
|
||||||
|
# Fix for plotting on linux
|
||||||
|
if sys.platform.startswith("linux"):
|
||||||
|
os.environ["QT_QPA_PLATFORM"] = "xcb"
|
||||||
|
|
||||||
# External library imports
|
# External library imports
|
||||||
import psutil
|
import psutil
|
||||||
|
|
||||||
@@ -28,13 +33,15 @@ from PySide6.QtWidgets import (
|
|||||||
QApplication, QWidget, QMessageBox, QVBoxLayout, QHBoxLayout, QTextEdit, QScrollArea, QComboBox, QGridLayout, QSplitter, QDialogButtonBox, QHeaderView,
|
QApplication, QWidget, QMessageBox, QVBoxLayout, QHBoxLayout, QTextEdit, QScrollArea, QComboBox, QGridLayout, QSplitter, QDialogButtonBox, QHeaderView,
|
||||||
QPushButton, QMainWindow, QLabel, QLineEdit, QGroupBox, QDialog, QMenu, QSpinBox, QTableWidget, QTableWidgetItem
|
QPushButton, QMainWindow, QLabel, QLineEdit, QGroupBox, QDialog, QMenu, QSpinBox, QTableWidget, QTableWidgetItem
|
||||||
)
|
)
|
||||||
from PySide6.QtCore import Signal, Qt, QTimer
|
from PySide6.QtCore import QEvent, QObject, Signal, Qt, QTimer
|
||||||
from PySide6.QtGui import QAction, QFontMetrics, QKeySequence, QIcon
|
from PySide6.QtGui import QAction, QActionGroup, QFontMetrics, QKeySequence, QIcon
|
||||||
from PySide6.QtSvgWidgets import QSvgWidget # needed to show svgs when app is not frozen
|
from PySide6.QtSvgWidgets import QSvgWidget # needed to show svgs when app is not frozen
|
||||||
|
|
||||||
from file_ext_registration import register_file_association, ELEVATION_FLAG
|
from file_ext_registration import register_file_association, ELEVATION_FLAG
|
||||||
|
from plugin_manager import PluginManager
|
||||||
from project_manager import ProjectManager
|
from project_manager import ProjectManager
|
||||||
from src.window.about import AboutWindow
|
from src.window.about import AboutWindow
|
||||||
|
from src.window.plugins import PluginsWindow
|
||||||
from src.window.terminal import TerminalWindow
|
from src.window.terminal import TerminalWindow
|
||||||
from src.window.updateevents import EventUpdateMode, UpdateEventsBlazesWindow, UpdateEventsWindow
|
from src.window.updateevents import EventUpdateMode, UpdateEventsBlazesWindow, UpdateEventsWindow
|
||||||
from src.window.updateoptodes import UpdateOptodesWindow
|
from src.window.updateoptodes import UpdateOptodesWindow
|
||||||
@@ -42,7 +49,7 @@ from src.window.userguide import UserGuideWindow
|
|||||||
from src.window.viewerlauncher import ViewerLauncherWidget
|
from src.window.viewerlauncher import ViewerLauncherWidget
|
||||||
from src.window.welcome import WelcomeDialog
|
from src.window.welcome import WelcomeDialog
|
||||||
from src.shared.flaresbasewidget import FilePickerWidget, ParamSection, ProgressBubble
|
from src.shared.flaresbasewidget import FilePickerWidget, ParamSection, ProgressBubble
|
||||||
from src.shared.shareddata import API_URL, API_URL_SECONDARY, APP_NAME, CURRENT_VERSION, PLATFORM_NAME, DATA_SCHEMA
|
from src.shared.shareddata import API_URL, API_URL_SECONDARY, APP_NAME, CURRENT_VERSION, PLATFORM_NAME, DATA_SCHEMA, get_app_dir
|
||||||
from startup_args import parse_startup_args
|
from startup_args import parse_startup_args
|
||||||
from updater import finish_update_if_needed, UpdateManager, LocalPendingUpdateCheckThread
|
from updater import finish_update_if_needed, UpdateManager, LocalPendingUpdateCheckThread
|
||||||
|
|
||||||
@@ -64,6 +71,7 @@ show_welcome_dialog = false
|
|||||||
first_startup = true
|
first_startup = true
|
||||||
|
|
||||||
[Preferences]
|
[Preferences]
|
||||||
|
theme = auto
|
||||||
2d_data_bypass = false
|
2d_data_bypass = false
|
||||||
incompatible_save_bypass = false
|
incompatible_save_bypass = false
|
||||||
missing_events_bypass = false
|
missing_events_bypass = false
|
||||||
@@ -129,10 +137,10 @@ SECTIONS = [
|
|||||||
"params": [
|
"params": [
|
||||||
{"name": "SCI", "default": True, "type": bool, "advanced": False, "help": "Calculate and mark channels bad based on their Scalp Coupling Index. This metric calculates the quality of the connection between the optode and the scalp."},
|
{"name": "SCI", "default": True, "type": bool, "advanced": False, "help": "Calculate and mark channels bad based on their Scalp Coupling Index. This metric calculates the quality of the connection between the optode and the scalp."},
|
||||||
{"name": "SCI_USE_HEART_RATE_BAND", "default": True, "type": bool, "depends_on": [{"parent_name": "SCI"}, {"parent_name": "HEART_RATE"}], "advanced": False, "help": "Adjust the SCI frequency band using the participant's estimated heart rate."},
|
{"name": "SCI_USE_HEART_RATE_BAND", "default": True, "type": bool, "depends_on": [{"parent_name": "SCI"}, {"parent_name": "HEART_RATE"}], "advanced": False, "help": "Adjust the SCI frequency band using the participant's estimated heart rate."},
|
||||||
{"name": "SCI_LOW_FREQ", "default": 0.7, "type": float, "depends_on": "SCI_USE_HEART_RATE_BAND", "depends_value": False,"advanced": True, "help": "Lower frequency cutoff for SCI bandpass filtering (Hz)."},
|
{"name": "SCI_LOW_FREQ", "default": 0.7, "type": float, "depends_on": "SCI_USE_HEART_RATE_BAND", "depends_value": False,"advanced": True, "help": "Lower frequency bound for the signal band used in SCI calculation (Hz)."},
|
||||||
{"name": "SCI_HIGH_FREQ", "default": 1.5, "type": float, "depends_on": "SCI_USE_HEART_RATE_BAND", "depends_value": False, "advanced": True, "help": "Upper frequency cutoff for SCI bandpass filtering (Hz)."},
|
{"name": "SCI_HIGH_FREQ", "default": 1.5, "type": float, "depends_on": "SCI_USE_HEART_RATE_BAND", "depends_value": False, "advanced": True, "help": "Lower frequency bound for the signal band used in SCI calculation (Hz)."},
|
||||||
{"name": "SCI_TIME_WINDOW", "default": 3, "type": int, "depends_on": "SCI", "advanced": False, "help": "Duration of each independent SCI calculation window in seconds."},
|
{"name": "SCI_TIME_WINDOW", "default": 3, "type": int, "depends_on": "SCI", "advanced": False, "help": "Duration of each independent SCI calculation window in seconds."},
|
||||||
{"name": "SCI_THRESHOLD", "default": 0.6, "type": float, "depends_on": "SCI", "advanced": False, "help": "SCI threshold on a scale of 0-1. Channels below this value are marked bad."},
|
{"name": "SCI_THRESHOLD", "default": 0.6, "type": float, "depends_on": "SCI", "advanced": False, "help": "SCI threshold on a scale of 0-1. Channels below this value will be marked bad."},
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -150,10 +158,11 @@ SECTIONS = [
|
|||||||
"title": "Peak Spectral Power",
|
"title": "Peak Spectral Power",
|
||||||
"params": [
|
"params": [
|
||||||
{"name": "PSP", "default": True, "type": bool, "advanced": False, "help": "Calculate and mark channels bad based on their Peak Spectral Power. This metric calculates the amplitude or strength of the most prominent frequency component in a specified spectral range."},
|
{"name": "PSP", "default": True, "type": bool, "advanced": False, "help": "Calculate and mark channels bad based on their Peak Spectral Power. This metric calculates the amplitude or strength of the most prominent frequency component in a specified spectral range."},
|
||||||
{"name": "PSP_TIME_WINDOW", "default": 3, "type": int, "depends_on": "PSP", "advanced": False, "help": "Length of each independent PSP calculation window in seconds."},
|
{"name": "PSP_USE_HEART_RATE_BAND", "default": True, "type": bool, "depends_on": [{"parent_name": "PSP"}, {"parent_name": "HEART_RATE"}], "advanced": False, "help": "Adjust the PSP frequency band using the participant's estimated heart rate."},
|
||||||
{"name": "PSP_THRESHOLD", "default": 0.1, "type": float, "depends_on": "PSP", "advanced": False, "help": "Channels with average PSP values below this threshold will be marked as bad."},
|
{"name": "PSP_LOW_FREQ", "default": 0.7, "type": float, "depends_on": "PSP", "advanced": True, "help": "Lower frequency bound for the signal band used in PSP calculation (Hz)."},
|
||||||
{"name": "PSP_LOW_FREQ", "default": 0.7, "type": float, "depends_on": "PSP", "advanced": True, "help": "Lower frequency cutoff for PSP bandpass filtering (Hz)."},
|
{"name": "PSP_HIGH_FREQ", "default": 1.5, "type": float, "depends_on": "PSP", "advanced": True, "help": "Upper frequency bound for the signal band used in PSP calculation (Hz)."},
|
||||||
{"name": "PSP_HIGH_FREQ", "default": 1.5, "type": float, "depends_on": "PSP", "advanced": True, "help": "Upper frequency cutoff for PSP bandpass filtering (Hz)."},
|
{"name": "PSP_TIME_WINDOW", "default": 3, "type": int, "depends_on": "PSP", "advanced": False, "help": "Duration of each independent PSP calculation window in seconds."},
|
||||||
|
{"name": "PSP_THRESHOLD", "default": 0.1, "type": float, "depends_on": "PSP", "advanced": False, "help": "PSP threshold on a scale of 0-1. Channels below this value will be marked bad."},
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -242,10 +251,10 @@ SECTIONS = [
|
|||||||
{"name": "H_TRANS_BANDWIDTH", "default": 0.002, "type": float, "depends_on": "FILTER", "advanced": True, "help": "Width of the upper transition band to prevent abrupt filter cutoff."},
|
{"name": "H_TRANS_BANDWIDTH", "default": 0.002, "type": float, "depends_on": "FILTER", "advanced": True, "help": "Width of the upper transition band to prevent abrupt filter cutoff."},
|
||||||
# {"name": "IIR_TYPE", "default": ["butterworth"], "type": list, "options": ["butterworth", "chebyshev1", "chebyshev2", "elliptic", "bessel"], "exclusive": True, "depends_on": "FILTER_ALGORITHM", "depends_value": "IIR", "advanced": True, "help": "IIR filter design."},
|
# {"name": "IIR_TYPE", "default": ["butterworth"], "type": list, "options": ["butterworth", "chebyshev1", "chebyshev2", "elliptic", "bessel"], "exclusive": True, "depends_on": "FILTER_ALGORITHM", "depends_value": "IIR", "advanced": True, "help": "IIR filter design."},
|
||||||
# {"name": "IIR_ORDER", "default": 4, "type": int, "depends_on": "FILTER_ALGORITHM", "depends_value": "IIR", "advanced": True, "help": "Order of the IIR filter."},
|
# {"name": "IIR_ORDER", "default": 4, "type": int, "depends_on": "FILTER_ALGORITHM", "depends_value": "IIR", "advanced": True, "help": "Order of the IIR filter."},
|
||||||
{"name": "FILTER_LENGTH", "default": "auto", "type": str, "depends_on": "FILTER_ALGORITHM", "depends_value": "FIR", "advanced": True, "help": "Length of the FIR filter. 'auto' allows automatic selection."},
|
{"name": "FILTER_LENGTH", "default": "auto", "type": str, "depends_on": "FILTER_ALGORITHM", "depends_value": "fir", "advanced": True, "help": "Length of the FIR filter. 'auto' allows automatic selection."},
|
||||||
{"name": "FILTER_PHASE", "default": ["zero"], "type": list, "options": ["zero", "zero-double", "minimum", "minimum-half", "linear"], "exclusive": True, "depends_on": "FILTER_ALGORITHM", "depends_value": "FIR", "advanced": True, "help": "Phase response of the FIR filter."},
|
{"name": "FILTER_PHASE", "default": ["zero"], "type": list, "options": ["zero", "zero-double", "minimum", "minimum-half", "linear"], "exclusive": True, "depends_on": "FILTER_ALGORITHM", "depends_value": "fir", "advanced": True, "help": "Phase response of the FIR filter."},
|
||||||
{"name": "FIR_WINDOW", "default": ["hamming"], "type": list, "options": ["hamming", "hann", "blackman"], "exclusive": True, "depends_on": "FILTER_ALGORITHM", "depends_value": "FIR", "advanced": True, "help": "Window function used when designing the FIR filter."},
|
{"name": "FIR_WINDOW", "default": ["hamming"], "type": list, "options": ["hamming", "hann", "blackman"], "exclusive": True, "depends_on": "FILTER_ALGORITHM", "depends_value": "fir", "advanced": True, "help": "Window function used when designing the FIR filter."},
|
||||||
{"name": "FIR_DESIGN", "default": ["firwin"], "type": list, "options": ["firwin", "firwin2"], "exclusive": True, "depends_on": "FILTER_ALGORITHM", "depends_value": "FIR", "advanced": True, "help": "Method used to design the FIR filter."},
|
{"name": "FIR_DESIGN", "default": ["firwin"], "type": list, "options": ["firwin", "firwin2"], "exclusive": True, "depends_on": "FILTER_ALGORITHM", "depends_value": "fir", "advanced": True, "help": "Method used to design the FIR filter."},
|
||||||
# {"name": "IIR_OUTPUT", "default": ["sos"], "type": list, "options": ["sos", "ba", "zpk"], "exclusive": True, "depends_on": "FILTER_ALGORITHM", "depends_value": "IIR", "advanced": True, "help": "Representation used for IIR filter coefficients."},
|
# {"name": "IIR_OUTPUT", "default": ["sos"], "type": list, "options": ["sos", "ba", "zpk"], "exclusive": True, "depends_on": "FILTER_ALGORITHM", "depends_value": "IIR", "advanced": True, "help": "Representation used for IIR filter coefficients."},
|
||||||
# {"name": "PASSBAND_RIPPLE", "default": 1.0, "type": float, "depends_on": "IIR_TYPE", "depends_value": ["chebyshev1", "elliptic"], "advanced": True, "help": "Maximum allowed ripple in the passband (dB)."},
|
# {"name": "PASSBAND_RIPPLE", "default": 1.0, "type": float, "depends_on": "IIR_TYPE", "depends_value": ["chebyshev1", "elliptic"], "advanced": True, "help": "Maximum allowed ripple in the passband (dB)."},
|
||||||
# {"name": "STOPBAND_ATTENUATION", "default": 40.0, "type": float, "depends_on": "IIR_TYPE", "depends_value": ["chebyshev2", "elliptic"], "advanced": True, "help": "Minimum attenuation in the stopband (dB)."},
|
# {"name": "STOPBAND_ATTENUATION", "default": 40.0, "type": float, "depends_on": "IIR_TYPE", "depends_value": ["chebyshev2", "elliptic"], "advanced": True, "help": "Minimum attenuation in the stopband (dB)."},
|
||||||
@@ -258,8 +267,8 @@ SECTIONS = [
|
|||||||
"title": "Extracting Events",
|
"title": "Extracting Events",
|
||||||
"params": [
|
"params": [
|
||||||
{"name": "EVENTS", "default": True, "type": bool, "advanced": True, "help": "Extract events from annotations for visualization and downstream event-based analysis."},
|
{"name": "EVENTS", "default": True, "type": bool, "advanced": True, "help": "Extract events from annotations for visualization and downstream event-based analysis."},
|
||||||
{"name": "EVENT_ID", "default": "auto", "type": str, "advanced": True, "help": "Controls how annotation descriptions are converted into event identifiers. Use 'auto' for automatic event detection."},
|
{"name": "EVENT_ID", "default": "auto", "type": str, "depends_on": "EVENTS", "advanced": True, "help": "Controls how annotation descriptions are converted into event identifiers. Use 'auto' for automatic event detection."},
|
||||||
{"name": "EVENT_REGEX", "default": r"^(?![Bb][Aa][Dd]|[Ee][Dd][Gg][Ee]).*$", "type": str, "advanced": True, "help": "Regular expression used to select which annotations are converted into events. By default, bad and edge annotations are ignored."},
|
{"name": "EVENT_REGEX", "default": r"^(?![Bb][Aa][Dd]|[Ee][Dd][Gg][Ee]).*$", "type": str, "depends_on": "EVENTS", "advanced": True, "help": "Regular expression used to select which annotations are converted into events. By default, bad and edge annotations are ignored."},
|
||||||
# {"name": "EVENT_CHUNK_DURATION", "default": 0.0, "type": float, "advanced": True, "help": "If provided, creates repeated events at this interval within longer annotations instead of only using annotation onset times."},
|
# {"name": "EVENT_CHUNK_DURATION", "default": 0.0, "type": float, "advanced": True, "help": "If provided, creates repeated events at this interval within longer annotations instead of only using annotation onset times."},
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -320,7 +329,7 @@ SECTIONS = [
|
|||||||
BIDS_FIELD_MAP = {
|
BIDS_FIELD_MAP = {
|
||||||
"BIDS - Age": "AGE",
|
"BIDS - Age": "AGE",
|
||||||
"BIDS - Sex": "SEX",
|
"BIDS - Sex": "SEX",
|
||||||
"BIDS - Hand": "HAND",
|
"BIDS - Handedness": "HAND",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -489,6 +498,89 @@ class GroupAssignmentDialog(QDialog):
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
class CustomApplication(QApplication):
|
||||||
|
"""
|
||||||
|
macOS delivers a file-open request (double-clicking a registered
|
||||||
|
file, or dropping one on the Dock icon — whether the app is already
|
||||||
|
running or being launched fresh) as an Apple Event, which Qt exposes
|
||||||
|
as QEvent.Type.FileOpen. Windows/Linux never send this; they pass
|
||||||
|
the path as a normal argv argument, which startup_args.initial_file
|
||||||
|
already handles. Without this override, double-clicking a project
|
||||||
|
file on macOS launches the app but it never learns which file to open.
|
||||||
|
"""
|
||||||
|
file_open_requested = Signal(str)
|
||||||
|
|
||||||
|
def event(self, e: QEvent) -> bool:
|
||||||
|
if e.type() == QEvent.Type.FileOpen:
|
||||||
|
self.file_open_requested.emit(e.file())
|
||||||
|
return True
|
||||||
|
return super().event(e)
|
||||||
|
|
||||||
|
|
||||||
|
class ThemeChangeWatcher(QObject):
|
||||||
|
def __init__(self, main_window):
|
||||||
|
super().__init__()
|
||||||
|
self.main_window = main_window
|
||||||
|
self._theme_timer = QTimer(self)
|
||||||
|
self._theme_timer.setSingleShot(True)
|
||||||
|
self._theme_timer.setInterval(100)
|
||||||
|
self._theme_timer.timeout.connect(self._apply_theme)
|
||||||
|
|
||||||
|
def eventFilter(self, obj, event):
|
||||||
|
if event.type() == QEvent.Type.ApplicationPaletteChange:
|
||||||
|
# Restart the timer instead of updating immediately.
|
||||||
|
# Multiple palette-change events collapse into one update.
|
||||||
|
self._theme_timer.start()
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _apply_theme(self):
|
||||||
|
print("OS theme changed")
|
||||||
|
self.main_window.update_theme()
|
||||||
|
|
||||||
|
|
||||||
|
class DropScrollArea(QScrollArea):
|
||||||
|
"""QScrollArea that accepts .snirf file and folder drops."""
|
||||||
|
|
||||||
|
def __init__(self, on_files_dropped_callback, parent=None):
|
||||||
|
super().__init__(parent)
|
||||||
|
self.on_files_dropped_callback = on_files_dropped_callback
|
||||||
|
self.setAcceptDrops(True)
|
||||||
|
# ScrollAreas require setting acceptDrops on their viewport as well
|
||||||
|
self.viewport().setAcceptDrops(True)
|
||||||
|
|
||||||
|
def dragEnterEvent(self, event) -> None:
|
||||||
|
if event.mimeData().hasUrls():
|
||||||
|
event.acceptProposedAction()
|
||||||
|
else:
|
||||||
|
event.ignore()
|
||||||
|
|
||||||
|
def dropEvent(self, event) -> None:
|
||||||
|
if not event.mimeData().hasUrls():
|
||||||
|
return
|
||||||
|
|
||||||
|
dropped_paths = [
|
||||||
|
url.toLocalFile() for url in event.mimeData().urls() if url.isLocalFile()
|
||||||
|
]
|
||||||
|
|
||||||
|
files_to_load = []
|
||||||
|
for path_str in dropped_paths:
|
||||||
|
p = Path(path_str)
|
||||||
|
if p.is_file() and p.suffix.lower() == ".snirf":
|
||||||
|
files_to_load.append(os.path.normpath(str(p)))
|
||||||
|
elif p.is_dir():
|
||||||
|
files_to_load.extend(
|
||||||
|
os.path.normpath(str(f)) for f in p.rglob("*.snirf")
|
||||||
|
)
|
||||||
|
|
||||||
|
if files_to_load:
|
||||||
|
self.on_files_dropped_callback(files_to_load)
|
||||||
|
else:
|
||||||
|
QMessageBox.information(
|
||||||
|
self,
|
||||||
|
"No Valid Files",
|
||||||
|
"None of the dropped items were .snirf files or directories containing .snirf files.",
|
||||||
|
)
|
||||||
|
|
||||||
class MainApplication(QMainWindow):
|
class MainApplication(QMainWindow):
|
||||||
"""
|
"""
|
||||||
@@ -513,6 +605,7 @@ class MainApplication(QMainWindow):
|
|||||||
self.optodes = None
|
self.optodes = None
|
||||||
self.events = None
|
self.events = None
|
||||||
self.events_blazes = None
|
self.events_blazes = None
|
||||||
|
self.plugins = None
|
||||||
self.terminal = None
|
self.terminal = None
|
||||||
self.bubble_widgets = {}
|
self.bubble_widgets = {}
|
||||||
self.param_sections = []
|
self.param_sections = []
|
||||||
@@ -534,6 +627,7 @@ class MainApplication(QMainWindow):
|
|||||||
self.files_are_dirty = False
|
self.files_are_dirty = False
|
||||||
|
|
||||||
self.project_manager = ProjectManager(self, file_cfg=file_cfg, cfg_path=cfg_path)
|
self.project_manager = ProjectManager(self, file_cfg=file_cfg, cfg_path=cfg_path)
|
||||||
|
self.plugin_manager = PluginManager(self)
|
||||||
|
|
||||||
# Initialization to ensure that saving can occur
|
# Initialization to ensure that saving can occur
|
||||||
for item in DATA_SCHEMA:
|
for item in DATA_SCHEMA:
|
||||||
@@ -576,6 +670,8 @@ class MainApplication(QMainWindow):
|
|||||||
self.local_check_thread.no_pending_update.connect(self.updater.on_no_pending_update)
|
self.local_check_thread.no_pending_update.connect(self.updater.on_no_pending_update)
|
||||||
self.local_check_thread.start()
|
self.local_check_thread.start()
|
||||||
|
|
||||||
|
self.plugin_manager.reload_plugins()
|
||||||
|
|
||||||
self.show()
|
self.show()
|
||||||
|
|
||||||
# Check if we should pop up the welcome screen
|
# Check if we should pop up the welcome screen
|
||||||
@@ -623,10 +719,10 @@ class MainApplication(QMainWindow):
|
|||||||
self.left_v_splitter.setChildrenCollapsible(False)
|
self.left_v_splitter.setChildrenCollapsible(False)
|
||||||
self.left_v_splitter.setMinimumWidth(460)
|
self.left_v_splitter.setMinimumWidth(460)
|
||||||
|
|
||||||
top_left_container = QGroupBox("File information")
|
self.top_left_container = QGroupBox("File Information")
|
||||||
top_left_container.setStyleSheet("QGroupBox { font-weight: bold; }")
|
self.top_left_container.setStyleSheet("QGroupBox { font-weight: bold; }")
|
||||||
top_left_container.setMinimumHeight(240)
|
self.top_left_container.setMinimumHeight(240)
|
||||||
top_left_layout = QHBoxLayout(top_left_container)
|
top_left_layout = QHBoxLayout(self.top_left_container)
|
||||||
|
|
||||||
self.top_left_widget = QTextEdit()
|
self.top_left_widget = QTextEdit()
|
||||||
self.top_left_widget.setReadOnly(True)
|
self.top_left_widget.setReadOnly(True)
|
||||||
@@ -639,22 +735,25 @@ class MainApplication(QMainWindow):
|
|||||||
font_metrics = QFontMetrics(self.font())
|
font_metrics = QFontMetrics(self.font())
|
||||||
label_width = max(font_metrics.horizontalAdvance(key.capitalize()) for key in self.meta_fields) + 10
|
label_width = max(font_metrics.horizontalAdvance(key.capitalize()) for key in self.meta_fields) + 10
|
||||||
|
|
||||||
|
self.meta_labels = {}
|
||||||
|
|
||||||
for key, field in self.meta_fields.items():
|
for key, field in self.meta_fields.items():
|
||||||
row_layout = QHBoxLayout()
|
row_layout = QHBoxLayout()
|
||||||
row_layout.setContentsMargins(0, 0, 0, 0)
|
row_layout.setContentsMargins(0, 0, 0, 0)
|
||||||
row_layout.setSpacing(0)
|
row_layout.setSpacing(0)
|
||||||
|
|
||||||
label = QLabel(key.capitalize() + ":")
|
label = QLabel(key.capitalize() + ":")
|
||||||
|
self.meta_labels[key] = label
|
||||||
label.setFixedWidth(label_width)
|
label.setFixedWidth(label_width)
|
||||||
row_layout.addWidget(label)
|
row_layout.addWidget(label)
|
||||||
row_layout.addWidget(field)
|
row_layout.addWidget(field)
|
||||||
right_column_layout.addLayout(row_layout)
|
right_column_layout.addLayout(row_layout)
|
||||||
field.textChanged.connect(self.sync_bubble_data)
|
field.textChanged.connect(self.sync_bubble_data)
|
||||||
|
|
||||||
label_desc = QLabel('<a href="#">Why are these useful?</a>')
|
self.label_desc = QLabel('<a href="#">Why are these useful?</a>')
|
||||||
label_desc.setTextInteractionFlags(Qt.TextInteractionFlag.TextBrowserInteraction)
|
self.label_desc.setTextInteractionFlags(Qt.TextInteractionFlag.TextBrowserInteraction)
|
||||||
label_desc.linkActivated.connect(lambda: QMessageBox.information(None, f"Info - {APP_NAME.upper()} ", "Age: Used in determing the participants PPF. Also used to assist in creating groups.\nGender: Used to assist in creating groups.\nHand: Used to assist in creating groups.\nGroup: Used to split participants into groups for comparisons between them."))
|
self.label_desc.linkActivated.connect(lambda: QMessageBox.information(None, f"Info - {APP_NAME.upper()} ", "Age: Used in determing the participants PPF. Also used to assist in creating groups.\nGender: Used to assist in creating groups.\nHand: Used to assist in creating groups.\nGroup: Used to split participants into groups for comparisons between them."))
|
||||||
right_column_layout.addWidget(label_desc)
|
right_column_layout.addWidget(self.label_desc)
|
||||||
right_column_layout.addStretch()
|
right_column_layout.addStretch()
|
||||||
self.right_column_widget.hide()
|
self.right_column_widget.hide()
|
||||||
top_left_layout.addWidget(self.right_column_widget, stretch=1)
|
top_left_layout.addWidget(self.right_column_widget, stretch=1)
|
||||||
@@ -663,12 +762,14 @@ class MainApplication(QMainWindow):
|
|||||||
self.bubble_layout = QGridLayout(self.bubble_container)
|
self.bubble_layout = QGridLayout(self.bubble_container)
|
||||||
self.bubble_layout.setAlignment(Qt.AlignmentFlag.AlignTop)
|
self.bubble_layout.setAlignment(Qt.AlignmentFlag.AlignTop)
|
||||||
|
|
||||||
self.scroll_area = QScrollArea()
|
self.scroll_area = DropScrollArea(
|
||||||
|
on_files_dropped_callback=self.project_manager.load_dropped_files
|
||||||
|
)
|
||||||
self.scroll_area.setWidgetResizable(True)
|
self.scroll_area.setWidgetResizable(True)
|
||||||
self.scroll_area.setWidget(self.bubble_container)
|
self.scroll_area.setWidget(self.bubble_container)
|
||||||
self.scroll_area.setMinimumHeight(200)
|
self.scroll_area.setMinimumHeight(200)
|
||||||
|
|
||||||
self.left_v_splitter.addWidget(top_left_container)
|
self.left_v_splitter.addWidget(self.top_left_container)
|
||||||
self.left_v_splitter.addWidget(self.scroll_area)
|
self.left_v_splitter.addWidget(self.scroll_area)
|
||||||
|
|
||||||
self.right_container = QWidget()
|
self.right_container = QWidget()
|
||||||
@@ -825,6 +926,31 @@ class MainApplication(QMainWindow):
|
|||||||
self.pref_actions = {}
|
self.pref_actions = {}
|
||||||
|
|
||||||
preferences_menu = menu_bar.addMenu("Preferences")
|
preferences_menu = menu_bar.addMenu("Preferences")
|
||||||
|
theme_menu = preferences_menu.addMenu("Theme")
|
||||||
|
|
||||||
|
theme_group = QActionGroup(self)
|
||||||
|
theme_group.setExclusive(True)
|
||||||
|
|
||||||
|
# 4. Define actions for the submenu
|
||||||
|
theme_actions = [
|
||||||
|
("Auto", "", "auto", resource_path("icons/warning_off_24dp_1F1F1F.svg"), "theme_auto"),
|
||||||
|
("Light", "", "light", resource_path("icons/warning_off_24dp_1F1F1F.svg"), "theme_light"),
|
||||||
|
("Dark", "", "dark", resource_path("icons/warning_off_24dp_1F1F1F.svg"), "theme_dark"),
|
||||||
|
]
|
||||||
|
|
||||||
|
for name, shortcut, mode, icon, config_key in theme_actions:
|
||||||
|
# Use partial to pass 'mode' to self.theme_change_func on click
|
||||||
|
slot = partial(self.theme_change_func, mode)
|
||||||
|
|
||||||
|
action = make_action(name, shortcut, slot, icon=icon, checkable=True)
|
||||||
|
theme_menu.addAction(action)
|
||||||
|
theme_group.addAction(action)
|
||||||
|
|
||||||
|
self.pref_actions[config_key] = action
|
||||||
|
|
||||||
|
# Set default selection (e.g., Auto)
|
||||||
|
self.pref_actions["theme_auto"].setChecked(True)
|
||||||
|
|
||||||
preferences_actions = [
|
preferences_actions = [
|
||||||
("2D Data Bypass", "", self.is_2d_bypass_func, resource_path("icons/warning_off_24dp_1F1F1F.svg"), "2d_data_bypass"),
|
("2D Data Bypass", "", self.is_2d_bypass_func, resource_path("icons/warning_off_24dp_1F1F1F.svg"), "2d_data_bypass"),
|
||||||
("Incompatible Save Bypass", "", self.incompatable_save_bypass_func, resource_path("icons/warning_off_24dp_1F1F1F.svg"), "incompatible_save_bypass"),
|
("Incompatible Save Bypass", "", self.incompatable_save_bypass_func, resource_path("icons/warning_off_24dp_1F1F1F.svg"), "incompatible_save_bypass"),
|
||||||
@@ -839,6 +965,13 @@ class MainApplication(QMainWindow):
|
|||||||
preferences_menu.addAction(action)
|
preferences_menu.addAction(action)
|
||||||
self.pref_actions[config_key] = action
|
self.pref_actions[config_key] = action
|
||||||
|
|
||||||
|
plugins_menu = menu_bar.addMenu("Plugins")
|
||||||
|
plugins_actions = [
|
||||||
|
("Plugin Manager", "Ctrl+Alt+P", self.plugins_gui, resource_path("icons/terminal_24dp_1F1F1F.svg")),
|
||||||
|
]
|
||||||
|
for name, shortcut, slot, icon in plugins_actions:
|
||||||
|
plugins_menu.addAction(make_action(name, shortcut, slot, icon=icon))
|
||||||
|
|
||||||
terminal_menu = menu_bar.addMenu("Terminal")
|
terminal_menu = menu_bar.addMenu("Terminal")
|
||||||
terminal_actions = [
|
terminal_actions = [
|
||||||
("New Terminal", "Ctrl+Alt+T", self.terminal_gui, resource_path("icons/terminal_24dp_1F1F1F.svg")),
|
("New Terminal", "Ctrl+Alt+T", self.terminal_gui, resource_path("icons/terminal_24dp_1F1F1F.svg")),
|
||||||
@@ -851,6 +984,31 @@ class MainApplication(QMainWindow):
|
|||||||
self.statusbar.showMessage("Ready")
|
self.statusbar.showMessage("Ready")
|
||||||
|
|
||||||
|
|
||||||
|
def update_theme(self):
|
||||||
|
text = self.label_desc.text()
|
||||||
|
self.label_desc.setText("")
|
||||||
|
self.label_desc.setText(text)
|
||||||
|
|
||||||
|
widgets = [
|
||||||
|
self.label_desc,
|
||||||
|
self.top_left_widget,
|
||||||
|
self.right_column_widget,
|
||||||
|
self.top_left_container,
|
||||||
|
]
|
||||||
|
|
||||||
|
widgets.extend(self.meta_fields.values())
|
||||||
|
widgets.extend(self.meta_labels.values())
|
||||||
|
|
||||||
|
for widget in widgets:
|
||||||
|
widget.style().unpolish(widget)
|
||||||
|
widget.style().polish(widget)
|
||||||
|
widget.update()
|
||||||
|
|
||||||
|
for section in self.param_sections:
|
||||||
|
print("hi")
|
||||||
|
section.update_theme_colors()
|
||||||
|
|
||||||
|
|
||||||
def update_sections(self, index):
|
def update_sections(self, index):
|
||||||
self.current_section_index = index
|
self.current_section_index = index
|
||||||
|
|
||||||
@@ -1075,6 +1233,7 @@ class MainApplication(QMainWindow):
|
|||||||
# for stat in stats[:10]:
|
# for stat in stats[:10]:
|
||||||
# print(stat)
|
# print(stat)
|
||||||
# print("Top 10 growing object types in RAM:")
|
# print("Top 10 growing object types in RAM:")
|
||||||
|
# objgraph has been removed so it will need to be reinstalled for this to work
|
||||||
# objgraph.show_most_common_types(limit=10)
|
# objgraph.show_most_common_types(limit=10)
|
||||||
|
|
||||||
def check_if_app_is_dirty(self):
|
def check_if_app_is_dirty(self):
|
||||||
@@ -1119,6 +1278,8 @@ class MainApplication(QMainWindow):
|
|||||||
data_map["fig_bytes_dict"],
|
data_map["fig_bytes_dict"],
|
||||||
data_map["contrast_results_dict"],
|
data_map["contrast_results_dict"],
|
||||||
data_map["roi_channel_map_dict"],
|
data_map["roi_channel_map_dict"],
|
||||||
|
data_map["fir_feature_dict"],
|
||||||
|
data_map["qc_dict"],
|
||||||
self.folding_bypass,
|
self.folding_bypass,
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -1137,6 +1298,19 @@ class MainApplication(QMainWindow):
|
|||||||
self.top_left_widget.paste() # Trigger paste
|
self.top_left_widget.paste() # Trigger paste
|
||||||
self.statusbar.showMessage("Pasted from clipboard") # Show status message
|
self.statusbar.showMessage("Pasted from clipboard") # Show status message
|
||||||
|
|
||||||
|
def theme_change_func(self, mode):
|
||||||
|
app = QApplication.instance()
|
||||||
|
style_hints = app.styleHints()
|
||||||
|
|
||||||
|
if mode == "auto":
|
||||||
|
style_hints.setColorScheme(Qt.ColorScheme.Unknown)
|
||||||
|
|
||||||
|
elif mode == "light":
|
||||||
|
style_hints.setColorScheme(Qt.ColorScheme.Light)
|
||||||
|
|
||||||
|
elif mode == "dark":
|
||||||
|
style_hints.setColorScheme(Qt.ColorScheme.Dark)
|
||||||
|
|
||||||
def _update_config_setting(self, group, key, value):
|
def _update_config_setting(self, group, key, value):
|
||||||
"""Helper to update memory configuration and save to disk."""
|
"""Helper to update memory configuration and save to disk."""
|
||||||
# configparser expects string values
|
# configparser expects string values
|
||||||
@@ -1205,9 +1379,14 @@ class MainApplication(QMainWindow):
|
|||||||
QMessageBox.Ok
|
QMessageBox.Ok
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def plugins_gui(self):
|
||||||
|
if self.plugins is None or not self.plugins.isVisible():
|
||||||
|
self.plugins = PluginsWindow(self, self.plugin_manager)
|
||||||
|
self.plugins.show()
|
||||||
|
|
||||||
def terminal_gui(self):
|
def terminal_gui(self):
|
||||||
if self.terminal is None or not self.terminal.isVisible():
|
if self.terminal is None or not self.terminal.isVisible():
|
||||||
self.terminal = TerminalWindow(self)
|
self.terminal = TerminalWindow(self, self.plugin_manager)
|
||||||
self.terminal.show()
|
self.terminal.show()
|
||||||
|
|
||||||
def update_optode_positions(self):
|
def update_optode_positions(self):
|
||||||
@@ -1334,7 +1513,6 @@ class MainApplication(QMainWindow):
|
|||||||
def apply_splitter_ratios(self):
|
def apply_splitter_ratios(self):
|
||||||
"""Applies saved ratio positions to main_h_splitter and left_v_splitter."""
|
"""Applies saved ratio positions to main_h_splitter and left_v_splitter."""
|
||||||
if hasattr(self, 'main_h_splitter'):
|
if hasattr(self, 'main_h_splitter'):
|
||||||
print("Splitter actual width:", self.main_h_splitter.width())
|
|
||||||
total_width = self.main_h_splitter.width()
|
total_width = self.main_h_splitter.width()
|
||||||
if total_width > 0:
|
if total_width > 0:
|
||||||
left_w = int(total_width * self.main_h_ratio)
|
left_w = int(total_width * self.main_h_ratio)
|
||||||
@@ -1532,7 +1710,7 @@ class MainApplication(QMainWindow):
|
|||||||
bubble.update_progress(step, active=False)
|
bubble.update_progress(step, active=False)
|
||||||
|
|
||||||
# Add to layout
|
# Add to layout
|
||||||
self.bubble_layout.addWidget(bubble, index, 1)
|
self.bubble_layout.addWidget(bubble, index, 0)
|
||||||
|
|
||||||
# 4. Status Bar
|
# 4. Status Bar
|
||||||
msg = f"Project loaded: {len(file_list)} files."
|
msg = f"Project loaded: {len(file_list)} files."
|
||||||
@@ -1836,7 +2014,7 @@ class MainApplication(QMainWindow):
|
|||||||
if self.button3.isVisible():
|
if self.button3.isVisible():
|
||||||
msg = QMessageBox(self)
|
msg = QMessageBox(self)
|
||||||
msg.setWindowTitle("Confirm - FLARES")
|
msg.setWindowTitle("Confirm - FLARES")
|
||||||
msg.setText("Processing new data will clear the current analysis. Continue? (If you do not want this dialog box to appear, toggle 'Analysis Clearing Bypass' from the Preferences menu.)")
|
msg.setText("Processing new data will clear the current analysis and close all other windows. Continue? (If you do not want this dialog box to appear, toggle 'Analysis Clearing Bypass' from the Preferences menu.)")
|
||||||
|
|
||||||
# Add the OK and Cancel buttons
|
# Add the OK and Cancel buttons
|
||||||
msg.setStandardButtons(QMessageBox.StandardButton.Ok | QMessageBox.StandardButton.Cancel)
|
msg.setStandardButtons(QMessageBox.StandardButton.Ok | QMessageBox.StandardButton.Cancel)
|
||||||
@@ -1852,10 +2030,17 @@ class MainApplication(QMainWindow):
|
|||||||
else:
|
else:
|
||||||
return
|
return
|
||||||
|
|
||||||
self.button3.setVisible(False)
|
self.button3.setVisible(False)
|
||||||
|
|
||||||
for item in DATA_SCHEMA:
|
for item in DATA_SCHEMA:
|
||||||
setattr(self, item["key"], {})
|
setattr(self, item["key"], {})
|
||||||
|
|
||||||
|
for bubble in self.bubble_widgets.values():
|
||||||
|
bubble.reset()
|
||||||
|
|
||||||
|
for widget in QApplication.topLevelWidgets():
|
||||||
|
if widget is not self and widget.isVisible():
|
||||||
|
widget.close()
|
||||||
|
|
||||||
self.button1.clicked.disconnect(self.on_run_task)
|
self.button1.clicked.disconnect(self.on_run_task)
|
||||||
self.button1.setText("Cancel")
|
self.button1.setText("Cancel")
|
||||||
@@ -2182,10 +2367,11 @@ class MainApplication(QMainWindow):
|
|||||||
return
|
return
|
||||||
|
|
||||||
for widget in list(QApplication.topLevelWidgets()):
|
for widget in list(QApplication.topLevelWidgets()):
|
||||||
if widget is not self:
|
if widget is not self and widget.isWindow() and not isinstance(widget, QMenu):
|
||||||
if not widget.close():
|
try:
|
||||||
event.ignore()
|
widget.close()
|
||||||
return
|
except RuntimeError:
|
||||||
|
pass
|
||||||
|
|
||||||
if hasattr(self, 'loading_session_id'):
|
if hasattr(self, 'loading_session_id'):
|
||||||
self.loading_session_id += 1
|
self.loading_session_id += 1
|
||||||
@@ -2628,12 +2814,13 @@ if __name__ == "__main__":
|
|||||||
log_path = os.path.join(os.path.dirname(sys.executable), f"../../../{APP_NAME}.log")
|
log_path = os.path.join(os.path.dirname(sys.executable), f"../../../{APP_NAME}.log")
|
||||||
cfg_path = os.path.join(os.path.dirname(sys.executable), f"../../../{APP_NAME}.cfg")
|
cfg_path = os.path.join(os.path.dirname(sys.executable), f"../../../{APP_NAME}.cfg")
|
||||||
else:
|
else:
|
||||||
log_path = os.path.join(os.getcwd(), f"{APP_NAME}.log")
|
log_path = os.path.join(get_app_dir(), f"{APP_NAME}.log")
|
||||||
cfg_path = os.path.join(os.getcwd(), f"{APP_NAME}.cfg")
|
cfg_path = os.path.join(get_app_dir(), f"{APP_NAME}.cfg")
|
||||||
try:
|
try:
|
||||||
os.remove(log_path)
|
if os.path.exists(log_path):
|
||||||
except:
|
os.remove(log_path)
|
||||||
pass
|
except Exception as e:
|
||||||
|
print(f"Warning: Could not remove old log file: {e}")
|
||||||
|
|
||||||
sys.stdout = open(log_path, "a", buffering=1)
|
sys.stdout = open(log_path, "a", buffering=1)
|
||||||
sys.stderr = sys.stdout
|
sys.stderr = sys.stdout
|
||||||
@@ -2648,13 +2835,16 @@ if __name__ == "__main__":
|
|||||||
|
|
||||||
# Only run GUI in the main process
|
# Only run GUI in the main process
|
||||||
if current_process().name == 'MainProcess':
|
if current_process().name == 'MainProcess':
|
||||||
app = QApplication(sys.argv)
|
app = CustomApplication(sys.argv)
|
||||||
finish_update_if_needed(PLATFORM_NAME, APP_NAME, cfg_path, startup_args.finish_update)
|
finish_update_if_needed(PLATFORM_NAME, APP_NAME, cfg_path, startup_args.finish_update)
|
||||||
icon_ext = "icns" if PLATFORM_NAME == "darwin" else "ico"
|
icon_ext = "icns" if PLATFORM_NAME == "darwin" else "ico"
|
||||||
app.setWindowIcon(QIcon(resource_path(f"icons/main.{icon_ext}")))
|
app.setWindowIcon(QIcon(resource_path(f"icons/main.{icon_ext}")))
|
||||||
window = MainApplication(file_to_open=startup_args.initial_file)
|
window = MainApplication(file_to_open=startup_args.initial_file)
|
||||||
|
app.theme_watcher = ThemeChangeWatcher(window)
|
||||||
|
app.installEventFilter(app.theme_watcher)
|
||||||
|
app.file_open_requested.connect(window.project_manager.load_project)
|
||||||
window.setWindowIcon(QIcon(resource_path(f"icons/main.{icon_ext}")))
|
window.setWindowIcon(QIcon(resource_path(f"icons/main.{icon_ext}")))
|
||||||
window.show()
|
window.show()
|
||||||
sys.exit(app.exec())
|
sys.exit(app.exec())
|
||||||
|
|
||||||
# Not 2600 lines yay!
|
# Not 2800 lines yay!
|
||||||
@@ -0,0 +1,712 @@
|
|||||||
|
"""
|
||||||
|
Filename: main_unit_tests.py
|
||||||
|
Description: Unit tests for functionality validation
|
||||||
|
|
||||||
|
Author: Tyler de Zeeuw
|
||||||
|
License: GPL-3.0
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Built-in imports
|
||||||
|
import os
|
||||||
|
import pickle
|
||||||
|
import configparser
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
# External library imports
|
||||||
|
import pytest
|
||||||
|
from PySide6.QtWidgets import QApplication, QMenu
|
||||||
|
|
||||||
|
import main
|
||||||
|
from updater import LocalPendingUpdateCheckThread, UpdateCheckThread
|
||||||
|
|
||||||
|
'''
|
||||||
|
These test fluff currently. Very basic "Does the UI exist?" and not the functionality.
|
||||||
|
main_test.py::test_save_project_actions_pass_correct_ask_parameter
|
||||||
|
main_test.py::test_main_window_opens
|
||||||
|
main_test.py::test_file_recent_submenus_exist
|
||||||
|
main_test.py::test_view_reset_layout
|
||||||
|
main_test.py::test_preferences_actions[2D Data Bypass-2d_data_bypass]
|
||||||
|
main_test.py::test_preferences_actions[Incompatible Save Bypass-incompatible_save_bypass]
|
||||||
|
main_test.py::test_preferences_actions[Missing Events Bypass-missing_events_bypass]
|
||||||
|
main_test.py::test_preferences_actions[Analysis Clearing Bypass-analysis_clearing_bypass]
|
||||||
|
main_test.py::test_preferences_actions[Folding Bypass-folding_bypass]
|
||||||
|
main_test.py::test_preferences_actions[Show Advanced Parameters-advanced_parameters]
|
||||||
|
'''
|
||||||
|
|
||||||
|
# ---------------------- HELPERS ----------------------
|
||||||
|
def get_menu_by_title(menu_bar, title):
|
||||||
|
"""Return the first QMenu with the given title, or None."""
|
||||||
|
for menu in menu_bar.findChildren(QMenu):
|
||||||
|
if menu.title() == title:
|
||||||
|
return menu
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------- FIXTURES ----------------------
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def disable_updater_threads():
|
||||||
|
"""Stops updater threads from running asynchronously during qtbot teardown."""
|
||||||
|
with patch.object(UpdateCheckThread, "start", return_value=None), \
|
||||||
|
patch.object(LocalPendingUpdateCheckThread, "start", return_value=None):
|
||||||
|
yield
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def setup_app_globals():
|
||||||
|
"""Initializes global configuration objects that main.py expects at runtime."""
|
||||||
|
main.cfg_path = os.path.join(os.getcwd(), f"{main.APP_NAME}.cfg")
|
||||||
|
main.file_cfg = configparser.ConfigParser()
|
||||||
|
main.ref_cfg = configparser.ConfigParser()
|
||||||
|
|
||||||
|
if hasattr(main, "config_init"):
|
||||||
|
main.config_init()
|
||||||
|
|
||||||
|
|
||||||
|
# ===================== FILE MENU =====================
|
||||||
|
def test_main_window_opens(qtbot):
|
||||||
|
"""Test 1: Verify MainApplication launches and becomes visible."""
|
||||||
|
window = main.MainApplication()
|
||||||
|
qtbot.addWidget(window)
|
||||||
|
window.show()
|
||||||
|
|
||||||
|
assert window.isVisible()
|
||||||
|
|
||||||
|
|
||||||
|
def test_open_file_dialog_with_mne_mock(qtbot, tmp_path):
|
||||||
|
dummy_snirf = tmp_path / "test_data.snirf"
|
||||||
|
dummy_snirf.write_text("dummy content")
|
||||||
|
expected_path = os.path.normpath(str(dummy_snirf))
|
||||||
|
|
||||||
|
# Mock MNE Raw object returned by read_raw_snirf
|
||||||
|
mock_raw = MagicMock()
|
||||||
|
mock_raw.info = {"meas_date": "2026-01-01", "ch_names": ["S1_D1 760"], "dig": None}
|
||||||
|
mock_raw.ch_names = ["S1_D1 760"]
|
||||||
|
mock_raw.annotations = []
|
||||||
|
|
||||||
|
window = main.MainApplication()
|
||||||
|
qtbot.addWidget(window)
|
||||||
|
window.show()
|
||||||
|
|
||||||
|
with patch("PySide6.QtWidgets.QFileDialog.getOpenFileName", return_value=(expected_path, "SNIRF Files (*.snirf)")), \
|
||||||
|
patch("mne.io.snirf.read_raw_snirf", return_value=mock_raw), \
|
||||||
|
patch("project_manager.source_detector_distances", return_value=[0.03]):
|
||||||
|
|
||||||
|
window.project_manager.open_file_dialog()
|
||||||
|
|
||||||
|
assert expected_path in window.selected_paths
|
||||||
|
assert expected_path in window.bubble_widgets
|
||||||
|
|
||||||
|
window.files_are_dirty = False
|
||||||
|
window.is_saved = True
|
||||||
|
|
||||||
|
|
||||||
|
def test_open_folder_dialog(qtbot, tmp_path):
|
||||||
|
"""Verify that open_folder_dialog recursively finds and loads all .snirf files."""
|
||||||
|
sub_dir = tmp_path / "sub_folder"
|
||||||
|
sub_dir.mkdir()
|
||||||
|
|
||||||
|
file1 = tmp_path / "root_file.snirf"
|
||||||
|
file2 = sub_dir / "nested_file.snirf"
|
||||||
|
ignored_file = tmp_path / "notes.txt"
|
||||||
|
|
||||||
|
file1.write_text("dummy snirf 1")
|
||||||
|
file2.write_text("dummy snirf 2")
|
||||||
|
ignored_file.write_text("text note")
|
||||||
|
|
||||||
|
folder_path = str(tmp_path)
|
||||||
|
expected_paths = {
|
||||||
|
os.path.normpath(str(file1)),
|
||||||
|
os.path.normpath(str(file2)),
|
||||||
|
}
|
||||||
|
|
||||||
|
window = main.MainApplication()
|
||||||
|
qtbot.addWidget(window)
|
||||||
|
window.show()
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"PySide6.QtWidgets.QFileDialog.getExistingDirectory",
|
||||||
|
return_value=folder_path,
|
||||||
|
):
|
||||||
|
window.project_manager.open_folder_dialog()
|
||||||
|
|
||||||
|
loaded_paths = set(window.selected_paths)
|
||||||
|
assert expected_paths.issubset(loaded_paths)
|
||||||
|
assert os.path.normpath(str(ignored_file)) not in loaded_paths
|
||||||
|
|
||||||
|
window.files_are_dirty = False
|
||||||
|
window.is_saved = True
|
||||||
|
|
||||||
|
|
||||||
|
def test_load_project_dialog(qtbot, tmp_path):
|
||||||
|
"""Verify loading a valid pickled .flare project restores application state."""
|
||||||
|
project_file = tmp_path / "test_project.flare"
|
||||||
|
|
||||||
|
dummy_project_data = {
|
||||||
|
"version": "1.1.7",
|
||||||
|
"file_metadata": {"rel_sample.snirf": {"channels": 4}},
|
||||||
|
"file_parameters": {"rel_sample.snirf": {"AGE": "25", "SEX": "M", "HAND": "R", "GROUP": "A"}},
|
||||||
|
"roi_channel_map_dict": {},
|
||||||
|
"file_list": ["rel_sample.snirf"],
|
||||||
|
"progress_states": {"rel_sample.snirf": "completed"},
|
||||||
|
"current_ui_params": {},
|
||||||
|
}
|
||||||
|
|
||||||
|
with open(project_file, "wb") as f:
|
||||||
|
pickle.dump(dummy_project_data, f)
|
||||||
|
|
||||||
|
file_path = str(project_file)
|
||||||
|
|
||||||
|
window = main.MainApplication()
|
||||||
|
qtbot.addWidget(window)
|
||||||
|
window.show()
|
||||||
|
|
||||||
|
if not hasattr(main, "DATA_SCHEMA"):
|
||||||
|
main.DATA_SCHEMA = []
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"PySide6.QtWidgets.QFileDialog.getOpenFileName",
|
||||||
|
return_value=(file_path, "FLARE Project (*.flare)"),
|
||||||
|
), patch("PySide6.QtWidgets.QMessageBox.information") as mock_info, patch.object(
|
||||||
|
window, "show_files_as_bubbles_from_list"
|
||||||
|
):
|
||||||
|
|
||||||
|
window.project_manager.load_project_dialog()
|
||||||
|
|
||||||
|
assert window.current_project_path == file_path
|
||||||
|
mock_info.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
|
def test_load_project_incompatible_version(qtbot, tmp_path):
|
||||||
|
"""Verify that a missing required key triggers an incompatibility error."""
|
||||||
|
invalid_file = tmp_path / "corrupt.flare"
|
||||||
|
|
||||||
|
incomplete_data = {
|
||||||
|
"file_metadata": {},
|
||||||
|
"file_parameters": {},
|
||||||
|
"roi_channel_map_dict": {},
|
||||||
|
}
|
||||||
|
|
||||||
|
with open(invalid_file, "wb") as f:
|
||||||
|
pickle.dump(incomplete_data, f)
|
||||||
|
|
||||||
|
window = main.MainApplication()
|
||||||
|
qtbot.addWidget(window)
|
||||||
|
window.show()
|
||||||
|
|
||||||
|
with patch("PySide6.QtWidgets.QMessageBox.critical") as mock_critical, \
|
||||||
|
patch("PySide6.QtWidgets.QMessageBox.warning") as mock_warning:
|
||||||
|
|
||||||
|
window.project_manager.load_project(str(invalid_file))
|
||||||
|
|
||||||
|
assert mock_critical.called or mock_warning.called, "Expected a QMessageBox warning or critical popup."
|
||||||
|
assert len(getattr(window, "selected_paths", [])) == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_save_project_no_data_shows_warning(qtbot):
|
||||||
|
"""Verify saving an empty project triggers a 'no data to save' warning."""
|
||||||
|
window = main.MainApplication()
|
||||||
|
qtbot.addWidget(window)
|
||||||
|
window.show()
|
||||||
|
|
||||||
|
with patch("PySide6.QtWidgets.QMessageBox.warning") as mock_warning:
|
||||||
|
window.project_manager.save_project(ask=True)
|
||||||
|
|
||||||
|
mock_warning.assert_called_once()
|
||||||
|
assert "no data" in mock_warning.call_args[0][2].lower()
|
||||||
|
|
||||||
|
|
||||||
|
def test_save_project_success(qtbot, tmp_path):
|
||||||
|
"""Verify saving a loaded project outputs a valid pickled .flare file."""
|
||||||
|
save_file_path = tmp_path / "test_project.flare"
|
||||||
|
dummy_snirf_path = str(tmp_path / "sample_subject.snirf")
|
||||||
|
|
||||||
|
window = main.MainApplication()
|
||||||
|
qtbot.addWidget(window)
|
||||||
|
window.show()
|
||||||
|
|
||||||
|
# 1. Satisfy 'has_files' check
|
||||||
|
window.selected_paths = [dummy_snirf_path]
|
||||||
|
|
||||||
|
# 2. Add mock bubble widget so step 4 populates file_list
|
||||||
|
mock_bubble = MagicMock()
|
||||||
|
mock_bubble.file_path = dummy_snirf_path
|
||||||
|
mock_bubble.current_step = 0
|
||||||
|
window.bubble_widgets = {dummy_snirf_path: mock_bubble}
|
||||||
|
|
||||||
|
with patch("PySide6.QtWidgets.QFileDialog.getSaveFileName", return_value=(str(save_file_path), "FLARE Project (*.flare)")), \
|
||||||
|
patch("PySide6.QtWidgets.QMessageBox.information") as mock_info:
|
||||||
|
|
||||||
|
window.project_manager.save_project(ask=True)
|
||||||
|
|
||||||
|
# Wait for SaveProjectThread to finish writing to disk
|
||||||
|
qtbot.waitUntil(lambda: save_file_path.exists(), timeout=3000)
|
||||||
|
mock_info.assert_called_once()
|
||||||
|
|
||||||
|
# 3. Verify the saved payload structure
|
||||||
|
assert save_file_path.is_file()
|
||||||
|
with open(save_file_path, "rb") as f:
|
||||||
|
data = pickle.load(f)
|
||||||
|
assert "version" in data
|
||||||
|
# file_list contains relative paths normalized by sanitize()
|
||||||
|
assert "sample_subject.snirf" in data["file_list"]
|
||||||
|
|
||||||
|
# Reset dirty state so teardown completes cleanly
|
||||||
|
window.files_are_dirty = False
|
||||||
|
window.is_saved = True
|
||||||
|
|
||||||
|
|
||||||
|
def test_save_project_actions_pass_correct_ask_parameter(qtbot):
|
||||||
|
"""
|
||||||
|
Verify that the 'Save Project...' action calls save_project(ask=False)
|
||||||
|
and 'Save Project As...' calls save_project(ask=True).
|
||||||
|
"""
|
||||||
|
window = main.MainApplication()
|
||||||
|
qtbot.addWidget(window)
|
||||||
|
window.show()
|
||||||
|
|
||||||
|
file_menu = get_menu_by_title(window.menuBar(), "File")
|
||||||
|
assert file_menu is not None, "File menu not found"
|
||||||
|
|
||||||
|
save_action = next(a for a in file_menu.actions() if a.text() == "Save Project...")
|
||||||
|
save_as_action = next(a for a in file_menu.actions() if a.text() == "Save Project As...")
|
||||||
|
|
||||||
|
with patch.object(window.project_manager, 'save_project') as mock_save:
|
||||||
|
save_action.trigger()
|
||||||
|
mock_save.assert_called_once_with(ask=False)
|
||||||
|
mock_save.reset_mock()
|
||||||
|
|
||||||
|
save_as_action.trigger()
|
||||||
|
mock_save.assert_called_once_with(ask=True)
|
||||||
|
|
||||||
|
|
||||||
|
def test_file_exit(qtbot):
|
||||||
|
"""Verify that File → Exit calls QApplication.quit()."""
|
||||||
|
with patch.object(QApplication, 'quit') as mock_quit:
|
||||||
|
window = main.MainApplication()
|
||||||
|
qtbot.addWidget(window)
|
||||||
|
window.show()
|
||||||
|
|
||||||
|
file_menu = get_menu_by_title(window.menuBar(), "File")
|
||||||
|
assert file_menu is not None, "File menu not found"
|
||||||
|
|
||||||
|
exit_action = next(a for a in file_menu.actions() if a.text() == "Exit")
|
||||||
|
exit_action.trigger()
|
||||||
|
mock_quit.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
|
def test_file_recent_submenus_exist(qtbot):
|
||||||
|
"""Verify that the 'Recent Files' and 'Recent Projects' submenus are created."""
|
||||||
|
window = main.MainApplication()
|
||||||
|
qtbot.addWidget(window)
|
||||||
|
window.show()
|
||||||
|
|
||||||
|
file_menu = get_menu_by_title(window.menuBar(), "File")
|
||||||
|
assert file_menu is not None, "File menu not found"
|
||||||
|
|
||||||
|
recent_files_action = next((a for a in file_menu.actions() if a.text() == "Recent Files"), None)
|
||||||
|
assert recent_files_action is not None
|
||||||
|
recent_files_menu = recent_files_action.menu()
|
||||||
|
assert recent_files_menu is not None
|
||||||
|
|
||||||
|
recent_projects_action = next((a for a in file_menu.actions() if a.text() == "Recent Projects"), None)
|
||||||
|
assert recent_projects_action is not None
|
||||||
|
recent_projects_menu = recent_projects_action.menu()
|
||||||
|
assert recent_projects_menu is not None
|
||||||
|
|
||||||
|
|
||||||
|
# ===================== EDIT MENU =====================
|
||||||
|
def test_edit_cut(qtbot):
|
||||||
|
"""Verify Edit → Cut calls top_left_widget.cut()."""
|
||||||
|
window = main.MainApplication()
|
||||||
|
qtbot.addWidget(window)
|
||||||
|
window.show()
|
||||||
|
|
||||||
|
edit_menu = get_menu_by_title(window.menuBar(), "Edit")
|
||||||
|
assert edit_menu is not None, "Edit menu not found"
|
||||||
|
|
||||||
|
cut_action = next(a for a in edit_menu.actions() if a.text() == "Cut")
|
||||||
|
with patch.object(window.top_left_widget, 'cut') as mock_cut:
|
||||||
|
cut_action.trigger()
|
||||||
|
mock_cut.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
|
def test_edit_copy(qtbot):
|
||||||
|
"""Verify Edit → Copy calls top_left_widget.copy()."""
|
||||||
|
window = main.MainApplication()
|
||||||
|
qtbot.addWidget(window)
|
||||||
|
window.show()
|
||||||
|
|
||||||
|
edit_menu = get_menu_by_title(window.menuBar(), "Edit")
|
||||||
|
assert edit_menu is not None, "Edit menu not found"
|
||||||
|
|
||||||
|
copy_action = next(a for a in edit_menu.actions() if a.text() == "Copy")
|
||||||
|
with patch.object(window.top_left_widget, 'copy') as mock_copy:
|
||||||
|
copy_action.trigger()
|
||||||
|
mock_copy.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
|
def test_edit_paste(qtbot):
|
||||||
|
"""Verify Edit → Paste calls top_left_widget.paste()."""
|
||||||
|
window = main.MainApplication()
|
||||||
|
qtbot.addWidget(window)
|
||||||
|
window.show()
|
||||||
|
|
||||||
|
edit_menu = get_menu_by_title(window.menuBar(), "Edit")
|
||||||
|
assert edit_menu is not None, "Edit menu not found"
|
||||||
|
|
||||||
|
paste_action = next(a for a in edit_menu.actions() if a.text() == "Paste")
|
||||||
|
with patch.object(window.top_left_widget, 'paste') as mock_paste:
|
||||||
|
paste_action.trigger()
|
||||||
|
mock_paste.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
|
# ===================== VIEW MENU =====================
|
||||||
|
def test_view_toggle_statusbar(qtbot):
|
||||||
|
"""Verify View → Toggle Status Bar toggles visibility and calls _update_config_setting."""
|
||||||
|
window = main.MainApplication()
|
||||||
|
qtbot.addWidget(window)
|
||||||
|
window.show()
|
||||||
|
|
||||||
|
view_menu = get_menu_by_title(window.menuBar(), "View")
|
||||||
|
assert view_menu is not None, "View menu not found"
|
||||||
|
|
||||||
|
toggle_action = next(a for a in view_menu.actions() if a.text() == "Toggle Status Bar")
|
||||||
|
assert toggle_action.isCheckable() is True
|
||||||
|
|
||||||
|
# Initially checked (True in create_menu_bar)
|
||||||
|
assert toggle_action.isChecked() is True
|
||||||
|
assert window.statusbar.isVisible() is True
|
||||||
|
|
||||||
|
# Trigger once to hide
|
||||||
|
with patch.object(window, '_update_config_setting') as mock_update:
|
||||||
|
toggle_action.trigger()
|
||||||
|
assert not toggle_action.isChecked()
|
||||||
|
assert not window.statusbar.isVisible()
|
||||||
|
mock_update.assert_called_once_with("View", "status_bar", False)
|
||||||
|
|
||||||
|
# Trigger again to show
|
||||||
|
with patch.object(window, '_update_config_setting') as mock_update:
|
||||||
|
toggle_action.trigger()
|
||||||
|
assert toggle_action.isChecked() is True
|
||||||
|
assert window.statusbar.isVisible() is True
|
||||||
|
mock_update.assert_called_once_with("View", "status_bar", True)
|
||||||
|
|
||||||
|
|
||||||
|
def test_view_reset_layout(qtbot):
|
||||||
|
"""Verify View → Reset Window Layout calls apply_splitter_ratios."""
|
||||||
|
window = main.MainApplication()
|
||||||
|
qtbot.addWidget(window)
|
||||||
|
window.show()
|
||||||
|
|
||||||
|
view_menu = get_menu_by_title(window.menuBar(), "View")
|
||||||
|
assert view_menu is not None, "View menu not found"
|
||||||
|
|
||||||
|
reset_action = next(a for a in view_menu.actions() if a.text() == "Reset Window Layout")
|
||||||
|
with patch.object(window, 'apply_splitter_ratios') as mock_apply:
|
||||||
|
reset_action.trigger()
|
||||||
|
mock_apply.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
|
# ===================== OPTIONS MENU =====================
|
||||||
|
def test_about_window_opens(qtbot):
|
||||||
|
"""Verify AboutWindow opens and prevents duplicate instances."""
|
||||||
|
window = main.MainApplication()
|
||||||
|
qtbot.addWidget(window)
|
||||||
|
window.show()
|
||||||
|
|
||||||
|
assert getattr(window, "about", None) is None
|
||||||
|
window.about_window()
|
||||||
|
|
||||||
|
assert window.about is not None
|
||||||
|
assert window.about.isVisible() is True
|
||||||
|
|
||||||
|
first_instance = window.about
|
||||||
|
window.about_window()
|
||||||
|
assert window.about is first_instance
|
||||||
|
|
||||||
|
|
||||||
|
def test_user_guide_window_opens(qtbot):
|
||||||
|
"""Verify UserGuideWindow opens and prevents duplicate instances."""
|
||||||
|
window = main.MainApplication()
|
||||||
|
qtbot.addWidget(window)
|
||||||
|
window.show()
|
||||||
|
|
||||||
|
assert getattr(window, "help", None) is None
|
||||||
|
window.user_guide()
|
||||||
|
|
||||||
|
assert window.help is not None
|
||||||
|
assert window.help.isVisible() is True
|
||||||
|
|
||||||
|
first_instance = window.help
|
||||||
|
window.user_guide()
|
||||||
|
assert window.help is first_instance
|
||||||
|
|
||||||
|
|
||||||
|
def test_show_update_changelog(qtbot):
|
||||||
|
"""Verify WelcomeDialog is instantiated and shown."""
|
||||||
|
window = main.MainApplication()
|
||||||
|
qtbot.addWidget(window)
|
||||||
|
window.show()
|
||||||
|
|
||||||
|
with patch.object(main, "WelcomeDialog") as mock_dialog_cls:
|
||||||
|
mock_dialog_instance = MagicMock()
|
||||||
|
mock_dialog_cls.return_value = mock_dialog_instance
|
||||||
|
|
||||||
|
window.show_update_changelog()
|
||||||
|
|
||||||
|
mock_dialog_cls.assert_called_once_with(window, direct=False)
|
||||||
|
mock_dialog_instance.show.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
|
def test_group_metadata_no_data_shows_msgbox(qtbot):
|
||||||
|
"""Verify group_metadata triggers an information QMessageBox when file_metadata is empty."""
|
||||||
|
window = main.MainApplication()
|
||||||
|
qtbot.addWidget(window)
|
||||||
|
window.show()
|
||||||
|
|
||||||
|
window.file_metadata = {}
|
||||||
|
|
||||||
|
with patch("PySide6.QtWidgets.QMessageBox.information") as mock_msgbox:
|
||||||
|
window.group_metadata()
|
||||||
|
|
||||||
|
mock_msgbox.assert_called_once()
|
||||||
|
assert "No Data" in mock_msgbox.call_args[0]
|
||||||
|
|
||||||
|
|
||||||
|
def test_group_metadata_with_data_applies_mappings(qtbot):
|
||||||
|
"""Verify group_metadata opens GroupAssignmentDialog and executes _apply_group_mappings on success."""
|
||||||
|
window = main.MainApplication()
|
||||||
|
qtbot.addWidget(window)
|
||||||
|
window.show()
|
||||||
|
|
||||||
|
window.file_metadata = {"sub-01.snirf": {"age": "25"}}
|
||||||
|
mock_result = ("Age", {"sub-01.snirf": "GroupA"})
|
||||||
|
|
||||||
|
with patch.object(main.GroupAssignmentDialog, "run", return_value=mock_result), \
|
||||||
|
patch.object(window, "_apply_group_mappings") as mock_apply:
|
||||||
|
|
||||||
|
window.group_metadata()
|
||||||
|
|
||||||
|
mock_apply.assert_called_once_with({"sub-01.snirf": "GroupA"}, field_name="Age")
|
||||||
|
|
||||||
|
|
||||||
|
def test_manual_check_for_updates(qtbot):
|
||||||
|
"""Verify Options → Check for Updates triggers the updater method."""
|
||||||
|
window = main.MainApplication()
|
||||||
|
qtbot.addWidget(window)
|
||||||
|
window.show()
|
||||||
|
|
||||||
|
options_menu = get_menu_by_title(window.menuBar(), "Options")
|
||||||
|
assert options_menu is not None, "Options menu not found"
|
||||||
|
|
||||||
|
update_action = next(a for a in options_menu.actions() if a.text() == "Check for Updates")
|
||||||
|
assert update_action is not None
|
||||||
|
assert update_action.isEnabled() is True
|
||||||
|
|
||||||
|
# Patch the updater's manual_check_for_updates method
|
||||||
|
with patch.object(window.updater, 'manual_check_for_updates') as mock_method:
|
||||||
|
update_action.trigger()
|
||||||
|
mock_method.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_optode_positions_opens(qtbot):
|
||||||
|
"""Verify UpdateOptodesWindow opens and prevents duplicate instances."""
|
||||||
|
window = main.MainApplication()
|
||||||
|
qtbot.addWidget(window)
|
||||||
|
window.show()
|
||||||
|
|
||||||
|
assert getattr(window, "optodes", None) is None
|
||||||
|
window.update_optode_positions()
|
||||||
|
|
||||||
|
assert window.optodes is not None
|
||||||
|
assert window.optodes.isVisible() is True
|
||||||
|
|
||||||
|
first_instance = window.optodes
|
||||||
|
window.update_optode_positions()
|
||||||
|
assert window.optodes is first_instance
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_event_markers_opens(qtbot):
|
||||||
|
"""Verify UpdateEventsWindow opens and prevents duplicate instances."""
|
||||||
|
window = main.MainApplication()
|
||||||
|
qtbot.addWidget(window)
|
||||||
|
window.show()
|
||||||
|
|
||||||
|
assert getattr(window, "events", None) is None
|
||||||
|
window.update_event_markers()
|
||||||
|
|
||||||
|
assert window.events is not None
|
||||||
|
assert window.events.isVisible() is True
|
||||||
|
|
||||||
|
first_instance = window.events
|
||||||
|
window.update_event_markers()
|
||||||
|
assert window.events is first_instance
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_event_markers_blazes_opens(qtbot):
|
||||||
|
"""Verify UpdateEventsBlazesWindow opens and prevents duplicate instances."""
|
||||||
|
window = main.MainApplication()
|
||||||
|
qtbot.addWidget(window)
|
||||||
|
window.show()
|
||||||
|
|
||||||
|
assert getattr(window, "events_blazes", None) is None
|
||||||
|
window.update_event_markers_blazes()
|
||||||
|
|
||||||
|
assert window.events_blazes is not None
|
||||||
|
assert window.events_blazes.isVisible() is True
|
||||||
|
|
||||||
|
first_instance = window.events_blazes
|
||||||
|
window.update_event_markers_blazes()
|
||||||
|
assert window.events_blazes is first_instance
|
||||||
|
|
||||||
|
|
||||||
|
def test_reset_to_default_configuration_user_cancels(qtbot):
|
||||||
|
"""Verify nothing is reset when the user clicks 'No' on the prompt."""
|
||||||
|
window = main.MainApplication()
|
||||||
|
qtbot.addWidget(window)
|
||||||
|
window.show()
|
||||||
|
|
||||||
|
with patch("main.QMessageBox.question", return_value=main.QMessageBox.StandardButton.No), \
|
||||||
|
patch("main.open") as mock_open, \
|
||||||
|
patch.object(window, "sync_app_with_config") as mock_sync:
|
||||||
|
|
||||||
|
window.reset_to_default_configuration()
|
||||||
|
|
||||||
|
mock_open.assert_not_called()
|
||||||
|
mock_sync.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
def test_reset_to_default_configuration_success(qtbot):
|
||||||
|
"""Verify file write, widget resets, config sync, and singleShot timer call when confirmed."""
|
||||||
|
window = main.MainApplication()
|
||||||
|
qtbot.addWidget(window)
|
||||||
|
window.show()
|
||||||
|
|
||||||
|
# Mock child ParamSection widgets
|
||||||
|
mock_section1 = MagicMock()
|
||||||
|
mock_section2 = MagicMock()
|
||||||
|
|
||||||
|
with patch("main.QMessageBox.question", return_value=main.QMessageBox.StandardButton.Yes), \
|
||||||
|
patch("main.open") as mock_open, \
|
||||||
|
patch("main.file_cfg") as mock_cfg, \
|
||||||
|
patch.object(window, "findChildren", return_value=[mock_section1, mock_section2]), \
|
||||||
|
patch.object(window, "sync_app_with_config") as mock_sync, \
|
||||||
|
patch.object(window, "update_sections") as mock_update, \
|
||||||
|
patch("main.QTimer.singleShot") as mock_timer:
|
||||||
|
|
||||||
|
window.reset_to_default_configuration()
|
||||||
|
|
||||||
|
# Check file overwrite and parser reload
|
||||||
|
mock_open.assert_called_once()
|
||||||
|
mock_cfg.read.assert_called_once_with(main.cfg_path)
|
||||||
|
|
||||||
|
# Check section UI resets and app syncing
|
||||||
|
mock_section1.reset_to_defaults.assert_called_once()
|
||||||
|
mock_section2.reset_to_defaults.assert_called_once()
|
||||||
|
mock_sync.assert_called_once()
|
||||||
|
mock_update.assert_called_once_with(0)
|
||||||
|
|
||||||
|
# Verify post-reset dialog singleShot queue
|
||||||
|
mock_timer.assert_called_once_with(100, window._show_reset_success_dialog)
|
||||||
|
|
||||||
|
|
||||||
|
def test_reset_to_default_configuration_file_error_fallback(qtbot):
|
||||||
|
"""Verify fallback to in-memory read_string when file writing raises an Exception."""
|
||||||
|
window = main.MainApplication()
|
||||||
|
qtbot.addWidget(window)
|
||||||
|
window.show()
|
||||||
|
|
||||||
|
with patch("main.QMessageBox.question", return_value=main.QMessageBox.StandardButton.Yes), \
|
||||||
|
patch("main.open", side_effect=PermissionError("Access denied")), \
|
||||||
|
patch("main.file_cfg") as mock_cfg, \
|
||||||
|
patch.object(window, "sync_app_with_config"), \
|
||||||
|
patch.object(window, "update_sections"), \
|
||||||
|
patch("main.QTimer.singleShot"):
|
||||||
|
|
||||||
|
window.reset_to_default_configuration()
|
||||||
|
|
||||||
|
# Verify fallback read_string execution
|
||||||
|
mock_cfg.read_string.assert_called_once_with(main.DEFAULT_CONFIG)
|
||||||
|
|
||||||
|
|
||||||
|
def test_show_reset_success_dialog(qtbot):
|
||||||
|
"""Verify success dialog pops up and statusbar updates."""
|
||||||
|
window = main.MainApplication()
|
||||||
|
qtbot.addWidget(window)
|
||||||
|
window.show()
|
||||||
|
|
||||||
|
window.statusbar = MagicMock()
|
||||||
|
|
||||||
|
with patch("main.QMessageBox.information") as mock_info:
|
||||||
|
window._show_reset_success_dialog()
|
||||||
|
|
||||||
|
mock_info.assert_called_once_with(
|
||||||
|
window,
|
||||||
|
"Reset Successful",
|
||||||
|
"All application settings have been successfully restored to their default values."
|
||||||
|
)
|
||||||
|
window.statusbar.showMessage.assert_called_once_with(
|
||||||
|
"All settings have been reset to their default values.", 5000
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ===================== PREFERENCES MENU =====================
|
||||||
|
@pytest.mark.parametrize("action_text, config_key", [
|
||||||
|
("2D Data Bypass", "2d_data_bypass"),
|
||||||
|
("Incompatible Save Bypass", "incompatible_save_bypass"),
|
||||||
|
("Missing Events Bypass", "missing_events_bypass"),
|
||||||
|
("Analysis Clearing Bypass", "analysis_clearing_bypass"),
|
||||||
|
("Folding Bypass", "folding_bypass"),
|
||||||
|
("Show Advanced Parameters", "advanced_parameters"),
|
||||||
|
])
|
||||||
|
def test_preferences_actions(qtbot, action_text, config_key):
|
||||||
|
"""
|
||||||
|
Verify each Preferences action toggles checked state and updates config.
|
||||||
|
Uses the current checked state as a starting point and verifies toggling.
|
||||||
|
"""
|
||||||
|
window = main.MainApplication()
|
||||||
|
qtbot.addWidget(window)
|
||||||
|
window.show()
|
||||||
|
|
||||||
|
pref_menu = get_menu_by_title(window.menuBar(), "Preferences")
|
||||||
|
assert pref_menu is not None, "Preferences menu not found"
|
||||||
|
|
||||||
|
action = next(a for a in pref_menu.actions() if a.text() == action_text)
|
||||||
|
assert action.isCheckable() is True
|
||||||
|
|
||||||
|
# Record the initial state
|
||||||
|
initial_checked = action.isChecked()
|
||||||
|
|
||||||
|
# Trigger once → state should toggle
|
||||||
|
with patch.object(window, '_update_config_setting') as mock_update:
|
||||||
|
action.trigger()
|
||||||
|
assert action.isChecked() == (not initial_checked)
|
||||||
|
mock_update.assert_called_once_with("Preferences", config_key, not initial_checked)
|
||||||
|
|
||||||
|
# Trigger again → should toggle back to initial
|
||||||
|
with patch.object(window, '_update_config_setting') as mock_update:
|
||||||
|
action.trigger()
|
||||||
|
assert action.isChecked() == initial_checked
|
||||||
|
mock_update.assert_called_once_with("Preferences", config_key, initial_checked)
|
||||||
|
|
||||||
|
|
||||||
|
# ===================== TERMINAL MENU =====================
|
||||||
|
def test_terminal_gui_opens(qtbot):
|
||||||
|
"""Verify TerminalWindow opens and prevents duplicate instances."""
|
||||||
|
window = main.MainApplication()
|
||||||
|
qtbot.addWidget(window)
|
||||||
|
window.show()
|
||||||
|
|
||||||
|
assert getattr(window, "terminal", None) is None
|
||||||
|
window.terminal_gui()
|
||||||
|
|
||||||
|
assert window.terminal is not None
|
||||||
|
assert window.terminal.isVisible() is True
|
||||||
|
|
||||||
|
first_instance = window.terminal
|
||||||
|
window.terminal_gui()
|
||||||
|
assert window.terminal is first_instance
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
pytest.main([__file__, "-v"])
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
Package Version Latest Type
|
||||||
|
------------------------- -------- ------------ -----
|
||||||
|
certifi 2026.1.4 2026.7.22 wheel
|
||||||
|
charset-normalizer 3.4.4 3.5.1 wheel
|
||||||
|
decorator 5.2.1 5.3.1 wheel
|
||||||
|
fonttools 4.61.1 4.64.0 wheel
|
||||||
|
h5py 3.15.1 3.16.0 wheel
|
||||||
|
idna 3.11 3.19 wheel
|
||||||
|
joblib 1.5.3 1.6.0 wheel
|
||||||
|
kiwisolver 1.4.9 1.5.1 wheel
|
||||||
|
lazy_loader 0.4 0.5 wheel
|
||||||
|
lxml 6.0.2 6.1.2 wheel
|
||||||
|
matplotlib 3.10.8 3.11.1 wheel
|
||||||
|
mne 1.11.0 1.12.1 wheel
|
||||||
|
mne-connectivity 0.7.0 0.9.0 wheel
|
||||||
|
neurokit2 0.2.12 0.2.13 wheel
|
||||||
|
nibabel 5.3.3 5.4.2 wheel
|
||||||
|
nilearn 0.13.0 0.14.0 wheel
|
||||||
|
numpy 2.4.1 2.5.2 wheel
|
||||||
|
packaging 25.0 26.3 wheel
|
||||||
|
pandas 2.3.3 3.0.5 wheel
|
||||||
|
patsy 1.0.2 1.0.3 wheel
|
||||||
|
pillow 12.1.0 12.3.0 wheel
|
||||||
|
pip 25.3 26.2.1 wheel
|
||||||
|
platformdirs 4.5.1 4.11.7 wheel
|
||||||
|
pooch 1.8.2 1.9.0 wheel
|
||||||
|
psutil 7.2.1 7.2.2 wheel
|
||||||
|
pyinstaller 6.19.0 6.22.2 wheel
|
||||||
|
pyinstaller-hooks-contrib 2026.3 2026.7 wheel
|
||||||
|
pyparsing 3.3.1 3.3.2 wheel
|
||||||
|
PySide6 6.10.1 6.11.2 wheel
|
||||||
|
PySide6_Addons 6.10.1 6.11.2 wheel
|
||||||
|
PySide6_Essentials 6.10.1 6.11.2 wheel
|
||||||
|
pytz 2025.2 2026.3.post1 wheel
|
||||||
|
pyvista 0.46.5 0.48.4 wheel
|
||||||
|
pyvistaqt 0.11.3 0.12.0 wheel
|
||||||
|
requests 2.32.5 2.34.2 wheel
|
||||||
|
scikit-learn 1.8.0 1.9.0 wheel
|
||||||
|
scipy 1.17.0 1.18.1 wheel
|
||||||
|
scooby 0.11.0 0.11.2 wheel
|
||||||
|
setuptools 82.0.1 84.0.0 wheel
|
||||||
|
shiboken6 6.10.1 6.11.2 wheel
|
||||||
|
statsmodels 0.14.6 0.15.0 wheel
|
||||||
|
tqdm 4.67.1 4.70.0 wheel
|
||||||
|
typing_extensions 4.15.0 4.16.0 wheel
|
||||||
|
tzdata 2025.3 2026.3 wheel
|
||||||
|
urllib3 2.6.3 2.7.0 wheel
|
||||||
|
vtk 9.5.2 9.7.0 wheel
|
||||||
|
xarray 2026.1.0 2026.7.0 wheel
|
||||||
@@ -0,0 +1,331 @@
|
|||||||
|
"""
|
||||||
|
Filename: plugin_manager.py
|
||||||
|
Description: Manager file for anything plugin related
|
||||||
|
Note: Compliant with pylance strict type checking
|
||||||
|
|
||||||
|
Author: Tyler de Zeeuw
|
||||||
|
License: GPL-3.0
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Built-in imports
|
||||||
|
import io
|
||||||
|
import ssl
|
||||||
|
import sys
|
||||||
|
import json
|
||||||
|
import shutil
|
||||||
|
import zipfile
|
||||||
|
import platform
|
||||||
|
import urllib.request
|
||||||
|
import importlib.util
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, cast
|
||||||
|
|
||||||
|
# External library imports
|
||||||
|
import certifi
|
||||||
|
from PySide6.QtCore import QObject, QUrl, Signal
|
||||||
|
from PySide6.QtGui import QDesktopServices
|
||||||
|
from PySide6.QtWidgets import QMainWindow, QMenu, QMessageBox
|
||||||
|
|
||||||
|
from src.shared.shareddata import APP_NAME, CURRENT_VERSION, PLATFORM_NAME
|
||||||
|
|
||||||
|
|
||||||
|
def get_ssl_context() -> ssl.SSLContext:
|
||||||
|
"""Returns a cross-platform SSL context configured with certifi's CA bundle."""
|
||||||
|
return ssl.create_default_context(cafile=certifi.where())
|
||||||
|
|
||||||
|
|
||||||
|
def get_current_platform_id() -> str:
|
||||||
|
"""Returns standardized platform identifier (win_x64, darwin_arm64, etc.)."""
|
||||||
|
sys_name = sys.platform
|
||||||
|
arch = platform.machine().lower()
|
||||||
|
|
||||||
|
if sys_name == "win32":
|
||||||
|
return "win_x64" if "64" in arch or "amd64" in arch else "win_x86"
|
||||||
|
elif sys_name == "darwin":
|
||||||
|
return "darwin_arm64" if "arm" in arch or "aarch64" in arch else "darwin_x64"
|
||||||
|
elif sys_name.startswith("linux"):
|
||||||
|
return "linux_x64" if "64" in arch else "linux_x86"
|
||||||
|
return sys_name
|
||||||
|
|
||||||
|
|
||||||
|
def parse_version(v_str: str) -> tuple[int, ...]:
|
||||||
|
"""Parses a version string into a comparable integer tuple."""
|
||||||
|
try:
|
||||||
|
return tuple(int(x) for x in v_str.strip().lstrip("v").split("."))
|
||||||
|
except ValueError:
|
||||||
|
return (0, 0, 0)
|
||||||
|
|
||||||
|
|
||||||
|
class PluginManager(QObject):
|
||||||
|
"""
|
||||||
|
Handles plugin discovery, dynamic loading, menu building,
|
||||||
|
and plugin lifecycle (install, toggle, uninstall).
|
||||||
|
"""
|
||||||
|
|
||||||
|
plugins_changed = Signal()
|
||||||
|
|
||||||
|
def __init__(self, main_window: QMainWindow) -> None:
|
||||||
|
super().__init__()
|
||||||
|
self.main_window: QMainWindow = main_window
|
||||||
|
self.plugins_dir: Path = self._resolve_plugins_dir()
|
||||||
|
self.loaded_plugins: list[Any] = []
|
||||||
|
self.current_platform: str = get_current_platform_id()
|
||||||
|
self.current_app_version: tuple[int, ...] = parse_version(str(CURRENT_VERSION))
|
||||||
|
|
||||||
|
def _resolve_plugins_dir(self) -> Path:
|
||||||
|
"""Determines the local plugins directory based on execution context."""
|
||||||
|
if PLATFORM_NAME == "darwin":
|
||||||
|
base_dir = Path(sys.executable).parent / "../../.."
|
||||||
|
else:
|
||||||
|
base_dir = Path.cwd()
|
||||||
|
|
||||||
|
plugins_path = (base_dir / "plugins").resolve()
|
||||||
|
plugins_path.mkdir(parents=True, exist_ok=True)
|
||||||
|
return plugins_path
|
||||||
|
|
||||||
|
|
||||||
|
def reload_plugins(self) -> list[Any]:
|
||||||
|
"""Scans directory, imports enabled plugins, and updates the main application menu."""
|
||||||
|
self.unload_plugins()
|
||||||
|
|
||||||
|
plugins_dir_str = str(self.plugins_dir)
|
||||||
|
if plugins_dir_str not in sys.path:
|
||||||
|
sys.path.insert(0, plugins_dir_str)
|
||||||
|
|
||||||
|
failed_plugins: list[tuple[str, str]] = []
|
||||||
|
|
||||||
|
for entry in sorted(self.plugins_dir.iterdir(), key=lambda p: p.name.lower()):
|
||||||
|
if entry.name.startswith((".", "__")) or entry.name.endswith(".disabled"):
|
||||||
|
continue
|
||||||
|
|
||||||
|
module_name = ""
|
||||||
|
init_file: Path | None = None
|
||||||
|
|
||||||
|
if entry.is_dir():
|
||||||
|
init_file = entry / "__init__.py"
|
||||||
|
module_name = entry.name
|
||||||
|
elif entry.is_file() and entry.suffix == ".py":
|
||||||
|
init_file = entry
|
||||||
|
module_name = entry.stem
|
||||||
|
|
||||||
|
if not init_file or not init_file.exists():
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
instance = self._import_and_instantiate(module_name, init_file)
|
||||||
|
if instance is not None:
|
||||||
|
self.loaded_plugins.append(instance)
|
||||||
|
except Exception as e:
|
||||||
|
failed_plugins.append((module_name, str(e)))
|
||||||
|
|
||||||
|
# Report failures after all plugins have attempted to load
|
||||||
|
if failed_plugins:
|
||||||
|
error_details = "\n".join(f"• {name}: {err}" for name, err in failed_plugins)
|
||||||
|
summary_msg = f"One or more plugins failed to load:\n\n{error_details}"
|
||||||
|
|
||||||
|
print(f"[PluginManager] {summary_msg}")
|
||||||
|
|
||||||
|
parent_widget = getattr(self, "main_window", None)
|
||||||
|
QMessageBox.warning(
|
||||||
|
parent_widget,
|
||||||
|
"Plugin Load Failures",
|
||||||
|
summary_msg
|
||||||
|
)
|
||||||
|
|
||||||
|
self.build_plugins_menu()
|
||||||
|
self.plugins_changed.emit()
|
||||||
|
return self.loaded_plugins
|
||||||
|
|
||||||
|
def _import_and_instantiate(self, module_name: str, file_path: Path) -> Any | None:
|
||||||
|
"""Dynamically imports a plugin module, attaches its metadata, and returns the Plugin instance."""
|
||||||
|
spec = importlib.util.spec_from_file_location(module_name, file_path)
|
||||||
|
if spec is None or spec.loader is None:
|
||||||
|
raise ImportError(f"Invalid or missing spec for file: {file_path}")
|
||||||
|
|
||||||
|
module = importlib.util.module_from_spec(spec)
|
||||||
|
sys.modules[module_name] = module
|
||||||
|
spec.loader.exec_module(module)
|
||||||
|
|
||||||
|
if hasattr(module, "Plugin") and isinstance(module.Plugin, type):
|
||||||
|
instance = module.Plugin(self.main_window)
|
||||||
|
|
||||||
|
# Attach manifest metadata directly to the plugin instance if plugin.json exists
|
||||||
|
manifest_path = file_path.parent / "plugin.json"
|
||||||
|
if manifest_path.exists():
|
||||||
|
try:
|
||||||
|
with open(manifest_path, "r", encoding="utf-8") as f:
|
||||||
|
instance.metadata = json.load(f)
|
||||||
|
# Override class name property if specified in manifest
|
||||||
|
if "name" in instance.metadata:
|
||||||
|
instance.name = instance.metadata["name"]
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[PluginManager] Could not attach metadata to '{module_name}': {e}")
|
||||||
|
|
||||||
|
return instance
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
def build_plugins_menu(self) -> None:
|
||||||
|
"""Constructs or refreshes submenus under the top-level 'Plugins' menu item."""
|
||||||
|
menubar = self.main_window.menuBar()
|
||||||
|
|
||||||
|
# Dynamically locate the existing 'Plugins' menu on the menubar
|
||||||
|
plugins_menu: QMenu | None = None
|
||||||
|
for action in menubar.actions():
|
||||||
|
clean_text = action.text().replace("&", "").strip().lower()
|
||||||
|
if clean_text == "plugins":
|
||||||
|
menu = action.menu()
|
||||||
|
if isinstance(menu, QMenu):
|
||||||
|
plugins_menu = menu
|
||||||
|
break
|
||||||
|
|
||||||
|
# If it doesn't exist yet, create it
|
||||||
|
if plugins_menu is None:
|
||||||
|
plugins_menu = menubar.addMenu("&Plugins")
|
||||||
|
|
||||||
|
plugins_menu.clear()
|
||||||
|
|
||||||
|
# 1. Populate loaded plugin submenus
|
||||||
|
if not self.loaded_plugins:
|
||||||
|
disabled_action = plugins_menu.addAction("No active plugins")
|
||||||
|
disabled_action.setEnabled(False)
|
||||||
|
else:
|
||||||
|
for plugin in self.loaded_plugins:
|
||||||
|
plugin_name = getattr(plugin, "name", "Unnamed Plugin")
|
||||||
|
plugin_submenu = plugins_menu.addMenu(plugin_name)
|
||||||
|
|
||||||
|
if hasattr(plugin, "register_menu") and callable(plugin.register_menu):
|
||||||
|
plugin.register_menu(plugin_submenu)
|
||||||
|
|
||||||
|
# 2. Append entry to open Plugin Manager GUI
|
||||||
|
plugins_menu.addSeparator()
|
||||||
|
manager_action = plugins_menu.addAction("Manage Plugins...")
|
||||||
|
|
||||||
|
plugins_gui_func = getattr(self.main_window, "plugins_gui", None)
|
||||||
|
if callable(plugins_gui_func):
|
||||||
|
manager_action.triggered.connect(plugins_gui_func)
|
||||||
|
|
||||||
|
def unload_plugins(self) -> None:
|
||||||
|
"""Clears current active plugin instances."""
|
||||||
|
self.loaded_plugins.clear()
|
||||||
|
|
||||||
|
def get_installed_plugins_info(self) -> list[dict[str, Any]]:
|
||||||
|
"""Returns metadata for all local plugins in the plugins folder by reading their plugin.json."""
|
||||||
|
results: list[dict[str, Any]] = []
|
||||||
|
|
||||||
|
for entry in sorted(self.plugins_dir.iterdir(), key=lambda p: p.name.lower()):
|
||||||
|
if entry.name.startswith((".", "__")):
|
||||||
|
continue
|
||||||
|
|
||||||
|
is_disabled = entry.name.endswith(".disabled")
|
||||||
|
clean_name = entry.name[:-9] if is_disabled else entry.name
|
||||||
|
|
||||||
|
if entry.is_dir() or (entry.is_file() and (entry.suffix == ".py" or entry.name.endswith(".py.disabled"))):
|
||||||
|
if entry.is_file() and entry.stem in ("__init__", "__init__.py"):
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Load local plugin.json if present
|
||||||
|
manifest: dict[str, Any] = {}
|
||||||
|
manifest_path = (entry / "plugin.json") if entry.is_dir() else None
|
||||||
|
|
||||||
|
if manifest_path and manifest_path.exists():
|
||||||
|
try:
|
||||||
|
with open(manifest_path, "r", encoding="utf-8") as f:
|
||||||
|
manifest = json.load(f)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[PluginManager] Warning: Failed to read manifest for '{entry.name}': {e}")
|
||||||
|
|
||||||
|
display_name = manifest.get("name", clean_name.removesuffix(".py"))
|
||||||
|
version = manifest.get("version", "1.0.0")
|
||||||
|
description = manifest.get("description", "")
|
||||||
|
author = manifest.get("author", "Unknown")
|
||||||
|
|
||||||
|
results.append({
|
||||||
|
"id": manifest.get("id", clean_name),
|
||||||
|
"name": display_name,
|
||||||
|
"version": version,
|
||||||
|
"description": description,
|
||||||
|
"author": author,
|
||||||
|
"path": entry,
|
||||||
|
"is_disabled": is_disabled,
|
||||||
|
"manifest": manifest,
|
||||||
|
})
|
||||||
|
|
||||||
|
return results
|
||||||
|
|
||||||
|
def toggle_plugin_state(self, plugin_path: Path) -> None:
|
||||||
|
"""Swaps a plugin between enabled and disabled by renaming with/without '.disabled'."""
|
||||||
|
if not plugin_path.exists():
|
||||||
|
return
|
||||||
|
|
||||||
|
if plugin_path.name.endswith(".disabled"):
|
||||||
|
new_path = plugin_path.with_name(plugin_path.name.removesuffix(".disabled"))
|
||||||
|
else:
|
||||||
|
new_path = plugin_path.with_name(f"{plugin_path.name}.disabled")
|
||||||
|
|
||||||
|
plugin_path.rename(new_path)
|
||||||
|
self.reload_plugins()
|
||||||
|
|
||||||
|
def uninstall_plugin(self, plugin_path: Path) -> None:
|
||||||
|
"""Deletes a plugin directory or file from disk."""
|
||||||
|
if not plugin_path.exists():
|
||||||
|
return
|
||||||
|
|
||||||
|
if plugin_path.is_dir():
|
||||||
|
shutil.rmtree(plugin_path)
|
||||||
|
else:
|
||||||
|
plugin_path.unlink()
|
||||||
|
|
||||||
|
self.reload_plugins()
|
||||||
|
|
||||||
|
def fetch_remote_repositories(self, repo_urls: list[str]) -> tuple[list[dict[str, Any]], bool]:
|
||||||
|
"""Fetches remote plugin metadata from repository URLs."""
|
||||||
|
aggregated: list[dict[str, Any]] = []
|
||||||
|
failed_count = 0
|
||||||
|
|
||||||
|
for url in repo_urls:
|
||||||
|
try:
|
||||||
|
req = urllib.request.Request(
|
||||||
|
url,
|
||||||
|
headers={"User-Agent": f"{APP_NAME}-PluginManager"},
|
||||||
|
)
|
||||||
|
with urllib.request.urlopen(req, timeout=5, context=get_ssl_context()) as response:
|
||||||
|
if response.status == 200:
|
||||||
|
raw_data = response.read().decode("utf-8")
|
||||||
|
data = json.loads(raw_data)
|
||||||
|
if isinstance(data, list):
|
||||||
|
aggregated.extend(cast(list[Any], data))
|
||||||
|
else:
|
||||||
|
failed_count += 1
|
||||||
|
except Exception:
|
||||||
|
failed_count += 1
|
||||||
|
|
||||||
|
all_failed = failed_count == len(repo_urls) and len(repo_urls) > 0
|
||||||
|
return aggregated, all_failed
|
||||||
|
|
||||||
|
def install_plugin_from_url(self, download_url: str, plugin_id: str = "") -> Path:
|
||||||
|
"""
|
||||||
|
Downloads a single-folder ZIP archive and extracts it directly into plugins/.
|
||||||
|
"""
|
||||||
|
req = urllib.request.Request(
|
||||||
|
download_url,
|
||||||
|
headers={"User-Agent": f"{APP_NAME}-PluginManager"},
|
||||||
|
)
|
||||||
|
|
||||||
|
with urllib.request.urlopen(req, timeout=15, context=get_ssl_context()) as response:
|
||||||
|
if response.status != 200:
|
||||||
|
raise RuntimeError(f"Download failed with HTTP status code {response.status}")
|
||||||
|
|
||||||
|
zip_bytes = response.read()
|
||||||
|
|
||||||
|
with zipfile.ZipFile(io.BytesIO(zip_bytes)) as zip_ref:
|
||||||
|
zip_ref.extractall(self.plugins_dir)
|
||||||
|
|
||||||
|
self.reload_plugins()
|
||||||
|
return self.plugins_dir
|
||||||
|
|
||||||
|
def open_plugins_directory(self) -> bool:
|
||||||
|
"""Opens the local plugins folder in the operating system's file manager."""
|
||||||
|
if self.plugins_dir.exists():
|
||||||
|
return QDesktopServices.openUrl(QUrl.fromLocalFile(str(self.plugins_dir)))
|
||||||
|
return False
|
||||||
+151
-43
@@ -6,40 +6,48 @@ Author: Tyler de Zeeuw
|
|||||||
License: GPL-3.0
|
License: GPL-3.0
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
# Built-in imports
|
# Built-in imports
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
import copy
|
import copy
|
||||||
import pickle
|
import pickle
|
||||||
import concurrent
|
import concurrent
|
||||||
|
import configparser
|
||||||
|
import concurrent.futures
|
||||||
from pathlib import Path, PurePosixPath
|
from pathlib import Path, PurePosixPath
|
||||||
|
from typing import TYPE_CHECKING, Any, List, Optional, Union
|
||||||
|
|
||||||
# External library imports
|
# External library imports
|
||||||
import pandas as pd
|
import pandas as pd
|
||||||
|
|
||||||
from PySide6.QtWidgets import QMessageBox, QVBoxLayout, QFileDialog, QLabel, QDialog
|
from PySide6.QtWidgets import QMessageBox, QVBoxLayout, QFileDialog, QLabel, QDialog, QWidget
|
||||||
from PySide6.QtCore import QThread, Signal, Qt, QTimer
|
from PySide6.QtCore import QThread, Signal, Qt, QTimer
|
||||||
from PySide6.QtGui import QAction
|
from PySide6.QtGui import QAction
|
||||||
|
|
||||||
from mne.io import read_raw_snirf
|
from mne.io import read_raw_snirf # type: ignore
|
||||||
from mne.preprocessing.nirs import source_detector_distances
|
from mne.preprocessing.nirs import source_detector_distances # type: ignore
|
||||||
from mne_nirs.channels import get_short_channels # type: ignore
|
from mne_nirs.channels import get_short_channels # type: ignore
|
||||||
|
|
||||||
from src.shared.flaresbasewidget import ProgressBubble
|
from src.shared.flaresbasewidget import ProgressBubble
|
||||||
from src.shared.shareddata import APP_NAME, CURRENT_VERSION, PLATFORM_NAME, DATA_SCHEMA
|
from src.shared.shareddata import APP_NAME, CURRENT_VERSION, PLATFORM_NAME, DATA_SCHEMA
|
||||||
|
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from main import MainApplication
|
||||||
|
|
||||||
|
|
||||||
class SaveProjectThread(QThread):
|
class SaveProjectThread(QThread):
|
||||||
finished_signal = Signal(str)
|
finished_signal = Signal(str)
|
||||||
error_signal = Signal(str)
|
error_signal = Signal(str)
|
||||||
|
|
||||||
def __init__(self, filename, project_data):
|
def __init__(self, filename: str, project_data: dict[str, Any]) -> None:
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self.filename = filename
|
self.filename = filename
|
||||||
self.project_data = project_data
|
self.project_data = project_data
|
||||||
|
|
||||||
def run(self):
|
def run(self) -> None:
|
||||||
try:
|
try:
|
||||||
with open(self.filename, "wb") as f:
|
with open(self.filename, "wb") as f:
|
||||||
pickle.dump(self.project_data, f)
|
pickle.dump(self.project_data, f)
|
||||||
@@ -50,7 +58,7 @@ class SaveProjectThread(QThread):
|
|||||||
|
|
||||||
|
|
||||||
class SavingOverlay(QDialog):
|
class SavingOverlay(QDialog):
|
||||||
def __init__(self, parent=None):
|
def __init__(self, parent: Optional[QWidget] = None) -> None:
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
self.setWindowFlags(Qt.WindowType.Dialog | Qt.WindowType.FramelessWindowHint)
|
self.setWindowFlags(Qt.WindowType.Dialog | Qt.WindowType.FramelessWindowHint)
|
||||||
self.setModal(True)
|
self.setModal(True)
|
||||||
@@ -75,7 +83,11 @@ class ProjectManager:
|
|||||||
- State baseline synchronization (dirty tracking)
|
- State baseline synchronization (dirty tracking)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, app, file_cfg, cfg_path):
|
def __init__(self,
|
||||||
|
app: MainApplication,
|
||||||
|
file_cfg: configparser.ConfigParser,
|
||||||
|
cfg_path: str,
|
||||||
|
) -> None:
|
||||||
self.app = app
|
self.app = app
|
||||||
self.file_cfg = file_cfg
|
self.file_cfg = file_cfg
|
||||||
self.cfg_path = cfg_path
|
self.cfg_path = cfg_path
|
||||||
@@ -83,7 +95,7 @@ class ProjectManager:
|
|||||||
# =========================================================================
|
# =========================================================================
|
||||||
# Path Utilities
|
# Path Utilities
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
def get_safe_path(self, target_path, project_dir):
|
def get_safe_path(self, target_path: Union[str, Path], project_dir: Union[str, Path]) -> str:
|
||||||
"""Converts an absolute file path to a relative path relative to project_dir."""
|
"""Converts an absolute file path to a relative path relative to project_dir."""
|
||||||
try:
|
try:
|
||||||
target = Path(target_path).resolve()
|
target = Path(target_path).resolve()
|
||||||
@@ -96,22 +108,117 @@ class ProjectManager:
|
|||||||
# =========================================================================
|
# =========================================================================
|
||||||
# File & Folder Opening Dialogs
|
# File & Folder Opening Dialogs
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
def open_file_dialog(self):
|
|
||||||
|
def is_valid_snirf(self, path: str) -> bool:
|
||||||
|
"""Fast header check to verify HDF5/SNIRF signature and ignore corrupt/shadow files."""
|
||||||
|
try:
|
||||||
|
if os.path.getsize(path) < 8:
|
||||||
|
return False
|
||||||
|
with open(path, "rb") as f:
|
||||||
|
return f.read(8) == b"\x89HDF\r\n\x1a\n"
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
def open_file_dialog(self) -> None:
|
||||||
"""Opens dialog to pick a single .snirf file."""
|
"""Opens dialog to pick a single .snirf file."""
|
||||||
file_path, _ = QFileDialog.getOpenFileName(
|
file_path, _ = QFileDialog.getOpenFileName(
|
||||||
self.app, "Open File", "", "SNIRF Files (*.snirf);;All Files (*)"
|
self.app, "Open File", "", "SNIRF Files (*.snirf);;All Files (*)"
|
||||||
)
|
)
|
||||||
if file_path:
|
if not file_path:
|
||||||
self._load_files_into_pipeline([os.path.normpath(file_path)])
|
return
|
||||||
|
|
||||||
def open_folder_dialog(self):
|
norm_path = os.path.normpath(file_path)
|
||||||
|
filename = os.path.basename(norm_path)
|
||||||
|
|
||||||
|
# 1. Check specifically for macOS shadow files
|
||||||
|
if filename.startswith("._"):
|
||||||
|
QMessageBox.warning(
|
||||||
|
self.app,
|
||||||
|
"Invalid SNIRF File",
|
||||||
|
f"'{filename}' is a macOS system shadow file (Apple Double resource fork), not a actual data file."
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
# 2. Check for general header validity / corruption
|
||||||
|
if not self.is_valid_snirf(norm_path):
|
||||||
|
QMessageBox.warning(
|
||||||
|
self.app,
|
||||||
|
"Invalid SNIRF File",
|
||||||
|
f"'{filename}' is not a valid SNIRF file or has a corrupted header."
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
self._load_files_into_pipeline([norm_path])
|
||||||
|
|
||||||
|
def open_folder_dialog(self)-> None:
|
||||||
"""Recursively finds all .snirf files in a selected directory."""
|
"""Recursively finds all .snirf files in a selected directory."""
|
||||||
folder_path = QFileDialog.getExistingDirectory(self.app, "Select Folder", "")
|
folder_path = QFileDialog.getExistingDirectory(self.app, "Select Folder", "")
|
||||||
if folder_path:
|
if not folder_path:
|
||||||
snirf_files = [os.path.normpath(str(f)) for f in Path(folder_path).rglob("*.snirf")]
|
return
|
||||||
self._load_files_into_pipeline(snirf_files)
|
|
||||||
|
|
||||||
def _load_files_into_pipeline(self, file_paths):
|
# Automatically filter out shadow files and invalid headers in batch mode
|
||||||
|
snirf_files = [
|
||||||
|
os.path.normpath(str(f))
|
||||||
|
for f in Path(folder_path).rglob("*.snirf")
|
||||||
|
if self.is_valid_snirf(str(f))
|
||||||
|
]
|
||||||
|
|
||||||
|
if not snirf_files:
|
||||||
|
QMessageBox.information(
|
||||||
|
self.app,
|
||||||
|
"No Valid Files",
|
||||||
|
"No valid .snirf files were found in the selected directory."
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
def load_dropped_files(self, file_paths: List[str]) -> None:
|
||||||
|
"""Public entry point for handling files or folders dropped onto the UI."""
|
||||||
|
if not file_paths:
|
||||||
|
return
|
||||||
|
|
||||||
|
# 1. Single file drop: Give explicit feedback like open_file_dialog
|
||||||
|
if len(file_paths) == 1:
|
||||||
|
norm_path = os.path.normpath(file_paths[0])
|
||||||
|
filename = os.path.basename(norm_path)
|
||||||
|
|
||||||
|
if filename.startswith("._"):
|
||||||
|
QMessageBox.warning(
|
||||||
|
self.app,
|
||||||
|
"Invalid SNIRF File",
|
||||||
|
f"'{filename}' is a macOS system shadow file (Apple Double resource fork), not an actual data file."
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
if not self.is_valid_snirf(norm_path):
|
||||||
|
QMessageBox.warning(
|
||||||
|
self.app,
|
||||||
|
"Invalid SNIRF File",
|
||||||
|
f"'{filename}' is not a valid SNIRF file or has a corrupted header."
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
valid_files = [norm_path]
|
||||||
|
|
||||||
|
# 2. Batch drop: Silently filter out shadow/corrupt files like open_folder_dialog
|
||||||
|
else:
|
||||||
|
valid_files = [
|
||||||
|
os.path.normpath(p)
|
||||||
|
for p in file_paths
|
||||||
|
if self.is_valid_snirf(os.path.normpath(p))
|
||||||
|
]
|
||||||
|
|
||||||
|
if not valid_files:
|
||||||
|
QMessageBox.information(
|
||||||
|
self.app,
|
||||||
|
"No Valid Files",
|
||||||
|
"None of the dropped items were valid .snirf files."
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Delegate to internal pipeline
|
||||||
|
self._load_files_into_pipeline(valid_files)
|
||||||
|
|
||||||
|
def _load_files_into_pipeline(self, file_paths: List[str]) -> None:
|
||||||
"""Loads .snirf files into UI using chunked batches and background workers."""
|
"""Loads .snirf files into UI using chunked batches and background workers."""
|
||||||
app = self.app
|
app = self.app
|
||||||
if not file_paths:
|
if not file_paths:
|
||||||
@@ -124,7 +231,6 @@ class ProjectManager:
|
|||||||
# 2. Track this session to prevent ghost updates
|
# 2. Track this session to prevent ghost updates
|
||||||
if not hasattr(app, "loading_session_id"):
|
if not hasattr(app, "loading_session_id"):
|
||||||
app.loading_session_id = 0
|
app.loading_session_id = 0
|
||||||
app.loading_session_id += 1
|
|
||||||
current_session = app.loading_session_id
|
current_session = app.loading_session_id
|
||||||
|
|
||||||
# 3. Setup internal tracking if not exists
|
# 3. Setup internal tracking if not exists
|
||||||
@@ -135,8 +241,11 @@ class ProjectManager:
|
|||||||
if not hasattr(app, "metadata_cache"):
|
if not hasattr(app, "metadata_cache"):
|
||||||
app.metadata_cache = {}
|
app.metadata_cache = {}
|
||||||
|
|
||||||
# Filter out files already in the UI to avoid duplicates
|
# Filter out duplicates AND non-SNIRF files (including macOS ._ shadow files)
|
||||||
new_files = [p for p in file_paths if p not in app.selected_paths]
|
new_files = [
|
||||||
|
p for p in file_paths
|
||||||
|
if p not in app.selected_paths and self.is_valid_snirf(p)
|
||||||
|
]
|
||||||
if not new_files:
|
if not new_files:
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -151,7 +260,7 @@ class ProjectManager:
|
|||||||
# Queue chunked widget creation
|
# Queue chunked widget creation
|
||||||
CHUNK_SIZE = 10
|
CHUNK_SIZE = 10
|
||||||
|
|
||||||
def process_chunk(file_queue):
|
def process_chunk(file_queue: List[str]) -> None:
|
||||||
chunk = file_queue[:CHUNK_SIZE]
|
chunk = file_queue[:CHUNK_SIZE]
|
||||||
remaining = file_queue[CHUNK_SIZE:]
|
remaining = file_queue[CHUNK_SIZE:]
|
||||||
|
|
||||||
@@ -187,7 +296,7 @@ class ProjectManager:
|
|||||||
|
|
||||||
process_chunk(new_files)
|
process_chunk(new_files)
|
||||||
|
|
||||||
def add_files_to_project(self, file_paths):
|
def add_files_to_project(self, file_paths: List[str]) -> None:
|
||||||
"""Adds file paths to the application state, creating bubble UI items."""
|
"""Adds file paths to the application state, creating bubble UI items."""
|
||||||
app = self.app
|
app = self.app
|
||||||
normalized_paths = [os.path.normpath(p) for p in file_paths]
|
normalized_paths = [os.path.normpath(p) for p in file_paths]
|
||||||
@@ -220,7 +329,7 @@ class ProjectManager:
|
|||||||
# =========================================================================
|
# =========================================================================
|
||||||
# Project Loading
|
# Project Loading
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
def load_project_dialog(self):
|
def load_project_dialog(self) -> None:
|
||||||
"""Prompts for a project file and loads it."""
|
"""Prompts for a project file and loads it."""
|
||||||
app = self.app
|
app = self.app
|
||||||
filename, _ = QFileDialog.getOpenFileName(
|
filename, _ = QFileDialog.getOpenFileName(
|
||||||
@@ -229,7 +338,7 @@ class ProjectManager:
|
|||||||
if filename:
|
if filename:
|
||||||
self.load_project(filename)
|
self.load_project(filename)
|
||||||
|
|
||||||
def load_project(self, filename):
|
def load_project(self, filename: str) -> None:
|
||||||
"""Loads a .flare project file into the application."""
|
"""Loads a .flare project file into the application."""
|
||||||
app = self.app
|
app = self.app
|
||||||
try:
|
try:
|
||||||
@@ -325,7 +434,7 @@ class ProjectManager:
|
|||||||
|
|
||||||
has_data = any(len(getattr(app, item["key"], {})) > 0 for item in DATA_SCHEMA)
|
has_data = any(len(getattr(app, item["key"], {})) > 0 for item in DATA_SCHEMA)
|
||||||
if hasattr(app, "button1"):
|
if hasattr(app, "button1"):
|
||||||
app.button1.setVisible(not has_data)
|
app.button1.setVisible(has_data)
|
||||||
if hasattr(app, "button3"):
|
if hasattr(app, "button3"):
|
||||||
app.button3.setVisible(has_data)
|
app.button3.setVisible(has_data)
|
||||||
|
|
||||||
@@ -343,7 +452,7 @@ class ProjectManager:
|
|||||||
# =========================================================================
|
# =========================================================================
|
||||||
# Project Saving (Save / Save As)
|
# Project Saving (Save / Save As)
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
def save_project(self, onCrash=False, ask=False):
|
def save_project(self, onCrash: bool = False, ask: bool = False) -> None:
|
||||||
"""
|
"""
|
||||||
Saves the project to disk.
|
Saves the project to disk.
|
||||||
- ask=False: Quick Save to self.app.current_project_path (prompts if unsaved).
|
- ask=False: Quick Save to self.app.current_project_path (prompts if unsaved).
|
||||||
@@ -436,7 +545,7 @@ class ProjectManager:
|
|||||||
current_params = app.config_dict[first_file]
|
current_params = app.config_dict[first_file]
|
||||||
|
|
||||||
# 5. Build Serialized Payload
|
# 5. Build Serialized Payload
|
||||||
project_data = {
|
project_data: dict[str, Any] = {
|
||||||
item["key"]: getattr(app, item["key"], {}) for item in DATA_SCHEMA
|
item["key"]: getattr(app, item["key"], {}) for item in DATA_SCHEMA
|
||||||
}
|
}
|
||||||
project_data.update({
|
project_data.update({
|
||||||
@@ -448,7 +557,7 @@ class ProjectManager:
|
|||||||
"current_ui_params": current_params,
|
"current_ui_params": current_params,
|
||||||
})
|
})
|
||||||
|
|
||||||
def sanitize(obj):
|
def sanitize(obj: Any) -> Any:
|
||||||
if isinstance(obj, Path):
|
if isinstance(obj, Path):
|
||||||
return str(PurePosixPath(obj))
|
return str(PurePosixPath(obj))
|
||||||
elif isinstance(obj, dict):
|
elif isinstance(obj, dict):
|
||||||
@@ -469,7 +578,7 @@ class ProjectManager:
|
|||||||
|
|
||||||
app.save_thread = SaveProjectThread(filename, project_data)
|
app.save_thread = SaveProjectThread(filename, project_data)
|
||||||
|
|
||||||
def _on_save_success(saved_file):
|
def _on_save_success(saved_file: str) -> None:
|
||||||
if hasattr(app, "saving_overlay"):
|
if hasattr(app, "saving_overlay"):
|
||||||
app.saving_overlay.close()
|
app.saving_overlay.close()
|
||||||
|
|
||||||
@@ -483,7 +592,7 @@ class ProjectManager:
|
|||||||
app, "Success", f"Project saved to:\n{saved_file}"
|
app, "Success", f"Project saved to:\n{saved_file}"
|
||||||
)
|
)
|
||||||
|
|
||||||
def _on_save_error(error_msg):
|
def _on_save_error(error_msg: str) -> None:
|
||||||
if hasattr(app, "saving_overlay"):
|
if hasattr(app, "saving_overlay"):
|
||||||
app.saving_overlay.close()
|
app.saving_overlay.close()
|
||||||
if not onCrash:
|
if not onCrash:
|
||||||
@@ -500,7 +609,7 @@ class ProjectManager:
|
|||||||
QMessageBox.critical(app, "Error", f"Failed to save project:\n{e}")
|
QMessageBox.critical(app, "Error", f"Failed to save project:\n{e}")
|
||||||
|
|
||||||
|
|
||||||
def update_recent_projects_menu(self):
|
def update_recent_projects_menu(self) -> None:
|
||||||
"""Clears and rebuilds the Recent Projects submenu items."""
|
"""Clears and rebuilds the Recent Projects submenu items."""
|
||||||
app = self.app
|
app = self.app
|
||||||
if not hasattr(app, "recent_projects_menu"):
|
if not hasattr(app, "recent_projects_menu"):
|
||||||
@@ -526,7 +635,7 @@ class ProjectManager:
|
|||||||
)
|
)
|
||||||
app.recent_projects_menu.addAction(action)
|
app.recent_projects_menu.addAction(action)
|
||||||
|
|
||||||
def add_to_recent_projects(self, project_path):
|
def add_to_recent_projects(self, project_path: str) -> None:
|
||||||
"""Adds a project path, moves it to the top, and hard caps at 10."""
|
"""Adds a project path, moves it to the top, and hard caps at 10."""
|
||||||
raw_projects = self.file_cfg.get("File", "recent_projects", fallback="")
|
raw_projects = self.file_cfg.get("File", "recent_projects", fallback="")
|
||||||
projects = [p.strip() for p in raw_projects.split(",") if p.strip()]
|
projects = [p.strip() for p in raw_projects.split(",") if p.strip()]
|
||||||
@@ -546,7 +655,7 @@ class ProjectManager:
|
|||||||
|
|
||||||
self.update_recent_projects_menu()
|
self.update_recent_projects_menu()
|
||||||
|
|
||||||
def open_recent_project(self, project_path):
|
def open_recent_project(self, project_path: str) -> None:
|
||||||
"""The slot that executes when a recent project entry is clicked."""
|
"""The slot that executes when a recent project entry is clicked."""
|
||||||
if os.path.exists(project_path):
|
if os.path.exists(project_path):
|
||||||
print(f"Opening recent project: {project_path}")
|
print(f"Opening recent project: {project_path}")
|
||||||
@@ -577,7 +686,7 @@ class ProjectManager:
|
|||||||
# =========================================================================
|
# =========================================================================
|
||||||
# Recent Files Operations
|
# Recent Files Operations
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
def update_recent_files_menu(self):
|
def update_recent_files_menu(self) -> None:
|
||||||
"""Clears and rebuilds the Recent Files submenu items."""
|
"""Clears and rebuilds the Recent Files submenu items."""
|
||||||
app = self.app
|
app = self.app
|
||||||
if not hasattr(app, "recent_files_menu"):
|
if not hasattr(app, "recent_files_menu"):
|
||||||
@@ -600,7 +709,7 @@ class ProjectManager:
|
|||||||
)
|
)
|
||||||
app.recent_files_menu.addAction(action)
|
app.recent_files_menu.addAction(action)
|
||||||
|
|
||||||
def add_to_recent_files(self, file_path):
|
def add_to_recent_files(self, file_path: str) -> None:
|
||||||
"""Adds a path, moves it to the top, and hard caps the list at 10."""
|
"""Adds a path, moves it to the top, and hard caps the list at 10."""
|
||||||
raw_files = self.file_cfg.get("File", "recent_files", fallback="")
|
raw_files = self.file_cfg.get("File", "recent_files", fallback="")
|
||||||
files = [f.strip() for f in raw_files.split(",") if f.strip()]
|
files = [f.strip() for f in raw_files.split(",") if f.strip()]
|
||||||
@@ -620,7 +729,7 @@ class ProjectManager:
|
|||||||
|
|
||||||
self.update_recent_files_menu()
|
self.update_recent_files_menu()
|
||||||
|
|
||||||
def open_recent_file(self, file_path):
|
def open_recent_file(self, file_path: str) -> None:
|
||||||
"""The slot that executes when someone clicks a recent file entry."""
|
"""The slot that executes when someone clicks a recent file entry."""
|
||||||
if os.path.exists(file_path):
|
if os.path.exists(file_path):
|
||||||
print(f"Opening recent file: {file_path}")
|
print(f"Opening recent file: {file_path}")
|
||||||
@@ -647,11 +756,11 @@ class ProjectManager:
|
|||||||
# =========================================================================
|
# =========================================================================
|
||||||
# Baseline Synchronization & Dirty Checks
|
# Baseline Synchronization & Dirty Checks
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
def sync_metadata_baseline(self):
|
def sync_metadata_baseline(self) -> None:
|
||||||
"""Captures current file_metadata state as baseline."""
|
"""Captures current file_metadata state as baseline."""
|
||||||
self.app.saved_file_metadata = copy.deepcopy(getattr(self.app, "file_metadata", {}))
|
self.app.saved_file_metadata = copy.deepcopy(getattr(self.app, "file_metadata", {}))
|
||||||
|
|
||||||
def is_metadata_dirty(self):
|
def is_metadata_dirty(self) -> bool:
|
||||||
"""Returns True if file metadata has been modified relative to saved baseline."""
|
"""Returns True if file metadata has been modified relative to saved baseline."""
|
||||||
current_meta = getattr(self.app, "file_metadata", {})
|
current_meta = getattr(self.app, "file_metadata", {})
|
||||||
saved_meta = getattr(self.app, "saved_file_metadata", {})
|
saved_meta = getattr(self.app, "saved_file_metadata", {})
|
||||||
@@ -671,7 +780,7 @@ class ProjectManager:
|
|||||||
|
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def reset_all_dirty_states(self):
|
def reset_all_dirty_states(self) -> None:
|
||||||
"""Resets parameter, file, and metadata baselines after load/save."""
|
"""Resets parameter, file, and metadata baselines after load/save."""
|
||||||
# 1. Sync file list baseline
|
# 1. Sync file list baseline
|
||||||
self.app.saved_selected_paths = copy.deepcopy(getattr(self.app, "selected_paths", []))
|
self.app.saved_selected_paths = copy.deepcopy(getattr(self.app, "selected_paths", []))
|
||||||
@@ -692,7 +801,6 @@ class ProjectManager:
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def _get_bids_demographics(snirf_path: str) -> dict[str, str]:
|
def _get_bids_demographics(snirf_path: str) -> dict[str, str]:
|
||||||
"""Traverses the path of a SNIRF file to extract age/sex/hand from BIDS TSV files.
|
"""Traverses the path of a SNIRF file to extract age/sex/hand from BIDS TSV files.
|
||||||
'hand' is only included if a value is present and isn't 'n/a' (case-insensitive) -
|
'hand' is only included if a value is present and isn't 'n/a' (case-insensitive) -
|
||||||
@@ -708,8 +816,8 @@ def _get_bids_demographics(snirf_path: str) -> dict[str, str]:
|
|||||||
if not sub_id:
|
if not sub_id:
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
def _row_to_dict(row) -> dict[str, str]:
|
def _row_to_dict(row: pd.Series) -> dict[str, str]:
|
||||||
result = {}
|
result: dict[str, str] = {}
|
||||||
for field in fields:
|
for field in fields:
|
||||||
if field not in row:
|
if field not in row:
|
||||||
continue
|
continue
|
||||||
@@ -756,7 +864,7 @@ def _get_bids_demographics(snirf_path: str) -> dict[str, str]:
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
def extract_metadata_worker(file_name):
|
def extract_metadata_worker(file_name: str) -> dict[str, Any]:
|
||||||
"""Runs in the separate worker process. Returns a clean dict."""
|
"""Runs in the separate worker process. Returns a clean dict."""
|
||||||
|
|
||||||
# 1. Use preload=False! We only need metadata.
|
# 1. Use preload=False! We only need metadata.
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
src\analysis\participantfoldchannels.py 84
|
||||||
|
src\shared\flaresbasewidget.py 1155
|
||||||
|
flares.py 2900
|
||||||
|
main_unit_tests.py 152
|
||||||
|
main.py 732
|
||||||
|
project_manager.py 109
|
||||||
Binary file not shown.
@@ -1,157 +0,0 @@
|
|||||||
"""
|
|
||||||
Filename: crossgroupbrainimage.py
|
|
||||||
Description: Logic for the Cross-Group Brain & Image analysis window
|
|
||||||
Note: Compliant with pylance strict type checking
|
|
||||||
|
|
||||||
Author: Tyler de Zeeuw
|
|
||||||
License: GPL-3.0
|
|
||||||
"""
|
|
||||||
|
|
||||||
# Built-in Imports
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Any, cast
|
|
||||||
|
|
||||||
# External library imports
|
|
||||||
from mne.io.base import BaseRaw
|
|
||||||
|
|
||||||
import pandas as pd
|
|
||||||
from pandas import DataFrame
|
|
||||||
|
|
||||||
from flares import aggregate_fnirs_group_geometry, plot_2d_3d_contrasts_between_groups
|
|
||||||
from src.shared.flaresbasewidget import CrossGroupUIMixin, FlaresBaseWidget
|
|
||||||
from src.shared.shareddata import APP_NAME
|
|
||||||
|
|
||||||
|
|
||||||
PARAMETERIZED_INDEXES: dict[int, list[dict[str, Any]]] = {
|
|
||||||
0: [
|
|
||||||
{
|
|
||||||
"key": "show_optodes",
|
|
||||||
"label": "Determine what is rendered above the brain. Valid values are 'sensors', 'labels', 'none', 'all'.",
|
|
||||||
"default": "all",
|
|
||||||
"type": str,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"key": "t_or_theta",
|
|
||||||
"label": "Specify if t values or theta values should be plotted. Valid values are 't', 'theta'",
|
|
||||||
"default": "theta",
|
|
||||||
"type": str,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"key": "show_text",
|
|
||||||
"label": "Display informative text on the top left corner about the contrast.",
|
|
||||||
"default": "True",
|
|
||||||
"type": bool,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"key": "brain_bounds",
|
|
||||||
"label": "Graph Upper/Lower Limit",
|
|
||||||
"default": "1.0",
|
|
||||||
"type": float,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"key": "is_3d",
|
|
||||||
"label": "Should we display the results in a 3D interactive window?",
|
|
||||||
"default": "True",
|
|
||||||
"type": bool,
|
|
||||||
}
|
|
||||||
],
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
class CrossGroupBrainImageWidget(CrossGroupUIMixin, FlaresBaseWidget):
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
haemo_dict: dict[str | Path, BaseRaw],
|
|
||||||
df_ind_dict: dict[str, DataFrame],
|
|
||||||
design_matrix_dict: dict[str, DataFrame],
|
|
||||||
contrast_results_dict: dict[str, dict[str, Any]],
|
|
||||||
group_dict: dict[str, str],
|
|
||||||
) -> None:
|
|
||||||
|
|
||||||
super().__init__("CrossGroupBrainImage")
|
|
||||||
self.setWindowTitle(f"Cross-Group Brain & Image Viewer - {APP_NAME.upper()}")
|
|
||||||
self.haemo_dict = haemo_dict
|
|
||||||
self.df_ind_dict = df_ind_dict
|
|
||||||
self.design_matrix_dict = design_matrix_dict
|
|
||||||
self.contrast_results_dict = contrast_results_dict
|
|
||||||
self.group_dict = group_dict
|
|
||||||
|
|
||||||
self.setup_cross_group_ui(["0 (Contrast Image)"])
|
|
||||||
|
|
||||||
|
|
||||||
def process_request(self):
|
|
||||||
|
|
||||||
request = self.get_common_request_data(PARAMETERIZED_INDEXES)
|
|
||||||
if request is None:
|
|
||||||
return
|
|
||||||
|
|
||||||
(selected_event, file_paths_a, file_paths_b, all_selected_paths, selected_indexes, raw_params) = request
|
|
||||||
|
|
||||||
param_values = cast(dict[int | str, dict[str, Any]], raw_params)
|
|
||||||
|
|
||||||
# Build group-level contrast DataFrames
|
|
||||||
def concat_group_contrasts(file_paths: list[str], event: str | None) -> pd.DataFrame:
|
|
||||||
group_df = pd.DataFrame()
|
|
||||||
for fp in file_paths:
|
|
||||||
print(f"Looking up contrast for: {fp}")
|
|
||||||
event_con_dict = self.contrast_results_dict.get(fp, {})
|
|
||||||
print("Available events for this file:", list(event_con_dict.keys()))
|
|
||||||
if event and event in event_con_dict:
|
|
||||||
df = event_con_dict[event]
|
|
||||||
print(f"Appending contrast df for event: {event}")
|
|
||||||
group_df = pd.concat([group_df, df], ignore_index=True)
|
|
||||||
else:
|
|
||||||
print(f"Event '{event}' not found for {fp}")
|
|
||||||
return group_df
|
|
||||||
|
|
||||||
print("Selected event:", selected_event)
|
|
||||||
print("File paths A:", file_paths_a)
|
|
||||||
print("File paths B:", file_paths_b)
|
|
||||||
|
|
||||||
contrast_df_a = concat_group_contrasts(file_paths_a, selected_event)
|
|
||||||
contrast_df_b = concat_group_contrasts(file_paths_b, selected_event)
|
|
||||||
|
|
||||||
print("contrast_df_a empty?", contrast_df_a.empty)
|
|
||||||
print("contrast_df_b empty?", contrast_df_b.empty)
|
|
||||||
|
|
||||||
all_raw_objs = [self.haemo_dict.get(fp) for fp in all_selected_paths if self.haemo_dict.get(fp)]
|
|
||||||
|
|
||||||
if len(all_raw_objs) > 1:
|
|
||||||
processed_raw = aggregate_fnirs_group_geometry(all_raw_objs)
|
|
||||||
elif len(all_raw_objs) == 1 and all_raw_objs[0] is not None:
|
|
||||||
processed_raw = all_raw_objs[0].copy()
|
|
||||||
processed_raw.pick(picks="hbo") # type: ignore
|
|
||||||
else:
|
|
||||||
processed_raw = None
|
|
||||||
|
|
||||||
# Visualizations
|
|
||||||
for idx in selected_indexes:
|
|
||||||
if idx == 0:
|
|
||||||
params = param_values.get(idx, {})
|
|
||||||
show_optodes = params.get("show_optodes", None)
|
|
||||||
t_or_theta = params.get("t_or_theta", None)
|
|
||||||
show_text = params.get("show_text", None)
|
|
||||||
brain_bounds = params.get("brain_bounds", None)
|
|
||||||
is_3d = params.get("is_3d", None)
|
|
||||||
|
|
||||||
if show_optodes is None or t_or_theta is None or show_text is None or brain_bounds is None or is_3d is None:
|
|
||||||
print(f"Missing parameters for index {idx}, skipping.")
|
|
||||||
continue
|
|
||||||
|
|
||||||
if not contrast_df_a.empty and not contrast_df_b.empty and processed_raw:
|
|
||||||
|
|
||||||
plot_2d_3d_contrasts_between_groups(
|
|
||||||
contrast_df_a,
|
|
||||||
contrast_df_b,
|
|
||||||
raw_haemo=processed_raw,
|
|
||||||
group_a_name=self.group_a_dropdown.currentText(),
|
|
||||||
group_b_name=self.group_b_dropdown.currentText(),
|
|
||||||
is_3d=is_3d,
|
|
||||||
t_or_theta=t_or_theta,
|
|
||||||
show_optodes=show_optodes,
|
|
||||||
show_text=show_text,
|
|
||||||
brain_bounds=brain_bounds
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
print(f"No method defined for index {idx}")
|
|
||||||
@@ -1,356 +0,0 @@
|
|||||||
"""
|
|
||||||
Filename: crossgroupstats.py
|
|
||||||
Description: Cross-Group stats analysis window
|
|
||||||
Note: Compliant with pylance strict type checking
|
|
||||||
|
|
||||||
Author: Tyler de Zeeuw
|
|
||||||
License: GPL-3.0
|
|
||||||
"""
|
|
||||||
|
|
||||||
# Built-in imports
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Any, cast
|
|
||||||
|
|
||||||
# External library imports
|
|
||||||
import pandas as pd
|
|
||||||
from pandas import DataFrame
|
|
||||||
|
|
||||||
from mne.io.base import BaseRaw
|
|
||||||
|
|
||||||
from flares import run_cross_group_contrast_analysis, run_cross_group_laterality_analysis, run_cross_group_second_level_analysis
|
|
||||||
from src.shared.flaresbasewidget import CrossGroupUIMixin, FlaresBaseWidget
|
|
||||||
from src.shared.shareddata import APP_NAME
|
|
||||||
|
|
||||||
|
|
||||||
PARAMETERIZED_INDEXES: dict[int, list[dict[str, Any]]] = {
|
|
||||||
0: [
|
|
||||||
{
|
|
||||||
"key": "p_threshold",
|
|
||||||
"label": "Significance threshold P-value (e.g. 0.05)",
|
|
||||||
"default": "0.05",
|
|
||||||
"type": float,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"key": "min_subjects",
|
|
||||||
"label": "Minimum number of participants to process",
|
|
||||||
"default": "3",
|
|
||||||
"type": int,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"key": "correction_method",
|
|
||||||
"label": "Correction method to utilize. Valid values are 'fdr_bh', 'None'",
|
|
||||||
"default": "fdr_bh",
|
|
||||||
"type": str,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"key": "target_chroma",
|
|
||||||
"label": "Which chroma to target. Valid values are 'hbo', 'hbr'",
|
|
||||||
"default": "hbo",
|
|
||||||
"type": str,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"key": "threshold_topo",
|
|
||||||
"label": "threshold_topo: TBD",
|
|
||||||
"default": False,
|
|
||||||
"type": bool,
|
|
||||||
}
|
|
||||||
],
|
|
||||||
1: [
|
|
||||||
{
|
|
||||||
"key": "p_threshold",
|
|
||||||
"label": "Significance threshold P-value (e.g. 0.05)",
|
|
||||||
"default": "0.05",
|
|
||||||
"type": float,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"key": "min_subjects",
|
|
||||||
"label": "Minimum number of participants to process",
|
|
||||||
"default": "3",
|
|
||||||
"type": int,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"key": "correction_method",
|
|
||||||
"label": "Correction method to utilize. Valid values are 'fdr_bh', 'None'",
|
|
||||||
"default": "None",
|
|
||||||
"type": str,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"key": "target_chroma",
|
|
||||||
"label": "Which chroma to target. Valid values are 'hbo', 'hbr'",
|
|
||||||
"default": "hbo",
|
|
||||||
"type": str,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"key": "roi_a",
|
|
||||||
"label": "ROI A (e.g. contralateral region name from regions.json)",
|
|
||||||
"default": [],
|
|
||||||
"type": list,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"key": "roi_b",
|
|
||||||
"label": "ROI B (e.g. ipsilateral region name from regions.json)",
|
|
||||||
"default": [],
|
|
||||||
"type": list,
|
|
||||||
}
|
|
||||||
],
|
|
||||||
2: [
|
|
||||||
{
|
|
||||||
"key": "p_value",
|
|
||||||
"label": "Significance threshold P-value (e.g. 0.05)",
|
|
||||||
"default": "0.05",
|
|
||||||
"type": float,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"key": "min_subjects",
|
|
||||||
"label": "Minimum number of participants to process",
|
|
||||||
"default": "3",
|
|
||||||
"type": int,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"key": "correction_method",
|
|
||||||
"label": "Correction method to utilize. Valid values are 'fdr_bh', 'None'",
|
|
||||||
"default": "fdr_bh",
|
|
||||||
"type": str,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"key": "target_chroma",
|
|
||||||
"label": "Which chroma to target. Valid values are 'hbo', 'hbr'",
|
|
||||||
"default": "hbo",
|
|
||||||
"type": str,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"key": "contrast_name",
|
|
||||||
"label": "Name of the contrast to use",
|
|
||||||
"default": [],
|
|
||||||
"type": list,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
DESCRIPTION = """0. Raw ROI Comparison (run_cross_group_second_level_analysis)
|
|
||||||
\nCompares one ROI's raw response magnitude between two independent groups (e.g. control vs. target) for a given condition, using Welch's t-test. A significant result means the two populations differ in this ROI's response magnitude for this condition. It does not tell you whether that difference is a real, localized, task-specific effect or a generic between-population difference - different overall vascular reactivity, arousal, or skull/scalp optical properties can produce the exact same statistical signature, and two independently recruited groups (especially patients vs. healthy controls) are considerably more likely to differ this way than two subsets of one study population.
|
|
||||||
\nIf you expected a group difference and didn't find one, the most common cause is within-group heterogeneity swallowing a real between-group difference - a "target" population (e.g. a clinical group) is often more variable than a tightly-screened control group, and that added within-group variance directly weakens a between-group t-test even if the group means truly differ. Small per-group sample sizes compound this. It's also possible the true difference between your groups isn't in raw magnitude at all, but in spatial specificity or task-differentiation - which is exactly why the laterality and contrast-comparison methods exist alongside this one; a null result here doesn't rule those out.
|
|
||||||
\n\n1. Laterality Comparison (run_cross_group_laterality_analysis)
|
|
||||||
\nComputes each subject's own contralateral-minus-ipsilateral laterality index first, then compares those indices between the two groups with Welch's t-test. A significant result means the degree of spatial specificity/lateralization differs between the two populations - a claim about lateralization itself, harder to explain away as a generic population confound since person-level differences in overall reactivity largely cancel before the group comparison happens. It says nothing about overall response magnitude between groups (a group could have identical laterality but very different raw amplitude), and it only uses subjects who have both the contra and ipsi ROI valid, so it can lose subjects the raw-ROI comparison would have kept.
|
|
||||||
\nNon-significance here has two likely sources, and it's worth distinguishing them. First, the same covariance issue from the within-group paired test applies across a whole group: if contra/ipsi responses aren't well-correlated within subjects, the laterality index itself is noisier than either ROI alone, and that added noise now has to clear a between-group test on top of it - a double power cost at small N. Second, and more informative if true: the groups may genuinely have similar lateralization but differ in overall magnitude instead, in which case this test correctly returns null while method 4 (raw comparison) should be the one to look at.
|
|
||||||
\n\n2. Contrast Comparison (run_cross_group_contrast_analysis)
|
|
||||||
\nCompares a jointly-fit task contrast (e.g. Task A minus Task B, estimated together within each subject's GLM), aggregated to ROI level, between two independent groups. A significant result means one group differentiates between the two tasks more or less than the other does, at this specific ROI - with systemic noise cancelled at the model-fitting stage, the same benefit that makes the within-group version of this method the strongest of that trio. As with the within-group version, it does not by itself say where a difference is localized unless you compare sign/pattern across multiple ROIs - opposite-signed group differences across regions point to something spatially specific, same-signed differences everywhere point to a diffuse/non-specific group difference (e.g. one group simply has stronger contrast responses across the whole head).
|
|
||||||
\nIf this comes back non-significant despite an expected group difference, check first whether the underlying single-subject contrast estimates are noisy for either group - small per-group N means the joint contrast's precision depends on the same limited subject count as everything else, and a noisy input propagates all the way through the ROI aggregation. It's also possible for a real, localized sub-regional effect to get washed out by ROI averaging itself: if only part of an ROI's channels actually show the group difference while others don't, the inverse-variance-weighted average can dilute it toward null - in that case, a finer-grained ROI definition (splitting the region further) may recover the effect that a coarser ROI averaged away. Finally, FDR correction across every ROI tested reduces power exactly as it does everywhere else in this framework - a real but modest effect can fail to survive correction even when the raw p-value would have looked convincing on its own.
|
|
||||||
\n\n
|
|
||||||
\nWhy channels needed to be aggregated into ROIs: Testing every channel independently means paying a steep multiple-comparisons tax - with dozens of channels, FDR/Bonferroni correction demands very large effect sizes to call anything significant, and at small subject counts (n=5) essentially nothing survives even when a real, consistent effect exists. Collapsing channels into a handful of anatomically meaningful ROIs cuts the number of independent tests from a minimum of ~40 down to 2-8, which lets a genuinely present effect actually clear correction. It also matches the scientific question better: you have a hypothesis about regions (contralateral motor cortex, prefrontal cortex), not about individual source-detector pairs, so testing at the ROI level is testing the thing you actually believe in, using inverse-variance weighting so noisier channels contribute less to the region's combined estimate rather than diluting it equally.
|
|
||||||
\nWhy some analyses needed contrasts instead of raw values: A single condition's GLM beta is only ever measured relative to the model's implicit intercept, and that intercept absorbs whatever's happening for the rest of the recording - including systemic physiology (blood pressure, arousal, general vascular reactivity) that rises during almost any active task, not just the one you care about. Testing a raw "vs. zero" value can't tell a real, localized neural response apart from that shared full-head noise. A contrast - either a within-subject spatial subtraction (ROI A minus ROI B) or a jointly-fit task contrast (Condition A minus Condition B, estimated together in one GLM) cancels out whatever's common to both halves of the subtraction, leaving something closer to the actual differential signal.
|
|
||||||
\nWhy a minimum subject count is enforced: Every one of these tests is a t-test, and a t-test's ability to detect a real effect (its power) depends heavily on degrees of freedom - at n=5 (df=4), even a fairly large true effect can produce a middling p-value, and at n=2 (df=1) the test is barely meaningful at all regardless of the underlying data. The min_subjects floor exists to stop a channel or ROI from being silently tested (and potentially reported as significant or non-significant) on a sample too small for the resulting p-value to mean anything reliable - it's better to explicitly skip and flag an underpowered channel than to quietly produce a number that looks statistically legitimate but isn't backed by enough independent observations to trust."""
|
|
||||||
|
|
||||||
|
|
||||||
class CrossGroupStatsWidget(CrossGroupUIMixin, FlaresBaseWidget):
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
haemo_dict: dict[str | Path, BaseRaw],
|
|
||||||
cha_dict: dict[str, DataFrame],
|
|
||||||
df_ind_dict: dict[str, DataFrame],
|
|
||||||
design_matrix_dict: dict[str, DataFrame],
|
|
||||||
contrast_results_dict: dict[str, dict[str, Any]],
|
|
||||||
roi_channel_map_dict: dict[str, dict[str, str]],
|
|
||||||
group_dict: dict[str, str],
|
|
||||||
) -> None:
|
|
||||||
|
|
||||||
super().__init__("CrossGroupStats")
|
|
||||||
self.setWindowTitle(f"Cross-Group Stats Viewer - {APP_NAME.upper()}")
|
|
||||||
self.haemo_dict = haemo_dict
|
|
||||||
self.cha_dict = cha_dict
|
|
||||||
self.df_ind_dict = df_ind_dict
|
|
||||||
self.design_matrix_dict = design_matrix_dict
|
|
||||||
self.contrast_results_dict = contrast_results_dict
|
|
||||||
self.roi_channel_map_dict = roi_channel_map_dict
|
|
||||||
self.group_dict = group_dict
|
|
||||||
|
|
||||||
self.setup_cross_group_ui(["0 (Raw ROI Comparison)", "1 (Laterality Comparison)", "2 (Contrast Comparison)",], placeholder_text=DESCRIPTION)
|
|
||||||
|
|
||||||
|
|
||||||
def process_request(self):
|
|
||||||
request = self.get_common_request_data(PARAMETERIZED_INDEXES, self.df_ind_dict, self.contrast_results_dict)
|
|
||||||
if request is None:
|
|
||||||
return
|
|
||||||
|
|
||||||
(selected_event, file_paths_a, file_paths_b, _, selected_indexes, raw_params) = request
|
|
||||||
|
|
||||||
param_values = cast(dict[int | str, dict[str, Any]], raw_params)
|
|
||||||
|
|
||||||
valid_dfs = [df for df in self.df_ind_dict.values() if not df.empty]
|
|
||||||
if valid_dfs:
|
|
||||||
df_ind_combined = pd.concat(valid_dfs, ignore_index=True)
|
|
||||||
else:
|
|
||||||
df_ind_combined = pd.DataFrame()
|
|
||||||
|
|
||||||
valid_chas = [df for df in self.cha_dict.values() if not df.empty]
|
|
||||||
cha_combined = pd.concat(valid_chas, ignore_index=True) if valid_chas else pd.DataFrame()
|
|
||||||
|
|
||||||
sample_path = file_paths_a[0]
|
|
||||||
p_haemo = self.haemo_dict.get(sample_path)
|
|
||||||
|
|
||||||
# Visualizations
|
|
||||||
for idx in selected_indexes:
|
|
||||||
if idx == 0:
|
|
||||||
params = param_values.get(idx, {})
|
|
||||||
p_threshold = params.get("p_threshold", 0.05)
|
|
||||||
min_subjects = params.get("min_subjects", 3)
|
|
||||||
correction_method = params.get("correction_method", "fdr_bh")
|
|
||||||
target_chroma = params.get("target_chroma", "hbo")
|
|
||||||
threshold_topo = params.get("threshold_topo", False)
|
|
||||||
|
|
||||||
selected_roi_maps = {
|
|
||||||
fp: self.roi_channel_map_dict[fp]
|
|
||||||
for fp in (file_paths_a + file_paths_b)
|
|
||||||
if fp in self.roi_channel_map_dict
|
|
||||||
}
|
|
||||||
|
|
||||||
run_cross_group_second_level_analysis(
|
|
||||||
df_roi_all=df_ind_combined, # Individual stats dataframe
|
|
||||||
file_paths_a=file_paths_a,
|
|
||||||
file_paths_b=file_paths_b,
|
|
||||||
group_a_name=self.group_a_dropdown.currentText(),
|
|
||||||
group_b_name=self.group_b_dropdown.currentText(),
|
|
||||||
df_cha_all=cha_combined,
|
|
||||||
raw_haemo=p_haemo,
|
|
||||||
p_threshold=p_threshold,
|
|
||||||
min_subjects=min_subjects,
|
|
||||||
correction_method=correction_method,
|
|
||||||
target_chroma=target_chroma,
|
|
||||||
selected_event=selected_event,
|
|
||||||
roi_channel_maps=selected_roi_maps,
|
|
||||||
threshold_topo=threshold_topo # Shows the raw difference map (Unthresholded)
|
|
||||||
)
|
|
||||||
elif idx == 1:
|
|
||||||
if not selected_event:
|
|
||||||
print("Laterality comparison requires a specific event/condition "
|
|
||||||
"to be selected first.")
|
|
||||||
continue
|
|
||||||
|
|
||||||
params = param_values.get(idx, {})
|
|
||||||
p_threshold = params.get("p_threshold", 0.05)
|
|
||||||
min_subjects = params.get("min_subjects", 3)
|
|
||||||
correction_method = params.get("correction_method", "None")
|
|
||||||
target_chroma = params.get("target_chroma", "hbo")
|
|
||||||
roi_a: str = params.get("roi_a", "").strip()
|
|
||||||
roi_b: str = params.get("roi_b", "").strip()
|
|
||||||
|
|
||||||
if not roi_a or not roi_b:
|
|
||||||
print("Both a contralateral and ipsilateral ROI name must be specified.")
|
|
||||||
continue
|
|
||||||
|
|
||||||
if correction_method == "None":
|
|
||||||
correction_method = None
|
|
||||||
|
|
||||||
# Build each group's dataframe directly from the dict using
|
|
||||||
# the file-path lists as keys - no ID cleaning/matching needed.
|
|
||||||
def _build_group_df(
|
|
||||||
file_paths: list[str],
|
|
||||||
dict_source: dict[str, DataFrame]
|
|
||||||
) -> DataFrame:
|
|
||||||
|
|
||||||
valid_dfs = [
|
|
||||||
dict_source[fp] for fp in file_paths
|
|
||||||
if fp in dict_source and not dict_source[fp].empty
|
|
||||||
]
|
|
||||||
|
|
||||||
return pd.concat(valid_dfs, ignore_index=True) if valid_dfs else pd.DataFrame()
|
|
||||||
|
|
||||||
|
|
||||||
df_roi_a = _build_group_df(file_paths_a, self.df_ind_dict)
|
|
||||||
df_roi_b = _build_group_df(file_paths_b, self.df_ind_dict)
|
|
||||||
|
|
||||||
if df_roi_a.empty or df_roi_b.empty:
|
|
||||||
print("No ROI data (df_ind) found for one or both groups.")
|
|
||||||
continue
|
|
||||||
|
|
||||||
run_cross_group_laterality_analysis(
|
|
||||||
df_roi_all_a=df_roi_a,
|
|
||||||
df_roi_all_b=df_roi_b,
|
|
||||||
roi_pairs=(roi_a, roi_b),
|
|
||||||
condition=selected_event,
|
|
||||||
group_a_name=self.group_a_dropdown.currentText(),
|
|
||||||
group_b_name=self.group_b_dropdown.currentText(),
|
|
||||||
target_chroma=target_chroma,
|
|
||||||
min_subjects=min_subjects,
|
|
||||||
p_threshold=p_threshold,
|
|
||||||
correction_method=correction_method,
|
|
||||||
roi_contra_label=roi_a,
|
|
||||||
roi_ipsi_label=roi_b,
|
|
||||||
)
|
|
||||||
|
|
||||||
elif idx == 2:
|
|
||||||
params = param_values.get(idx, {})
|
|
||||||
p_threshold = params.get("p_threshold", 0.05)
|
|
||||||
min_subjects = params.get("min_subjects", 3)
|
|
||||||
correction_method = params.get("correction_method", "fdr_bh")
|
|
||||||
target_chroma = params.get("target_chroma", "hbo")
|
|
||||||
contrast_name = params.get("contrast_name", "")
|
|
||||||
|
|
||||||
if not contrast_name:
|
|
||||||
print("A contrast name must be specified.")
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Build each group's channel-level contrast dataframe
|
|
||||||
# directly from contrast_results_dict, keyed by file path -
|
|
||||||
# same dict-key approach as the laterality patch, avoids
|
|
||||||
# any ID-string matching.
|
|
||||||
def _build_group_contrast_df(
|
|
||||||
file_paths: list[str],
|
|
||||||
contrast_dict: dict[str, dict[str, pd.DataFrame]],
|
|
||||||
name: str,
|
|
||||||
) -> pd.DataFrame:
|
|
||||||
|
|
||||||
all_rows: list[DataFrame] = []
|
|
||||||
for fp in file_paths:
|
|
||||||
condition_dfs = contrast_dict.get(fp)
|
|
||||||
if condition_dfs is None:
|
|
||||||
print(f" [MISSING] '{fp}' not found in contrast_results.")
|
|
||||||
continue
|
|
||||||
if name in condition_dfs:
|
|
||||||
df = condition_dfs[name].copy()
|
|
||||||
df["ID"] = fp
|
|
||||||
df["contrast_name"] = name
|
|
||||||
all_rows.append(df)
|
|
||||||
else:
|
|
||||||
print(f" [MISSING CONTRAST] '{name}' not available for '{fp}'.")
|
|
||||||
return pd.concat(all_rows, ignore_index=True) if all_rows else pd.DataFrame()
|
|
||||||
|
|
||||||
df_contrasts_a = _build_group_contrast_df(file_paths_a, self.contrast_results_dict, contrast_name)
|
|
||||||
df_contrasts_b = _build_group_contrast_df(file_paths_b, self.contrast_results_dict, contrast_name)
|
|
||||||
|
|
||||||
if df_contrasts_a.empty or df_contrasts_b.empty:
|
|
||||||
print("No contrast data found for one or both groups.")
|
|
||||||
continue
|
|
||||||
|
|
||||||
roi_maps_a = {
|
|
||||||
fp: self.roi_channel_map_dict[fp]
|
|
||||||
for fp in file_paths_a
|
|
||||||
if fp in self.roi_channel_map_dict
|
|
||||||
}
|
|
||||||
roi_maps_b = {
|
|
||||||
fp: self.roi_channel_map_dict[fp]
|
|
||||||
for fp in file_paths_b
|
|
||||||
if fp in self.roi_channel_map_dict
|
|
||||||
}
|
|
||||||
if not roi_maps_a or not roi_maps_b:
|
|
||||||
print("No channel-to-ROI mapping available for one or both groups.")
|
|
||||||
continue
|
|
||||||
|
|
||||||
run_cross_group_contrast_analysis(
|
|
||||||
df_contrasts_a=df_contrasts_a,
|
|
||||||
df_contrasts_b=df_contrasts_b,
|
|
||||||
contrast_name=contrast_name,
|
|
||||||
roi_channel_maps_a=roi_maps_a,
|
|
||||||
roi_channel_maps_b=roi_maps_b,
|
|
||||||
group_a_name=self.group_a_dropdown.currentText(),
|
|
||||||
group_b_name=self.group_b_dropdown.currentText(),
|
|
||||||
target_chroma=target_chroma,
|
|
||||||
min_subjects=min_subjects,
|
|
||||||
p_threshold=p_threshold,
|
|
||||||
correction_method=correction_method,
|
|
||||||
)
|
|
||||||
|
|
||||||
else:
|
|
||||||
print("no")
|
|
||||||
@@ -9,7 +9,6 @@ License: GPL-3.0
|
|||||||
|
|
||||||
# Built-in imports
|
# Built-in imports
|
||||||
import os
|
import os
|
||||||
from pathlib import Path
|
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
# External library imports
|
# External library imports
|
||||||
@@ -27,26 +26,33 @@ from src.shared.shareddata import APP_NAME
|
|||||||
class ExportToCSVWidget(CSVUIMixin, FlaresBaseWidget):
|
class ExportToCSVWidget(CSVUIMixin, FlaresBaseWidget):
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
haemo_dict: dict[str | Path, BaseRaw],
|
haemo_dict: dict[str, BaseRaw],
|
||||||
cha_dict: dict[str, DataFrame],
|
cha_dict: dict[str, DataFrame],
|
||||||
df_ind_dict: dict[str, DataFrame],
|
df_ind_dict: dict[str, DataFrame],
|
||||||
design_matrix_dict: dict[str, DataFrame],
|
design_matrix_dict: dict[str, DataFrame],
|
||||||
contrast_results_dict: dict[str, dict[str, Any]],
|
contrast_results_dict: dict[str, dict[str, Any]],
|
||||||
group_dict: dict[str, str],
|
group_dict: dict[str, str],
|
||||||
config_dict: dict[str, str],
|
config_dict: dict[str, dict[str, Any]],
|
||||||
|
fir_feature_dict: dict[str, dict[str, Any]],
|
||||||
|
qc_dict: dict[str, dict[str, Any]],
|
||||||
) -> None:
|
) -> None:
|
||||||
|
|
||||||
super().__init__("ExportToCSV")
|
super().__init__("ExportToCSV")
|
||||||
self.setWindowTitle(f"Export To CSV Viewer - {APP_NAME.upper()}")
|
self.setWindowTitle(f"Export To CSV Viewer - {APP_NAME.upper()}")
|
||||||
self.haemo_dict = haemo_dict
|
self.haemo_dict = haemo_dict
|
||||||
self.cha_dict = cha_dict
|
self.cha_dict = cha_dict
|
||||||
# self.df_ind = df_ind_dict
|
self.df_ind_dict = df_ind_dict
|
||||||
# self.design_matrix = design_matrix_dict
|
self.design_matrix = design_matrix_dict
|
||||||
# self.contrast_results_dict = contrast_results_dict
|
self.contrast_results_dict = contrast_results_dict
|
||||||
# self.group = group_dict
|
self.group_dict = group_dict
|
||||||
self.config_dict = config_dict
|
self.config_dict = config_dict
|
||||||
|
self.fir_feature_dict = fir_feature_dict
|
||||||
|
self.qc_dict = qc_dict
|
||||||
|
|
||||||
self.setup_csv_ui(["0 (Export Data to CSV)", "1 (CSV for SPARKS)", "2 (Export Configuration to CSV)", "3 (Paragraph of Configuration)"])
|
self.setup_csv_ui(["0 (Export Data to CSV)", "1 (CSV for SPARKS)", "2 (Export Configuration to CSV)", "3 (Paragraph of Configuration)", "4 (Export FIR Waveform Features to CSV)",
|
||||||
|
"5 (Export Quality Control Metrics to CSV)",
|
||||||
|
"6 (Export Master Consolidated Matrix [All Participants into 1 CSV])"
|
||||||
|
])
|
||||||
|
|
||||||
|
|
||||||
def process_request(self):
|
def process_request(self):
|
||||||
@@ -114,6 +120,68 @@ class ExportToCSVWidget(CSVUIMixin, FlaresBaseWidget):
|
|||||||
magic_string = self.gen_magic_str(first_params)
|
magic_string = self.gen_magic_str(first_params)
|
||||||
self.placeholder_label.setText(magic_string)
|
self.placeholder_label.setText(magic_string)
|
||||||
|
|
||||||
|
# elif idx == 4:
|
||||||
|
# # FIR Waveform Features Export
|
||||||
|
# fir_data = self.fir_feature_dict.get(file_path)
|
||||||
|
# if fir_data and isinstance(fir_data, dict):
|
||||||
|
# names = fir_data.get("feature_names", [])
|
||||||
|
# vals = fir_data.get("features", [])
|
||||||
|
# chans = fir_data.get("feature_channels", [])
|
||||||
|
|
||||||
|
# fir_df = DataFrame({
|
||||||
|
# "Feature_Name": names,
|
||||||
|
# "Channel": chans if len(chans) == len(names) else ["N/A"] * len(names),
|
||||||
|
# "Value": vals
|
||||||
|
# })
|
||||||
|
# save_path = os.path.join(output_dir, f"{base_filename}_fir_features.csv")
|
||||||
|
# fir_df.to_csv(save_path, index=False)
|
||||||
|
# success_count += 1
|
||||||
|
|
||||||
|
# elif idx == 5:
|
||||||
|
# # Quality Control (QC) Metrics Export
|
||||||
|
# qc_data = self.qc_dict.get(file_path)
|
||||||
|
# if qc_data and isinstance(qc_data, dict):
|
||||||
|
# qc_df = DataFrame(list(qc_data.items()), columns=["Metric", "Value"])
|
||||||
|
# save_path = os.path.join(output_dir, f"{base_filename}_qc_metrics.csv")
|
||||||
|
# qc_df.to_csv(save_path, index=False)
|
||||||
|
# success_count += 1
|
||||||
|
|
||||||
|
# elif idx == 6:
|
||||||
|
# # Master Consolidated Matrix (1 single CSV combining all selected participants)
|
||||||
|
# save_path = os.path.join(output_dir, f"{APP_NAME}_master_consolidated.csv")
|
||||||
|
# if not os.path.exists(save_path):
|
||||||
|
# master_rows: list[dict[str, Any]] = []
|
||||||
|
# for fp in selected_file_paths:
|
||||||
|
# abs_path = os.path.abspath(fp)
|
||||||
|
# grp = self.group_dict.get(fp, "Unknown")
|
||||||
|
# row: dict[str, Any] = {"Participant": abs_path, "Group": grp}
|
||||||
|
|
||||||
|
# # QC Metrics
|
||||||
|
# qc_info = self.qc_dict.get(fp, {})
|
||||||
|
# if isinstance(qc_info, dict):
|
||||||
|
# for mk, mv in qc_info.items():
|
||||||
|
# row[f"QC_{mk}"] = mv
|
||||||
|
|
||||||
|
# # FIR Features
|
||||||
|
# fir_info = self.fir_feature_dict.get(fp, {})
|
||||||
|
# print("1")
|
||||||
|
# if isinstance(fir_info, dict):
|
||||||
|
# print("2")
|
||||||
|
# f_names = fir_info.get("feature_names", [])
|
||||||
|
# f_vals = fir_info.get("features", [])
|
||||||
|
# print("3")
|
||||||
|
# if len(f_names) == len(f_vals):
|
||||||
|
# print("4")
|
||||||
|
# for fn, fv in zip(f_names, f_vals):
|
||||||
|
# row[f"FIR_{fn}"] = fv
|
||||||
|
|
||||||
|
# master_rows.append(row)
|
||||||
|
|
||||||
|
# if master_rows:
|
||||||
|
# df_master = DataFrame(master_rows)
|
||||||
|
# df_master.to_csv(save_path, index=False)
|
||||||
|
# success_count += 1
|
||||||
|
|
||||||
else:
|
else:
|
||||||
print(f"No method defined for index {idx}")
|
print(f"No method defined for index {idx}")
|
||||||
|
|
||||||
@@ -134,7 +202,7 @@ class ExportToCSVWidget(CSVUIMixin, FlaresBaseWidget):
|
|||||||
# win.show()
|
# win.show()
|
||||||
|
|
||||||
|
|
||||||
def gen_magic_str(self, all_params):
|
def gen_magic_str(self, all_params: dict[str, str]) -> str:
|
||||||
|
|
||||||
magic_str = "To start, the data was loaded into the application. "
|
magic_str = "To start, the data was loaded into the application. "
|
||||||
if all_params['DOWNSAMPLE']:
|
if all_params['DOWNSAMPLE']:
|
||||||
|
|||||||
@@ -7,52 +7,22 @@ Author: Tyler de Zeeuw
|
|||||||
License: GPL-3.0
|
License: GPL-3.0
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# Built-in Imports
|
# Built-in imports
|
||||||
from pathlib import Path
|
|
||||||
from typing import Any, cast
|
from typing import Any, cast
|
||||||
|
|
||||||
# External library imports
|
# External library imports
|
||||||
|
from mne.io.base import BaseRaw
|
||||||
|
|
||||||
import pandas as pd
|
import pandas as pd
|
||||||
from pandas import DataFrame
|
from pandas import DataFrame
|
||||||
|
|
||||||
from mne import Annotations
|
from flares import aggregate_fnirs_group_geometry, plot_2d_3d_contrasts_between_groups
|
||||||
from mne.io.base import BaseRaw
|
|
||||||
|
|
||||||
from flares import aggregate_fnirs_group_geometry, plot_fir_model_results, brain_3d_visualization
|
|
||||||
from src.shared.flaresbasewidget import InterGroupUIMixin, FlaresBaseWidget
|
from src.shared.flaresbasewidget import InterGroupUIMixin, FlaresBaseWidget
|
||||||
from src.shared.shareddata import APP_NAME
|
from src.shared.shareddata import APP_NAME
|
||||||
|
|
||||||
|
|
||||||
PARAMETERIZED_INDEXES: dict[int, list[dict[str, Any]]] = {
|
PARAMETERIZED_INDEXES: dict[int, list[dict[str, Any]]] = {
|
||||||
0: [
|
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",
|
"key": "show_optodes",
|
||||||
"label": "Determine what is rendered above the brain. Valid values are 'sensors', 'labels', 'none', 'all'.",
|
"label": "Determine what is rendered above the brain. Valid values are 'sensors', 'labels', 'none', 'all'.",
|
||||||
@@ -67,8 +37,8 @@ PARAMETERIZED_INDEXES: dict[int, list[dict[str, Any]]] = {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"key": "show_text",
|
"key": "show_text",
|
||||||
"label": "Display informative text on the top left corner. THIS DOES NOT WORK AND SHOULD BE LEFT AT FALSE",
|
"label": "Display informative text on the top left corner about the contrast.",
|
||||||
"default": "False",
|
"default": "True",
|
||||||
"type": bool,
|
"type": bool,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -76,145 +46,118 @@ PARAMETERIZED_INDEXES: dict[int, list[dict[str, Any]]] = {
|
|||||||
"label": "Graph Upper/Lower Limit",
|
"label": "Graph Upper/Lower Limit",
|
||||||
"default": "1.0",
|
"default": "1.0",
|
||||||
"type": float,
|
"type": float,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "is_3d",
|
||||||
|
"label": "Should we display the results in a 3D interactive window?",
|
||||||
|
"default": "True",
|
||||||
|
"type": bool,
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
DESCRIPTION = """\n1. Group Contrast 2D/3D (plot_2d_3d_contrasts_between_groups)
|
||||||
|
\nCompares two participant groups' contrast results (e.g. condition-vs-baseline effects) channel-by-channel, fitting a mixed-effects model with group, channel, and chromophore as factors. Produces BOTH directions of the contrast (Group A minus Group B, and Group B minus Group A) as separate plots, so the sign convention is explicit either way you read it.
|
||||||
|
\nis_3d controls the display: True renders a 3D weighted brain map per contrast direction (same rendering as intra method 1, but showing the between-group difference rather than a single group's estimate); False renders a 2D topographic map instead, which is faster and sometimes easier to read at a glance for a whole-head pattern.
|
||||||
|
\nA channel is only included if BOTH groups have at least min_participants_per_group (default 2) contributing participants for that channel - channels present in only one group, or with too few participants in either group to estimate within-group variance, are dropped before fitting. If this drops too many channels, check that both groups have enough participants with usable data for the selected event/channels.
|
||||||
|
\nAs with other mixed-effects models in this app, small participant counts can produce convergence warnings; when that happens, the model falls back to pooled OLS, which does not account for the repeated-measures structure of the data and may understate uncertainty - treat results run this way with extra caution.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
class InterGroupBrainImageWidget(InterGroupUIMixin, FlaresBaseWidget):
|
class InterGroupBrainImageWidget(InterGroupUIMixin, FlaresBaseWidget):
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
haemo_dict: dict[str | Path, BaseRaw],
|
haemo_dict: dict[str, BaseRaw],
|
||||||
cha_dict: dict[str, DataFrame],
|
|
||||||
df_ind_dict: dict[str, DataFrame],
|
df_ind_dict: dict[str, DataFrame],
|
||||||
design_matrix_dict: dict[str, DataFrame],
|
design_matrix_dict: dict[str, DataFrame],
|
||||||
contrast_results_dict: dict[str, dict[str, Any]],
|
contrast_results_dict: dict[str, dict[str, Any]],
|
||||||
group_dict: dict[str, str]
|
group_dict: dict[str, str],
|
||||||
) -> None:
|
) -> None:
|
||||||
|
|
||||||
super().__init__("InterGroupBrainImage")
|
super().__init__("InterGroupBrainImage")
|
||||||
self.setWindowTitle(f"Inter-Group Brain & Image Viewer - {APP_NAME.upper()}")
|
self.setWindowTitle(f"Inter-Group Brain & Image Viewer - {APP_NAME.upper()}")
|
||||||
self.haemo_dict = haemo_dict
|
self.haemo_dict = haemo_dict
|
||||||
self.cha_dict = cha_dict
|
|
||||||
self.df_ind_dict = df_ind_dict
|
self.df_ind_dict = df_ind_dict
|
||||||
self.design_matrix_dict = design_matrix_dict
|
self.design_matrix_dict = design_matrix_dict
|
||||||
self.contrast_results_dict = contrast_results_dict
|
self.contrast_results_dict = contrast_results_dict
|
||||||
self.group_dict = group_dict
|
self.group_dict = group_dict
|
||||||
|
|
||||||
self.setup_inter_group_ui(["0 (GLM Results)", "1 (Significance)", "2 (Brain Activity Visualization)",])
|
self.setup_inter_group_ui(["0 (Group Contrast 2D/3D)"], placeholder_text=DESCRIPTION)
|
||||||
|
|
||||||
|
|
||||||
def process_request(self):
|
def process_request(self):
|
||||||
|
|
||||||
request = self.get_common_request_data(PARAMETERIZED_INDEXES)
|
request = self.get_common_request_data(PARAMETERIZED_INDEXES)
|
||||||
if request is None:
|
if request is None:
|
||||||
return
|
return
|
||||||
|
|
||||||
(selected_event, selected_file_paths, selected_indexes, raw_params) = request
|
(selected_event, file_paths_a, file_paths_b, all_selected_paths, selected_indexes, raw_params) = request
|
||||||
|
|
||||||
param_values = cast(dict[int | str, dict[str, Any]], raw_params)
|
param_values = cast(dict[int | str, dict[str, Any]], raw_params)
|
||||||
|
|
||||||
all_cha = pd.DataFrame()
|
# Build group-level contrast DataFrames
|
||||||
for file_path in selected_file_paths:
|
def concat_group_contrasts(file_paths: list[str], event: str | None) -> pd.DataFrame:
|
||||||
haemo_obj = self.haemo_dict.get(file_path)
|
group_df = pd.DataFrame()
|
||||||
|
for fp in file_paths:
|
||||||
if haemo_obj is None:
|
print(f"Looking up contrast for: {fp}")
|
||||||
continue
|
event_con_dict = self.contrast_results_dict.get(fp, {})
|
||||||
|
print("Available events for this file:", list(event_con_dict.keys()))
|
||||||
if selected_event:
|
if event and event in event_con_dict:
|
||||||
raw_annotations = getattr(haemo_obj, "annotations", None)
|
df = event_con_dict[event]
|
||||||
|
print(f"Appending contrast df for event: {event}")
|
||||||
if raw_annotations is not None:
|
group_df = pd.concat([group_df, df], ignore_index=True)
|
||||||
annotations = cast(Annotations, raw_annotations)
|
|
||||||
descriptions = cast(list[str], list(annotations.description))
|
|
||||||
participant_events: set[str] = set(descriptions)
|
|
||||||
else:
|
else:
|
||||||
participant_events: set[str] = set()
|
print(f"Event '{event}' not found for {fp}")
|
||||||
|
return group_df
|
||||||
|
|
||||||
if selected_event not in participant_events:
|
print("Selected event:", selected_event)
|
||||||
print(f"Skipping {self.participant_map[file_path]}: Event '{selected_event}' not found.")
|
print("File paths A:", file_paths_a)
|
||||||
continue
|
print("File paths B:", file_paths_b)
|
||||||
|
|
||||||
cha_df = self.cha_dict.get(file_path)
|
contrast_df_a = concat_group_contrasts(file_paths_a, selected_event)
|
||||||
if cha_df is not None:
|
contrast_df_b = concat_group_contrasts(file_paths_b, selected_event)
|
||||||
all_cha = pd.concat([all_cha, cha_df], ignore_index=True)
|
|
||||||
|
|
||||||
# Pass the necessary arguments to each method
|
print("contrast_df_a empty?", contrast_df_a.empty)
|
||||||
file_path = selected_file_paths[0]
|
print("contrast_df_b empty?", contrast_df_b.empty)
|
||||||
p_haemo = self.haemo_dict.get(file_path)
|
|
||||||
p_design_matrix = self.design_matrix_dict.get(file_path)
|
|
||||||
|
|
||||||
df_group = pd.DataFrame()
|
all_raw_objs = [self.haemo_dict.get(fp) for fp in all_selected_paths if self.haemo_dict.get(fp)]
|
||||||
|
|
||||||
if selected_file_paths:
|
|
||||||
for file_path in selected_file_paths:
|
|
||||||
df = self.df_ind_dict.get(file_path)
|
|
||||||
if df is not None:
|
|
||||||
df_group = pd.concat([df_group, df], ignore_index=True)
|
|
||||||
|
|
||||||
|
if len(all_raw_objs) > 1:
|
||||||
|
processed_raw = aggregate_fnirs_group_geometry(all_raw_objs)
|
||||||
|
elif len(all_raw_objs) == 1 and all_raw_objs[0] is not None:
|
||||||
|
processed_raw = all_raw_objs[0].copy()
|
||||||
|
processed_raw.pick(picks="hbo") # type: ignore
|
||||||
|
else:
|
||||||
|
processed_raw = None
|
||||||
|
|
||||||
|
# Visualizations
|
||||||
for idx in selected_indexes:
|
for idx in selected_indexes:
|
||||||
if idx == 0:
|
if idx == 0:
|
||||||
params = param_values.get(idx, {})
|
|
||||||
lower_bound = params.get("lower_bound", None)
|
|
||||||
upper_bound = params.get("upper_bound", None)
|
|
||||||
|
|
||||||
if lower_bound is None or upper_bound is None:
|
|
||||||
print(f"Missing parameters for index {idx}, skipping.")
|
|
||||||
continue
|
|
||||||
|
|
||||||
|
|
||||||
plot_fir_model_results(df_group, p_haemo, p_design_matrix, selected_event, lower_bound, upper_bound)
|
|
||||||
|
|
||||||
elif idx == 1:
|
|
||||||
params = param_values.get(idx, {})
|
|
||||||
p_val = params.get("p_value", None)
|
|
||||||
graph_bounds = params.get("graph_bounds", None)
|
|
||||||
|
|
||||||
if p_val is None or graph_bounds is None:
|
|
||||||
print(f"Missing parameters for index {idx}, skipping.")
|
|
||||||
continue
|
|
||||||
|
|
||||||
all_contrasts: list[DataFrame] = []
|
|
||||||
for fp in selected_file_paths:
|
|
||||||
condition_dfs = self.contrast_results_dict.get(fp, {})
|
|
||||||
if selected_event in condition_dfs:
|
|
||||||
df = condition_dfs[selected_event].copy()
|
|
||||||
df["ID"] = fp
|
|
||||||
all_contrasts.append(df)
|
|
||||||
|
|
||||||
if not all_contrasts:
|
|
||||||
print("No contrast data found for selected participants and event.")
|
|
||||||
return
|
|
||||||
|
|
||||||
# TODO: look at intergroupstats and figure out what to do
|
|
||||||
_ = pd.concat(all_contrasts, ignore_index=True)
|
|
||||||
#flares.run_second_level_analysis(df_contrasts, p_haemo, p_val, graph_bounds)
|
|
||||||
|
|
||||||
elif idx == 2:
|
|
||||||
params = param_values.get(idx, {})
|
params = param_values.get(idx, {})
|
||||||
show_optodes = params.get("show_optodes", None)
|
show_optodes = params.get("show_optodes", None)
|
||||||
t_or_theta = params.get("t_or_theta", None)
|
t_or_theta = params.get("t_or_theta", None)
|
||||||
show_text = params.get("show_text", None)
|
show_text = params.get("show_text", None)
|
||||||
brain_bounds = params.get("brain_bounds", 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:
|
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.")
|
print(f"Missing parameters for index {idx}, skipping.")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
all_raw_objs = [self.haemo_dict.get(fp) for fp in selected_file_paths if self.haemo_dict.get(fp)]
|
if not contrast_df_a.empty and not contrast_df_b.empty and processed_raw:
|
||||||
|
|
||||||
if len(all_raw_objs) > 1:
|
|
||||||
processed_raw = aggregate_fnirs_group_geometry(all_raw_objs)
|
|
||||||
elif len(all_raw_objs) == 1 and all_raw_objs[0] is not None:
|
|
||||||
processed_raw = all_raw_objs[0].copy()
|
|
||||||
processed_raw.pick(picks="hbo") # type: ignore
|
|
||||||
else:
|
|
||||||
processed_raw = None
|
|
||||||
|
|
||||||
brain_3d_visualization(processed_raw, all_cha, selected_event, t_or_theta=t_or_theta, show_optodes=show_optodes, show_text=show_text, brain_bounds=brain_bounds)
|
|
||||||
|
|
||||||
elif idx == 3:
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
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:
|
else:
|
||||||
print(f"No method defined for index {idx}")
|
print(f"No method defined for index {idx}")
|
||||||
@@ -1,83 +0,0 @@
|
|||||||
"""
|
|
||||||
Filename: intergroupfunctionalconnectivity.py
|
|
||||||
Description: Logic for the Inter-Group Functional Connectivity analysis window
|
|
||||||
Note: Compliant with pylance strict type checking
|
|
||||||
|
|
||||||
Author: Tyler de Zeeuw
|
|
||||||
License: GPL-3.0
|
|
||||||
"""
|
|
||||||
|
|
||||||
# Built-in imports
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Any, cast
|
|
||||||
|
|
||||||
# External library imports
|
|
||||||
from PySide6.QtWidgets import QMessageBox
|
|
||||||
|
|
||||||
from mne.io.base import BaseRaw
|
|
||||||
|
|
||||||
from flares import run_group_functional_connectivity
|
|
||||||
from src.shared.flaresbasewidget import InterGroupUIMixin, FlaresBaseWidget
|
|
||||||
from src.shared.shareddata import APP_NAME
|
|
||||||
|
|
||||||
|
|
||||||
PARAMETERIZED_INDEXES: dict[int, list[dict[str, Any]]] = {
|
|
||||||
0: [
|
|
||||||
{
|
|
||||||
"key": "n_lines",
|
|
||||||
"label": "<Description>",
|
|
||||||
"default": "20",
|
|
||||||
"type": int,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"key": "vmin",
|
|
||||||
"label": "<Description>",
|
|
||||||
"default": "0.9",
|
|
||||||
"type": float,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class InterGroupFunctionalConnectivityWidget(InterGroupUIMixin, FlaresBaseWidget):
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
haemo_dict: dict[str | Path, BaseRaw],
|
|
||||||
group_dict: dict[str, str],
|
|
||||||
config_dict: dict[str, str]
|
|
||||||
) -> None:
|
|
||||||
|
|
||||||
super().__init__("InterGroupFunctionalConnectivity")
|
|
||||||
self.setWindowTitle(f"Inter-Group Functional Connectivity Viewer [BETA] - {APP_NAME.upper()}")
|
|
||||||
self.haemo_dict = haemo_dict
|
|
||||||
#self.group_dict = group_dict
|
|
||||||
self.config_dict = config_dict
|
|
||||||
|
|
||||||
QMessageBox.warning(self, f"Warning - {APP_NAME.upper()}", f"Functional Connectivity is still in development and the results should currently be taken with a grain of salt. "
|
|
||||||
"By clicking OK, you accept that the images generated may not be factual.")
|
|
||||||
|
|
||||||
self.setup_inter_group_ui(["0 (Betas)",])
|
|
||||||
|
|
||||||
|
|
||||||
def process_request(self):
|
|
||||||
request = self.get_common_request_data(PARAMETERIZED_INDEXES)
|
|
||||||
if request is None:
|
|
||||||
return
|
|
||||||
|
|
||||||
(selected_event, selected_file_paths, selected_indexes, raw_params) = request
|
|
||||||
|
|
||||||
param_values = cast(dict[int | str, dict[str, Any]], raw_params)
|
|
||||||
|
|
||||||
for idx in selected_indexes:
|
|
||||||
if idx == 0:
|
|
||||||
params = param_values.get(idx, {})
|
|
||||||
n_lines = params.get("n_lines", None)
|
|
||||||
vmin = params.get("vmin", None)
|
|
||||||
|
|
||||||
if n_lines is None or vmin is None:
|
|
||||||
print(f"Missing parameters for index {idx}, skipping.")
|
|
||||||
continue
|
|
||||||
run_group_functional_connectivity(self.haemo_dict, self.config_dict, selected_file_paths, selected_event, 50, 0.5)
|
|
||||||
|
|
||||||
else:
|
|
||||||
print(f"No method defined for index {idx}")
|
|
||||||
+138
-186
@@ -8,17 +8,15 @@ License: GPL-3.0
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
# Built-in imports
|
# Built-in imports
|
||||||
from pathlib import Path
|
|
||||||
from typing import Any, cast
|
from typing import Any, cast
|
||||||
|
|
||||||
# External library imports
|
# External library imports
|
||||||
import pandas as pd
|
import pandas as pd
|
||||||
from pandas import DataFrame
|
from pandas import DataFrame
|
||||||
|
|
||||||
from mne import Annotations
|
|
||||||
from mne.io.base import BaseRaw
|
from mne.io.base import BaseRaw
|
||||||
|
|
||||||
from flares import run_roi_paired_contrast_analysis, run_roi_second_level_analysis, aggregate_channel_contrasts_to_roi
|
from flares import run_inter_group_contrast_analysis, run_inter_group_laterality_analysis, run_inter_group_second_level_analysis
|
||||||
from src.shared.flaresbasewidget import InterGroupUIMixin, FlaresBaseWidget
|
from src.shared.flaresbasewidget import InterGroupUIMixin, FlaresBaseWidget
|
||||||
from src.shared.shareddata import APP_NAME
|
from src.shared.shareddata import APP_NAME
|
||||||
|
|
||||||
@@ -34,7 +32,7 @@ PARAMETERIZED_INDEXES: dict[int, list[dict[str, Any]]] = {
|
|||||||
{
|
{
|
||||||
"key": "min_subjects",
|
"key": "min_subjects",
|
||||||
"label": "Minimum number of participants to process",
|
"label": "Minimum number of participants to process",
|
||||||
"default": "5",
|
"default": "3",
|
||||||
"type": int,
|
"type": int,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -50,10 +48,10 @@ PARAMETERIZED_INDEXES: dict[int, list[dict[str, Any]]] = {
|
|||||||
"type": str,
|
"type": str,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"key": "graph_bounds",
|
"key": "threshold_topo",
|
||||||
"label": "Graph Upper/Lower Limit",
|
"label": "threshold_topo: TBD",
|
||||||
"default": "0.0",
|
"default": False,
|
||||||
"type": float,
|
"type": bool,
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
1: [
|
1: [
|
||||||
@@ -66,7 +64,7 @@ PARAMETERIZED_INDEXES: dict[int, list[dict[str, Any]]] = {
|
|||||||
{
|
{
|
||||||
"key": "min_subjects",
|
"key": "min_subjects",
|
||||||
"label": "Minimum number of participants to process",
|
"label": "Minimum number of participants to process",
|
||||||
"default": "5",
|
"default": "3",
|
||||||
"type": int,
|
"type": int,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -104,7 +102,7 @@ PARAMETERIZED_INDEXES: dict[int, list[dict[str, Any]]] = {
|
|||||||
{
|
{
|
||||||
"key": "min_subjects",
|
"key": "min_subjects",
|
||||||
"label": "Minimum number of participants to process",
|
"label": "Minimum number of participants to process",
|
||||||
"default": "5",
|
"default": "3",
|
||||||
"type": int,
|
"type": int,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -125,31 +123,19 @@ PARAMETERIZED_INDEXES: dict[int, list[dict[str, Any]]] = {
|
|||||||
"default": [],
|
"default": [],
|
||||||
"type": list,
|
"type": list,
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"key": "weighted",
|
|
||||||
"label": "Use inverse-variance weighting to minimize noisy channels",
|
|
||||||
"default": True,
|
|
||||||
"type": bool,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"key": "graph_bounds",
|
|
||||||
"label": "Graph Upper/Lower Limit",
|
|
||||||
"default": "0.0",
|
|
||||||
"type": float,
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
DESCRIPTION = """0. ROI vs. Zero (run_roi_second_level_analysis)
|
DESCRIPTION = """0. Raw ROI Comparison (run_inter_group_second_level_analysis)
|
||||||
\nTests whether one ROI's response during one condition reliably differs from zero across subjects - a one-sample t-test on each subject's ROI-averaged theta. A significant result means the region's signal during this condition is consistently non-zero across your sample, not just noise. It does not tell you whether the response is localized/specific to this region, or whether it reflects real neural activity versus systemic physiology (blood pressure, arousal) shared across the whole head during any active task - a single-condition "vs. zero" test can't distinguish those two explanations on its own.
|
\nCompares one ROI's raw response magnitude between two independent groups (e.g. control vs. target) for a given condition, using Welch's t-test. A significant result means the two populations differ in this ROI's response magnitude for this condition. It does not tell you whether that difference is a real, localized, task-specific effect or a generic between-population difference - different overall vascular reactivity, arousal, or skull/scalp optical properties can produce the exact same statistical signature, and two independently recruited groups (especially patients vs. healthy controls) are considerably more likely to differ this way than two subsets of one study population.
|
||||||
\nIf you expected significance here and didn't get it, likely causes include: the sample size is simply small relative to between-subject variability in true response magnitude or HRF shape (individual differences in timing/amplitude inflate the variance a t-test divides by); the ROI's channel composition differs slightly across subjects (missing channels get down-weighted or excluded from the inverse-variance average, diluting a real signal); FDR correction across many ROIs is suppressing a modest true effect that would clear an uncorrected threshold; or the condition itself may not reliably engage this region the way you assumed (worth checking the single-subject/individual-level results for this ROI before concluding the group effect isn't there).
|
\nIf you expected a group difference and didn't find one, the most common cause is within-group heterogeneity swallowing a real between-group difference - a "target" population (e.g. a clinical group) is often more variable than a tightly-screened control group, and that added within-group variance directly weakens a between-group t-test even if the group means truly differ. Small per-group sample sizes compound this. It's also possible the true difference between your groups isn't in raw magnitude at all, but in spatial specificity or task-differentiation - which is exactly why the laterality and contrast-comparison methods exist alongside this one; a null result here doesn't rule those out.
|
||||||
\n\n1. Paired ROI Contrast (run_roi_paired_contrast_analysis)
|
\n\n1. Laterality Comparison (run_inter_group_laterality_analysis)
|
||||||
\nFor one condition, subtracts each subject's ROI_A response from their ROI_B response, then tests whether that per-subject difference is reliably non-zero. A significant result is a genuine spatial contrast - the two regions respond differently from each other during this specific condition, with shared systemic noise partially cancelling in the subtraction. It says nothing about whether the condition produced meaningful activity at all (only a relative difference between two places), and its power depends entirely on ROI_A and ROI_B varying together across subjects - an assumption that isn't guaranteed.
|
\nComputes each subject's own contralateral-minus-ipsilateral laterality index first, then compares those indices between the two groups with Welch's t-test. A significant result means the degree of spatial specificity/lateralization differs between the two populations - a claim about lateralization itself, harder to explain away as a generic population confound since person-level differences in overall reactivity largely cancel before the group comparison happens. It says nothing about overall response magnitude between groups (a group could have identical laterality but very different raw amplitude), and it only uses subjects who have both the contra and ipsi ROI valid, so it can lose subjects the raw-ROI comparison would have kept.
|
||||||
\nIf this test underperforms a plain ROI-vs-zero result, which can occur, the most likely explanation is that ROI_A and ROI_B's noise isn't well-correlated across your subjects. The math is variance(A - B) = variance(A) + variance(B) - 2·covariance(A,B): subtraction only helps when the shared/systemic component is large relative to independent noise in each region. If the two regions are picking up largely independent noise sources (motion artifact affecting one side more, different channel quality, etc.), subtracting adds variance rather than removing it, and can turn a detectable single-ROI effect into an underpowered paired one. Small sample size makes this worse, since the covariance itself is poorly estimated with few subjects.
|
\nNon-significance here has two likely sources, and it's worth distinguishing them. First, the same covariance issue from the within-group paired test applies across a whole group: if contra/ipsi responses aren't well-correlated within subjects, the laterality index itself is noisier than either ROI alone, and that added noise now has to clear a between-group test on top of it - a double power cost at small N. Second, and more informative if true: the groups may genuinely have similar lateralization but differ in overall magnitude instead, in which case this test correctly returns null while method 4 (raw comparison) should be the one to look at.
|
||||||
\n\n2. Joint Contrast, ROI-Aggregated (aggregate_channel_contrasts_to_roi + one-sample test)
|
\n\n2. Contrast Comparison (run_inter_group_contrast_analysis)
|
||||||
\nUses a contrast fit jointly within each subject's GLM (Condition A minus Condition B, estimated together), then aggregates that per-channel contrast to ROI level using inverse-variance weighting, and tests it against zero across subjects. A significant result means the two conditions produce reliably different responses at this ROI, with systemic noise largely cancelled at the model-fitting stage itself - the most statistically efficient of the three within-group methods, since the correlation between conditions is handled natively rather than inferred afterward. It does not tell you where the difference is localized on its own - for that, compare the sign/pattern across multiple ROIs: opposite signs across regions indicates a real, spatially-specific effect, while the same sign everywhere suggests diffuse/systemic noise rather than localized activity (as seen when comparing a real task-vs-task contrast against a task-vs-inert-marker contrast).
|
\nCompares a jointly-fit task contrast (e.g. Task A minus Task B, estimated together within each subject's GLM), aggregated to ROI level, between two independent groups. A significant result means one group differentiates between the two tasks more or less than the other does, at this specific ROI - with systemic noise cancelled at the model-fitting stage, the same benefit that makes the within-group version of this method the strongest of that trio. As with the within-group version, it does not by itself say where a difference is localized unless you compare sign/pattern across multiple ROIs - opposite-signed group differences across regions point to something spatially specific, same-signed differences everywhere point to a diffuse/non-specific group difference (e.g. one group simply has stronger contrast responses across the whole head).
|
||||||
\nIf this comes back non-significant despite expecting an effect, first check whether the two conditions are actually similar enough in their neural engagement of this ROI that a small or genuinely near-zero contrast is the correct answer - not every ROI should differentiate every pair of tasks, and a null result here can be the right result. Beyond that: FDR correction across every ROI in your regions file can suppress a real but modest contrast; the inverse-variance weighting can be destabilized if a few channels within the ROI have very noisy or near-zero t-statistics (their standard error estimate becomes huge or unstable); and - as always - small subject counts limit the achievable degrees of freedom regardless of how clean the underlying per-channel estimates are.
|
\nIf this comes back non-significant despite an expected group difference, check first whether the underlying single-subject contrast estimates are noisy for either group - small per-group N means the joint contrast's precision depends on the same limited subject count as everything else, and a noisy input propagates all the way through the ROI aggregation. It's also possible for a real, localized sub-regional effect to get washed out by ROI averaging itself: if only part of an ROI's channels actually show the group difference while others don't, the inverse-variance-weighted average can dilute it toward null - in that case, a finer-grained ROI definition (splitting the region further) may recover the effect that a coarser ROI averaged away. Finally, FDR correction across every ROI tested reduces power exactly as it does everywhere else in this framework - a real but modest effect can fail to survive correction even when the raw p-value would have looked convincing on its own.
|
||||||
\n\n
|
\n\n
|
||||||
\nWhy channels needed to be aggregated into ROIs: Testing every channel independently means paying a steep multiple-comparisons tax - with dozens of channels, FDR/Bonferroni correction demands very large effect sizes to call anything significant, and at small subject counts (n=5) essentially nothing survives even when a real, consistent effect exists. Collapsing channels into a handful of anatomically meaningful ROIs cuts the number of independent tests from a minimum of ~40 down to 2-8, which lets a genuinely present effect actually clear correction. It also matches the scientific question better: you have a hypothesis about regions (contralateral motor cortex, prefrontal cortex), not about individual source-detector pairs, so testing at the ROI level is testing the thing you actually believe in, using inverse-variance weighting so noisier channels contribute less to the region's combined estimate rather than diluting it equally.
|
\nWhy channels needed to be aggregated into ROIs: Testing every channel independently means paying a steep multiple-comparisons tax - with dozens of channels, FDR/Bonferroni correction demands very large effect sizes to call anything significant, and at small subject counts (n=5) essentially nothing survives even when a real, consistent effect exists. Collapsing channels into a handful of anatomically meaningful ROIs cuts the number of independent tests from a minimum of ~40 down to 2-8, which lets a genuinely present effect actually clear correction. It also matches the scientific question better: you have a hypothesis about regions (contralateral motor cortex, prefrontal cortex), not about individual source-detector pairs, so testing at the ROI level is testing the thing you actually believe in, using inverse-variance weighting so noisier channels contribute less to the region's combined estimate rather than diluting it equally.
|
||||||
\nWhy some analyses needed contrasts instead of raw values: A single condition's GLM beta is only ever measured relative to the model's implicit intercept, and that intercept absorbs whatever's happening for the rest of the recording - including systemic physiology (blood pressure, arousal, general vascular reactivity) that rises during almost any active task, not just the one you care about. Testing a raw "vs. zero" value can't tell a real, localized neural response apart from that shared full-head noise. A contrast - either a within-subject spatial subtraction (ROI A minus ROI B) or a jointly-fit task contrast (Condition A minus Condition B, estimated together in one GLM) cancels out whatever's common to both halves of the subtraction, leaving something closer to the actual differential signal.
|
\nWhy some analyses needed contrasts instead of raw values: A single condition's GLM beta is only ever measured relative to the model's implicit intercept, and that intercept absorbs whatever's happening for the rest of the recording - including systemic physiology (blood pressure, arousal, general vascular reactivity) that rises during almost any active task, not just the one you care about. Testing a raw "vs. zero" value can't tell a real, localized neural response apart from that shared full-head noise. A contrast - either a within-subject spatial subtraction (ROI A minus ROI B) or a jointly-fit task contrast (Condition A minus Condition B, estimated together in one GLM) cancels out whatever's common to both halves of the subtraction, leaving something closer to the actual differential signal.
|
||||||
@@ -157,9 +143,10 @@ DESCRIPTION = """0. ROI vs. Zero (run_roi_second_level_analysis)
|
|||||||
|
|
||||||
|
|
||||||
class InterGroupStatsWidget(InterGroupUIMixin, FlaresBaseWidget):
|
class InterGroupStatsWidget(InterGroupUIMixin, FlaresBaseWidget):
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
haemo_dict: dict[str | Path, BaseRaw],
|
haemo_dict: dict[str, BaseRaw],
|
||||||
cha_dict: dict[str, DataFrame],
|
cha_dict: dict[str, DataFrame],
|
||||||
df_ind_dict: dict[str, DataFrame],
|
df_ind_dict: dict[str, DataFrame],
|
||||||
design_matrix_dict: dict[str, DataFrame],
|
design_matrix_dict: dict[str, DataFrame],
|
||||||
@@ -178,7 +165,7 @@ class InterGroupStatsWidget(InterGroupUIMixin, FlaresBaseWidget):
|
|||||||
self.roi_channel_map_dict = roi_channel_map_dict
|
self.roi_channel_map_dict = roi_channel_map_dict
|
||||||
self.group_dict = group_dict
|
self.group_dict = group_dict
|
||||||
|
|
||||||
self.setup_inter_group_ui(["0 (ROI vs. Zero)", "1 (Paired ROI Contrast)", "2 (Joint Contrast, ROI-Aggregated)"], placeholder_text=DESCRIPTION)
|
self.setup_inter_group_ui(["0 (Raw ROI Comparison)", "1 (Laterality Comparison)", "2 (Contrast Comparison)",], placeholder_text=DESCRIPTION)
|
||||||
|
|
||||||
|
|
||||||
def process_request(self):
|
def process_request(self):
|
||||||
@@ -186,218 +173,183 @@ class InterGroupStatsWidget(InterGroupUIMixin, FlaresBaseWidget):
|
|||||||
if request is None:
|
if request is None:
|
||||||
return
|
return
|
||||||
|
|
||||||
(selected_event, selected_file_paths, selected_indexes, raw_params) = request
|
(selected_event, file_paths_a, file_paths_b, _, selected_indexes, raw_params) = request
|
||||||
|
|
||||||
param_values = cast(dict[int | str, dict[str, Any]], raw_params)
|
param_values = cast(dict[int | str, dict[str, Any]], raw_params)
|
||||||
|
|
||||||
all_cha = DataFrame()
|
valid_dfs = [df for df in self.df_ind_dict.values() if not df.empty]
|
||||||
for file_path in selected_file_paths:
|
if valid_dfs:
|
||||||
haemo_obj = self.haemo_dict.get(file_path)
|
df_ind_combined = pd.concat(valid_dfs, ignore_index=True)
|
||||||
|
else:
|
||||||
|
df_ind_combined = pd.DataFrame()
|
||||||
|
|
||||||
if haemo_obj is None:
|
valid_chas = [df for df in self.cha_dict.values() if not df.empty]
|
||||||
continue
|
cha_combined = pd.concat(valid_chas, ignore_index=True) if valid_chas else pd.DataFrame()
|
||||||
|
|
||||||
if selected_event:
|
sample_path = file_paths_a[0]
|
||||||
raw_annotations = getattr(haemo_obj, "annotations", None)
|
p_haemo = self.haemo_dict.get(sample_path)
|
||||||
|
|
||||||
if raw_annotations is not None:
|
|
||||||
annotations = cast(Annotations, raw_annotations)
|
|
||||||
descriptions = cast(list[str], list(annotations.description))
|
|
||||||
participant_events: set[str] = set(descriptions)
|
|
||||||
else:
|
|
||||||
participant_events: set[str] = set()
|
|
||||||
|
|
||||||
if selected_event not in participant_events:
|
|
||||||
print(f"Skipping {self.participant_map[file_path]}: Event '{selected_event}' not found.")
|
|
||||||
continue
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
cha_df = self.cha_dict.get(file_path)
|
|
||||||
if cha_df is not None:
|
|
||||||
all_cha = pd.concat([all_cha, cha_df], ignore_index=True)
|
|
||||||
|
|
||||||
file_path = selected_file_paths[0]
|
|
||||||
p_haemo = self.haemo_dict.get(file_path)
|
|
||||||
|
|
||||||
# Concatenate individual ROI stats (df_ind) for all chosen subjects
|
|
||||||
df_group = DataFrame()
|
|
||||||
if selected_file_paths:
|
|
||||||
for file_path in selected_file_paths:
|
|
||||||
df = self.df_ind_dict.get(file_path)
|
|
||||||
if df is not None:
|
|
||||||
df_group = pd.concat([df_group, df], ignore_index=True)
|
|
||||||
|
|
||||||
|
# Visualizations
|
||||||
for idx in selected_indexes:
|
for idx in selected_indexes:
|
||||||
if idx == 0:
|
if idx == 0:
|
||||||
params = param_values.get(idx, {})
|
params = param_values.get(idx, {})
|
||||||
p_threshold = params.get("p_threshold", 0.05)
|
p_threshold = params.get("p_threshold", 0.05)
|
||||||
min_subjects = params.get("min_subjects", 5)
|
min_subjects = params.get("min_subjects", 3)
|
||||||
correction_method = params.get("correction_method", "fdr_bh")
|
correction_method = params.get("correction_method", "fdr_bh")
|
||||||
target_chroma = params.get("target_chroma", "hbo")
|
target_chroma = params.get("target_chroma", "hbo")
|
||||||
graph_bounds = params.get("graph_bounds", 0.0)
|
threshold_topo = params.get("threshold_topo", False)
|
||||||
|
|
||||||
if correction_method == "None":
|
selected_roi_maps = {
|
||||||
correction_method = None
|
fp: self.roi_channel_map_dict[fp]
|
||||||
|
for fp in (file_paths_a + file_paths_b)
|
||||||
|
if fp in self.roi_channel_map_dict
|
||||||
|
}
|
||||||
|
|
||||||
if df_group.empty:
|
run_inter_group_second_level_analysis(
|
||||||
print("No ROI data (df_ind) found for selected participants.")
|
df_roi_all=df_ind_combined, # Individual stats dataframe
|
||||||
continue
|
file_paths_a=file_paths_a,
|
||||||
|
file_paths_b=file_paths_b,
|
||||||
# Filter down to the selected experimental event/condition
|
group_a_name=self.group_a_dropdown.currentText(),
|
||||||
if selected_event:
|
group_b_name=self.group_b_dropdown.currentText(),
|
||||||
if 'Condition' in df_group.columns:
|
df_cha_all=cha_combined,
|
||||||
df_filtered = df_group[df_group['Condition'] == selected_event]
|
|
||||||
else:
|
|
||||||
print("Warning: 'Condition' column not found in ROI data.")
|
|
||||||
df_filtered = df_group
|
|
||||||
else:
|
|
||||||
df_filtered = df_group
|
|
||||||
|
|
||||||
if df_filtered.empty:
|
|
||||||
print(f"No ROI data matches the condition '{selected_event}'.")
|
|
||||||
continue
|
|
||||||
|
|
||||||
all_cha_filtered = DataFrame()
|
|
||||||
if not all_cha.empty:
|
|
||||||
if selected_event and 'Condition' in all_cha.columns:
|
|
||||||
all_cha_filtered = all_cha[all_cha['Condition'] == selected_event]
|
|
||||||
else:
|
|
||||||
all_cha_filtered = all_cha
|
|
||||||
|
|
||||||
|
|
||||||
run_roi_second_level_analysis(
|
|
||||||
df_roi_all=df_filtered,
|
|
||||||
df_cha_all=all_cha_filtered,
|
|
||||||
raw_haemo=p_haemo,
|
raw_haemo=p_haemo,
|
||||||
p_threshold=p_threshold,
|
p_threshold=p_threshold,
|
||||||
min_subjects=min_subjects,
|
min_subjects=min_subjects,
|
||||||
correction_method=correction_method,
|
correction_method=correction_method,
|
||||||
target_chroma=target_chroma,
|
target_chroma=target_chroma,
|
||||||
graph_bounds=graph_bounds if graph_bounds > 0.0 else None,
|
selected_event=selected_event,
|
||||||
|
roi_channel_maps=selected_roi_maps,
|
||||||
|
threshold_topo=threshold_topo # Shows the raw difference map (Unthresholded)
|
||||||
)
|
)
|
||||||
|
|
||||||
elif idx == 1:
|
elif idx == 1:
|
||||||
params = param_values.get(idx, {})
|
|
||||||
p_threshold = params.get("p_threshold", 0.05)
|
|
||||||
min_subjects = params.get("min_subjects", 5)
|
|
||||||
correction_method = params.get("correction_method", "None")
|
|
||||||
target_chroma = params.get("target_chroma", "hbo")
|
|
||||||
roi_a = params.get("roi_a", "").strip()
|
|
||||||
roi_b = params.get("roi_b", "").strip()
|
|
||||||
|
|
||||||
if not selected_event:
|
if not selected_event:
|
||||||
print("Paired ROI contrast requires a specific event/condition "
|
print("Laterality comparison requires a specific event/condition "
|
||||||
"to be selected - pick one from the Event dropdown first.")
|
"to be selected first.")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
if df_group.empty:
|
params = param_values.get(idx, {})
|
||||||
print("No ROI data (df_ind) found for selected participants.")
|
p_threshold = params.get("p_threshold", 0.05)
|
||||||
|
min_subjects = params.get("min_subjects", 3)
|
||||||
|
correction_method = params.get("correction_method", "None")
|
||||||
|
target_chroma = params.get("target_chroma", "hbo")
|
||||||
|
roi_a: str = params.get("roi_a", "").strip()
|
||||||
|
roi_b: str = params.get("roi_b", "").strip()
|
||||||
|
|
||||||
|
if not roi_a or not roi_b:
|
||||||
|
print("Both a contralateral and ipsilateral ROI name must be specified.")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
if correction_method == "None":
|
if correction_method == "None":
|
||||||
correction_method = None
|
correction_method = None
|
||||||
|
|
||||||
if not roi_a or not roi_b:
|
# Build each group's dataframe directly from the dict using
|
||||||
print("Both ROI A and ROI B must be specified.")
|
# the file-path lists as keys - no ID cleaning/matching needed.
|
||||||
|
def _build_group_df(
|
||||||
|
file_paths: list[str],
|
||||||
|
dict_source: dict[str, DataFrame]
|
||||||
|
) -> DataFrame:
|
||||||
|
|
||||||
|
valid_dfs = [
|
||||||
|
dict_source[fp] for fp in file_paths
|
||||||
|
if fp in dict_source and not dict_source[fp].empty
|
||||||
|
]
|
||||||
|
|
||||||
|
return pd.concat(valid_dfs, ignore_index=True) if valid_dfs else pd.DataFrame()
|
||||||
|
|
||||||
|
|
||||||
|
df_roi_a = _build_group_df(file_paths_a, self.df_ind_dict)
|
||||||
|
df_roi_b = _build_group_df(file_paths_b, self.df_ind_dict)
|
||||||
|
|
||||||
|
if df_roi_a.empty or df_roi_b.empty:
|
||||||
|
print("No ROI data (df_ind) found for one or both groups.")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
print(min_subjects)
|
run_inter_group_laterality_analysis(
|
||||||
run_roi_paired_contrast_analysis(
|
df_roi_all_a=df_roi_a,
|
||||||
df_roi_all=df_group,
|
df_roi_all_b=df_roi_b,
|
||||||
roi_pairs=(roi_a, roi_b),
|
roi_pairs=(roi_a, roi_b),
|
||||||
condition=selected_event,
|
condition=selected_event,
|
||||||
|
group_a_name=self.group_a_dropdown.currentText(),
|
||||||
|
group_b_name=self.group_b_dropdown.currentText(),
|
||||||
target_chroma=target_chroma,
|
target_chroma=target_chroma,
|
||||||
min_subjects=min_subjects,
|
min_subjects=min_subjects,
|
||||||
p_threshold=p_threshold,
|
p_threshold=p_threshold,
|
||||||
correction_method=correction_method,
|
correction_method=correction_method,
|
||||||
roi_a_label=roi_a,
|
roi_contra_label=roi_a,
|
||||||
roi_b_label=roi_b,
|
roi_ipsi_label=roi_b,
|
||||||
)
|
)
|
||||||
|
|
||||||
elif idx == 2:
|
elif idx == 2:
|
||||||
params = param_values.get(idx, {})
|
params = param_values.get(idx, {})
|
||||||
p_threshold = params.get("p_threshold", 0.05)
|
p_threshold = params.get("p_threshold", 0.05)
|
||||||
min_subjects = params.get("min_subjects", 5)
|
min_subjects = params.get("min_subjects", 3)
|
||||||
correction_method = params.get("correction_method", "fdr_bh")
|
correction_method = params.get("correction_method", "fdr_bh")
|
||||||
target_chroma = params.get("target_chroma", "hbo")
|
target_chroma = params.get("target_chroma", "hbo")
|
||||||
contrast_name = params.get("contrast_name", "")
|
contrast_name = params.get("contrast_name", "")
|
||||||
weighted = params.get("weighted", True)
|
|
||||||
graph_bounds = params.get("graph_bounds", 0.0)
|
|
||||||
|
|
||||||
if not selected_event:
|
|
||||||
print("Joint contrast ROI analysis requires a specific contrast "
|
|
||||||
"to be selected from the Event dropdown first.")
|
|
||||||
continue
|
|
||||||
|
|
||||||
if not contrast_name:
|
if not contrast_name:
|
||||||
print("Contrast name must be specified.")
|
print("A contrast name must be specified.")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
# Build each group's channel-level contrast dataframe
|
||||||
|
# directly from contrast_results_dict, keyed by file path -
|
||||||
|
# same dict-key approach as the laterality patch, avoids
|
||||||
|
# any ID-string matching.
|
||||||
|
def _build_group_contrast_df(
|
||||||
|
file_paths: list[str],
|
||||||
|
contrast_dict: dict[str, dict[str, pd.DataFrame]],
|
||||||
|
name: str,
|
||||||
|
) -> pd.DataFrame:
|
||||||
|
|
||||||
all_contrasts: list[DataFrame] = []
|
all_rows: list[DataFrame] = []
|
||||||
for fp in selected_file_paths:
|
for fp in file_paths:
|
||||||
condition_dfs = self.contrast_results_dict.get(fp)
|
condition_dfs = contrast_dict.get(fp)
|
||||||
if condition_dfs is None:
|
if condition_dfs is None:
|
||||||
print(f" [MISSING] '{fp}' not found in contrast_results.")
|
print(f" [MISSING] '{fp}' not found in contrast_results.")
|
||||||
continue
|
continue
|
||||||
if contrast_name in condition_dfs:
|
if name in condition_dfs:
|
||||||
df = condition_dfs[contrast_name].copy()
|
df = condition_dfs[name].copy()
|
||||||
df["ID"] = fp
|
df["ID"] = fp
|
||||||
df["contrast_name"] = contrast_name
|
df["contrast_name"] = name
|
||||||
all_contrasts.append(df)
|
all_rows.append(df)
|
||||||
else:
|
else:
|
||||||
print(f" [MISSING CONTRAST] '{contrast_name}' not "
|
print(f" [MISSING CONTRAST] '{name}' not available for '{fp}'.")
|
||||||
f"available for {self.participant_map.get(fp, fp)}.")
|
return pd.concat(all_rows, ignore_index=True) if all_rows else pd.DataFrame()
|
||||||
|
|
||||||
if not all_contrasts:
|
df_contrasts_a = _build_group_contrast_df(file_paths_a, self.contrast_results_dict, contrast_name)
|
||||||
print(f"No contrast data found for '{contrast_name}' "
|
df_contrasts_b = _build_group_contrast_df(file_paths_b, self.contrast_results_dict, contrast_name)
|
||||||
f"across selected participants.")
|
|
||||||
|
if df_contrasts_a.empty or df_contrasts_b.empty:
|
||||||
|
print("No contrast data found for one or both groups.")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
df_contrasts = pd.concat(all_contrasts, ignore_index=True)
|
roi_maps_a = {
|
||||||
|
|
||||||
selected_roi_maps = {
|
|
||||||
fp: self.roi_channel_map_dict[fp]
|
fp: self.roi_channel_map_dict[fp]
|
||||||
for fp in selected_file_paths
|
for fp in file_paths_a
|
||||||
if fp in self.roi_channel_map_dict
|
if fp in self.roi_channel_map_dict
|
||||||
}
|
}
|
||||||
if not selected_roi_maps:
|
roi_maps_b = {
|
||||||
print("No channel-to-ROI mapping available for selected participants.")
|
fp: self.roi_channel_map_dict[fp]
|
||||||
|
for fp in file_paths_b
|
||||||
|
if fp in self.roi_channel_map_dict
|
||||||
|
}
|
||||||
|
if not roi_maps_a or not roi_maps_b:
|
||||||
|
print("No channel-to-ROI mapping available for one or both groups.")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
try:
|
run_inter_group_contrast_analysis(
|
||||||
roi_theta = aggregate_channel_contrasts_to_roi(
|
df_contrasts_a=df_contrasts_a,
|
||||||
df_contrasts,
|
df_contrasts_b=df_contrasts_b,
|
||||||
roi_channel_maps=selected_roi_maps,
|
contrast_name=contrast_name,
|
||||||
weighted=weighted,
|
roi_channel_maps_a=roi_maps_a,
|
||||||
)
|
roi_channel_maps_b=roi_maps_b,
|
||||||
|
group_a_name=self.group_a_dropdown.currentText(),
|
||||||
except Exception as e:
|
group_b_name=self.group_b_dropdown.currentText(),
|
||||||
print(f"Failed to aggregate contrasts to ROI: {e}")
|
|
||||||
continue
|
|
||||||
|
|
||||||
if roi_theta.empty:
|
|
||||||
print("No ROI-level contrast values could be computed "
|
|
||||||
"(check regions.json channel names against this montage).")
|
|
||||||
continue
|
|
||||||
|
|
||||||
# TODO: Come back to this
|
|
||||||
# df_cha_all intentionally omitted (None): the topography
|
|
||||||
# section of run_roi_second_level_analysis expects
|
|
||||||
# single-condition Condition values in df_cha_all, which
|
|
||||||
# doesn't semantically match a contrast name - skip it here
|
|
||||||
# rather than pass mismatched data.
|
|
||||||
run_roi_second_level_analysis(
|
|
||||||
df_roi_all=roi_theta,
|
|
||||||
df_cha_all=None,
|
|
||||||
raw_haemo=p_haemo,
|
|
||||||
p_threshold=p_threshold,
|
|
||||||
min_subjects=min_subjects,
|
|
||||||
correction_method=correction_method,
|
|
||||||
target_chroma=target_chroma,
|
target_chroma=target_chroma,
|
||||||
graph_bounds=graph_bounds if graph_bounds > 0.0 else None,
|
min_subjects=min_subjects,
|
||||||
|
p_threshold=p_threshold,
|
||||||
|
correction_method=correction_method,
|
||||||
)
|
)
|
||||||
|
|
||||||
else:
|
else:
|
||||||
print(f"No method defined for index {idx}")
|
print("no")
|
||||||
@@ -0,0 +1,185 @@
|
|||||||
|
"""
|
||||||
|
Filename: intragroupbrainimage.py
|
||||||
|
Description: Logic for the Intra-Group Brain & Image analysis window
|
||||||
|
Note: Compliant with pylance strict type checking
|
||||||
|
|
||||||
|
Author: Tyler de Zeeuw
|
||||||
|
License: GPL-3.0
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Built-in imports
|
||||||
|
from typing import Any, cast
|
||||||
|
|
||||||
|
# External library imports
|
||||||
|
import pandas as pd
|
||||||
|
from pandas import DataFrame
|
||||||
|
|
||||||
|
from mne import Annotations
|
||||||
|
from mne.io.base import BaseRaw
|
||||||
|
|
||||||
|
from flares import aggregate_fnirs_group_geometry, plot_fir_model_results, brain_3d_visualization
|
||||||
|
from src.shared.flaresbasewidget import IntraGroupUIMixin, FlaresBaseWidget
|
||||||
|
from src.shared.shareddata import APP_NAME
|
||||||
|
|
||||||
|
|
||||||
|
PARAMETERIZED_INDEXES: dict[int, list[dict[str, Any]]] = {
|
||||||
|
0: [
|
||||||
|
{
|
||||||
|
"key": "lower_bound",
|
||||||
|
"label": "Lower bound + <description>",
|
||||||
|
"default": "-0.3",
|
||||||
|
"type": float, # specify int here
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "upper_bound",
|
||||||
|
"label": "Upper bound + <description>",
|
||||||
|
"default": "0.8",
|
||||||
|
"type": float, # specify int here
|
||||||
|
}
|
||||||
|
],
|
||||||
|
1: [
|
||||||
|
{
|
||||||
|
"key": "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,
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
DESCRIPTION = """0. FIR Model Results (plot_fir_model_results)
|
||||||
|
\nCURRENTLY NON-FUNCTIONAL. This method requires per-FIR-delay Condition rows (e.g. "Tapping_delay_3") to plot the shape of the evoked response over time. The dataframe it receives (df_ind_dict) has already had delay information collapsed away upstream in generate_roi_results, regardless of HRF model setting - so this will always fail with an empty-data error. Needs an uncollapsed, per-delay ROI dataframe threaded through separately before it can work again.
|
||||||
|
|
||||||
|
\n1. Brain Activity Visualization (brain_3d_visualization)
|
||||||
|
\nRenders a single group's (or single participant's) channel-level GLM estimates (t or theta values) as a 3D weighted brain map. Fits a mixed-effects model across participants (falling back to OLS for a single participant) to get one estimate per channel, then displays it on a template brain surface with optional optode/sensor overlay.
|
||||||
|
\nUses collapsed (non-FIR-delay) condition data - shows the overall magnitude of the response per channel, not its time course. Geometry for multi-participant views is averaged across participants' actual optode positions where available; channels or optodes missing valid 3D coordinates for every participant are silently excluded from the map.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class IntraGroupBrainImageWidget(IntraGroupUIMixin, FlaresBaseWidget):
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
haemo_dict: dict[str, BaseRaw],
|
||||||
|
cha_dict: dict[str, DataFrame],
|
||||||
|
df_ind_dict: dict[str, DataFrame],
|
||||||
|
design_matrix_dict: dict[str, DataFrame],
|
||||||
|
contrast_results_dict: dict[str, dict[str, Any]],
|
||||||
|
group_dict: dict[str, str]
|
||||||
|
) -> None:
|
||||||
|
|
||||||
|
super().__init__("IntraGroupBrainImage")
|
||||||
|
self.setWindowTitle(f"Intra-Group Brain & Image Viewer - {APP_NAME.upper()}")
|
||||||
|
self.haemo_dict = haemo_dict
|
||||||
|
self.cha_dict = cha_dict
|
||||||
|
self.df_ind_dict = df_ind_dict
|
||||||
|
self.design_matrix_dict = design_matrix_dict
|
||||||
|
self.contrast_results_dict = contrast_results_dict
|
||||||
|
self.group_dict = group_dict
|
||||||
|
|
||||||
|
self.setup_intra_group_ui(["0 (GLM Results)", "1 (Brain Activity Visualization)"], placeholder_text=DESCRIPTION)
|
||||||
|
|
||||||
|
|
||||||
|
def process_request(self):
|
||||||
|
request = self.get_common_request_data(PARAMETERIZED_INDEXES)
|
||||||
|
if request is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
(selected_event, selected_file_paths, selected_indexes, raw_params) = request
|
||||||
|
|
||||||
|
param_values = cast(dict[int | str, dict[str, Any]], raw_params)
|
||||||
|
|
||||||
|
all_cha = pd.DataFrame()
|
||||||
|
for file_path in selected_file_paths:
|
||||||
|
haemo_obj = self.haemo_dict.get(file_path)
|
||||||
|
|
||||||
|
if haemo_obj is None:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if selected_event:
|
||||||
|
raw_annotations = getattr(haemo_obj, "annotations", None)
|
||||||
|
|
||||||
|
if raw_annotations is not None:
|
||||||
|
annotations = cast(Annotations, raw_annotations)
|
||||||
|
descriptions = cast(list[str], list(annotations.description))
|
||||||
|
participant_events: set[str] = set(descriptions)
|
||||||
|
else:
|
||||||
|
participant_events: set[str] = set()
|
||||||
|
|
||||||
|
if selected_event not in participant_events:
|
||||||
|
print(f"Skipping {self.participant_map[file_path]}: Event '{selected_event}' not found.")
|
||||||
|
continue
|
||||||
|
|
||||||
|
cha_df = self.cha_dict.get(file_path)
|
||||||
|
if cha_df is not None:
|
||||||
|
all_cha = pd.concat([all_cha, cha_df], ignore_index=True)
|
||||||
|
|
||||||
|
# Pass the necessary arguments to each method
|
||||||
|
file_path = selected_file_paths[0]
|
||||||
|
p_haemo = self.haemo_dict.get(file_path)
|
||||||
|
p_design_matrix = self.design_matrix_dict.get(file_path)
|
||||||
|
|
||||||
|
df_group = pd.DataFrame()
|
||||||
|
|
||||||
|
if selected_file_paths:
|
||||||
|
for file_path in selected_file_paths:
|
||||||
|
df = self.df_ind_dict.get(file_path)
|
||||||
|
if df is not None:
|
||||||
|
df_group = pd.concat([df_group, df], ignore_index=True)
|
||||||
|
|
||||||
|
|
||||||
|
for idx in selected_indexes:
|
||||||
|
if idx == 0:
|
||||||
|
params = param_values.get(idx, {})
|
||||||
|
lower_bound = params.get("lower_bound", None)
|
||||||
|
upper_bound = params.get("upper_bound", None)
|
||||||
|
|
||||||
|
if lower_bound is None or upper_bound is None:
|
||||||
|
print(f"Missing parameters for index {idx}, skipping.")
|
||||||
|
continue
|
||||||
|
|
||||||
|
plot_fir_model_results(df_group, p_haemo, p_design_matrix, selected_event, lower_bound, upper_bound)
|
||||||
|
|
||||||
|
elif idx == 1:
|
||||||
|
params = param_values.get(idx, {})
|
||||||
|
show_optodes = params.get("show_optodes", None)
|
||||||
|
t_or_theta = params.get("t_or_theta", None)
|
||||||
|
show_text = params.get("show_text", None)
|
||||||
|
brain_bounds = params.get("brain_bounds", None)
|
||||||
|
|
||||||
|
if show_optodes is None or t_or_theta is None or show_text is None or brain_bounds is None:
|
||||||
|
print(f"Missing parameters for index {idx}, skipping.")
|
||||||
|
continue
|
||||||
|
|
||||||
|
all_raw_objs = [self.haemo_dict.get(fp) for fp in selected_file_paths if self.haemo_dict.get(fp)]
|
||||||
|
|
||||||
|
if len(all_raw_objs) > 1:
|
||||||
|
processed_raw = aggregate_fnirs_group_geometry(all_raw_objs)
|
||||||
|
elif len(all_raw_objs) == 1 and all_raw_objs[0] is not None:
|
||||||
|
processed_raw = all_raw_objs[0].copy()
|
||||||
|
processed_raw.pick(picks="hbo") # type: ignore
|
||||||
|
else:
|
||||||
|
processed_raw = None
|
||||||
|
|
||||||
|
brain_3d_visualization(processed_raw, all_cha, selected_event, t_or_theta=t_or_theta, show_optodes=show_optodes, show_text=show_text, brain_bounds=brain_bounds)
|
||||||
|
|
||||||
|
else:
|
||||||
|
print(f"No method defined for index {idx}")
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
"""
|
||||||
|
Filename: intragroupfunctionalconnectivity.py
|
||||||
|
Description: Logic for the Intra-Group Functional Connectivity analysis window
|
||||||
|
Note: Compliant with pylance strict type checking
|
||||||
|
|
||||||
|
Author: Tyler de Zeeuw
|
||||||
|
License: GPL-3.0
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Built-in imports
|
||||||
|
from typing import Any, cast
|
||||||
|
|
||||||
|
# External library imports
|
||||||
|
from PySide6.QtWidgets import QMessageBox
|
||||||
|
|
||||||
|
from mne import Epochs
|
||||||
|
from mne.io.base import BaseRaw
|
||||||
|
|
||||||
|
from flares import run_group_functional_connectivity_betas, run_group_functional_connectivity_epochs
|
||||||
|
from src.shared.flaresbasewidget import IntraGroupUIMixin, FlaresBaseWidget
|
||||||
|
from src.shared.shareddata import APP_NAME
|
||||||
|
|
||||||
|
|
||||||
|
PARAMETERIZED_INDEXES: dict[int, list[dict[str, Any]]] = {
|
||||||
|
0: [ # Beta-Series Correlation
|
||||||
|
{"key": "n_lines", "label": "Number of strongest connections to draw", "default": "20", "type": int},
|
||||||
|
{"key": "vmin", "label": "Minimum |r| to display (group average)", "default": "0.5", "type": float},
|
||||||
|
{"key": "drift_model", "label": "Drift model", "default": "cosine", "type": list, "options": ["cosine", "polynomial"]},
|
||||||
|
{"key": "drift_order", "label": "Drift order", "default": "1", "type": int},
|
||||||
|
{"key": "hrf_model", "label": "HRF model", "default": "glover", "type": list, "options": ["glover", "spm", "fir"]},
|
||||||
|
{"key": "apply_gsr", "label": "Apply Global Signal Regression", "default": "True", "type": bool},
|
||||||
|
{"key": "resample_freq", "label": "Resample rate before GLM fit (Hz) - lower is much faster", "default": "4.0", "type": float},
|
||||||
|
{"key": "alpha", "label": "FDR significance threshold (group-level)", "default": "0.05", "type": float},
|
||||||
|
{"key": "min_participants", "label": "Minimum participants required to run the group test", "default": "3", "type": int},
|
||||||
|
],
|
||||||
|
1: [ # Spectral Coherence
|
||||||
|
{"key": "method", "label": "Connectivity method", "default": "wpli2_debiased", "type": list, "options": ["coh", "pli", "wpli2_debiased"]},
|
||||||
|
{"key": "n_lines", "label": "Number of strongest connections to draw", "default": "20", "type": int},
|
||||||
|
{"key": "vmin", "label": "Minimum |r| to display (group average)", "default": "0.5", "type": float},
|
||||||
|
{"key": "fmin", "label": "Lower frequency bound (Hz)", "default": "0.04", "type": float},
|
||||||
|
{"key": "fmax", "label": "Upper frequency bound (Hz)", "default": "0.2", "type": float},
|
||||||
|
{"key": "alpha", "label": "FDR significance threshold (group-level)", "default": "0.05", "type": float},
|
||||||
|
{"key": "min_participants", "label": "Minimum participants required to run the group test", "default": "3", "type": int},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
DESCRIPTION = """0. Beta-Series Correlation (run_group_functional_connectivity_betas)
|
||||||
|
\nFor each selected participant, resamples to resample_freq (default 4 Hz - well above what's needed to resolve trial-level GLM amplitudes, but far cheaper than running the fit at full acquisition rate) and computes trial-level GLM betas per channel, correlating them within-subject WITHOUT thresholding at the individual level. Those raw per-subject correlation matrices are Fisher-Z transformed and combined across the group using a one-sample t-test (against zero) per channel pair, then FDR-corrected (q < alpha) across all pairs. A significant connection means the group, on average, shows consistent trial-evoked co-activation between two channels - not that every individual participant showed it.
|
||||||
|
\nRequires at least min_participants (default 3, more is stronger) participants with usable data - each needs enough trials of the selected event to compute their own beta series. Participants with channel sets that don't overlap with the rest of the group are excluded from the shared channel set before analysis.
|
||||||
|
\nWith a small number of participants and many channel pairs, FDR correction is often the limiting factor even when there's a real underlying effect - check the p-value histogram and top-pairs report generated alongside the main plot: a cluster of small (but not FDR-significant) p-values well below what's expected by chance suggests a real but underpowered effect, worth revisiting with more participants, rather than a true null result.
|
||||||
|
|
||||||
|
\n1. Spectral Coherence (run_group_functional_connectivity_epochs)
|
||||||
|
\nFor each selected participant, computes spectral connectivity between HbO channels using the selected method: coherence ('coh'), Phase Lag Index ('pli'), or debiased weighted PLI squared ('wpli2_debiased', default). PLI/wPLI-family methods discount zero-lag contributions to connectivity, making them substantially more robust to shared systemic/vascular signal (which tends to hit multiple channels near-simultaneously) than plain coherence - recommended over 'coh' unless you have a specific reason to want raw coherence. Raw per-subject matrices are combined across the group the same way as the Beta-Series method: Fisher-Z, one-sample t-test per channel pair, FDR correction.
|
||||||
|
\nfmin must satisfy at least 5 full oscillation cycles within your epoch length (epoch_duration x fmin >= 5) for a reliable estimate - if it doesn't, the analysis will refuse to run with an error stating the minimum viable fmin for your epoch length, rather than silently producing an unreliable result. Shorter epochs require a higher fmin, which moves you out of the classic 0.04-0.2 Hz "low-frequency oscillation" band used in longer resting-state recordings - this is a real trade-off in what the analysis measures, not just a technical constraint.
|
||||||
|
\nSame minimum-participant, channel-alignment, and underpowered-vs-null-result caveats apply as the Beta-Series method above.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class IntraGroupFunctionalConnectivityWidget(IntraGroupUIMixin, FlaresBaseWidget):
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
haemo_dict: dict[str, BaseRaw],
|
||||||
|
epochs_dict: dict[str, Epochs],
|
||||||
|
group_dict: dict[str, str],
|
||||||
|
) -> None:
|
||||||
|
|
||||||
|
super().__init__("IntraGroupFunctionalConnectivity")
|
||||||
|
self.setWindowTitle(f"Intra-Group Functional Connectivity Viewer [BETA] - {APP_NAME.upper()}")
|
||||||
|
self.haemo_dict = haemo_dict
|
||||||
|
self.epochs_dict = epochs_dict
|
||||||
|
self.group_dict = group_dict
|
||||||
|
|
||||||
|
QMessageBox.warning(self, f"Warning - {APP_NAME.upper()}", f"Functional Connectivity is still in beta. While the results are now almost finalized, the processing is slow and it WILL hang the application for HOURS.")
|
||||||
|
|
||||||
|
self.setup_intra_group_ui(["0 (Beta-Series Correlation)", "1 (Spectral Coherence)"], placeholder_text=DESCRIPTION)
|
||||||
|
|
||||||
|
|
||||||
|
def process_request(self):
|
||||||
|
request = self.get_common_request_data(PARAMETERIZED_INDEXES)
|
||||||
|
if request is None:
|
||||||
|
return
|
||||||
|
(selected_event, selected_file_paths, selected_indexes, raw_params) = request
|
||||||
|
param_values = cast(dict[int | str, dict[str, Any]], raw_params)
|
||||||
|
|
||||||
|
for idx in selected_indexes:
|
||||||
|
params = param_values.get(idx, {})
|
||||||
|
|
||||||
|
if idx == 0:
|
||||||
|
n_lines = params.get("n_lines", 20)
|
||||||
|
vmin = params.get("vmin", 0.5)
|
||||||
|
drift_model = params.get("drift_model", "cosine")
|
||||||
|
drift_order = params.get("drift_order", 1)
|
||||||
|
hrf_model = params.get("hrf_model", "glover")
|
||||||
|
apply_gsr = params.get("apply_gsr", True)
|
||||||
|
resample_freq = params.get("resample_freq", 4.0)
|
||||||
|
alpha = params.get("alpha", 0.05)
|
||||||
|
min_participants = params.get("min_participants", 3)
|
||||||
|
|
||||||
|
run_group_functional_connectivity_betas(
|
||||||
|
self.haemo_dict,
|
||||||
|
selected_file_paths,
|
||||||
|
selected_event,
|
||||||
|
n_lines,
|
||||||
|
vmin,
|
||||||
|
drift_model=drift_model,
|
||||||
|
drift_order=drift_order,
|
||||||
|
hrf_model=hrf_model,
|
||||||
|
apply_gsr=apply_gsr,
|
||||||
|
resample_freq=resample_freq,
|
||||||
|
alpha=alpha,
|
||||||
|
min_participants=min_participants,
|
||||||
|
)
|
||||||
|
|
||||||
|
elif idx == 1:
|
||||||
|
method = params.get("method", "wpli2_debiased")
|
||||||
|
n_lines = params.get("n_lines", 20)
|
||||||
|
vmin = params.get("vmin", 0.5)
|
||||||
|
fmin = params.get("fmin", 0.04)
|
||||||
|
fmax = params.get("fmax", 0.2)
|
||||||
|
alpha = params.get("alpha", 0.05)
|
||||||
|
min_participants = params.get("min_participants", 3)
|
||||||
|
|
||||||
|
run_group_functional_connectivity_epochs(
|
||||||
|
self.epochs_dict,
|
||||||
|
selected_file_paths,
|
||||||
|
event_name=selected_event,
|
||||||
|
n_lines=n_lines,
|
||||||
|
vmin=vmin,
|
||||||
|
fmin=fmin,
|
||||||
|
method=method,
|
||||||
|
fmax=fmax,
|
||||||
|
alpha=alpha,
|
||||||
|
min_participants=min_participants,
|
||||||
|
)
|
||||||
|
|
||||||
|
else:
|
||||||
|
print(f"No method defined for index {idx}")
|
||||||
@@ -0,0 +1,400 @@
|
|||||||
|
"""
|
||||||
|
Filename: intragroupstats.py
|
||||||
|
Description: Logic for the Intra-Group Stats analysis window
|
||||||
|
Note: Compliant with pylance strict type checking
|
||||||
|
|
||||||
|
Author: Tyler de Zeeuw
|
||||||
|
License: GPL-3.0
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Built-in imports
|
||||||
|
from typing import Any, cast
|
||||||
|
|
||||||
|
# External library imports
|
||||||
|
import pandas as pd
|
||||||
|
from pandas import DataFrame
|
||||||
|
|
||||||
|
from mne import Annotations
|
||||||
|
from mne.io.base import BaseRaw
|
||||||
|
|
||||||
|
from flares import run_roi_paired_contrast_analysis, run_roi_second_level_analysis, aggregate_channel_contrasts_to_roi
|
||||||
|
from src.shared.flaresbasewidget import IntraGroupUIMixin, FlaresBaseWidget
|
||||||
|
from src.shared.shareddata import APP_NAME
|
||||||
|
|
||||||
|
|
||||||
|
PARAMETERIZED_INDEXES: dict[int, list[dict[str, Any]]] = {
|
||||||
|
0: [
|
||||||
|
{
|
||||||
|
"key": "p_threshold",
|
||||||
|
"label": "Significance threshold P-value (e.g. 0.05)",
|
||||||
|
"default": "0.05",
|
||||||
|
"type": float,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "min_subjects",
|
||||||
|
"label": "Minimum number of participants to process",
|
||||||
|
"default": "5",
|
||||||
|
"type": int,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "correction_method",
|
||||||
|
"label": "Correction method to utilize. Valid values are 'fdr_bh', 'None'",
|
||||||
|
"default": "fdr_bh",
|
||||||
|
"type": str,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "target_chroma",
|
||||||
|
"label": "Which chroma to target. Valid values are 'hbo', 'hbr'",
|
||||||
|
"default": "hbo",
|
||||||
|
"type": str,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "graph_bounds",
|
||||||
|
"label": "Graph Upper/Lower Limit",
|
||||||
|
"default": "0.0",
|
||||||
|
"type": float,
|
||||||
|
}
|
||||||
|
],
|
||||||
|
1: [
|
||||||
|
{
|
||||||
|
"key": "p_threshold",
|
||||||
|
"label": "Significance threshold P-value (e.g. 0.05)",
|
||||||
|
"default": "0.05",
|
||||||
|
"type": float,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "min_subjects",
|
||||||
|
"label": "Minimum number of participants to process",
|
||||||
|
"default": "5",
|
||||||
|
"type": int,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "correction_method",
|
||||||
|
"label": "Correction method to utilize. Valid values are 'fdr_bh', 'None'",
|
||||||
|
"default": "None",
|
||||||
|
"type": str,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "target_chroma",
|
||||||
|
"label": "Which chroma to target. Valid values are 'hbo', 'hbr'",
|
||||||
|
"default": "hbo",
|
||||||
|
"type": str,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "roi_a",
|
||||||
|
"label": "ROI A (e.g. contralateral region name from regions.json)",
|
||||||
|
"default": [],
|
||||||
|
"type": list,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "roi_b",
|
||||||
|
"label": "ROI B (e.g. ipsilateral region name from regions.json)",
|
||||||
|
"default": [],
|
||||||
|
"type": list,
|
||||||
|
}
|
||||||
|
],
|
||||||
|
2: [
|
||||||
|
{
|
||||||
|
"key": "p_value",
|
||||||
|
"label": "Significance threshold P-value (e.g. 0.05)",
|
||||||
|
"default": "0.05",
|
||||||
|
"type": float,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "min_subjects",
|
||||||
|
"label": "Minimum number of participants to process",
|
||||||
|
"default": "5",
|
||||||
|
"type": int,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "correction_method",
|
||||||
|
"label": "Correction method to utilize. Valid values are 'fdr_bh', 'None'",
|
||||||
|
"default": "fdr_bh",
|
||||||
|
"type": str,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "target_chroma",
|
||||||
|
"label": "Which chroma to target. Valid values are 'hbo', 'hbr'",
|
||||||
|
"default": "hbo",
|
||||||
|
"type": str,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "contrast_name",
|
||||||
|
"label": "Name of the contrast to use",
|
||||||
|
"default": [],
|
||||||
|
"type": list,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "weighted",
|
||||||
|
"label": "Use inverse-variance weighting to minimize noisy channels",
|
||||||
|
"default": True,
|
||||||
|
"type": bool,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "graph_bounds",
|
||||||
|
"label": "Graph Upper/Lower Limit",
|
||||||
|
"default": "0.0",
|
||||||
|
"type": float,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
DESCRIPTION = """0. ROI vs. Zero (run_roi_second_level_analysis)
|
||||||
|
\nTests whether one ROI's response during one condition reliably differs from zero across subjects - a one-sample t-test on each subject's ROI-averaged theta. A significant result means the region's signal during this condition is consistently non-zero across your sample, not just noise. It does not tell you whether the response is localized/specific to this region, or whether it reflects real neural activity versus systemic physiology (blood pressure, arousal) shared across the whole head during any active task - a single-condition "vs. zero" test can't distinguish those two explanations on its own.
|
||||||
|
\nIf you expected significance here and didn't get it, likely causes include: the sample size is simply small relative to between-subject variability in true response magnitude or HRF shape (individual differences in timing/amplitude inflate the variance a t-test divides by); the ROI's channel composition differs slightly across subjects (missing channels get down-weighted or excluded from the inverse-variance average, diluting a real signal); FDR correction across many ROIs is suppressing a modest true effect that would clear an uncorrected threshold; or the condition itself may not reliably engage this region the way you assumed (worth checking the single-subject/individual-level results for this ROI before concluding the group effect isn't there).
|
||||||
|
\n\n1. Paired ROI Contrast (run_roi_paired_contrast_analysis)
|
||||||
|
\nFor one condition, subtracts each subject's ROI_A response from their ROI_B response, then tests whether that per-subject difference is reliably non-zero. A significant result is a genuine spatial contrast - the two regions respond differently from each other during this specific condition, with shared systemic noise partially cancelling in the subtraction. It says nothing about whether the condition produced meaningful activity at all (only a relative difference between two places), and its power depends entirely on ROI_A and ROI_B varying together across subjects - an assumption that isn't guaranteed.
|
||||||
|
\nIf this test underperforms a plain ROI-vs-zero result, which can occur, the most likely explanation is that ROI_A and ROI_B's noise isn't well-correlated across your subjects. The math is variance(A - B) = variance(A) + variance(B) - 2·covariance(A,B): subtraction only helps when the shared/systemic component is large relative to independent noise in each region. If the two regions are picking up largely independent noise sources (motion artifact affecting one side more, different channel quality, etc.), subtracting adds variance rather than removing it, and can turn a detectable single-ROI effect into an underpowered paired one. Small sample size makes this worse, since the covariance itself is poorly estimated with few subjects.
|
||||||
|
\n\n2. Joint Contrast, ROI-Aggregated (aggregate_channel_contrasts_to_roi + one-sample test)
|
||||||
|
\nUses a contrast fit jointly within each subject's GLM (Condition A minus Condition B, estimated together), then aggregates that per-channel contrast to ROI level using inverse-variance weighting, and tests it against zero across subjects. A significant result means the two conditions produce reliably different responses at this ROI, with systemic noise largely cancelled at the model-fitting stage itself - the most statistically efficient of the three within-group methods, since the correlation between conditions is handled natively rather than inferred afterward. It does not tell you where the difference is localized on its own - for that, compare the sign/pattern across multiple ROIs: opposite signs across regions indicates a real, spatially-specific effect, while the same sign everywhere suggests diffuse/systemic noise rather than localized activity (as seen when comparing a real task-vs-task contrast against a task-vs-inert-marker contrast).
|
||||||
|
\nIf this comes back non-significant despite expecting an effect, first check whether the two conditions are actually similar enough in their neural engagement of this ROI that a small or genuinely near-zero contrast is the correct answer - not every ROI should differentiate every pair of tasks, and a null result here can be the right result. Beyond that: FDR correction across every ROI in your regions file can suppress a real but modest contrast; the inverse-variance weighting can be destabilized if a few channels within the ROI have very noisy or near-zero t-statistics (their standard error estimate becomes huge or unstable); and - as always - small subject counts limit the achievable degrees of freedom regardless of how clean the underlying per-channel estimates are.
|
||||||
|
\n\n
|
||||||
|
\nWhy channels needed to be aggregated into ROIs: Testing every channel independently means paying a steep multiple-comparisons tax - with dozens of channels, FDR/Bonferroni correction demands very large effect sizes to call anything significant, and at small subject counts (n=5) essentially nothing survives even when a real, consistent effect exists. Collapsing channels into a handful of anatomically meaningful ROIs cuts the number of independent tests from a minimum of ~40 down to 2-8, which lets a genuinely present effect actually clear correction. It also matches the scientific question better: you have a hypothesis about regions (contralateral motor cortex, prefrontal cortex), not about individual source-detector pairs, so testing at the ROI level is testing the thing you actually believe in, using inverse-variance weighting so noisier channels contribute less to the region's combined estimate rather than diluting it equally.
|
||||||
|
\nWhy some analyses needed contrasts instead of raw values: A single condition's GLM beta is only ever measured relative to the model's implicit intercept, and that intercept absorbs whatever's happening for the rest of the recording - including systemic physiology (blood pressure, arousal, general vascular reactivity) that rises during almost any active task, not just the one you care about. Testing a raw "vs. zero" value can't tell a real, localized neural response apart from that shared full-head noise. A contrast - either a within-subject spatial subtraction (ROI A minus ROI B) or a jointly-fit task contrast (Condition A minus Condition B, estimated together in one GLM) cancels out whatever's common to both halves of the subtraction, leaving something closer to the actual differential signal.
|
||||||
|
\nWhy a minimum subject count is enforced: Every one of these tests is a t-test, and a t-test's ability to detect a real effect (its power) depends heavily on degrees of freedom - at n=5 (df=4), even a fairly large true effect can produce a middling p-value, and at n=2 (df=1) the test is barely meaningful at all regardless of the underlying data. The min_subjects floor exists to stop a channel or ROI from being silently tested (and potentially reported as significant or non-significant) on a sample too small for the resulting p-value to mean anything reliable - it's better to explicitly skip and flag an underpowered channel than to quietly produce a number that looks statistically legitimate but isn't backed by enough independent observations to trust."""
|
||||||
|
|
||||||
|
|
||||||
|
class IntraGroupStatsWidget(IntraGroupUIMixin, FlaresBaseWidget):
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
haemo_dict: dict[str, BaseRaw],
|
||||||
|
cha_dict: dict[str, DataFrame],
|
||||||
|
df_ind_dict: dict[str, DataFrame],
|
||||||
|
design_matrix_dict: dict[str, DataFrame],
|
||||||
|
contrast_results_dict: dict[str, dict[str, Any]],
|
||||||
|
roi_channel_map_dict: dict[str, dict[str, str]],
|
||||||
|
group_dict: dict[str, str],
|
||||||
|
) -> None:
|
||||||
|
|
||||||
|
super().__init__("IntraGroupStats")
|
||||||
|
self.setWindowTitle(f"Intra-Group Stats Viewer - {APP_NAME.upper()}")
|
||||||
|
self.haemo_dict = haemo_dict
|
||||||
|
self.cha_dict = cha_dict
|
||||||
|
self.df_ind_dict = df_ind_dict
|
||||||
|
self.design_matrix_dict = design_matrix_dict
|
||||||
|
self.contrast_results_dict = contrast_results_dict
|
||||||
|
self.roi_channel_map_dict = roi_channel_map_dict
|
||||||
|
self.group_dict = group_dict
|
||||||
|
|
||||||
|
self.setup_intra_group_ui(["0 (ROI vs. Zero)", "1 (Paired ROI Contrast)", "2 (Joint Contrast, ROI-Aggregated)"], placeholder_text=DESCRIPTION)
|
||||||
|
|
||||||
|
|
||||||
|
def process_request(self):
|
||||||
|
request = self.get_common_request_data(PARAMETERIZED_INDEXES, self.df_ind_dict, self.contrast_results_dict)
|
||||||
|
if request is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
(selected_event, selected_file_paths, selected_indexes, raw_params) = request
|
||||||
|
|
||||||
|
param_values = cast(dict[int | str, dict[str, Any]], raw_params)
|
||||||
|
|
||||||
|
all_cha = DataFrame()
|
||||||
|
for file_path in selected_file_paths:
|
||||||
|
haemo_obj = self.haemo_dict.get(file_path)
|
||||||
|
|
||||||
|
if haemo_obj is None:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if selected_event:
|
||||||
|
raw_annotations = getattr(haemo_obj, "annotations", None)
|
||||||
|
|
||||||
|
if raw_annotations is not None:
|
||||||
|
annotations = cast(Annotations, raw_annotations)
|
||||||
|
descriptions = cast(list[str], list(annotations.description))
|
||||||
|
participant_events: set[str] = set(descriptions)
|
||||||
|
else:
|
||||||
|
participant_events: set[str] = set()
|
||||||
|
|
||||||
|
if selected_event not in participant_events:
|
||||||
|
print(f"Skipping {self.participant_map[file_path]}: Event '{selected_event}' not found.")
|
||||||
|
continue
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
cha_df = self.cha_dict.get(file_path)
|
||||||
|
if cha_df is not None:
|
||||||
|
all_cha = pd.concat([all_cha, cha_df], ignore_index=True)
|
||||||
|
|
||||||
|
file_path = selected_file_paths[0]
|
||||||
|
p_haemo = self.haemo_dict.get(file_path)
|
||||||
|
|
||||||
|
# Concatenate individual ROI stats (df_ind) for all chosen subjects
|
||||||
|
df_group = DataFrame()
|
||||||
|
if selected_file_paths:
|
||||||
|
for file_path in selected_file_paths:
|
||||||
|
df = self.df_ind_dict.get(file_path)
|
||||||
|
if df is not None:
|
||||||
|
df_group = pd.concat([df_group, df], ignore_index=True)
|
||||||
|
|
||||||
|
for idx in selected_indexes:
|
||||||
|
if idx == 0:
|
||||||
|
params = param_values.get(idx, {})
|
||||||
|
p_threshold = params.get("p_threshold", 0.05)
|
||||||
|
min_subjects = params.get("min_subjects", 5)
|
||||||
|
correction_method = params.get("correction_method", "fdr_bh")
|
||||||
|
target_chroma = params.get("target_chroma", "hbo")
|
||||||
|
graph_bounds = params.get("graph_bounds", 0.0)
|
||||||
|
|
||||||
|
if correction_method == "None":
|
||||||
|
correction_method = None
|
||||||
|
|
||||||
|
if not selected_event:
|
||||||
|
print("Warning: No event condition selected for ROI analysis.")
|
||||||
|
continue
|
||||||
|
|
||||||
|
if df_group.empty:
|
||||||
|
print("No ROI data (df_ind) found for selected participants.")
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Filter down to the selected experimental event/condition
|
||||||
|
if selected_event:
|
||||||
|
if 'Condition' in df_group.columns:
|
||||||
|
df_filtered = df_group[df_group['Condition'] == selected_event]
|
||||||
|
else:
|
||||||
|
print("Warning: 'Condition' column not found in ROI data.")
|
||||||
|
df_filtered = df_group
|
||||||
|
else:
|
||||||
|
df_filtered = df_group
|
||||||
|
|
||||||
|
if df_filtered.empty:
|
||||||
|
print(f"No ROI data matches the condition '{selected_event}'.")
|
||||||
|
continue
|
||||||
|
|
||||||
|
all_cha_filtered = DataFrame()
|
||||||
|
if not all_cha.empty:
|
||||||
|
if selected_event and 'Condition' in all_cha.columns:
|
||||||
|
all_cha_filtered = all_cha[all_cha['Condition'] == selected_event]
|
||||||
|
else:
|
||||||
|
all_cha_filtered = all_cha
|
||||||
|
|
||||||
|
run_roi_second_level_analysis(
|
||||||
|
df_roi_all=df_filtered,
|
||||||
|
condition=selected_event,
|
||||||
|
df_cha_all=all_cha_filtered,
|
||||||
|
raw_haemo=p_haemo,
|
||||||
|
p_threshold=p_threshold,
|
||||||
|
min_subjects=min_subjects,
|
||||||
|
correction_method=correction_method,
|
||||||
|
target_chroma=target_chroma,
|
||||||
|
graph_bounds=graph_bounds if graph_bounds > 0.0 else None,
|
||||||
|
)
|
||||||
|
|
||||||
|
elif idx == 1:
|
||||||
|
params = param_values.get(idx, {})
|
||||||
|
p_threshold = params.get("p_threshold", 0.05)
|
||||||
|
min_subjects = params.get("min_subjects", 5)
|
||||||
|
correction_method = params.get("correction_method", "None")
|
||||||
|
target_chroma = params.get("target_chroma", "hbo")
|
||||||
|
roi_a = params.get("roi_a", "").strip()
|
||||||
|
roi_b = params.get("roi_b", "").strip()
|
||||||
|
|
||||||
|
if not selected_event:
|
||||||
|
print("Paired ROI contrast requires a specific event/condition "
|
||||||
|
"to be selected - pick one from the Event dropdown first.")
|
||||||
|
continue
|
||||||
|
|
||||||
|
if df_group.empty:
|
||||||
|
print("No ROI data (df_ind) found for selected participants.")
|
||||||
|
continue
|
||||||
|
|
||||||
|
if correction_method == "None":
|
||||||
|
correction_method = None
|
||||||
|
|
||||||
|
if not roi_a or not roi_b:
|
||||||
|
print("Both ROI A and ROI B must be specified.")
|
||||||
|
continue
|
||||||
|
|
||||||
|
run_roi_paired_contrast_analysis(
|
||||||
|
df_roi_all=df_group,
|
||||||
|
roi_pairs=(roi_a, roi_b),
|
||||||
|
condition=selected_event,
|
||||||
|
target_chroma=target_chroma,
|
||||||
|
min_subjects=min_subjects,
|
||||||
|
p_threshold=p_threshold,
|
||||||
|
correction_method=correction_method,
|
||||||
|
roi_a_label=roi_a,
|
||||||
|
roi_b_label=roi_b,
|
||||||
|
)
|
||||||
|
|
||||||
|
elif idx == 2:
|
||||||
|
params = param_values.get(idx, {})
|
||||||
|
p_threshold = params.get("p_threshold", 0.05)
|
||||||
|
min_subjects = params.get("min_subjects", 5)
|
||||||
|
correction_method = params.get("correction_method", "fdr_bh")
|
||||||
|
target_chroma = params.get("target_chroma", "hbo")
|
||||||
|
contrast_name = params.get("contrast_name", "")
|
||||||
|
weighted = params.get("weighted", True)
|
||||||
|
graph_bounds = params.get("graph_bounds", 0.0)
|
||||||
|
|
||||||
|
if not selected_event:
|
||||||
|
print("Joint contrast ROI analysis requires a specific contrast "
|
||||||
|
"to be selected from the Event dropdown first.")
|
||||||
|
continue
|
||||||
|
|
||||||
|
if not contrast_name:
|
||||||
|
print("Contrast name must be specified.")
|
||||||
|
continue
|
||||||
|
|
||||||
|
|
||||||
|
all_contrasts: list[DataFrame] = []
|
||||||
|
for fp in selected_file_paths:
|
||||||
|
condition_dfs = self.contrast_results_dict.get(fp)
|
||||||
|
if condition_dfs is None:
|
||||||
|
print(f" [MISSING] '{fp}' not found in contrast_results.")
|
||||||
|
continue
|
||||||
|
if contrast_name in condition_dfs:
|
||||||
|
df = condition_dfs[contrast_name].copy()
|
||||||
|
df["ID"] = fp
|
||||||
|
df["contrast_name"] = contrast_name
|
||||||
|
all_contrasts.append(df)
|
||||||
|
else:
|
||||||
|
print(f" [MISSING CONTRAST] '{contrast_name}' not "
|
||||||
|
f"available for {self.participant_map.get(fp, fp)}.")
|
||||||
|
|
||||||
|
if not all_contrasts:
|
||||||
|
print(f"No contrast data found for '{contrast_name}' "
|
||||||
|
f"across selected participants.")
|
||||||
|
continue
|
||||||
|
|
||||||
|
df_contrasts = pd.concat(all_contrasts, ignore_index=True)
|
||||||
|
|
||||||
|
selected_roi_maps = {
|
||||||
|
fp: self.roi_channel_map_dict[fp]
|
||||||
|
for fp in selected_file_paths
|
||||||
|
if fp in self.roi_channel_map_dict
|
||||||
|
}
|
||||||
|
if not selected_roi_maps:
|
||||||
|
print("No channel-to-ROI mapping available for selected participants.")
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
roi_theta = aggregate_channel_contrasts_to_roi(
|
||||||
|
df_contrasts,
|
||||||
|
roi_channel_maps=selected_roi_maps,
|
||||||
|
weighted=weighted,
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Failed to aggregate contrasts to ROI: {e}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
if roi_theta.empty:
|
||||||
|
print("No ROI-level contrast values could be computed "
|
||||||
|
"(check regions.json channel names against this montage).")
|
||||||
|
continue
|
||||||
|
|
||||||
|
run_roi_second_level_analysis(
|
||||||
|
df_roi_all=roi_theta,
|
||||||
|
condition=contrast_name,
|
||||||
|
df_cha_all=None,
|
||||||
|
raw_haemo=p_haemo,
|
||||||
|
p_threshold=p_threshold,
|
||||||
|
min_subjects=min_subjects,
|
||||||
|
correction_method=correction_method,
|
||||||
|
target_chroma=target_chroma,
|
||||||
|
graph_bounds=graph_bounds if graph_bounds > 0.0 else None,
|
||||||
|
)
|
||||||
|
|
||||||
|
else:
|
||||||
|
print(f"No method defined for index {idx}")
|
||||||
@@ -8,7 +8,6 @@ License: GPL-3.0
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
# Built-in imports
|
# Built-in imports
|
||||||
from pathlib import Path
|
|
||||||
from typing import Any, cast
|
from typing import Any, cast
|
||||||
|
|
||||||
# External library imports
|
# External library imports
|
||||||
@@ -69,7 +68,7 @@ PARAMETERIZED_INDEXES: dict[int, list[dict[str, Any]]] = {
|
|||||||
class ParticipantBrainViewerWidget(ParticipantUIMixin, FlaresBaseWidget):
|
class ParticipantBrainViewerWidget(ParticipantUIMixin, FlaresBaseWidget):
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
haemo_dict: dict[str | Path, BaseRaw],
|
haemo_dict: dict[str, BaseRaw],
|
||||||
cha_dict: dict[str, DataFrame],
|
cha_dict: dict[str, DataFrame],
|
||||||
) -> None:
|
) -> None:
|
||||||
|
|
||||||
|
|||||||
@@ -6,37 +6,46 @@ Author: Tyler de Zeeuw
|
|||||||
License: GPL-3.0
|
License: GPL-3.0
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
# Built-in Imports
|
||||||
import os
|
import os
|
||||||
import time
|
import time
|
||||||
import traceback
|
import traceback
|
||||||
from multiprocessing import Process, current_process, Manager
|
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
from multiprocessing import Process, current_process, Manager
|
||||||
|
from typing import Any, Callable, Dict, List, Optional, Tuple, Union
|
||||||
|
|
||||||
|
# External library imports
|
||||||
import numpy as np
|
import numpy as np
|
||||||
|
from pandas import DataFrame
|
||||||
|
|
||||||
import matplotlib.pyplot as plt
|
import matplotlib.pyplot as plt
|
||||||
import matplotlib.image as mpimg
|
import matplotlib.image as mpimg
|
||||||
from matplotlib.figure import Figure
|
from matplotlib.figure import Figure
|
||||||
|
from matplotlib.backend_bases import Event
|
||||||
from matplotlib.backends.backend_qtagg import FigureCanvasQTAgg as FigureCanvas
|
from matplotlib.backends.backend_qtagg import FigureCanvasQTAgg as FigureCanvas
|
||||||
|
|
||||||
from PySide6.QtWidgets import QFrame, QGridLayout, QHBoxLayout, QLabel, QProgressBar, QPushButton, QScrollArea, QSizePolicy, QWidget, QDialog, QVBoxLayout
|
from PySide6.QtWidgets import QFrame, QGridLayout, QHBoxLayout, QLabel, QLayout, QProgressBar, QPushButton, QScrollArea, QSizePolicy, QWidget, QDialog, QVBoxLayout
|
||||||
from PySide6.QtCore import QThread, Qt, QSize, QTimer
|
from PySide6.QtCore import QThread, Qt, QSize, QTimer, QObject, Signal
|
||||||
from PySide6.QtGui import QPixmap, QImage
|
from PySide6.QtGui import QCloseEvent, QMouseEvent, QPixmap, QImage
|
||||||
|
|
||||||
|
from mne.io.base import BaseRaw
|
||||||
from src.shared.flaresbasewidget import FlaresBaseWidget
|
from src.shared.flaresbasewidget import FlaresBaseWidget
|
||||||
from src.shared.shareddata import APP_NAME, resource_path
|
from src.shared.shareddata import APP_NAME, resource_path
|
||||||
|
from flares import fold_channels
|
||||||
|
|
||||||
|
|
||||||
class MultiProgressDialog(QDialog):
|
class MultiProgressDialog(QDialog):
|
||||||
def __init__(self, parent=None):
|
def __init__(self, parent: Optional[QWidget] = None) -> None:
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
self.setWindowTitle("fOLD Analysis Progress")
|
self.setWindowTitle("fOLD Analysis Progress")
|
||||||
self.setFixedWidth(400)
|
self.setFixedWidth(400)
|
||||||
self.setWindowModality(Qt.WindowModality.NonModal)
|
self.setWindowModality(Qt.WindowModality.NonModal)
|
||||||
self.layout = QVBoxLayout(self)
|
self.main_layout = QVBoxLayout(self)
|
||||||
self.bars = {}
|
self.bars: Dict[str, QProgressBar] = {}
|
||||||
self.allow_closing = False
|
self.allow_closing = False
|
||||||
|
|
||||||
def add_participant(self, label, total_steps):
|
def add_participant(self, label: Any, total_steps: Union[int, float, str]) -> None:
|
||||||
clean_key = str(label).strip()
|
clean_key = str(label).strip()
|
||||||
label_widget = QLabel(f"Analyzing {clean_key}...")
|
label_widget = QLabel(f"Analyzing {clean_key}...")
|
||||||
pbar = QProgressBar()
|
pbar = QProgressBar()
|
||||||
@@ -44,16 +53,17 @@ class MultiProgressDialog(QDialog):
|
|||||||
pbar.setMaximum(int(total_steps)) # Ensure this is a strict integer
|
pbar.setMaximum(int(total_steps)) # Ensure this is a strict integer
|
||||||
pbar.setValue(0)
|
pbar.setValue(0)
|
||||||
|
|
||||||
self.layout.addWidget(label_widget)
|
self.main_layout.addWidget(label_widget)
|
||||||
self.layout.addWidget(pbar)
|
self.main_layout.addWidget(pbar)
|
||||||
self.bars[label] = pbar
|
self.bars[clean_key] = pbar
|
||||||
|
|
||||||
def update_bar(self, label, value):
|
def update_bar(self, label: Any, value: Union[int, float, str]) -> None:
|
||||||
if label in self.bars:
|
clean_key = str(label).strip()
|
||||||
|
if clean_key in self.bars:
|
||||||
# Force integers to prevent QProgressBar from breaking or flickering
|
# Force integers to prevent QProgressBar from breaking or flickering
|
||||||
self.bars[label].setValue(int(value))
|
self.bars[clean_key].setValue(int(value))
|
||||||
|
|
||||||
def closeEvent(self, event):
|
def closeEvent(self, event: QCloseEvent) -> None:
|
||||||
if self.allow_closing:
|
if self.allow_closing:
|
||||||
event.accept()
|
event.accept()
|
||||||
else:
|
else:
|
||||||
@@ -64,12 +74,16 @@ class MultiProgressDialog(QDialog):
|
|||||||
self.close()
|
self.close()
|
||||||
|
|
||||||
|
|
||||||
|
def single_participant_worker(
|
||||||
|
file_path: str,
|
||||||
|
raw_data: Any,
|
||||||
|
result_queue: Any,
|
||||||
|
progress_queue: Any,
|
||||||
|
) -> None:
|
||||||
|
|
||||||
def single_participant_worker(file_path, raw_data, result_queue, progress_queue):
|
|
||||||
""" Runs inside its own dedicated process """
|
""" Runs inside its own dedicated process """
|
||||||
p_name = os.path.basename(file_path)
|
p_name = os.path.basename(file_path)
|
||||||
try:
|
try:
|
||||||
from flares import fold_channels
|
|
||||||
# Perform the heavy fold_channels logic
|
# Perform the heavy fold_channels logic
|
||||||
channel_results = fold_channels(raw=raw_data, p_name=p_name, progress_queue=progress_queue)
|
channel_results = fold_channels(raw=raw_data, p_name=p_name, progress_queue=progress_queue)
|
||||||
|
|
||||||
@@ -81,8 +95,7 @@ def single_participant_worker(file_path, raw_data, result_queue, progress_queue)
|
|||||||
progress_queue.put(f"ERROR: {p_name} - {str(e)}")
|
progress_queue.put(f"ERROR: {p_name} - {str(e)}")
|
||||||
|
|
||||||
|
|
||||||
|
def get_landmark_color_map() -> Dict[str, Tuple[float, float, float, float]]:
|
||||||
def get_landmark_color_map():
|
|
||||||
"""Generates the unified 40-color map for fOLD landmarks."""
|
"""Generates the unified 40-color map for fOLD landmarks."""
|
||||||
landmarks = [
|
landmarks = [
|
||||||
"1 - Primary Somatosensory Cortex", "2 - Primary Somatosensory Cortex",
|
"1 - Primary Somatosensory Cortex", "2 - Primary Somatosensory Cortex",
|
||||||
@@ -116,7 +129,15 @@ class StaticChannelCanvas(FigureCanvas):
|
|||||||
"""The Pop-up Window Canvas.
|
"""The Pop-up Window Canvas.
|
||||||
Renders the interactive pie chart on the left, and a matching PNG image on the right.
|
Renders the interactive pie chart on the left, and a matching PNG image on the right.
|
||||||
"""
|
"""
|
||||||
def __init__(self, channel_name, data_list, color_map, image_path=None, parent=None):
|
def __init__(
|
||||||
|
self,
|
||||||
|
channel_name: str,
|
||||||
|
data_list: List[Dict[str, Any]],
|
||||||
|
color_map: Dict[str, Union[str, Tuple[float, float, float, float]]],
|
||||||
|
image_path: Optional[str] = None,
|
||||||
|
parent: Optional[QWidget] = None,
|
||||||
|
) -> None:
|
||||||
|
|
||||||
self.fig = Figure(figsize=(11.0, 5.5))
|
self.fig = Figure(figsize=(11.0, 5.5))
|
||||||
|
|
||||||
self.ax = self.fig.subplots(1, 2)
|
self.ax = self.fig.subplots(1, 2)
|
||||||
@@ -194,7 +215,7 @@ class StaticChannelCanvas(FigureCanvas):
|
|||||||
|
|
||||||
self.mpl_connect('motion_notify_event', self._on_hover)
|
self.mpl_connect('motion_notify_event', self._on_hover)
|
||||||
|
|
||||||
def _on_hover(self, event):
|
def _on_hover(self, event: Event) -> None:
|
||||||
try:
|
try:
|
||||||
# FIX: Only track mouse events when hovering over the LEFT axis frame containing the pie chart
|
# FIX: Only track mouse events when hovering over the LEFT axis frame containing the pie chart
|
||||||
if event.inaxes != self.ax[0]:
|
if event.inaxes != self.ax[0]:
|
||||||
@@ -231,10 +252,10 @@ class StaticChannelCanvas(FigureCanvas):
|
|||||||
self.draw_idle()
|
self.draw_idle()
|
||||||
|
|
||||||
except Exception as err:
|
except Exception as err:
|
||||||
print("[ERROR] Internal failure inside _on_hover loop:")
|
print(f"[ERROR] Internal failure inside _on_hover loop: {err}")
|
||||||
traceback.print_exc()
|
traceback.print_exc()
|
||||||
|
|
||||||
def _explode_wedge(self, index_to_expand):
|
def _explode_wedge(self, index_to_expand: int) -> None:
|
||||||
changed = False
|
changed = False
|
||||||
for idx, wedge in enumerate(self.wedges):
|
for idx, wedge in enumerate(self.wedges):
|
||||||
if idx == index_to_expand:
|
if idx == index_to_expand:
|
||||||
@@ -252,7 +273,7 @@ class StaticChannelCanvas(FigureCanvas):
|
|||||||
if changed:
|
if changed:
|
||||||
self.draw_idle()
|
self.draw_idle()
|
||||||
|
|
||||||
def _reset_wedges(self):
|
def _reset_wedges(self) -> None:
|
||||||
changed = False
|
changed = False
|
||||||
for wedge in self.wedges:
|
for wedge in self.wedges:
|
||||||
if wedge.center != (0.0, 0.0):
|
if wedge.center != (0.0, 0.0):
|
||||||
@@ -264,7 +285,7 @@ class StaticChannelCanvas(FigureCanvas):
|
|||||||
|
|
||||||
|
|
||||||
class StandaloneLegendDialog(QWidget):
|
class StandaloneLegendDialog(QWidget):
|
||||||
def __init__(self, canvas_engine, title_prefix, parent=None):
|
def __init__(self, canvas_engine: Any, title_prefix: str, parent: QWidget | None = None) -> None:
|
||||||
super().__init__(None)
|
super().__init__(None)
|
||||||
self.setWindowTitle("Full View - Brodmann Legend")
|
self.setWindowTitle("Full View - Brodmann Legend")
|
||||||
self.setMinimumSize(500, 600)
|
self.setMinimumSize(500, 600)
|
||||||
@@ -274,7 +295,7 @@ class StandaloneLegendDialog(QWidget):
|
|||||||
layout.setContentsMargins(10, 10, 10, 10)
|
layout.setContentsMargins(10, 10, 10, 10)
|
||||||
|
|
||||||
# Reuse your exact card creation method to render inside the popup window
|
# Reuse your exact card creation method to render inside the popup window
|
||||||
legend_card = canvas_engine.create_legend_card(title_prefix, self)
|
legend_card = canvas_engine.create_legend_card(title_prefix)
|
||||||
layout.addWidget(legend_card)
|
layout.addWidget(legend_card)
|
||||||
|
|
||||||
|
|
||||||
@@ -282,7 +303,14 @@ class InteractiveParticipantGridCanvas(FigureCanvas):
|
|||||||
"""The Big Grid Canvas.
|
"""The Big Grid Canvas.
|
||||||
Dynamically scales row and column configurations to maintain a crisp 16:9 layout orientation.
|
Dynamically scales row and column configurations to maintain a crisp 16:9 layout orientation.
|
||||||
"""
|
"""
|
||||||
def __init__(self, channels_data, color_map, is_fullscreen_copy=False, parent=None):
|
def __init__(
|
||||||
|
self,
|
||||||
|
channels_data: dict[str, list[dict[str, Any]]],
|
||||||
|
color_map: dict[str, str],
|
||||||
|
is_fullscreen_copy: bool = False,
|
||||||
|
parent: QWidget | None = None
|
||||||
|
) -> None:
|
||||||
|
|
||||||
self.channels_data = channels_data
|
self.channels_data = channels_data
|
||||||
self.color_map = color_map
|
self.color_map = color_map
|
||||||
self.is_fullscreen_copy = is_fullscreen_copy
|
self.is_fullscreen_copy = is_fullscreen_copy
|
||||||
@@ -381,7 +409,7 @@ class InteractiveParticipantGridCanvas(FigureCanvas):
|
|||||||
self.mpl_connect('button_press_event', self._on_canvas_click)
|
self.mpl_connect('button_press_event', self._on_canvas_click)
|
||||||
|
|
||||||
|
|
||||||
def create_matrix_card(self, title_prefix, layout_to_attach_to):
|
def create_matrix_card(self, title_prefix: str, layout_to_attach_to: QLayout) -> QFrame:
|
||||||
"""Wraps the channel matrix layout inside a responsive, matching hover-stylized card frame."""
|
"""Wraps the channel matrix layout inside a responsive, matching hover-stylized card frame."""
|
||||||
# 1. Create matching styled container card frame
|
# 1. Create matching styled container card frame
|
||||||
card_frame = QFrame()
|
card_frame = QFrame()
|
||||||
@@ -422,7 +450,7 @@ class InteractiveParticipantGridCanvas(FigureCanvas):
|
|||||||
layout_to_attach_to.addWidget(card_frame)
|
layout_to_attach_to.addWidget(card_frame)
|
||||||
return card_frame
|
return card_frame
|
||||||
|
|
||||||
def _on_canvas_click(self, event):
|
def _on_canvas_click(self, event: Any) -> None:
|
||||||
# CASE 1: Whitespace Clicked -> Open full 25-matrix in fullscreen window
|
# CASE 1: Whitespace Clicked -> Open full 25-matrix in fullscreen window
|
||||||
if event.inaxes is None:
|
if event.inaxes is None:
|
||||||
self._open_fullscreen_grid()
|
self._open_fullscreen_grid()
|
||||||
@@ -473,7 +501,8 @@ class InteractiveParticipantGridCanvas(FigureCanvas):
|
|||||||
self._fullscreen_refs = [w for w in self._fullscreen_refs if w.isVisible()]
|
self._fullscreen_refs = [w for w in self._fullscreen_refs if w.isVisible()]
|
||||||
self._fullscreen_refs.append(fullscreen_window)
|
self._fullscreen_refs.append(fullscreen_window)
|
||||||
|
|
||||||
def _calculate_total_brodmann_profile(self, channels_data):
|
|
||||||
|
def _calculate_total_brodmann_profile(self, channels_data: dict[str, list[dict[str, Any]]]) -> list[dict[str, Any]]:
|
||||||
"""Sums and normalizes the specificity profile across all channels."""
|
"""Sums and normalizes the specificity profile across all channels."""
|
||||||
totals = {}
|
totals = {}
|
||||||
num_channels = len(channels_data)
|
num_channels = len(channels_data)
|
||||||
@@ -482,7 +511,7 @@ class InteractiveParticipantGridCanvas(FigureCanvas):
|
|||||||
return []
|
return []
|
||||||
|
|
||||||
# Sum up specificities across all channels
|
# Sum up specificities across all channels
|
||||||
for channel_name, data_list in channels_data.items():
|
for _, data_list in channels_data.items():
|
||||||
for entry in data_list:
|
for entry in data_list:
|
||||||
landmark = entry['Landmark']
|
landmark = entry['Landmark']
|
||||||
specificity = entry['Specificity']
|
specificity = entry['Specificity']
|
||||||
@@ -511,7 +540,7 @@ class InteractiveParticipantGridCanvas(FigureCanvas):
|
|||||||
|
|
||||||
return normalized_data_list
|
return normalized_data_list
|
||||||
|
|
||||||
def _open_expanded_view(self, channel_name, data_list):
|
def _open_expanded_view(self, channel_name: str, data_list: list[dict[str, Any]]) -> None:
|
||||||
# 1. Create a plain QWidget with NO parent (None)
|
# 1. Create a plain QWidget with NO parent (None)
|
||||||
# This instantly makes it a top-level desktop window
|
# This instantly makes it a top-level desktop window
|
||||||
popup = QWidget(None)
|
popup = QWidget(None)
|
||||||
@@ -553,7 +582,7 @@ class InteractiveParticipantGridCanvas(FigureCanvas):
|
|||||||
self._open_popups.append(popup)
|
self._open_popups.append(popup)
|
||||||
|
|
||||||
|
|
||||||
def create_total_summary_card(self, title_prefix, layout_to_attach_to):
|
def create_total_summary_card(self, title_prefix: str, layout_to_attach_to: QLayout) -> QFrame:
|
||||||
"""Generates a highly compact, clickable embedded card on the main window showing aggregated data."""
|
"""Generates a highly compact, clickable embedded card on the main window showing aggregated data."""
|
||||||
# 1. Calculate the normalized profile data payload using the instance's own data
|
# 1. Calculate the normalized profile data payload using the instance's own data
|
||||||
summary_data = self._calculate_total_brodmann_profile(self.channels_data)
|
summary_data = self._calculate_total_brodmann_profile(self.channels_data)
|
||||||
@@ -609,7 +638,7 @@ class InteractiveParticipantGridCanvas(FigureCanvas):
|
|||||||
card_layout.addWidget(summary_canvas)
|
card_layout.addWidget(summary_canvas)
|
||||||
card_layout.addStretch(0)
|
card_layout.addStretch(0)
|
||||||
|
|
||||||
def handle_card_click(event):
|
def handle_card_click(event: QMouseEvent) -> None:
|
||||||
# Only trigger expansion if it's a primary left-click action
|
# Only trigger expansion if it's a primary left-click action
|
||||||
if event.button() == Qt.MouseButton.LeftButton:
|
if event.button() == Qt.MouseButton.LeftButton:
|
||||||
self._open_expanded_summary_window(title_prefix, summary_data)
|
self._open_expanded_summary_window(title_prefix, summary_data)
|
||||||
@@ -626,7 +655,7 @@ class InteractiveParticipantGridCanvas(FigureCanvas):
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
def create_legend_card(self, title_prefix, layout_to_attach_to):
|
def create_legend_card(self, title_prefix: str) -> QFrame:
|
||||||
card = QFrame()
|
card = QFrame()
|
||||||
card.setStyleSheet("QFrame { background-color: #ffffff; border-radius: 8px; border: 1px solid #e9ecef; }")
|
card.setStyleSheet("QFrame { background-color: #ffffff; border-radius: 8px; border: 1px solid #e9ecef; }")
|
||||||
|
|
||||||
@@ -686,7 +715,7 @@ class InteractiveParticipantGridCanvas(FigureCanvas):
|
|||||||
return card
|
return card
|
||||||
|
|
||||||
|
|
||||||
def _open_expanded_summary_window(self, title_prefix, summary_data):
|
def _open_expanded_summary_window(self, title_prefix: str, summary_data: List[Any]) -> None:
|
||||||
"""Pops open a beautifully scaled, independent large window when the card is clicked."""
|
"""Pops open a beautifully scaled, independent large window when the card is clicked."""
|
||||||
popup = QWidget(None)
|
popup = QWidget(None)
|
||||||
popup.setWindowTitle(f"Grand Total Profile Details - {title_prefix}")
|
popup.setWindowTitle(f"Grand Total Profile Details - {title_prefix}")
|
||||||
@@ -719,16 +748,19 @@ class InteractiveParticipantGridCanvas(FigureCanvas):
|
|||||||
self._summary_popups.append(popup)
|
self._summary_popups.append(popup)
|
||||||
|
|
||||||
|
|
||||||
from PySide6.QtCore import QObject, Signal
|
|
||||||
from multiprocessing import Manager, Process
|
|
||||||
|
|
||||||
class ProcessOrchestrator(QObject):
|
class ProcessOrchestrator(QObject):
|
||||||
# Fires when Manager + Processes are completely ready
|
# Fires when Manager + Processes are completely ready
|
||||||
# Emits: (manager_instance, result_queue, progress_queue, active_processes_list)
|
# Emits: (manager_instance, result_queue, progress_queue, active_processes_list)
|
||||||
setup_finished = Signal(object, object, object, list)
|
setup_finished = Signal(object, object, object, list)
|
||||||
setup_failed = Signal(str)
|
setup_failed = Signal(str)
|
||||||
|
|
||||||
def __init__(self, selected_files, haemo_dict, worker_func):
|
def __init__(
|
||||||
|
self,
|
||||||
|
selected_files: list[str],
|
||||||
|
haemo_dict: dict[str, Any],
|
||||||
|
worker_func: Callable[..., Any]
|
||||||
|
) -> None:
|
||||||
|
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self.selected_files = selected_files
|
self.selected_files = selected_files
|
||||||
self.haemo_dict = haemo_dict
|
self.haemo_dict = haemo_dict
|
||||||
@@ -758,7 +790,12 @@ class ProcessOrchestrator(QObject):
|
|||||||
|
|
||||||
|
|
||||||
class ParticipantFoldChannelsWidget(FlaresBaseWidget):
|
class ParticipantFoldChannelsWidget(FlaresBaseWidget):
|
||||||
def __init__(self, haemo_dict, cha_dict):
|
def __init__(
|
||||||
|
self,
|
||||||
|
haemo_dict: dict[str | Path, BaseRaw],
|
||||||
|
cha_dict: dict[str, DataFrame]
|
||||||
|
) -> None:
|
||||||
|
|
||||||
super().__init__("ParticipantFoldChannels")
|
super().__init__("ParticipantFoldChannels")
|
||||||
self.setWindowTitle(f"Participant Fold Channels Viewer - {APP_NAME.upper()}")
|
self.setWindowTitle(f"Participant Fold Channels Viewer - {APP_NAME.upper()}")
|
||||||
self.haemo_dict = haemo_dict
|
self.haemo_dict = haemo_dict
|
||||||
@@ -773,18 +810,15 @@ class ParticipantFoldChannelsWidget(FlaresBaseWidget):
|
|||||||
self.participant_map[file_path] = short_label
|
self.participant_map[file_path] = short_label
|
||||||
self.participant_dropdown_items.append(display_label)
|
self.participant_dropdown_items.append(display_label)
|
||||||
|
|
||||||
self.layout = QVBoxLayout(self)
|
self.main_layout = QVBoxLayout(self)
|
||||||
self.top_bar = QHBoxLayout()
|
self.top_bar = QHBoxLayout()
|
||||||
self.layout.addLayout(self.top_bar)
|
self.main_layout.addLayout(self.top_bar)
|
||||||
|
|
||||||
self.participant_dropdown = self._create_multiselect_dropdown(self.participant_dropdown_items)
|
self.participant_dropdown = self._create_multiselect_dropdown(self.participant_dropdown_items)
|
||||||
self.participant_dropdown.currentIndexChanged.connect(self.update_participant_dropdown_label)
|
self.participant_dropdown.currentIndexChanged.connect(self.update_participant_dropdown_label)
|
||||||
|
|
||||||
self.index_texts = [
|
self.index_texts = [
|
||||||
"0 (Fold Channels)",
|
"0 (Fold Channels)",
|
||||||
# "1 (second image)",
|
|
||||||
# "2 (third image)",
|
|
||||||
# "3 (fourth image)",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
self.image_index_dropdown = self._create_multiselect_dropdown(self.index_texts)
|
self.image_index_dropdown = self._create_multiselect_dropdown(self.index_texts)
|
||||||
@@ -803,11 +837,10 @@ class ParticipantFoldChannelsWidget(FlaresBaseWidget):
|
|||||||
self.scroll_area.setWidgetResizable(True)
|
self.scroll_area.setWidgetResizable(True)
|
||||||
self.scroll_area.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
|
self.scroll_area.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
|
||||||
self.scroll_area.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded)
|
self.scroll_area.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded)
|
||||||
self.scroll_area.setStyleSheet("QScrollArea { border: none; background-color: #f1f3f5; }")
|
self.scroll_area.setStyleSheet("QScrollArea { border: none; }")
|
||||||
|
|
||||||
# 2. Create the central canvas widget that inside the scroll block
|
# 2. Create the central canvas widget that inside the scroll block
|
||||||
self.scroll_content_widget = QWidget()
|
self.scroll_content_widget = QWidget()
|
||||||
self.scroll_content_widget.setStyleSheet("background-color: #f1f3f5;")
|
|
||||||
|
|
||||||
# 3. Establish the strict 3-column layout grid engine
|
# 3. Establish the strict 3-column layout grid engine
|
||||||
self.grid_layout = QGridLayout(self.scroll_content_widget)
|
self.grid_layout = QGridLayout(self.scroll_content_widget)
|
||||||
@@ -829,7 +862,7 @@ class ParticipantFoldChannelsWidget(FlaresBaseWidget):
|
|||||||
self.scroll_area.setWidget(self.scroll_content_widget)
|
self.scroll_area.setWidget(self.scroll_content_widget)
|
||||||
|
|
||||||
# Add the self.scroll_area widget to your root layout view frame panel
|
# Add the self.scroll_area widget to your root layout view frame panel
|
||||||
self.layout.addWidget(self.scroll_area)
|
self.main_layout.addWidget(self.scroll_area)
|
||||||
|
|
||||||
self.thumb_size = QSize(280, 180)
|
self.thumb_size = QSize(280, 180)
|
||||||
self.showMaximized()
|
self.showMaximized()
|
||||||
@@ -861,13 +894,8 @@ class ParticipantFoldChannelsWidget(FlaresBaseWidget):
|
|||||||
|
|
||||||
self.multi_progress.add_participant(os.path.basename(file_path), total_channels)
|
self.multi_progress.add_participant(os.path.basename(file_path), total_channels)
|
||||||
|
|
||||||
from datetime import datetime
|
|
||||||
|
|
||||||
print(f"Before: {datetime.now()}")
|
|
||||||
self.multi_progress.show()
|
self.multi_progress.show()
|
||||||
|
|
||||||
print(f"After 1: {datetime.now()}")
|
|
||||||
|
|
||||||
if current_process().name == 'MainProcess':
|
if current_process().name == 'MainProcess':
|
||||||
# Create a clean background thread worker execution channel
|
# Create a clean background thread worker execution channel
|
||||||
self.orchestrator_thread = QThread()
|
self.orchestrator_thread = QThread()
|
||||||
@@ -887,9 +915,15 @@ class ParticipantFoldChannelsWidget(FlaresBaseWidget):
|
|||||||
self.orchestrator.setup_failed.connect(self.orchestrator.deleteLater)
|
self.orchestrator.setup_failed.connect(self.orchestrator.deleteLater)
|
||||||
|
|
||||||
self.orchestrator_thread.start()
|
self.orchestrator_thread.start()
|
||||||
print(f"After 4: {datetime.now()}")
|
|
||||||
|
|
||||||
def on_orchestration_success(self, manager, result_queue, progress_queue, active_processes):
|
def on_orchestration_success(
|
||||||
|
self,
|
||||||
|
manager: Any,
|
||||||
|
result_queue: Any,
|
||||||
|
progress_queue: Any,
|
||||||
|
active_processes: List[Any]
|
||||||
|
) -> None:
|
||||||
|
|
||||||
""" Executed on the Main GUI Thread once background process setup finishes """
|
""" Executed on the Main GUI Thread once background process setup finishes """
|
||||||
self.manager = manager
|
self.manager = manager
|
||||||
self.result_queue = result_queue
|
self.result_queue = result_queue
|
||||||
@@ -902,15 +936,15 @@ class ParticipantFoldChannelsWidget(FlaresBaseWidget):
|
|||||||
self.result_timer.timeout.connect(self.check_parallel_results)
|
self.result_timer.timeout.connect(self.check_parallel_results)
|
||||||
self.result_timer.start()
|
self.result_timer.start()
|
||||||
|
|
||||||
def on_orchestration_failed(self, error_msg):
|
|
||||||
|
def on_orchestration_failed(self, error_msg: str) -> None:
|
||||||
""" Fallback handler if Windows permissions or pickling fails in background """
|
""" Fallback handler if Windows permissions or pickling fails in background """
|
||||||
if hasattr(self, 'multi_progress'):
|
if hasattr(self, 'multi_progress'):
|
||||||
self.multi_progress.close()
|
self.multi_progress.close()
|
||||||
print(f"[CRITICAL FAILURE] Background Orchestration Failed:\n{error_msg}")
|
print(f"[CRITICAL FAILURE] Background Orchestration Failed:\n{error_msg}")
|
||||||
|
|
||||||
|
|
||||||
|
def check_parallel_results(self) -> None:
|
||||||
def check_parallel_results(self):
|
|
||||||
# Check for progress/completion signals
|
# Check for progress/completion signals
|
||||||
|
|
||||||
while not self.progress_queue.empty():
|
while not self.progress_queue.empty():
|
||||||
@@ -991,8 +1025,7 @@ class ParticipantFoldChannelsWidget(FlaresBaseWidget):
|
|||||||
|
|
||||||
legend_title = "Grand Total Brodmann Mapping Profile"
|
legend_title = "Grand Total Brodmann Mapping Profile"
|
||||||
legend_card = global_canvas.create_legend_card(
|
legend_card = global_canvas.create_legend_card(
|
||||||
title_prefix=legend_title,
|
title_prefix=legend_title
|
||||||
layout_to_attach_to=self.scroll_content_widget.layout()
|
|
||||||
)
|
)
|
||||||
|
|
||||||
def handle_legend_click(event):
|
def handle_legend_click(event):
|
||||||
@@ -1007,51 +1040,7 @@ class ParticipantFoldChannelsWidget(FlaresBaseWidget):
|
|||||||
self.grid_layout.addWidget(legend_card, row, col)
|
self.grid_layout.addWidget(legend_card, row, col)
|
||||||
|
|
||||||
|
|
||||||
|
def add_images_to_grid(self, result_dict: Dict[str, Dict[str, Any]]) -> None:
|
||||||
|
|
||||||
# def add_images_to_grid(self, result_dict):
|
|
||||||
# """
|
|
||||||
# result_dict format: { file_path: {"main": bytes, "legend": bytes} }
|
|
||||||
# """
|
|
||||||
# for file_path, images in result_dict.items():
|
|
||||||
|
|
||||||
# if self.grid_layout.count() == 0 and "legend" in images:
|
|
||||||
# self._add_legend_to_grid(images["legend"])
|
|
||||||
|
|
||||||
# # Create a container for this participant's results
|
|
||||||
# container = QFrame()
|
|
||||||
# container.setFrameShape(QFrame.StyledPanel)
|
|
||||||
# vbox = QVBoxLayout(container)
|
|
||||||
|
|
||||||
# participant_label = self.participant_map.get(file_path, os.path.basename(file_path))
|
|
||||||
# title = QLabel(f"<b>{participant_label}</b>")
|
|
||||||
# title.setAlignment(Qt.AlignCenter)
|
|
||||||
# vbox.addWidget(title)
|
|
||||||
|
|
||||||
# # We primarily want to show the 'main' plot in the grid
|
|
||||||
# if "main" in images:
|
|
||||||
# pixmap = self._bytes_to_pixmap(images["main"])
|
|
||||||
# img_label = QLabel()
|
|
||||||
# # Scale it to fit the thumbnail size defined in __init__
|
|
||||||
# img_label.setPixmap(pixmap.scaled(
|
|
||||||
# self.thumb_size,
|
|
||||||
# Qt.KeepAspectRatio,
|
|
||||||
# Qt.SmoothTransformation
|
|
||||||
# ))
|
|
||||||
# img_label.setAlignment(Qt.AlignCenter)
|
|
||||||
|
|
||||||
# # Optional: Click to open full size
|
|
||||||
# img_label.mousePressEvent = lambda e, p=pixmap, t=participant_label: self._open_full_size(p, t)
|
|
||||||
|
|
||||||
# vbox.addWidget(img_label)
|
|
||||||
|
|
||||||
# # Determine grid position (row-major order)
|
|
||||||
# count = self.grid_layout.count()
|
|
||||||
# row = count // 3 # 3 columns wide
|
|
||||||
# col = count % 3
|
|
||||||
# self.grid_layout.addWidget(container, row, col)
|
|
||||||
|
|
||||||
def add_images_to_grid(self, result_dict):
|
|
||||||
color_map = get_landmark_color_map()
|
color_map = get_landmark_color_map()
|
||||||
|
|
||||||
for file_path, channels_data in result_dict.items():
|
for file_path, channels_data in result_dict.items():
|
||||||
@@ -1091,12 +1080,12 @@ class ParticipantFoldChannelsWidget(FlaresBaseWidget):
|
|||||||
self.grid_layout.addWidget(summary_card, row, col)
|
self.grid_layout.addWidget(summary_card, row, col)
|
||||||
|
|
||||||
|
|
||||||
def _bytes_to_pixmap(self, png_bytes):
|
def _bytes_to_pixmap(self, png_bytes: bytes) -> QPixmap:
|
||||||
"""Converts raw bytes from the multiprocess queue to a QPixmap."""
|
"""Converts raw bytes from the multiprocess queue to a QPixmap."""
|
||||||
image = QImage.fromData(png_bytes)
|
image = QImage.fromData(png_bytes)
|
||||||
return QPixmap.fromImage(image)
|
return QPixmap.fromImage(image)
|
||||||
|
|
||||||
def _open_full_size(self, pixmap, title):
|
def _open_full_size(self, pixmap: QPixmap, title: str) -> None:
|
||||||
"""Simple popup to view the image at a readable scale."""
|
"""Simple popup to view the image at a readable scale."""
|
||||||
view = QDialog(self)
|
view = QDialog(self)
|
||||||
view.setWindowTitle(f"Full View - {title}")
|
view.setWindowTitle(f"Full View - {title}")
|
||||||
@@ -1106,14 +1095,14 @@ class ParticipantFoldChannelsWidget(FlaresBaseWidget):
|
|||||||
layout.addWidget(label)
|
layout.addWidget(label)
|
||||||
view.show()
|
view.show()
|
||||||
|
|
||||||
def _add_legend_to_grid(self, legend_bytes):
|
def _add_legend_to_grid(self, legend_bytes: bytes) -> None:
|
||||||
"""Helper to put the legend in the first slot."""
|
"""Helper to put the legend in the first slot."""
|
||||||
container = QFrame()
|
container = QFrame()
|
||||||
container.setStyleSheet("background-color: #f9f9f9; border: 1px solid #ccc;")
|
container.setStyleSheet("background-color: #f9f9f9; border: 1px solid #ccc;")
|
||||||
vbox = QVBoxLayout(container)
|
vbox = QVBoxLayout(container)
|
||||||
|
|
||||||
title = QLabel("<b>Brodmann Area Legend</b>")
|
title = QLabel("<b>Brodmann Area Legend</b>")
|
||||||
title.setAlignment(Qt.AlignCenter)
|
title.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||||
vbox.addWidget(title)
|
vbox.addWidget(title)
|
||||||
|
|
||||||
pixmap = self._bytes_to_pixmap(legend_bytes)
|
pixmap = self._bytes_to_pixmap(legend_bytes)
|
||||||
@@ -1121,10 +1110,10 @@ class ParticipantFoldChannelsWidget(FlaresBaseWidget):
|
|||||||
# Legends are usually tall, so we scale it differently or keep it smaller
|
# Legends are usually tall, so we scale it differently or keep it smaller
|
||||||
legend_label.setPixmap(pixmap.scaled(
|
legend_label.setPixmap(pixmap.scaled(
|
||||||
self.thumb_size,
|
self.thumb_size,
|
||||||
Qt.KeepAspectRatio,
|
Qt.AspectRatioMode.KeepAspectRatio,
|
||||||
Qt.SmoothTransformation
|
Qt.TransformationMode.SmoothTransformation
|
||||||
))
|
))
|
||||||
legend_label.setAlignment(Qt.AlignCenter)
|
legend_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||||
legend_label.mousePressEvent = lambda e, p=pixmap: self._open_full_size(p, "Brodmann Legend")
|
legend_label.mousePressEvent = lambda e, p=pixmap: self._open_full_size(p, "Brodmann Legend")
|
||||||
|
|
||||||
vbox.addWidget(legend_label)
|
vbox.addWidget(legend_label)
|
||||||
|
|||||||
@@ -7,16 +7,13 @@ Author: Tyler de Zeeuw
|
|||||||
License: GPL-3.0
|
License: GPL-3.0
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# Built-in Imports
|
# Built-in imports
|
||||||
from pathlib import Path
|
|
||||||
from typing import Any, cast
|
from typing import Any, cast
|
||||||
|
|
||||||
# External library imports
|
# External library imports
|
||||||
from PySide6.QtWidgets import QMessageBox
|
from PySide6.QtWidgets import QMessageBox
|
||||||
|
|
||||||
from pandas import DataFrame
|
from mne import Annotations, Epochs
|
||||||
|
|
||||||
from mne import Annotations
|
|
||||||
from mne.io.base import BaseRaw
|
from mne.io.base import BaseRaw
|
||||||
|
|
||||||
from flares import functional_connectivity_betas, functional_connectivity_envelope, functional_connectivity_spectral_epochs, functional_connectivity_spectral_time
|
from flares import functional_connectivity_betas, functional_connectivity_envelope, functional_connectivity_spectral_epochs, functional_connectivity_spectral_time
|
||||||
@@ -25,73 +22,66 @@ from src.shared.shareddata import APP_NAME
|
|||||||
|
|
||||||
|
|
||||||
PARAMETERIZED_INDEXES: dict[int, list[dict[str, Any]]] = {
|
PARAMETERIZED_INDEXES: dict[int, list[dict[str, Any]]] = {
|
||||||
0: [
|
0: [ # Spectral Coherence
|
||||||
{
|
{"key": "method", "label": "Connectivity method", "default": "wpli2_debiased", "type": list, "options": ["coh", "pli", "wpli2_debiased"]},
|
||||||
"key": "n_lines",
|
{"key": "n_lines", "label": "Number of strongest connections to draw", "default": "20", "type": int},
|
||||||
"label": "<Description>",
|
{"key": "vmin", "label": "Minimum coherence value to display", "default": "0.3", "type": float},
|
||||||
"default": "20",
|
{"key": "fmin", "label": "Lower frequency bound (Hz)", "default": "0.04", "type": float},
|
||||||
"type": int,
|
{"key": "fmax", "label": "Upper frequency bound (Hz)", "default": "0.2", "type": float},
|
||||||
},
|
|
||||||
{
|
|
||||||
"key": "vmin",
|
|
||||||
"label": "<Description>",
|
|
||||||
"default": "0.9",
|
|
||||||
"type": float,
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
1: [
|
1: [ # Envelope Correlation
|
||||||
{
|
{"key": "n_lines", "label": "Number of strongest connections to draw", "default": "20", "type": int},
|
||||||
"key": "n_lines",
|
{"key": "vmin", "label": "Minimum correlation value to display", "default": "0.9", "type": float},
|
||||||
"label": "<Description>",
|
{"key": "fmin", "label": "Band-pass lower frequency (Hz)", "default": "0.04", "type": float},
|
||||||
"default": "20",
|
{"key": "fmax", "label": "Band-pass upper frequency (Hz)", "default": "0.2", "type": float},
|
||||||
"type": int,
|
{"key": "orthogonalize", "label": "Orthogonalize (reduce signal leakage between channels)", "default": "False", "type": bool},
|
||||||
},
|
{"key": "absolute", "label": "Use absolute value (discard anti-correlation sign)", "default": "True", "type": bool},
|
||||||
{
|
|
||||||
"key": "vmin",
|
|
||||||
"label": "<Description>",
|
|
||||||
"default": "0.9",
|
|
||||||
"type": float,
|
|
||||||
},
|
|
||||||
|
|
||||||
],
|
],
|
||||||
2: [
|
2: [ # Beta-Series Correlation
|
||||||
{
|
{"key": "n_lines", "label": "Number of strongest connections to draw", "default": "20", "type": int},
|
||||||
"key": "n_lines",
|
{"key": "drift_model", "label": "Drift model", "default": "cosine", "type": list, "options": ["cosine", "polynomial"]},
|
||||||
"label": "<Description>",
|
{"key": "drift_order", "label": "Drift order", "default": "1", "type": int},
|
||||||
"default": "20",
|
{"key": "hrf_model", "label": "HRF model", "default": "glover", "type": list, "options": ["glover", "spm", "fir"]},
|
||||||
"type": int,
|
{"key": "apply_gsr", "label": "Apply Global Signal Regression", "default": "True", "type": bool},
|
||||||
},
|
{"key": "resample_freq", "label": "Resample rate before GLM fit (Hz) - lower is much faster", "default": "4.0", "type": float},
|
||||||
{
|
{"key": "min_effect_size", "label": "Minimum |r| to display", "default": "0.7", "type": float},
|
||||||
"key": "vmin",
|
{"key": "alpha", "label": "FDR significance threshold", "default": "0.05", "type": float},
|
||||||
"label": "<Description>",
|
|
||||||
"default": "0.9",
|
|
||||||
"type": float,
|
|
||||||
},
|
|
||||||
|
|
||||||
],
|
],
|
||||||
3: [
|
3: [ # Time-Resolved Spectral Coherence
|
||||||
{
|
{"key": "method", "label": "Connectivity method", "default": "wpli", "type": list, "options": ["coh", "pli", "wpli"]},
|
||||||
"key": "n_lines",
|
{"key": "n_lines", "label": "Number of strongest connections to draw", "default": "20", "type": int},
|
||||||
"label": "<Description>",
|
{"key": "vmin", "label": "Minimum coherence value to display", "default": "0.3", "type": float},
|
||||||
"default": "20",
|
{"key": "fmin", "label": "Lower frequency bound (Hz)", "default": "0.04", "type": float},
|
||||||
"type": int,
|
{"key": "fmax", "label": "Upper frequency bound (Hz)", "default": "0.2", "type": float},
|
||||||
},
|
{"key": "n_freqs", "label": "Number of frequency bins", "default": "10", "type": int},
|
||||||
{
|
{"key": "cycles_multiplier", "label": "Cycles per frequency (window length control)", "default": "2.0", "type": float},
|
||||||
"key": "vmin",
|
|
||||||
"label": "<Description>",
|
|
||||||
"default": "0.9",
|
|
||||||
"type": float,
|
|
||||||
},
|
|
||||||
|
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
DESCRIPTION = """0. Spectral Coherence (functional_connectivity_spectral_epochs)
|
||||||
|
\nTests for connectivity between channel pairs using the selected method: coherence ('coh'), Phase Lag Index ('pli'), or debiased weighted PLI squared ('wpli2_debiased', default). PLI/wPLI-family methods discount zero-lag contributions, making them substantially more robust to shared systemic/vascular signal (which tends to hit multiple channels near-simultaneously) than plain coherence - recommended over 'coh' unless you have a specific reason to want raw coherence.
|
||||||
|
\nfmin must satisfy at least 5 full oscillation cycles within your epoch length (epoch_duration x fmin >= 5) for a reliable estimate - if it doesn't, this will refuse to run with an error stating the minimum viable fmin for your epoch length, rather than silently producing an unreliable result. Note that different methods have very different typical value ranges (coherence commonly 0.3-1.0; wPLI/wPLI2-debiased often much lower, sometimes 0.1-0.4) - vmin needs to be recalibrated when switching methods, or real connections may not render.
|
||||||
|
|
||||||
|
\n1. Envelope Correlation (functional_connectivity_envelope)
|
||||||
|
\nExtracts the Hilbert amplitude envelope from bandpass-filtered signals to measure slow amplitude power correlations across time within epoched data. A significant result indicates that the overall energy profiles or activation magnitudes of two regions co-vary over time, independent of sub-second phase locking. Its power is heavily degraded if epoch lengths are too short to capture multiple complete cycles at fmin - same cycle-count requirement as the Spectral Coherence method above, though this method does not currently enforce it automatically.
|
||||||
|
\nUncorrected global motion or systemic arterial pressure shifts can globally inflate envelope correlations across the whole head - consider this alongside orthogonalize/absolute when interpreting results.
|
||||||
|
|
||||||
|
\n2. Beta-Series Correlation (functional_connectivity_betas)
|
||||||
|
\nFits a GLM to estimate trial-by-trial activation magnitudes (betas), optionally applies Global Signal Regression (GSR) to strip head-wide systemic noise, and correlates those beta series across trials with FDR (q < alpha) and effect-size thresholding. A significant connection means that when Region A responds more strongly on a given trial, Region B also responds more strongly. Not subject to the epoch-length/frequency-resolution constraint that affects the spectral methods above, since no spectral estimation is involved.
|
||||||
|
\nRequires at least 4 (ideally 15+) repeated trials of the selected event. hrf_model='fir' is far more computationally expensive than 'glover'/'spm' (a separate regressor column per FIR delay per trial) - if this method is slow to the point of appearing frozen, check hrf_model is not set to 'fir' before assuming something is broken.
|
||||||
|
|
||||||
|
\n3. Time-Resolved Spectral Coherence (functional_connectivity_spectral_time)
|
||||||
|
\nSame connectivity methods as Spectral Coherence above ('coh'/'pli'/'wpli' - note: 'wpli2_debiased' is NOT available for this method, unlike the epochs-based one), but tracks how connectivity evolves over multiple frequency bins across the trial duration rather than a single averaged value. Same fmin/epoch-length cycle-count requirement as method 0 applies and is enforced the same way.
|
||||||
|
\nMore computationally expensive than method 0 due to the additional frequency/time resolution - if timing matters, prefer method 0 unless the time-resolved view is specifically needed.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
class ParticipantFunctionalConnectivityWidget(ParticipantUIMixin, FlaresBaseWidget):
|
class ParticipantFunctionalConnectivityWidget(ParticipantUIMixin, FlaresBaseWidget):
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
haemo_dict: dict[str | Path, BaseRaw],
|
haemo_dict: dict[str, BaseRaw],
|
||||||
epochs_dict: dict[str, DataFrame],
|
epochs_dict: dict[str, Epochs],
|
||||||
) -> None:
|
) -> None:
|
||||||
|
|
||||||
super().__init__("ParticipantFunctionalConnectivity")
|
super().__init__("ParticipantFunctionalConnectivity")
|
||||||
@@ -99,11 +89,12 @@ class ParticipantFunctionalConnectivityWidget(ParticipantUIMixin, FlaresBaseWidg
|
|||||||
self.haemo_dict = haemo_dict
|
self.haemo_dict = haemo_dict
|
||||||
self.epochs_dict = epochs_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. "
|
QMessageBox.warning(self, f"Warning - {APP_NAME.upper()}", f"Functional Connectivity is still in beta. While the results are now almost finalized, the processing is slow and it WILL hang the application for minutes.")
|
||||||
"By clicking OK, you accept that the images generated may not be factual.")
|
|
||||||
|
|
||||||
self.setup_participant_ui(["0 (Spectral Connectivity Epochs)", "1 (Envelope Correlation)", "2 (Betas)", "3 (Spectral Connectivity Epochs)",])
|
|
||||||
|
|
||||||
|
self.setup_participant_ui(
|
||||||
|
["0 (Spectral Coherence)", "1 (Envelope Correlation)", "2 (Beta-Series Correlation)", "3 (Time-Resolved Spectral Coherence)"],
|
||||||
|
placeholder_text=DESCRIPTION
|
||||||
|
)
|
||||||
|
|
||||||
def process_request(self):
|
def process_request(self):
|
||||||
request = self.get_common_request_data(PARAMETERIZED_INDEXES)
|
request = self.get_common_request_data(PARAMETERIZED_INDEXES)
|
||||||
@@ -119,7 +110,7 @@ class ParticipantFunctionalConnectivityWidget(ParticipantUIMixin, FlaresBaseWidg
|
|||||||
haemo_obj = self.haemo_dict.get(file_path)
|
haemo_obj = self.haemo_dict.get(file_path)
|
||||||
epochs_obj = self.epochs_dict.get(file_path)
|
epochs_obj = self.epochs_dict.get(file_path)
|
||||||
|
|
||||||
if haemo_obj is None:
|
if haemo_obj is None or epochs_obj is None:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
if selected_event:
|
if selected_event:
|
||||||
@@ -137,46 +128,75 @@ class ParticipantFunctionalConnectivityWidget(ParticipantUIMixin, FlaresBaseWidg
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
for idx in selected_indexes:
|
for idx in selected_indexes:
|
||||||
|
params = param_values.get(idx, {})
|
||||||
if idx == 0:
|
if idx == 0:
|
||||||
|
method = params.get("method", "wpli2_debiased")
|
||||||
|
n_lines = params.get("n_lines", 20)
|
||||||
|
vmin = params.get("vmin", 0.9)
|
||||||
|
fmin = params.get("fmin", 0.04)
|
||||||
|
fmax = params.get("fmax", 0.2)
|
||||||
|
|
||||||
params = param_values.get(idx, {})
|
functional_connectivity_spectral_epochs(epochs=epochs_obj, n_lines=n_lines, vmin=vmin, fmin=fmin, fmax=fmax, method=method)
|
||||||
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
|
|
||||||
functional_connectivity_spectral_epochs(epochs_obj, n_lines, vmin)
|
|
||||||
|
|
||||||
elif idx == 1:
|
elif idx == 1:
|
||||||
params = param_values.get(idx, {})
|
n_lines = params.get("n_lines", 20)
|
||||||
n_lines = params.get("n_lines", None)
|
vmin = params.get("vmin", 0.9)
|
||||||
vmin = params.get("vmin", None)
|
fmin = params.get("fmin", 0.04)
|
||||||
|
fmax = params.get("fmax", 0.2)
|
||||||
|
orthogonalize = params.get("orthogonalize", False)
|
||||||
|
absolute = params.get("absolute", True)
|
||||||
|
|
||||||
if n_lines is None or vmin is None:
|
functional_connectivity_envelope(
|
||||||
print(f"Missing parameters for index {idx}, skipping.")
|
epochs=epochs_obj,
|
||||||
continue
|
n_lines=n_lines,
|
||||||
functional_connectivity_envelope(epochs_obj, n_lines, vmin)
|
vmin=vmin, fmin=fmin,
|
||||||
|
fmax=fmax,
|
||||||
|
orthogonalize=orthogonalize,
|
||||||
|
absolute=absolute,
|
||||||
|
)
|
||||||
|
|
||||||
elif idx == 2:
|
elif idx == 2:
|
||||||
params = param_values.get(idx, {})
|
n_lines = params.get("n_lines", 20)
|
||||||
n_lines = params.get("n_lines", None)
|
drift_model = params.get("drift_model", "cosine")
|
||||||
vmin = params.get("vmin", None)
|
drift_order = params.get("drift_order", 1)
|
||||||
|
hrf_model = params.get("hrf_model", "glover")
|
||||||
|
apply_gsr = params.get("apply_gsr", True)
|
||||||
|
min_effect_size = params.get("min_effect_size", 0.7)
|
||||||
|
alpha = params.get("alpha", 0.05)
|
||||||
|
resample_freq = params.get("resample_freq", 4.0)
|
||||||
|
|
||||||
if n_lines is None or vmin is None:
|
functional_connectivity_betas(
|
||||||
print(f"Missing parameters for index {idx}, skipping.")
|
raw_hbo=haemo_obj,
|
||||||
continue
|
n_lines=n_lines,
|
||||||
functional_connectivity_betas(haemo_obj, n_lines, vmin, selected_event)
|
event_name=selected_event,
|
||||||
|
drift_model=drift_model,
|
||||||
|
drift_order=drift_order,
|
||||||
|
hrf_model=hrf_model,
|
||||||
|
apply_gsr=apply_gsr,
|
||||||
|
min_effect_size=min_effect_size,
|
||||||
|
alpha=alpha,
|
||||||
|
resample_freq=resample_freq,
|
||||||
|
)
|
||||||
|
|
||||||
elif idx == 3:
|
elif idx == 3:
|
||||||
params = param_values.get(idx, {})
|
method = params.get("method", "wpli")
|
||||||
n_lines = params.get("n_lines", None)
|
n_lines = params.get("n_lines", 20)
|
||||||
vmin = params.get("vmin", None)
|
vmin = params.get("vmin", 0.9)
|
||||||
|
fmin = params.get("fmin", 0.04)
|
||||||
|
fmax = params.get("fmax", 0.2)
|
||||||
|
n_freqs = params.get("n_freqs", 10)
|
||||||
|
cycles_multiplier = params.get("cycles_multiplier", 2.0)
|
||||||
|
|
||||||
if n_lines is None or vmin is None:
|
functional_connectivity_spectral_time(
|
||||||
print(f"Missing parameters for index {idx}, skipping.")
|
epochs=epochs_obj,
|
||||||
continue
|
n_lines=n_lines,
|
||||||
functional_connectivity_spectral_time(epochs_obj, n_lines, vmin)
|
vmin=vmin,
|
||||||
|
fmin=fmin,
|
||||||
|
fmax=fmax,
|
||||||
|
n_freqs=n_freqs,
|
||||||
|
cycles_multiplier=cycles_multiplier,
|
||||||
|
method=method
|
||||||
|
)
|
||||||
|
|
||||||
else:
|
else:
|
||||||
print(f"No method defined for index {idx}")
|
print(f"No method defined for index {idx}")
|
||||||
@@ -7,7 +7,7 @@ Author: Tyler de Zeeuw
|
|||||||
License: GPL-3.0
|
License: GPL-3.0
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# Built-in Imports
|
# Built-in imports
|
||||||
import os.path as op
|
import os.path as op
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
@@ -24,7 +24,6 @@ from src.shared.shareddata import APP_NAME
|
|||||||
|
|
||||||
|
|
||||||
class ParticipantImageViewerWidget(FlaresBaseWidget):
|
class ParticipantImageViewerWidget(FlaresBaseWidget):
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
haemo_dict: dict[str, BaseRaw],
|
haemo_dict: dict[str, BaseRaw],
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import pandas as pd
|
|||||||
from pandas import DataFrame
|
from pandas import DataFrame
|
||||||
|
|
||||||
from PySide6.QtWidgets import QApplication, QComboBox, QDialog, QGridLayout, QHBoxLayout, QLabel, QLineEdit, QListView, QMessageBox, QPushButton, QScrollArea, QSizePolicy, QVBoxLayout, QWidget, QFrame, QSpinBox, QFileDialog
|
from PySide6.QtWidgets import QApplication, QComboBox, QDialog, QGridLayout, QHBoxLayout, QLabel, QLineEdit, QListView, QMessageBox, QPushButton, QScrollArea, QSizePolicy, QVBoxLayout, QWidget, QFrame, QSpinBox, QFileDialog
|
||||||
from PySide6.QtGui import QStandardItemModel, QStandardItem, QPixmap, QIntValidator, QDoubleValidator
|
from PySide6.QtGui import QPalette, QStandardItemModel, QStandardItem, QPixmap, QIntValidator, QDoubleValidator
|
||||||
from PySide6.QtCore import QEvent, QPoint, QSize, QTimer, Qt, Signal
|
from PySide6.QtCore import QEvent, QPoint, QSize, QTimer, Qt, Signal
|
||||||
|
|
||||||
from src.shared.shareddata import APP_NAME, PIPELINE_STAGES
|
from src.shared.shareddata import APP_NAME, PIPELINE_STAGES
|
||||||
@@ -323,10 +323,10 @@ class ParamSection(QWidget):
|
|||||||
self._updating_checkstates = False
|
self._updating_checkstates = False
|
||||||
|
|
||||||
# Title label
|
# Title label
|
||||||
title_label = QLabel(section_data["title"])
|
self.title_label = QLabel(section_data["title"])
|
||||||
title_label.setStyleSheet("font-weight: bold; font-size: 14px; margin-top: 10px; margin-bottom: 5px;")
|
self.title_label.setStyleSheet("font-weight: bold; font-size: 14px; margin-top: 10px; margin-bottom: 5px;")
|
||||||
layout.addWidget(title_label)
|
layout.addWidget(self.title_label)
|
||||||
self.header_widgets.append(title_label)
|
self.header_widgets.append(self.title_label)
|
||||||
|
|
||||||
# Horizontal line
|
# Horizontal line
|
||||||
line = QFrame()
|
line = QFrame()
|
||||||
@@ -444,6 +444,10 @@ class ParamSection(QWidget):
|
|||||||
|
|
||||||
self.update_dependencies()
|
self.update_dependencies()
|
||||||
|
|
||||||
|
def update_theme_colors(self):
|
||||||
|
self.title_label.style().unpolish(self.title_label)
|
||||||
|
self.title_label.style().polish(self.title_label)
|
||||||
|
|
||||||
def is_different(self, val_a, val_b, param_type=None):
|
def is_different(self, val_a, val_b, param_type=None):
|
||||||
"""Compares two parameter values to determine if they differ."""
|
"""Compares two parameter values to determine if they differ."""
|
||||||
type_str = str(param_type).lower()
|
type_str = str(param_type).lower()
|
||||||
@@ -890,6 +894,7 @@ class ProgressBubble(QWidget):
|
|||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
padding: 8px 12px;
|
padding: 8px 12px;
|
||||||
background-color: #e0f0ff;
|
background-color: #e0f0ff;
|
||||||
|
color: #000000;
|
||||||
}
|
}
|
||||||
""")
|
""")
|
||||||
|
|
||||||
@@ -985,6 +990,13 @@ class ProgressBubble(QWidget):
|
|||||||
self.spinner_idx += 1
|
self.spinner_idx += 1
|
||||||
self._update_label_text()
|
self._update_label_text()
|
||||||
|
|
||||||
|
def reset(self):
|
||||||
|
"""Resets the bubble's visual state, progress bars, timers, and labels back to initial state."""
|
||||||
|
# Reset progress metrics and visual rectangles back to white
|
||||||
|
self.current_step = 0
|
||||||
|
for rect in self.rects:
|
||||||
|
rect.setStyleSheet("background-color: white; border: 1px solid gray;")
|
||||||
|
|
||||||
|
|
||||||
class FlaresBaseWidget(QWidget):
|
class FlaresBaseWidget(QWidget):
|
||||||
def __init__(self, caller):
|
def __init__(self, caller):
|
||||||
@@ -1346,11 +1358,11 @@ class FlaresBaseWidget(QWidget):
|
|||||||
self.update_participant_dropdown_label(combo=target_combo)
|
self.update_participant_dropdown_label(combo=target_combo)
|
||||||
|
|
||||||
|
|
||||||
class CrossGroupUIMixin:
|
class InterGroupUIMixin:
|
||||||
|
|
||||||
participant_map: dict[str, str]
|
participant_map: dict[str, str]
|
||||||
|
|
||||||
def setup_cross_group_ui(
|
def setup_inter_group_ui(
|
||||||
self,
|
self,
|
||||||
index_texts: Sequence[str],
|
index_texts: Sequence[str],
|
||||||
placeholder_text: str = ""
|
placeholder_text: str = ""
|
||||||
@@ -1678,9 +1690,9 @@ class CSVUIMixin:
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
class InterGroupUIMixin:
|
class IntraGroupUIMixin:
|
||||||
|
|
||||||
def setup_inter_group_ui(
|
def setup_intra_group_ui(
|
||||||
self,
|
self,
|
||||||
index_texts: Sequence[str],
|
index_texts: Sequence[str],
|
||||||
placeholder_text: str = ""
|
placeholder_text: str = ""
|
||||||
@@ -1877,7 +1889,8 @@ class InterGroupUIMixin:
|
|||||||
class ParticipantUIMixin:
|
class ParticipantUIMixin:
|
||||||
def setup_participant_ui(
|
def setup_participant_ui(
|
||||||
self,
|
self,
|
||||||
index_texts: Sequence[str]
|
index_texts: Sequence[str],
|
||||||
|
placeholder_text: str = ""
|
||||||
) -> None:
|
) -> None:
|
||||||
|
|
||||||
# Create mappings: file_path -> participant label and dropdown display text
|
# Create mappings: file_path -> participant label and dropdown display text
|
||||||
@@ -1890,9 +1903,9 @@ class ParticipantUIMixin:
|
|||||||
self.participant_map[file_path] = short_label
|
self.participant_map[file_path] = short_label
|
||||||
self.participant_dropdown_items.append(display_label)
|
self.participant_dropdown_items.append(display_label)
|
||||||
|
|
||||||
self.layout = QVBoxLayout(self)
|
self.main_layout = QVBoxLayout(self)
|
||||||
self.top_bar = QHBoxLayout()
|
self.top_bar = QHBoxLayout()
|
||||||
self.layout.addLayout(self.top_bar)
|
self.main_layout.addLayout(self.top_bar)
|
||||||
|
|
||||||
self.participant_dropdown = self._create_multiselect_dropdown(self.participant_dropdown_items)
|
self.participant_dropdown = self._create_multiselect_dropdown(self.participant_dropdown_items)
|
||||||
self.participant_dropdown.currentIndexChanged.connect(self.update_participant_dropdown_label)
|
self.participant_dropdown.currentIndexChanged.connect(self.update_participant_dropdown_label)
|
||||||
@@ -1917,12 +1930,16 @@ class ParticipantUIMixin:
|
|||||||
self.top_bar.addWidget(self.image_index_dropdown)
|
self.top_bar.addWidget(self.image_index_dropdown)
|
||||||
self.top_bar.addWidget(self.submit_button)
|
self.top_bar.addWidget(self.submit_button)
|
||||||
|
|
||||||
self.scroll = QScrollArea()
|
self.scroll_area = QScrollArea()
|
||||||
self.scroll.setWidgetResizable(True)
|
self.scroll_area.setWidgetResizable(True)
|
||||||
self.scroll_content = QWidget()
|
self.scroll_content = QWidget()
|
||||||
self.grid_layout = QGridLayout(self.scroll_content)
|
self.grid_layout = QGridLayout(self.scroll_content)
|
||||||
self.scroll.setWidget(self.scroll_content)
|
self.scroll_area.setWidget(self.scroll_content)
|
||||||
self.layout.addWidget(self.scroll)
|
self.placeholder_label = QLabel(placeholder_text)
|
||||||
|
self.grid_layout.addWidget(self.placeholder_label, 0, 0)
|
||||||
|
self.placeholder_label.setWordWrap(True)
|
||||||
|
self.placeholder_label.setScaledContents(True)
|
||||||
|
self.main_layout.addWidget(self.scroll_area)
|
||||||
|
|
||||||
self.thumb_size = QSize(280, 180)
|
self.thumb_size = QSize(280, 180)
|
||||||
self.showMaximized()
|
self.showMaximized()
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import sys
|
|||||||
import platform
|
import platform
|
||||||
|
|
||||||
|
|
||||||
CURRENT_VERSION = "1.6.0"
|
CURRENT_VERSION = "1.7.2"
|
||||||
APP_NAME = "flares"
|
APP_NAME = "flares"
|
||||||
APP_NAME_EXPANDED = "fNIRS Lightweight Analysis, Research, & Evaluation Suite"
|
APP_NAME_EXPANDED = "fNIRS Lightweight Analysis, Research, & Evaluation Suite"
|
||||||
API_URL = f"https://git.research.dezeeuw.ca/api/v1/repos/tyler/{APP_NAME}/releases"
|
API_URL = f"https://git.research.dezeeuw.ca/api/v1/repos/tyler/{APP_NAME}/releases"
|
||||||
@@ -21,6 +21,7 @@ API_URL_SECONDARY = f"https://git.research2.dezeeuw.ca/api/v1/repos/tyler/{APP_N
|
|||||||
PLATFORM_NAME = platform.system().lower()
|
PLATFORM_NAME = platform.system().lower()
|
||||||
CHANGELOG_URL = f"https://git.research.dezeeuw.ca/tyler/{APP_NAME}/raw/branch/main/changelog_major.md"
|
CHANGELOG_URL = f"https://git.research.dezeeuw.ca/tyler/{APP_NAME}/raw/branch/main/changelog_major.md"
|
||||||
WIKI_URL = f"https://git.research.dezeeuw.ca/tyler/{APP_NAME}/wiki"
|
WIKI_URL = f"https://git.research.dezeeuw.ca/tyler/{APP_NAME}/wiki"
|
||||||
|
PLUGINS_URL = f"https://git.research.dezeeuw.ca/tyler/{APP_NAME}-plugins/raw/branch/main/plugins.json"
|
||||||
|
|
||||||
|
|
||||||
PIPELINE_STAGES = [
|
PIPELINE_STAGES = [
|
||||||
@@ -64,15 +65,21 @@ DATA_SCHEMA = [
|
|||||||
{"key": "config_dict", "help": "Dict[file_path, dict]: Processing configuration parameters"},
|
{"key": "config_dict", "help": "Dict[file_path, dict]: Processing configuration parameters"},
|
||||||
{"key": "fig_bytes_dict", "help": "Dict[file_path, dict]: Serialized figure data"},
|
{"key": "fig_bytes_dict", "help": "Dict[file_path, dict]: Serialized figure data"},
|
||||||
{"key": "contrast_results_dict", "help": "Dict[file_path, dict]: Calculated contrast statistical results"},
|
{"key": "contrast_results_dict", "help": "Dict[file_path, dict]: Calculated contrast statistical results"},
|
||||||
{"key": "roi_channel_map_dict", "help": "Dict[file_path, dict]: Calculated contrast statistical results"},
|
{"key": "roi_channel_map_dict", "help": "Dict[file_path, dict]: ROI channel mappings"},
|
||||||
|
{"key": "fir_feature_dict", "help": "Dict[file_path, dict]: Extracted FIR waveform features (features, names, channels)"},
|
||||||
|
{"key": "qc_dict", "help": "Dict[file_path, dict]: Quality control metrics"},
|
||||||
{"key": "valid_dict", "help": "Dict[file_path, bool]: Boolean validity status per file"}
|
{"key": "valid_dict", "help": "Dict[file_path, bool]: Boolean validity status per file"}
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
def resource_path(relative_path: str) -> str:
|
def resource_path(relative_path: str) -> str:
|
||||||
"""
|
"""Get absolute path to resource regardless of running directly or packaged using PyInstaller"""
|
||||||
Get absolute path to resource regardless of running directly or packaged using PyInstaller
|
|
||||||
"""
|
|
||||||
|
|
||||||
base_path = getattr(sys, "_MEIPASS", os.path.abspath("."))
|
base_path = getattr(sys, "_MEIPASS", os.path.abspath("."))
|
||||||
return os.path.join(base_path, relative_path)
|
return os.path.join(base_path, relative_path)
|
||||||
|
|
||||||
|
|
||||||
|
def get_app_dir() -> str:
|
||||||
|
"""Returns the root directory of the application executable or working directory."""
|
||||||
|
if getattr(sys, 'frozen', False):
|
||||||
|
return os.path.dirname(sys.executable)
|
||||||
|
return os.getcwd()
|
||||||
@@ -0,0 +1,391 @@
|
|||||||
|
"""
|
||||||
|
Filename: plugins.py
|
||||||
|
Description: Plugins window
|
||||||
|
Note: Compliant with pylance strict type checking
|
||||||
|
|
||||||
|
Author: Tyler de Zeeuw
|
||||||
|
License: GPL-3.0
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Built-in imports
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, cast
|
||||||
|
|
||||||
|
# External library imports
|
||||||
|
from PySide6.QtCore import Qt, QThread, Signal
|
||||||
|
from PySide6.QtGui import QColor
|
||||||
|
from PySide6.QtWidgets import (
|
||||||
|
QHBoxLayout, QLabel, QLineEdit, QListWidget, QListWidgetItem,
|
||||||
|
QMessageBox, QPushButton, QTabWidget, QTextBrowser, QVBoxLayout, QWidget
|
||||||
|
)
|
||||||
|
|
||||||
|
from plugin_manager import PluginManager, parse_version
|
||||||
|
from src.shared.shareddata import APP_NAME, CURRENT_VERSION, PLUGINS_URL
|
||||||
|
|
||||||
|
|
||||||
|
class RemoteFetchWorker(QThread):
|
||||||
|
"""Background thread to fetch remote repository data without lagging the main UI."""
|
||||||
|
fetched = Signal(list, bool) # (plugins_data, all_failed)
|
||||||
|
|
||||||
|
def __init__(self, manager: PluginManager, urls: list[str]) -> None:
|
||||||
|
super().__init__()
|
||||||
|
self.manager = manager
|
||||||
|
self.urls = urls
|
||||||
|
|
||||||
|
def run(self) -> None:
|
||||||
|
plugins, all_failed = self.manager.fetch_remote_repositories(self.urls)
|
||||||
|
self.fetched.emit(plugins, all_failed)
|
||||||
|
|
||||||
|
|
||||||
|
class PluginsWindow(QWidget):
|
||||||
|
|
||||||
|
def __init__(self, parent: QWidget | None, plugin_manager: PluginManager) -> None:
|
||||||
|
super().__init__(None, Qt.WindowType.Window)
|
||||||
|
|
||||||
|
self.main_app_window = parent
|
||||||
|
self.manager: PluginManager = plugin_manager
|
||||||
|
|
||||||
|
self.setWindowTitle(f"{APP_NAME.upper()} - Plugins")
|
||||||
|
self.resize(750, 500)
|
||||||
|
|
||||||
|
self.repository_urls: list[str] = [PLUGINS_URL]
|
||||||
|
self.remote_plugins_data: list[dict[str, Any]] = []
|
||||||
|
self._has_fetched_remote: bool = False
|
||||||
|
self._fetch_worker: RemoteFetchWorker | None = None
|
||||||
|
|
||||||
|
# Refresh UI automatically if manager updates state
|
||||||
|
self.manager.plugins_changed.connect(self.refresh_installed_plugins)
|
||||||
|
|
||||||
|
main_layout = QVBoxLayout(self)
|
||||||
|
self.tab_widget = QTabWidget(self)
|
||||||
|
|
||||||
|
self.installed_tab = self._create_installed_tab()
|
||||||
|
self.browser_tab = self._create_browser_tab()
|
||||||
|
|
||||||
|
self.tab_widget.addTab(self.installed_tab, "Installed Plugins")
|
||||||
|
self.tab_widget.addTab(self.browser_tab, "Plugin Browser")
|
||||||
|
self.tab_widget.currentChanged.connect(self._on_tab_changed)
|
||||||
|
|
||||||
|
main_layout.addWidget(self.tab_widget)
|
||||||
|
self.setLayout(main_layout)
|
||||||
|
|
||||||
|
self.refresh_installed_plugins()
|
||||||
|
|
||||||
|
def refresh_installed_plugins(self) -> None:
|
||||||
|
"""Refreshes installed list using metadata from PluginManager."""
|
||||||
|
self.installed_list.clear()
|
||||||
|
plugins_info = self.manager.get_installed_plugins_info()
|
||||||
|
|
||||||
|
for info in plugins_info:
|
||||||
|
display_text = f"{info['name']} (v{info.get('version', '1.0.0')})"
|
||||||
|
if info["is_disabled"]:
|
||||||
|
display_text += " [Disabled]"
|
||||||
|
|
||||||
|
item = QListWidgetItem(display_text, self.installed_list)
|
||||||
|
item.setData(Qt.ItemDataRole.UserRole, info)
|
||||||
|
|
||||||
|
if info["is_disabled"]:
|
||||||
|
item.setForeground(QColor("#757575"))
|
||||||
|
|
||||||
|
if self.installed_list.count() == 0:
|
||||||
|
self.installed_list.addItem("No plugins installed.")
|
||||||
|
self._clear_details_panel()
|
||||||
|
|
||||||
|
# Re-populate browser list if remote data was already fetched to reflect newly installed/uninstalled plugins
|
||||||
|
if self._has_fetched_remote and self.remote_plugins_data:
|
||||||
|
self._populate_browser_list()
|
||||||
|
|
||||||
|
def _on_installed_item_changed(self, current: QListWidgetItem | None, _: Any) -> None:
|
||||||
|
"""Updates the right-hand details panel when a plugin is selected."""
|
||||||
|
if not current:
|
||||||
|
self._clear_details_panel()
|
||||||
|
return
|
||||||
|
|
||||||
|
info = current.data(Qt.ItemDataRole.UserRole)
|
||||||
|
if not isinstance(info, dict):
|
||||||
|
self._clear_details_panel()
|
||||||
|
return
|
||||||
|
|
||||||
|
plugin_info = cast(dict[str, Any], info)
|
||||||
|
|
||||||
|
name = str(plugin_info.get("name", "Unknown"))
|
||||||
|
version = str(plugin_info.get("version", "1.0.0"))
|
||||||
|
author = str(plugin_info.get("author", "Unknown"))
|
||||||
|
desc = str(plugin_info.get("description", "No description provided."))
|
||||||
|
is_disabled = bool(plugin_info.get("is_disabled", False))
|
||||||
|
path = str(plugin_info.get("path", ""))
|
||||||
|
|
||||||
|
self.lbl_plugin_title.setText(name)
|
||||||
|
self.lbl_plugin_meta.setText(f"<b>Version:</b> {version} | <b>Author:</b> {author}")
|
||||||
|
|
||||||
|
if is_disabled:
|
||||||
|
self.lbl_plugin_status.setText("<font color='#d32f2f'><b>Status: Disabled</b></font>")
|
||||||
|
else:
|
||||||
|
self.lbl_plugin_status.setText("<font color='#2e7d32'><b>Status: Active</b></font>")
|
||||||
|
|
||||||
|
self.txt_plugin_desc.setHtml(desc)
|
||||||
|
self.lbl_plugin_path.setText(f"<b>Path:</b> <code>{path}</code>")
|
||||||
|
|
||||||
|
def _clear_details_panel(self) -> None:
|
||||||
|
"""Clears details display when nothing is selected."""
|
||||||
|
self.lbl_plugin_title.setText("Select a plugin")
|
||||||
|
self.lbl_plugin_meta.setText("")
|
||||||
|
self.lbl_plugin_status.setText("")
|
||||||
|
self.txt_plugin_desc.clear()
|
||||||
|
self.lbl_plugin_path.setText("")
|
||||||
|
|
||||||
|
def toggle_enable_disable(self) -> None:
|
||||||
|
selected = self.installed_list.currentItem()
|
||||||
|
if not selected:
|
||||||
|
return
|
||||||
|
raw_info = selected.data(Qt.ItemDataRole.UserRole)
|
||||||
|
if isinstance(raw_info, dict):
|
||||||
|
info = cast(dict[str, Any], raw_info)
|
||||||
|
plugin_path = info.get("path")
|
||||||
|
if isinstance(plugin_path, str) and plugin_path:
|
||||||
|
self.manager.toggle_plugin_state(Path(plugin_path))
|
||||||
|
|
||||||
|
def uninstall_plugin(self) -> None:
|
||||||
|
selected = self.installed_list.currentItem()
|
||||||
|
if not selected:
|
||||||
|
return
|
||||||
|
|
||||||
|
raw_info = selected.data(Qt.ItemDataRole.UserRole)
|
||||||
|
if not isinstance(raw_info, dict):
|
||||||
|
return
|
||||||
|
|
||||||
|
info = cast(dict[str, Any], raw_info)
|
||||||
|
plugin_path = info.get("path")
|
||||||
|
plugin_name = info.get("name", "this plugin")
|
||||||
|
|
||||||
|
if not isinstance(plugin_path, str) or not plugin_path:
|
||||||
|
return
|
||||||
|
|
||||||
|
reply = QMessageBox.question(
|
||||||
|
self,
|
||||||
|
"Confirm Uninstall",
|
||||||
|
f"Are you sure you want to delete '{plugin_name}'?",
|
||||||
|
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
|
||||||
|
)
|
||||||
|
if reply == QMessageBox.StandardButton.Yes:
|
||||||
|
self.manager.uninstall_plugin(Path(plugin_path))
|
||||||
|
|
||||||
|
def fetch_remote_plugins(self) -> None:
|
||||||
|
"""Asynchronously fetches remote plugins on a background thread."""
|
||||||
|
if self._fetch_worker is not None and self._fetch_worker.isRunning():
|
||||||
|
return
|
||||||
|
|
||||||
|
self.browser_list.clear()
|
||||||
|
self.browser_list.addItem("Fetching remote repositories...")
|
||||||
|
self.btn_install.setEnabled(False)
|
||||||
|
self.btn_fetch.setEnabled(False)
|
||||||
|
|
||||||
|
# Defer network call to background worker to avoid UI freeze
|
||||||
|
self._fetch_worker = RemoteFetchWorker(self.manager, self.repository_urls)
|
||||||
|
self._fetch_worker.fetched.connect(self._on_remote_fetched)
|
||||||
|
self._fetch_worker.start()
|
||||||
|
|
||||||
|
def _on_remote_fetched(self, plugins: list[dict[str, Any]], all_failed: bool) -> None:
|
||||||
|
"""Callback executed on the main UI thread when remote fetching finishes."""
|
||||||
|
self.remote_plugins_data = plugins
|
||||||
|
self._has_fetched_remote = True
|
||||||
|
self.btn_fetch.setEnabled(True)
|
||||||
|
|
||||||
|
if plugins:
|
||||||
|
self._populate_browser_list()
|
||||||
|
elif all_failed:
|
||||||
|
self.browser_list.clear()
|
||||||
|
self.browser_list.addItem("Unable to load plugins from configured repositories.")
|
||||||
|
else:
|
||||||
|
self.browser_list.clear()
|
||||||
|
self.browser_list.addItem("No plugins found across configured repositories.")
|
||||||
|
|
||||||
|
def _populate_browser_list(self) -> None:
|
||||||
|
"""Populates the browser tab list and evaluates compatibility and installation state."""
|
||||||
|
self.browser_list.clear()
|
||||||
|
|
||||||
|
installed_info = self.manager.get_installed_plugins_info()
|
||||||
|
installed_ids = {p.get("id") for p in installed_info if p.get("id")}
|
||||||
|
installed_names = {p.get("name") for p in installed_info if p.get("name")}
|
||||||
|
|
||||||
|
for plugin in self.remote_plugins_data:
|
||||||
|
name = plugin.get("name", "Unknown")
|
||||||
|
p_id = plugin.get("id", "")
|
||||||
|
version = plugin.get("version", "v0.0")
|
||||||
|
desc = plugin.get("description", "")
|
||||||
|
platforms = str(plugin.get("platforms", []))
|
||||||
|
min_v_str = plugin.get("min_app_version", "0.0.0")
|
||||||
|
|
||||||
|
is_platform_ok = not platforms or self.manager.current_platform in platforms
|
||||||
|
is_version_ok = self.manager.current_app_version >= parse_version(min_v_str)
|
||||||
|
is_compatible = is_platform_ok and is_version_ok
|
||||||
|
|
||||||
|
# Evaluate reasons for incompatibility
|
||||||
|
incompat_reasons: list[str] = []
|
||||||
|
if not is_platform_ok:
|
||||||
|
plat_str = ", ".join(platforms) if isinstance(platforms, list) else str(platforms)
|
||||||
|
incompat_reasons.append(f"Requires platform: {plat_str}")
|
||||||
|
if not is_version_ok:
|
||||||
|
incompat_reasons.append(f"Requires App v{min_v_str}+")
|
||||||
|
|
||||||
|
is_installed = (bool(p_id) and p_id in installed_ids) or (bool(name) and name in installed_names)
|
||||||
|
|
||||||
|
display_text = f"{name} (v{version}) - {desc}"
|
||||||
|
if is_installed:
|
||||||
|
display_text += " [Installed]"
|
||||||
|
elif incompat_reasons:
|
||||||
|
reason_str = "; ".join(incompat_reasons)
|
||||||
|
display_text += f" [Incompatible: {reason_str}]"
|
||||||
|
|
||||||
|
item = QListWidgetItem(display_text, self.browser_list)
|
||||||
|
item.setData(Qt.ItemDataRole.UserRole, plugin)
|
||||||
|
item.setData(Qt.ItemDataRole.UserRole + 1, is_compatible)
|
||||||
|
item.setData(Qt.ItemDataRole.UserRole + 2, is_installed)
|
||||||
|
|
||||||
|
if is_installed:
|
||||||
|
item.setForeground(QColor("#2e7d32")) # Green for installed
|
||||||
|
elif not is_compatible:
|
||||||
|
item.setForeground(QColor("#d32f2f")) # Red for incompatible
|
||||||
|
|
||||||
|
def install_selected_plugin(self) -> None:
|
||||||
|
selected = self.browser_list.currentItem()
|
||||||
|
if not selected:
|
||||||
|
return
|
||||||
|
data = selected.data(Qt.ItemDataRole.UserRole)
|
||||||
|
if not data:
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
target_path = self.manager.install_plugin_from_url(
|
||||||
|
data.get("download_url", ""),
|
||||||
|
data.get("id", "unnamed")
|
||||||
|
)
|
||||||
|
QMessageBox.information(self, "Success", f"Installed plugin to:\n{target_path}")
|
||||||
|
except Exception as e:
|
||||||
|
QMessageBox.critical(self, "Installation Failed", f"Could not install plugin:\n{e}")
|
||||||
|
|
||||||
|
def _on_tab_changed(self, index: int) -> None:
|
||||||
|
if index == 1 and not self._has_fetched_remote:
|
||||||
|
self.fetch_remote_plugins()
|
||||||
|
|
||||||
|
def _on_browser_item_changed(self, current: QListWidgetItem | None, _: Any) -> None:
|
||||||
|
if current:
|
||||||
|
is_compatible = bool(current.data(Qt.ItemDataRole.UserRole + 1))
|
||||||
|
is_installed = bool(current.data(Qt.ItemDataRole.UserRole + 2))
|
||||||
|
|
||||||
|
# Button lights up ONLY if plugin is compatible and NOT yet installed
|
||||||
|
self.btn_install.setEnabled(is_compatible and not is_installed)
|
||||||
|
else:
|
||||||
|
self.btn_install.setEnabled(False)
|
||||||
|
|
||||||
|
def _on_add_repo(self) -> None:
|
||||||
|
"""Adds a custom repository URL and re-fetches plugins."""
|
||||||
|
url = self.repo_input.text().strip()
|
||||||
|
if url and url not in self.repository_urls:
|
||||||
|
self.repository_urls.append(url)
|
||||||
|
self.repo_input.clear()
|
||||||
|
self.fetch_remote_plugins()
|
||||||
|
|
||||||
|
def _create_installed_tab(self) -> QWidget:
|
||||||
|
tab = QWidget()
|
||||||
|
layout = QVBoxLayout(tab)
|
||||||
|
|
||||||
|
path_layout = QHBoxLayout()
|
||||||
|
path_label = QLabel(f"<b>Plugins Directory:</b> <code>{self.manager.plugins_dir}</code>", tab)
|
||||||
|
btn_open_folder = QPushButton("Open Folder", tab)
|
||||||
|
btn_open_folder.setToolTip("Open this directory in Explorer / Finder")
|
||||||
|
btn_open_folder.clicked.connect(self.manager.open_plugins_directory)
|
||||||
|
|
||||||
|
path_layout.addWidget(path_label)
|
||||||
|
path_layout.addStretch()
|
||||||
|
path_layout.addWidget(btn_open_folder)
|
||||||
|
|
||||||
|
content_layout = QHBoxLayout()
|
||||||
|
|
||||||
|
self.installed_list = QListWidget(tab)
|
||||||
|
self.installed_list.currentItemChanged.connect(self._on_installed_item_changed)
|
||||||
|
|
||||||
|
self.details_panel = QWidget(tab)
|
||||||
|
details_layout = QVBoxLayout(self.details_panel)
|
||||||
|
details_layout.setContentsMargins(10, 0, 0, 0)
|
||||||
|
|
||||||
|
self.lbl_plugin_title = QLabel("Select a plugin", self.details_panel)
|
||||||
|
self.lbl_plugin_title.setStyleSheet("font-size: 13pt; font-weight: bold;")
|
||||||
|
|
||||||
|
self.lbl_plugin_meta = QLabel("", self.details_panel)
|
||||||
|
self.lbl_plugin_status = QLabel("", self.details_panel)
|
||||||
|
|
||||||
|
self.txt_plugin_desc = QTextBrowser(self.details_panel)
|
||||||
|
self.txt_plugin_desc.setPlaceholderText("Select an installed plugin to view details...")
|
||||||
|
|
||||||
|
self.lbl_plugin_path = QLabel("", self.details_panel)
|
||||||
|
self.lbl_plugin_path.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse)
|
||||||
|
self.lbl_plugin_path.setWordWrap(True)
|
||||||
|
|
||||||
|
details_layout.addWidget(self.lbl_plugin_title)
|
||||||
|
details_layout.addWidget(self.lbl_plugin_meta)
|
||||||
|
details_layout.addWidget(self.lbl_plugin_status)
|
||||||
|
details_layout.addWidget(QLabel("<b>Description:</b>", self.details_panel))
|
||||||
|
details_layout.addWidget(self.txt_plugin_desc)
|
||||||
|
details_layout.addWidget(self.lbl_plugin_path)
|
||||||
|
|
||||||
|
content_layout.addWidget(self.installed_list, stretch=1)
|
||||||
|
content_layout.addWidget(self.details_panel, stretch=1)
|
||||||
|
|
||||||
|
btn_layout = QHBoxLayout()
|
||||||
|
btn_refresh = QPushButton("Refresh List", tab)
|
||||||
|
btn_enable = QPushButton("Enable / Disable", tab)
|
||||||
|
btn_uninstall = QPushButton("Uninstall", tab)
|
||||||
|
|
||||||
|
btn_refresh.clicked.connect(self.refresh_installed_plugins)
|
||||||
|
btn_enable.clicked.connect(self.toggle_enable_disable)
|
||||||
|
btn_uninstall.clicked.connect(self.uninstall_plugin)
|
||||||
|
|
||||||
|
btn_layout.addWidget(btn_refresh)
|
||||||
|
btn_layout.addStretch()
|
||||||
|
btn_layout.addWidget(btn_enable)
|
||||||
|
btn_layout.addWidget(btn_uninstall)
|
||||||
|
|
||||||
|
layout.addLayout(path_layout)
|
||||||
|
layout.addLayout(content_layout)
|
||||||
|
layout.addLayout(btn_layout)
|
||||||
|
return tab
|
||||||
|
|
||||||
|
def _create_browser_tab(self) -> QWidget:
|
||||||
|
tab = QWidget()
|
||||||
|
layout = QVBoxLayout(tab)
|
||||||
|
|
||||||
|
info = QLabel(
|
||||||
|
f"<b>Platform:</b> <code>{self.manager.current_platform}</code> | "
|
||||||
|
f"<b>App Version:</b> <code>v{CURRENT_VERSION}</code>", tab
|
||||||
|
)
|
||||||
|
|
||||||
|
repo_layout = QHBoxLayout()
|
||||||
|
self.repo_input = QLineEdit(tab)
|
||||||
|
self.repo_input.setPlaceholderText("Enter custom plugins.json URL...")
|
||||||
|
btn_add = QPushButton("Add Repo", tab)
|
||||||
|
btn_add.clicked.connect(self._on_add_repo)
|
||||||
|
|
||||||
|
repo_layout.addWidget(self.repo_input)
|
||||||
|
repo_layout.addWidget(btn_add)
|
||||||
|
|
||||||
|
self.browser_list = QListWidget(tab)
|
||||||
|
self.browser_list.currentItemChanged.connect(self._on_browser_item_changed)
|
||||||
|
|
||||||
|
btn_layout = QHBoxLayout()
|
||||||
|
self.btn_fetch = QPushButton("Fetch Remote Lists", tab)
|
||||||
|
self.btn_install = QPushButton("Install Plugin", tab)
|
||||||
|
self.btn_install.setEnabled(False)
|
||||||
|
|
||||||
|
self.btn_fetch.clicked.connect(self.fetch_remote_plugins)
|
||||||
|
self.btn_install.clicked.connect(self.install_selected_plugin)
|
||||||
|
|
||||||
|
btn_layout.addWidget(self.btn_fetch)
|
||||||
|
btn_layout.addStretch()
|
||||||
|
btn_layout.addWidget(self.btn_install)
|
||||||
|
|
||||||
|
layout.addWidget(info)
|
||||||
|
layout.addLayout(repo_layout)
|
||||||
|
layout.addWidget(self.browser_list)
|
||||||
|
layout.addLayout(btn_layout)
|
||||||
|
return tab
|
||||||
+61
-5
@@ -8,15 +8,19 @@ License: GPL-3.0
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
# Built-in imports
|
# Built-in imports
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
from typing import Any, Callable
|
from typing import Any, Callable
|
||||||
|
|
||||||
# External library imports
|
# External library imports
|
||||||
from PySide6.QtWidgets import QWidget, QVBoxLayout, QTextEdit, QLineEdit
|
from PySide6.QtWidgets import QWidget, QVBoxLayout, QTextEdit, QLineEdit, QMainWindow
|
||||||
from PySide6.QtCore import Qt, QThread, Signal
|
from PySide6.QtCore import QProcess, Qt, QThread, Signal
|
||||||
|
|
||||||
from file_ext_registration import register_file_association, is_windows_admin
|
from file_ext_registration import register_file_association, is_windows_admin
|
||||||
|
from plugin_manager import PluginManager
|
||||||
from src.shared.shareddata import API_URL, API_URL_SECONDARY, APP_NAME, CURRENT_VERSION, PLATFORM_NAME
|
from src.shared.shareddata import API_URL, API_URL_SECONDARY, APP_NAME, CURRENT_VERSION, PLATFORM_NAME
|
||||||
from src.window.about import AboutWindow
|
from src.window.about import AboutWindow
|
||||||
|
from src.window.plugins import PluginsWindow
|
||||||
from updater import UpdateManager
|
from updater import UpdateManager
|
||||||
|
|
||||||
|
|
||||||
@@ -40,7 +44,7 @@ class _AssocWorker(QThread):
|
|||||||
|
|
||||||
|
|
||||||
class TerminalWindow(QWidget):
|
class TerminalWindow(QWidget):
|
||||||
def __init__(self, parent: QWidget | None = None) -> None:
|
def __init__(self, parent: QWidget | None, plugin_manager: PluginManager) -> None:
|
||||||
super().__init__(parent, Qt.WindowType.Window)
|
super().__init__(parent, Qt.WindowType.Window)
|
||||||
self.setWindowTitle(f"Terminal - {APP_NAME.upper()}")
|
self.setWindowTitle(f"Terminal - {APP_NAME.upper()}")
|
||||||
self.resize(320, 180)
|
self.resize(320, 180)
|
||||||
@@ -55,6 +59,9 @@ class TerminalWindow(QWidget):
|
|||||||
layout.addWidget(self.input_line)
|
layout.addWidget(self.input_line)
|
||||||
self.setLayout(layout)
|
self.setLayout(layout)
|
||||||
|
|
||||||
|
self._process: QProcess | None = None
|
||||||
|
self.plugin_manager = plugin_manager
|
||||||
|
|
||||||
self.commands: dict[str, Callable[..., Any]] = {
|
self.commands: dict[str, Callable[..., Any]] = {
|
||||||
"hello": self.cmd_hello,
|
"hello": self.cmd_hello,
|
||||||
"help": self.cmd_help,
|
"help": self.cmd_help,
|
||||||
@@ -62,6 +69,8 @@ class TerminalWindow(QWidget):
|
|||||||
"about": self.cmd_about,
|
"about": self.cmd_about,
|
||||||
"assoc": self.cmd_assoc,
|
"assoc": self.cmd_assoc,
|
||||||
"update": self.cmd_update,
|
"update": self.cmd_update,
|
||||||
|
"plugins": self.cmd_plugins,
|
||||||
|
"utest": self.cmd_utest,
|
||||||
}
|
}
|
||||||
|
|
||||||
self._pending_assoc_confirmation: bool = False
|
self._pending_assoc_confirmation: bool = False
|
||||||
@@ -107,7 +116,8 @@ class TerminalWindow(QWidget):
|
|||||||
return "Hello from the terminal!"
|
return "Hello from the terminal!"
|
||||||
|
|
||||||
def cmd_help(self, *args: Any) -> str:
|
def cmd_help(self, *args: Any) -> str:
|
||||||
return f"Available commands: {', '.join(self.commands.keys())}"
|
available_cmds = [cmd for cmd in self.commands.keys() if cmd != "utest"]
|
||||||
|
return f"Available commands: {', '.join(available_cmds)}"
|
||||||
|
|
||||||
def cmd_version(self, *args: Any) -> str:
|
def cmd_version(self, *args: Any) -> str:
|
||||||
return f"{APP_NAME.upper()} is running version {CURRENT_VERSION}."
|
return f"{APP_NAME.upper()} is running version {CURRENT_VERSION}."
|
||||||
@@ -116,9 +126,13 @@ class TerminalWindow(QWidget):
|
|||||||
self.about = AboutWindow(self)
|
self.about = AboutWindow(self)
|
||||||
self.about.show()
|
self.about.show()
|
||||||
|
|
||||||
|
def cmd_plugins(self, *args: Any) -> None:
|
||||||
|
self.about = PluginsWindow(self, self.plugin_manager)
|
||||||
|
self.about.show()
|
||||||
|
|
||||||
def cmd_update(self, *args: Any) -> str:
|
def cmd_update(self, *args: Any) -> str:
|
||||||
main_win = self.parent()
|
main_win = self.parent()
|
||||||
if not isinstance(main_win, QWidget):
|
if not isinstance(main_win, QMainWindow):
|
||||||
return "[Error] Main window context not found."
|
return "[Error] Main window context not found."
|
||||||
|
|
||||||
self.updater = UpdateManager(
|
self.updater = UpdateManager(
|
||||||
@@ -164,3 +178,45 @@ class TerminalWindow(QWidget):
|
|||||||
def _on_assoc_result(self, ok: bool, msg: str) -> None:
|
def _on_assoc_result(self, ok: bool, msg: str) -> None:
|
||||||
self.output_area.append(msg)
|
self.output_area.append(msg)
|
||||||
self._assoc_worker = None
|
self._assoc_worker = None
|
||||||
|
|
||||||
|
def cmd_utest(self, *args: Any) -> str | None:
|
||||||
|
"""Executes a specific pre-defined python script non-blockingly."""
|
||||||
|
if self._process and self._process.state() != QProcess.ProcessState.NotRunning:
|
||||||
|
return "[Error] A process is already running."
|
||||||
|
|
||||||
|
target_script = Path("main_unit_tests.py")
|
||||||
|
|
||||||
|
if not target_script.exists():
|
||||||
|
return f"[Error] Target script not found at: {target_script}"
|
||||||
|
|
||||||
|
self._process = QProcess(self)
|
||||||
|
|
||||||
|
# Stream stdout and stderr live to output_area
|
||||||
|
self._process.readyReadStandardOutput.connect(self._handle_stdout)
|
||||||
|
self._process.readyReadStandardError.connect(self._handle_stderr)
|
||||||
|
self._process.finished.connect(self._handle_process_finished)
|
||||||
|
|
||||||
|
# Use current Python interpreter executable. Works when packaged?
|
||||||
|
python_executable = sys.executable
|
||||||
|
|
||||||
|
self.output_area.append(f"Starting {target_script.name}...")
|
||||||
|
self._process.start(python_executable, [str(target_script)])
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _handle_stdout(self) -> None:
|
||||||
|
if self._process:
|
||||||
|
raw_bytes = bytes(self._process.readAllStandardOutput().data())
|
||||||
|
data = raw_bytes.decode("utf-8")
|
||||||
|
if data.strip():
|
||||||
|
self.output_area.append(data.strip())
|
||||||
|
|
||||||
|
def _handle_stderr(self) -> None:
|
||||||
|
if self._process:
|
||||||
|
raw_bytes = bytes(self._process.readAllStandardError().data())
|
||||||
|
data = raw_bytes.decode("utf-8")
|
||||||
|
if data.strip():
|
||||||
|
self.output_area.append(f"[Error] {data.strip()}")
|
||||||
|
|
||||||
|
def _handle_process_finished(self, exit_code: int, exit_status: QProcess.ExitStatus) -> None:
|
||||||
|
self.output_area.append(f"Process finished with code {exit_code}.")
|
||||||
|
self._process = None
|
||||||
+228
-199
@@ -1,35 +1,193 @@
|
|||||||
"""
|
"""
|
||||||
Filename: updateevents.py
|
Filename: updateevents.py
|
||||||
Description: Methods to update snirf events for FLARES
|
Description: Methods to update snirf events
|
||||||
|
Note: Compliant with pylance strict type checking
|
||||||
|
|
||||||
Author: Tyler de Zeeuw
|
Author: Tyler de Zeeuw
|
||||||
License: GPL-3.0
|
License: GPL-3.0
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
# Built-in imports
|
||||||
import os
|
import os
|
||||||
import json
|
import json
|
||||||
|
import concurrent.futures
|
||||||
from enum import Enum, auto
|
from enum import Enum, auto
|
||||||
from datetime import datetime
|
from typing import Any, List, Optional, Sequence, Union, cast
|
||||||
|
|
||||||
import numpy as np
|
|
||||||
|
|
||||||
|
# External library imports
|
||||||
from PySide6.QtWidgets import QWidget, QVBoxLayout, QLabel, QLineEdit, QPushButton, QComboBox, QHBoxLayout, QMessageBox, QFileDialog
|
from PySide6.QtWidgets import QWidget, QVBoxLayout, QLabel, QLineEdit, QPushButton, QComboBox, QHBoxLayout, QMessageBox, QFileDialog
|
||||||
from PySide6.QtCore import Qt
|
from PySide6.QtCore import Qt
|
||||||
|
|
||||||
from mne import Annotations
|
from mne import Annotations
|
||||||
from mne.io import read_raw_snirf
|
from mne.io import read_raw_snirf #type: ignore
|
||||||
from mne_nirs.io import write_raw_snirf
|
from mne_nirs.io import write_raw_snirf #type: ignore
|
||||||
|
|
||||||
|
from mne.io.base import BaseRaw
|
||||||
from src.shared.shareddata import APP_NAME
|
from src.shared.shareddata import APP_NAME
|
||||||
|
|
||||||
|
|
||||||
|
def _load_annotations_worker(file_path: str) -> Annotations:
|
||||||
|
"""Isolated worker process to load SNIRF annotations without leaving HDF5 memory leaks in main process."""
|
||||||
|
raw: BaseRaw = read_raw_snirf(file_path, preload=False, verbose=False)
|
||||||
|
return cast(Annotations, getattr(raw, "annotations"))
|
||||||
|
|
||||||
|
|
||||||
|
def load_snirf_annotations_isolated(file_path: str) -> Annotations:
|
||||||
|
"""Spawns a short-lived process to extract annotations and cleanly releases C-memory allocations."""
|
||||||
|
with concurrent.futures.ProcessPoolExecutor(max_workers=1) as executor:
|
||||||
|
future = executor.submit(_load_annotations_worker, file_path)
|
||||||
|
return future.result()
|
||||||
|
|
||||||
|
|
||||||
|
def _write_snirf_worker(
|
||||||
|
file_path: str,
|
||||||
|
save_path: str,
|
||||||
|
onsets: list[float],
|
||||||
|
durations: list[float],
|
||||||
|
descriptions: list[str]
|
||||||
|
) -> None:
|
||||||
|
"""Isolated process worker to read, annotate, write, and immediately free memory/file handles."""
|
||||||
|
raw = read_raw_snirf(file_path, preload=True, verbose=False)
|
||||||
|
new_annotations = Annotations(onset=onsets, duration=durations, description=descriptions)
|
||||||
|
raw.set_annotations(new_annotations) #type: ignore
|
||||||
|
write_raw_snirf(raw, save_path)
|
||||||
|
|
||||||
|
|
||||||
|
def write_snirf_with_annotations_isolated(
|
||||||
|
file_path: str,
|
||||||
|
save_path: str,
|
||||||
|
onsets: list[float],
|
||||||
|
durations: list[float],
|
||||||
|
descriptions: list[str]
|
||||||
|
) -> None:
|
||||||
|
"""Executes SNIRF writing in a dedicated worker process to ensure 100% memory/handle cleanup."""
|
||||||
|
with concurrent.futures.ProcessPoolExecutor(max_workers=1) as executor:
|
||||||
|
future = executor.submit(
|
||||||
|
_write_snirf_worker,
|
||||||
|
file_path,
|
||||||
|
save_path,
|
||||||
|
onsets,
|
||||||
|
durations,
|
||||||
|
descriptions
|
||||||
|
)
|
||||||
|
future.result()
|
||||||
|
|
||||||
|
|
||||||
|
def _align_boris_and_write_snirf_worker(
|
||||||
|
file_path: str,
|
||||||
|
save_path: str,
|
||||||
|
boris_events: list[Any],
|
||||||
|
time_shift: float
|
||||||
|
) -> int:
|
||||||
|
"""
|
||||||
|
Isolated process worker to read SNIRF, align BORIS events, write updated file,
|
||||||
|
and cleanly exit releasing all HDF5 C-memory allocations and file locks.
|
||||||
|
"""
|
||||||
|
raw = read_raw_snirf(file_path, preload=True, verbose=False)
|
||||||
|
|
||||||
|
# Type-safe extraction of sampling frequency from raw.info
|
||||||
|
info_dict = cast(dict[str, Any], raw.info)
|
||||||
|
sfreq = float(info_dict.get("sfreq", 10.0))
|
||||||
|
min_shift = 1.0 / sfreq if sfreq > 0 else 0.1
|
||||||
|
max_attempts = 10
|
||||||
|
|
||||||
|
onsets: list[float] = []
|
||||||
|
durations: list[float] = []
|
||||||
|
descriptions: list[str] = []
|
||||||
|
|
||||||
|
open_events: dict[str, list[float]] = {}
|
||||||
|
label_counts: dict[str, int] = {}
|
||||||
|
used_times: set[float] = set()
|
||||||
|
|
||||||
|
for raw_event in boris_events:
|
||||||
|
if not isinstance(raw_event, (list, tuple)):
|
||||||
|
continue
|
||||||
|
|
||||||
|
event = cast(Sequence[Any], raw_event)
|
||||||
|
|
||||||
|
if len(event) < 3:
|
||||||
|
continue
|
||||||
|
|
||||||
|
event_time = float(event[0])
|
||||||
|
label = str(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(round(duration, 6))
|
||||||
|
descriptions.append(label)
|
||||||
|
|
||||||
|
# Handle unmatched start markers
|
||||||
|
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) #type: ignore
|
||||||
|
write_raw_snirf(raw, save_path)
|
||||||
|
|
||||||
|
return len(onsets)
|
||||||
|
|
||||||
|
|
||||||
|
def align_boris_and_write_snirf_isolated(
|
||||||
|
file_path: str,
|
||||||
|
save_path: str,
|
||||||
|
boris_events: list[Any],
|
||||||
|
time_shift: float
|
||||||
|
) -> int:
|
||||||
|
"""Executes BORIS alignment and SNIRF writing in a dedicated worker process."""
|
||||||
|
with concurrent.futures.ProcessPoolExecutor(max_workers=1) as executor:
|
||||||
|
future = executor.submit(
|
||||||
|
_align_boris_and_write_snirf_worker,
|
||||||
|
file_path,
|
||||||
|
save_path,
|
||||||
|
boris_events,
|
||||||
|
time_shift
|
||||||
|
)
|
||||||
|
return future.result()
|
||||||
|
|
||||||
|
|
||||||
class EventUpdateMode(Enum):
|
class EventUpdateMode(Enum):
|
||||||
WRITE_SNIRF = auto() # destructive
|
WRITE_SNIRF = auto() # destructive
|
||||||
WRITE_JSON = auto() # non-destructive
|
WRITE_JSON = auto() # non-destructive
|
||||||
|
|
||||||
|
|
||||||
class UpdateEventsWindow(QWidget):
|
class UpdateEventsWindow(QWidget):
|
||||||
def __init__(self, parent=None, mode=EventUpdateMode.WRITE_SNIRF, caller=None):
|
def __init__(self, parent: Optional[QWidget]=None, mode: EventUpdateMode=EventUpdateMode.WRITE_SNIRF, caller: Optional[Union[str, object]] = None,):
|
||||||
super().__init__(parent, Qt.WindowType.Window)
|
super().__init__(parent, Qt.WindowType.Window)
|
||||||
|
|
||||||
self.mode = mode
|
self.mode = mode
|
||||||
@@ -91,7 +249,7 @@ class UpdateEventsWindow(QWidget):
|
|||||||
help_btn_a = QPushButton("?")
|
help_btn_a = QPushButton("?")
|
||||||
help_btn_a.setFixedWidth(25)
|
help_btn_a.setFixedWidth(25)
|
||||||
help_btn_a.setToolTip(help_text_a)
|
help_btn_a.setToolTip(help_text_a)
|
||||||
help_btn_a.clicked.connect(lambda _, text=help_text_a: self.show_help_popup(text))
|
help_btn_a.clicked.connect(lambda: self.show_help_popup(help_text_a))
|
||||||
file_a_layout.addWidget(help_btn_a)
|
file_a_layout.addWidget(help_btn_a)
|
||||||
|
|
||||||
# Container for label + line_edit + browse button with tooltip
|
# Container for label + line_edit + browse button with tooltip
|
||||||
@@ -114,7 +272,7 @@ class UpdateEventsWindow(QWidget):
|
|||||||
help_btn_b = QPushButton("?")
|
help_btn_b = QPushButton("?")
|
||||||
help_btn_b.setFixedWidth(25)
|
help_btn_b.setFixedWidth(25)
|
||||||
help_btn_b.setToolTip(help_text_b)
|
help_btn_b.setToolTip(help_text_b)
|
||||||
help_btn_b.clicked.connect(lambda _, text=help_text_b: self.show_help_popup(text))
|
help_btn_b.clicked.connect(lambda: self.show_help_popup(help_text_b))
|
||||||
file_b_layout.addWidget(help_btn_b)
|
file_b_layout.addWidget(help_btn_b)
|
||||||
|
|
||||||
file_b_container = QWidget()
|
file_b_container = QWidget()
|
||||||
@@ -136,7 +294,7 @@ class UpdateEventsWindow(QWidget):
|
|||||||
help_btn_suffix = QPushButton("?")
|
help_btn_suffix = QPushButton("?")
|
||||||
help_btn_suffix.setFixedWidth(25)
|
help_btn_suffix.setFixedWidth(25)
|
||||||
help_btn_suffix.setToolTip(help_text_suffix)
|
help_btn_suffix.setToolTip(help_text_suffix)
|
||||||
help_btn_suffix.clicked.connect(lambda _, text=help_text_suffix: self.show_help_popup(text))
|
help_btn_suffix.clicked.connect(lambda: self.show_help_popup(help_text_suffix))
|
||||||
suffix_layout.addWidget(help_btn_suffix)
|
suffix_layout.addWidget(help_btn_suffix)
|
||||||
|
|
||||||
suffix_container = QWidget()
|
suffix_container = QWidget()
|
||||||
@@ -157,7 +315,7 @@ class UpdateEventsWindow(QWidget):
|
|||||||
help_btn_suffix = QPushButton("?")
|
help_btn_suffix = QPushButton("?")
|
||||||
help_btn_suffix.setFixedWidth(25)
|
help_btn_suffix.setFixedWidth(25)
|
||||||
help_btn_suffix.setToolTip(help_text_suffix)
|
help_btn_suffix.setToolTip(help_text_suffix)
|
||||||
help_btn_suffix.clicked.connect(lambda _, text=help_text_suffix: self.show_help_popup(text))
|
help_btn_suffix.clicked.connect(lambda: self.show_help_popup(help_text_suffix))
|
||||||
suffix2_layout.addWidget(help_btn_suffix)
|
suffix2_layout.addWidget(help_btn_suffix)
|
||||||
|
|
||||||
suffix2_container = QWidget()
|
suffix2_container = QWidget()
|
||||||
@@ -177,7 +335,7 @@ class UpdateEventsWindow(QWidget):
|
|||||||
help_btn_snirf_events = QPushButton("?")
|
help_btn_snirf_events = QPushButton("?")
|
||||||
help_btn_snirf_events.setFixedWidth(25)
|
help_btn_snirf_events.setFixedWidth(25)
|
||||||
help_btn_snirf_events.setToolTip(help_text_snirf_events)
|
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))
|
help_btn_snirf_events.clicked.connect(lambda: self.show_help_popup(help_text_snirf_events))
|
||||||
snirf_events_layout.addWidget(help_btn_snirf_events)
|
snirf_events_layout.addWidget(help_btn_snirf_events)
|
||||||
|
|
||||||
snirf_events_container = QWidget()
|
snirf_events_container = QWidget()
|
||||||
@@ -199,23 +357,24 @@ class UpdateEventsWindow(QWidget):
|
|||||||
|
|
||||||
self.setLayout(layout)
|
self.setLayout(layout)
|
||||||
|
|
||||||
def show_help_popup(self, text):
|
def show_help_popup(self, text: str) -> None:
|
||||||
msg = QMessageBox(self)
|
msg = QMessageBox(self)
|
||||||
msg.setWindowTitle(f"Parameter Info - {APP_NAME.upper()}")
|
msg.setWindowTitle(f"Parameter Info - {APP_NAME.upper()}")
|
||||||
msg.setText(text)
|
msg.setText(text)
|
||||||
msg.exec()
|
msg.exec()
|
||||||
|
|
||||||
def browse_file_a(self):
|
def browse_file_a(self) -> None:
|
||||||
file_path, _ = QFileDialog.getOpenFileName(self, "Select SNIRF File", "", "SNIRF Files (*.snirf)")
|
file_path, _ = QFileDialog.getOpenFileName(self, "Select SNIRF File", "", "SNIRF Files (*.snirf)")
|
||||||
if file_path:
|
if file_path:
|
||||||
self.line_edit_file_a.setText(file_path)
|
self.line_edit_file_a.setText(file_path)
|
||||||
try:
|
try:
|
||||||
# TODO: Bad! read_raw_snirf doesnt release memory properly! Should be spawned in a seperate process and killed once completed
|
# Memory leak safe: Extracted in a separate process that terminates immediately
|
||||||
raw = read_raw_snirf(file_path, preload=False)
|
annotations: Annotations = load_snirf_annotations_isolated(file_path)
|
||||||
annotations = raw.annotations
|
|
||||||
|
print(f"Loaded {len(annotations)} annotations from {file_path}")
|
||||||
|
|
||||||
# Build individual event entries
|
# Build individual event entries
|
||||||
event_entries = []
|
event_entries: list[str] = []
|
||||||
for onset, description in zip(annotations.onset, annotations.description):
|
for onset, description in zip(annotations.onset, annotations.description):
|
||||||
event_str = f"{description} @ {onset:.3f}s"
|
event_str = f"{description} @ {onset:.3f}s"
|
||||||
event_entries.append(event_str)
|
event_entries.append(event_str)
|
||||||
@@ -235,7 +394,7 @@ class UpdateEventsWindow(QWidget):
|
|||||||
self.combo_snirf_events.clear()
|
self.combo_snirf_events.clear()
|
||||||
self.combo_snirf_events.setEnabled(False)
|
self.combo_snirf_events.setEnabled(False)
|
||||||
|
|
||||||
def browse_file_b(self):
|
def browse_file_b(self) -> None:
|
||||||
file_path, _ = QFileDialog.getOpenFileName(self, "Select BORIS File", "", "BORIS project Files (*.boris)")
|
file_path, _ = QFileDialog.getOpenFileName(self, "Select BORIS File", "", "BORIS project Files (*.boris)")
|
||||||
if file_path:
|
if file_path:
|
||||||
self.line_edit_file_b.setText(file_path)
|
self.line_edit_file_b.setText(file_path)
|
||||||
@@ -252,16 +411,15 @@ class UpdateEventsWindow(QWidget):
|
|||||||
except (json.JSONDecodeError, FileNotFoundError, KeyError) as e:
|
except (json.JSONDecodeError, FileNotFoundError, KeyError) as e:
|
||||||
QMessageBox.warning(self, "Error", f"Failed to parse BORIS file:\n{e}")
|
QMessageBox.warning(self, "Error", f"Failed to parse BORIS file:\n{e}")
|
||||||
|
|
||||||
def extract_boris_observation_keys(self, data):
|
|
||||||
|
def extract_boris_observation_keys(self, data: dict[str, Any]) -> List[str]:
|
||||||
if "observations" not in data:
|
if "observations" not in data:
|
||||||
raise KeyError("Missing 'observations' key in BORIS file.")
|
raise KeyError("Missing 'observations' key in BORIS file.")
|
||||||
|
|
||||||
observations = data["observations"]
|
observations = cast(dict[str, Any], data["observations"])
|
||||||
if not isinstance(observations, dict):
|
|
||||||
raise TypeError("'observations' must be a dictionary.")
|
|
||||||
|
|
||||||
return list(observations.keys())
|
return list(observations.keys())
|
||||||
|
|
||||||
|
|
||||||
def on_observation_selected(self):
|
def on_observation_selected(self):
|
||||||
selected_obs = self.combo_suffix.currentText()
|
selected_obs = self.combo_suffix.currentText()
|
||||||
if not selected_obs or not hasattr(self, 'boris_data'):
|
if not selected_obs or not hasattr(self, 'boris_data'):
|
||||||
@@ -276,11 +434,16 @@ class UpdateEventsWindow(QWidget):
|
|||||||
self.combo_events.setEnabled(False)
|
self.combo_events.setEnabled(False)
|
||||||
return
|
return
|
||||||
|
|
||||||
event_entries = []
|
event_entries: list[str] = []
|
||||||
for event in events:
|
for raw_event in events:
|
||||||
if isinstance(event, list) and len(event) >= 3:
|
if not isinstance(raw_event, (list, tuple)):
|
||||||
timestamp = event[0]
|
continue
|
||||||
label = event[2]
|
|
||||||
|
event = cast(Sequence[Any], raw_event)
|
||||||
|
|
||||||
|
if len(event) >= 3:
|
||||||
|
timestamp = float(event[0])
|
||||||
|
label = str(event[2])
|
||||||
display = f"{label} @ {timestamp:.3f}"
|
display = f"{label} @ {timestamp:.3f}"
|
||||||
event_entries.append(display)
|
event_entries.append(display)
|
||||||
|
|
||||||
@@ -288,11 +451,11 @@ class UpdateEventsWindow(QWidget):
|
|||||||
self.combo_events.addItems(event_entries)
|
self.combo_events.addItems(event_entries)
|
||||||
self.combo_events.setEnabled(bool(event_entries))
|
self.combo_events.setEnabled(bool(event_entries))
|
||||||
|
|
||||||
def clear_files(self):
|
def clear_files(self) -> None:
|
||||||
self.line_edit_file_a.clear()
|
self.line_edit_file_a.clear()
|
||||||
self.line_edit_file_b.clear()
|
self.line_edit_file_b.clear()
|
||||||
|
|
||||||
def go_action(self):
|
def go_action(self) -> None:
|
||||||
|
|
||||||
file_a = self.line_edit_file_a.text()
|
file_a = self.line_edit_file_a.text()
|
||||||
suffix = "flare"
|
suffix = "flare"
|
||||||
@@ -365,71 +528,15 @@ class UpdateEventsWindow(QWidget):
|
|||||||
save_path += ".snirf"
|
save_path += ".snirf"
|
||||||
|
|
||||||
try:
|
try:
|
||||||
raw = read_raw_snirf(file_a, preload=True)
|
# Memory leak safe: Worker handles alignment calculation, read, write, and process termination
|
||||||
|
event_count = align_boris_and_write_snirf_isolated(
|
||||||
|
file_a,
|
||||||
|
save_path,
|
||||||
|
boris_events,
|
||||||
|
time_shift
|
||||||
|
)
|
||||||
|
|
||||||
# --- Align BORIS events to SNIRF ---
|
QMessageBox.information(self, "Success", f"SNIRF file updated with {event_count} aligned BORIS events.")
|
||||||
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:
|
except Exception as e:
|
||||||
QMessageBox.critical(self, "Error", f"Failed to update SNIRF file:\n{e}")
|
QMessageBox.critical(self, "Error", f"Failed to update SNIRF file:\n{e}")
|
||||||
@@ -451,7 +558,7 @@ class UpdateEventsWindow(QWidget):
|
|||||||
save_path += ".json"
|
save_path += ".json"
|
||||||
|
|
||||||
# Build JSON dict
|
# Build JSON dict
|
||||||
json_data = {
|
json_data: dict[str, Any] = {
|
||||||
"observation": selected_obs,
|
"observation": selected_obs,
|
||||||
"snirf_anchor": {"label": snirf_label, "time": snirf_anchor_time},
|
"snirf_anchor": {"label": snirf_label, "time": snirf_anchor_time},
|
||||||
"boris_anchor": {"label": boris_label, "time": boris_anchor_time},
|
"boris_anchor": {"label": boris_label, "time": boris_anchor_time},
|
||||||
@@ -468,79 +575,9 @@ class UpdateEventsWindow(QWidget):
|
|||||||
QMessageBox.critical(self, "Error", f"Failed to write JSON:\n{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):
|
class UpdateEventsBlazesWindow(QWidget):
|
||||||
|
|
||||||
def __init__(self, parent=None, mode=EventUpdateMode.WRITE_SNIRF, caller=None):
|
def __init__(self, parent: Optional[QWidget]=None, mode: EventUpdateMode=EventUpdateMode.WRITE_SNIRF, caller: Optional[Union[str, object]] = None,):
|
||||||
super().__init__(parent, Qt.WindowType.Window)
|
super().__init__(parent, Qt.WindowType.Window)
|
||||||
|
|
||||||
self.mode = mode
|
self.mode = mode
|
||||||
@@ -595,7 +632,7 @@ class UpdateEventsBlazesWindow(QWidget):
|
|||||||
help_btn_a = QPushButton("?")
|
help_btn_a = QPushButton("?")
|
||||||
help_btn_a.setFixedWidth(25)
|
help_btn_a.setFixedWidth(25)
|
||||||
help_btn_a.setToolTip(help_text_a)
|
help_btn_a.setToolTip(help_text_a)
|
||||||
help_btn_a.clicked.connect(lambda _, text=help_text_a: self.show_help_popup(text))
|
help_btn_a.clicked.connect(lambda: self.show_help_popup(help_text_a))
|
||||||
file_a_layout.addWidget(help_btn_a)
|
file_a_layout.addWidget(help_btn_a)
|
||||||
|
|
||||||
# Container for label + line_edit + browse button with tooltip
|
# Container for label + line_edit + browse button with tooltip
|
||||||
@@ -618,7 +655,7 @@ class UpdateEventsBlazesWindow(QWidget):
|
|||||||
help_btn_b = QPushButton("?")
|
help_btn_b = QPushButton("?")
|
||||||
help_btn_b.setFixedWidth(25)
|
help_btn_b.setFixedWidth(25)
|
||||||
help_btn_b.setToolTip(help_text_b)
|
help_btn_b.setToolTip(help_text_b)
|
||||||
help_btn_b.clicked.connect(lambda _, text=help_text_b: self.show_help_popup(text))
|
help_btn_b.clicked.connect(lambda: self.show_help_popup(help_text_b))
|
||||||
file_b_layout.addWidget(help_btn_b)
|
file_b_layout.addWidget(help_btn_b)
|
||||||
|
|
||||||
file_b_container = QWidget()
|
file_b_container = QWidget()
|
||||||
@@ -640,7 +677,7 @@ class UpdateEventsBlazesWindow(QWidget):
|
|||||||
help_btn_suffix = QPushButton("?")
|
help_btn_suffix = QPushButton("?")
|
||||||
help_btn_suffix.setFixedWidth(25)
|
help_btn_suffix.setFixedWidth(25)
|
||||||
help_btn_suffix.setToolTip(help_text_suffix)
|
help_btn_suffix.setToolTip(help_text_suffix)
|
||||||
help_btn_suffix.clicked.connect(lambda _, text=help_text_suffix: self.show_help_popup(text))
|
help_btn_suffix.clicked.connect(lambda: self.show_help_popup(help_text_suffix))
|
||||||
suffix2_layout.addWidget(help_btn_suffix)
|
suffix2_layout.addWidget(help_btn_suffix)
|
||||||
|
|
||||||
suffix2_container = QWidget()
|
suffix2_container = QWidget()
|
||||||
@@ -660,7 +697,7 @@ class UpdateEventsBlazesWindow(QWidget):
|
|||||||
help_btn_snirf_events = QPushButton("?")
|
help_btn_snirf_events = QPushButton("?")
|
||||||
help_btn_snirf_events.setFixedWidth(25)
|
help_btn_snirf_events.setFixedWidth(25)
|
||||||
help_btn_snirf_events.setToolTip(help_text_snirf_events)
|
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))
|
help_btn_snirf_events.clicked.connect(lambda: self.show_help_popup(help_text_snirf_events))
|
||||||
snirf_events_layout.addWidget(help_btn_snirf_events)
|
snirf_events_layout.addWidget(help_btn_snirf_events)
|
||||||
|
|
||||||
snirf_events_container = QWidget()
|
snirf_events_container = QWidget()
|
||||||
@@ -683,23 +720,24 @@ class UpdateEventsBlazesWindow(QWidget):
|
|||||||
self.setLayout(layout)
|
self.setLayout(layout)
|
||||||
|
|
||||||
|
|
||||||
def show_help_popup(self, text):
|
def show_help_popup(self, text: str) -> None:
|
||||||
msg = QMessageBox(self)
|
msg = QMessageBox(self)
|
||||||
msg.setWindowTitle(f"Parameter Info - {APP_NAME.upper()}")
|
msg.setWindowTitle(f"Parameter Info - {APP_NAME.upper()}")
|
||||||
msg.setText(text)
|
msg.setText(text)
|
||||||
msg.exec()
|
msg.exec()
|
||||||
|
|
||||||
def browse_file_a(self):
|
def browse_file_a(self) -> None:
|
||||||
file_path, _ = QFileDialog.getOpenFileName(self, "Select SNIRF File", "", "SNIRF Files (*.snirf)")
|
file_path, _ = QFileDialog.getOpenFileName(self, "Select SNIRF File", "", "SNIRF Files (*.snirf)")
|
||||||
if file_path:
|
if file_path:
|
||||||
self.line_edit_file_a.setText(file_path)
|
self.line_edit_file_a.setText(file_path)
|
||||||
try:
|
try:
|
||||||
# TODO: Bad! read_raw_snirf doesnt release memory properly! Should be spawned in a seperate process and killed once completed
|
# Memory leak safe: Extracted in a separate process that terminates immediately
|
||||||
raw = read_raw_snirf(file_path, preload=False)
|
annotations: Annotations = load_snirf_annotations_isolated(file_path)
|
||||||
annotations = raw.annotations
|
|
||||||
|
print(f"Loaded {len(annotations)} annotations from {file_path}")
|
||||||
|
|
||||||
# Build individual event entries
|
# Build individual event entries
|
||||||
event_entries = []
|
event_entries: list[str] = []
|
||||||
for onset, description in zip(annotations.onset, annotations.description):
|
for onset, description in zip(annotations.onset, annotations.description):
|
||||||
event_str = f"{description} @ {onset:.3f}s"
|
event_str = f"{description} @ {onset:.3f}s"
|
||||||
event_entries.append(event_str)
|
event_entries.append(event_str)
|
||||||
@@ -719,7 +757,7 @@ class UpdateEventsBlazesWindow(QWidget):
|
|||||||
self.combo_snirf_events.clear()
|
self.combo_snirf_events.clear()
|
||||||
self.combo_snirf_events.setEnabled(False)
|
self.combo_snirf_events.setEnabled(False)
|
||||||
|
|
||||||
def browse_file_b(self):
|
def browse_file_b(self) -> None:
|
||||||
file_path, _ = QFileDialog.getOpenFileName(self, "Select JSON Timeline File", "", "JSON Files (*.json)")
|
file_path, _ = QFileDialog.getOpenFileName(self, "Select JSON Timeline File", "", "JSON Files (*.json)")
|
||||||
if file_path:
|
if file_path:
|
||||||
self.line_edit_file_b.setText(file_path)
|
self.line_edit_file_b.setText(file_path)
|
||||||
@@ -744,32 +782,31 @@ class UpdateEventsBlazesWindow(QWidget):
|
|||||||
self.combo_events.setEnabled(False)
|
self.combo_events.setEnabled(False)
|
||||||
|
|
||||||
|
|
||||||
def extract_json_observation_strings(self, data):
|
def extract_json_observation_strings(self, data: dict[str, Any]) -> List[str]:
|
||||||
if "events" not in data:
|
if "events" not in data:
|
||||||
raise KeyError("Missing 'events' key in JSON file.")
|
raise KeyError("Missing 'events' key in JSON file.")
|
||||||
|
|
||||||
event_strings = []
|
event_strings: List[str] = []
|
||||||
|
|
||||||
# The new format is a flat list chronologically ordered
|
# Flat list chronologically ordered
|
||||||
for event in data["events"]:
|
for event in data["events"]:
|
||||||
track_name = event.get("track_name", "Unknown")
|
track_name = event.get("track_name", "Unknown")
|
||||||
onset = event.get("start_sec", 0.0)
|
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"
|
display_str = f"{track_name} @ {onset:.3f}s"
|
||||||
event_strings.append(display_str)
|
event_strings.append(display_str)
|
||||||
|
|
||||||
return event_strings
|
return event_strings
|
||||||
|
|
||||||
|
|
||||||
def clear_files(self):
|
def clear_files(self) -> None:
|
||||||
self.line_edit_file_a.clear()
|
self.line_edit_file_a.clear()
|
||||||
self.line_edit_file_b.clear()
|
self.line_edit_file_b.clear()
|
||||||
|
|
||||||
|
|
||||||
def go_action(self):
|
def go_action(self) -> None:
|
||||||
file_a = self.line_edit_file_a.text()
|
file_a = self.line_edit_file_a.text()
|
||||||
file_b = self.line_edit_file_b.text()
|
_ = self.line_edit_file_b.text()
|
||||||
suffix = APP_NAME
|
suffix = APP_NAME
|
||||||
|
|
||||||
if not hasattr(self, "json_data") or self.combo_events.count() == 0 or self.combo_snirf_events.count() == 0:
|
if not hasattr(self, "json_data") or self.combo_events.count() == 0 or self.combo_snirf_events.count() == 0:
|
||||||
@@ -794,7 +831,9 @@ class UpdateEventsBlazesWindow(QWidget):
|
|||||||
|
|
||||||
time_shift = snirf_anchor_time - json_anchor_time
|
time_shift = snirf_anchor_time - json_anchor_time
|
||||||
|
|
||||||
onsets, durations, descriptions = [], [], []
|
onsets: list[float] = []
|
||||||
|
durations: list[float] = []
|
||||||
|
descriptions: list[str] = []
|
||||||
skipped_count = 0
|
skipped_count = 0
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -808,7 +847,7 @@ class UpdateEventsBlazesWindow(QWidget):
|
|||||||
original_end = event.get("end_sec", original_start)
|
original_end = event.get("end_sec", original_start)
|
||||||
duration = original_end - original_start
|
duration = original_end - original_start
|
||||||
|
|
||||||
# FILTER: Minimum 0.1s duration
|
# Minimum 0.1s duration
|
||||||
if duration < 0.1:
|
if duration < 0.1:
|
||||||
skipped_count += 1
|
skipped_count += 1
|
||||||
continue
|
continue
|
||||||
@@ -836,19 +875,9 @@ class UpdateEventsBlazesWindow(QWidget):
|
|||||||
if not save_path.lower().endswith(".snirf"): save_path += ".snirf"
|
if not save_path.lower().endswith(".snirf"): save_path += ".snirf"
|
||||||
|
|
||||||
try:
|
try:
|
||||||
raw = read_raw_snirf(file_a, preload=True)
|
# Memory leak safe: Worker handles read, write, and complete memory reclamation upon exit
|
||||||
|
write_snirf_with_annotations_isolated(file_a, save_path, onsets, durations, descriptions)
|
||||||
|
|
||||||
# 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",
|
QMessageBox.information(self, "Success",
|
||||||
f"Aligned {len(onsets)} events.\n(Filtered out {skipped_count} short events)")
|
f"Aligned {len(onsets)} events.\n(Filtered out {skipped_count} short events)")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
+107
-47
@@ -1,16 +1,20 @@
|
|||||||
"""
|
"""
|
||||||
Filename: updateoptodes.py
|
Filename: updateoptodes.py
|
||||||
Description: Methods to update optode locations for FLARES
|
Description: Methods to update optode locations
|
||||||
|
Note: Compliant with pylance strict type checking
|
||||||
|
|
||||||
Author: Tyler de Zeeuw
|
Author: Tyler de Zeeuw
|
||||||
License: GPL-3.0
|
License: GPL-3.0
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import os
|
# Built-in imports
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from typing import Dict, Optional, Union
|
||||||
|
|
||||||
|
# External library imports
|
||||||
import pandas as pd
|
import pandas as pd
|
||||||
import numpy as np
|
import numpy as np
|
||||||
|
import numpy.typing as npt
|
||||||
|
|
||||||
from PySide6.QtWidgets import QWidget, QVBoxLayout, QLabel, QHBoxLayout, QMessageBox, QLineEdit, QPushButton, QFileDialog
|
from PySide6.QtWidgets import QWidget, QVBoxLayout, QLabel, QHBoxLayout, QMessageBox, QLineEdit, QPushButton, QFileDialog
|
||||||
from PySide6.QtCore import Qt
|
from PySide6.QtCore import Qt
|
||||||
@@ -24,12 +28,14 @@ from src.shared.shareddata import APP_NAME
|
|||||||
|
|
||||||
class UpdateOptodesWindow(QWidget):
|
class UpdateOptodesWindow(QWidget):
|
||||||
|
|
||||||
def __init__(self, parent=None):
|
def __init__(self, parent: Optional[QWidget] = None) -> None:
|
||||||
super().__init__(parent, Qt.WindowType.Window)
|
super().__init__(parent, Qt.WindowType.Window)
|
||||||
self.setWindowTitle(f"Update optode positions - {APP_NAME.upper()}")
|
self.setWindowTitle(f"Update optode positions - {APP_NAME.upper()}")
|
||||||
self.resize(760, 200)
|
self.resize(760, 200)
|
||||||
|
|
||||||
self.label_file_a = QLabel("SNIRF file:")
|
self.selected_snirf_files: list[str] = []
|
||||||
|
|
||||||
|
self.label_file_a = QLabel("SNIRF files:")
|
||||||
self.line_edit_file_a = QLineEdit()
|
self.line_edit_file_a = QLineEdit()
|
||||||
self.line_edit_file_a.setReadOnly(True)
|
self.line_edit_file_a.setReadOnly(True)
|
||||||
self.btn_browse_a = QPushButton("Browse .snirf")
|
self.btn_browse_a = QPushButton("Browse .snirf")
|
||||||
@@ -50,7 +56,6 @@ class UpdateOptodesWindow(QWidget):
|
|||||||
self.btn_clear.clicked.connect(self.clear_files)
|
self.btn_clear.clicked.connect(self.clear_files)
|
||||||
self.btn_go.clicked.connect(self.go_action)
|
self.btn_go.clicked.connect(self.go_action)
|
||||||
|
|
||||||
# ---
|
|
||||||
layout = QVBoxLayout()
|
layout = QVBoxLayout()
|
||||||
self.description = QLabel()
|
self.description = QLabel()
|
||||||
self.description.setTextFormat(Qt.TextFormat.RichText)
|
self.description.setTextFormat(Qt.TextFormat.RichText)
|
||||||
@@ -75,7 +80,7 @@ class UpdateOptodesWindow(QWidget):
|
|||||||
help_btn_a = QPushButton("?")
|
help_btn_a = QPushButton("?")
|
||||||
help_btn_a.setFixedWidth(25)
|
help_btn_a.setFixedWidth(25)
|
||||||
help_btn_a.setToolTip(help_text_a)
|
help_btn_a.setToolTip(help_text_a)
|
||||||
help_btn_a.clicked.connect(lambda _, text=help_text_a: self.show_help_popup(text))
|
help_btn_a.clicked.connect(lambda: self.show_help_popup(help_text_a))
|
||||||
file_a_layout.addWidget(help_btn_a)
|
file_a_layout.addWidget(help_btn_a)
|
||||||
|
|
||||||
# Container for label + line_edit + browse button with tooltip
|
# Container for label + line_edit + browse button with tooltip
|
||||||
@@ -98,7 +103,7 @@ class UpdateOptodesWindow(QWidget):
|
|||||||
help_btn_b = QPushButton("?")
|
help_btn_b = QPushButton("?")
|
||||||
help_btn_b.setFixedWidth(25)
|
help_btn_b.setFixedWidth(25)
|
||||||
help_btn_b.setToolTip(help_text_b)
|
help_btn_b.setToolTip(help_text_b)
|
||||||
help_btn_b.clicked.connect(lambda _, text=help_text_b: self.show_help_popup(text))
|
help_btn_b.clicked.connect(lambda: self.show_help_popup(help_text_b))
|
||||||
file_b_layout.addWidget(help_btn_b)
|
file_b_layout.addWidget(help_btn_b)
|
||||||
|
|
||||||
file_b_container = QWidget()
|
file_b_container = QWidget()
|
||||||
@@ -121,7 +126,7 @@ class UpdateOptodesWindow(QWidget):
|
|||||||
help_btn_suffix = QPushButton("?")
|
help_btn_suffix = QPushButton("?")
|
||||||
help_btn_suffix.setFixedWidth(25)
|
help_btn_suffix.setFixedWidth(25)
|
||||||
help_btn_suffix.setToolTip(help_text_suffix)
|
help_btn_suffix.setToolTip(help_text_suffix)
|
||||||
help_btn_suffix.clicked.connect(lambda _, text=help_text_suffix: self.show_help_popup(text))
|
help_btn_suffix.clicked.connect(lambda: self.show_help_popup(help_text_suffix))
|
||||||
suffix_layout.addWidget(help_btn_suffix)
|
suffix_layout.addWidget(help_btn_suffix)
|
||||||
|
|
||||||
suffix_container = QWidget()
|
suffix_container = QWidget()
|
||||||
@@ -143,13 +148,13 @@ class UpdateOptodesWindow(QWidget):
|
|||||||
|
|
||||||
self.setLayout(layout)
|
self.setLayout(layout)
|
||||||
|
|
||||||
def show_help_popup(self, text):
|
def show_help_popup(self, text: str) -> None:
|
||||||
msg = QMessageBox(self)
|
msg = QMessageBox(self)
|
||||||
msg.setWindowTitle(f"Parameter Info - {APP_NAME.upper()}")
|
msg.setWindowTitle(f"Parameter Info - {APP_NAME.upper()}")
|
||||||
msg.setText(text)
|
msg.setText(text)
|
||||||
msg.exec()
|
msg.exec()
|
||||||
|
|
||||||
def handle_link_click(self, link):
|
def handle_link_click(self, link: str) -> None:
|
||||||
if link == "custom_link":
|
if link == "custom_link":
|
||||||
msg = QMessageBox(self)
|
msg = QMessageBox(self)
|
||||||
msg.setWindowTitle("Example Digitization File")
|
msg.setWindowTitle("Example Digitization File")
|
||||||
@@ -166,61 +171,110 @@ class UpdateOptodesWindow(QWidget):
|
|||||||
msg.setText(text)
|
msg.setText(text)
|
||||||
msg.exec()
|
msg.exec()
|
||||||
|
|
||||||
def browse_file_a(self):
|
def browse_file_a(self) -> None:
|
||||||
file_path, _ = QFileDialog.getOpenFileName(self, "Select SNIRF File", "", "SNIRF Files (*.snirf)")
|
file_paths, _ = QFileDialog.getOpenFileNames(
|
||||||
if file_path:
|
self,
|
||||||
self.line_edit_file_a.setText(file_path)
|
"Select SNIRF Files",
|
||||||
|
"",
|
||||||
|
"SNIRF Files (*.snirf)"
|
||||||
|
)
|
||||||
|
|
||||||
def browse_file_b(self):
|
if file_paths:
|
||||||
|
self.selected_snirf_files = file_paths
|
||||||
|
self.line_edit_file_a.setText("; ".join(Path(p).name for p in file_paths))
|
||||||
|
|
||||||
|
def browse_file_b(self) -> None:
|
||||||
file_path, _ = QFileDialog.getOpenFileName(self, "Select File", "", "Supported Files (*.txt *.xlsx)")
|
file_path, _ = QFileDialog.getOpenFileName(self, "Select File", "", "Supported Files (*.txt *.xlsx)")
|
||||||
if file_path:
|
if file_path:
|
||||||
self.line_edit_file_b.setText(file_path)
|
self.line_edit_file_b.setText(file_path)
|
||||||
|
|
||||||
def clear_files(self):
|
def clear_files(self) -> None:
|
||||||
|
self.selected_snirf_files.clear()
|
||||||
self.line_edit_file_a.clear()
|
self.line_edit_file_a.clear()
|
||||||
self.line_edit_file_b.clear()
|
self.line_edit_file_b.clear()
|
||||||
|
|
||||||
def go_action(self):
|
def go_action(self) -> None:
|
||||||
file_a = self.line_edit_file_a.text()
|
file_a = self.line_edit_file_a.text()
|
||||||
file_b = self.line_edit_file_b.text()
|
file_b = self.line_edit_file_b.text()
|
||||||
suffix = self.line_edit_suffix.text().strip() or "flare"
|
suffix = self.line_edit_suffix.text().strip() or "flare"
|
||||||
|
|
||||||
if not file_a:
|
if not self.selected_snirf_files:
|
||||||
QMessageBox.critical(self, "Missing File", "Please select a SNIRF file.")
|
QMessageBox.critical(self, "Missing File", "Please select at least one SNIRF file.")
|
||||||
return
|
return
|
||||||
if not file_b:
|
if not file_b:
|
||||||
QMessageBox.critical(self, "Missing File", "Please select a TXT file.")
|
QMessageBox.critical(self, "Missing File", "Please select a TXT or XLSX digitization file.")
|
||||||
return
|
return
|
||||||
|
|
||||||
# Get original filename without extension
|
output_dir = QFileDialog.getExistingDirectory(
|
||||||
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,
|
self,
|
||||||
"Save SNIRF File As",
|
"Select Output Directory"
|
||||||
suggested_name,
|
|
||||||
"SNIRF Files (*.snirf)"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
if not save_path:
|
if not output_dir:
|
||||||
print("Save cancelled.")
|
print("Save cancelled.")
|
||||||
return
|
return
|
||||||
|
|
||||||
# Ensure .snirf extension
|
output_path = Path(output_dir)
|
||||||
if not save_path.lower().endswith(".snirf"):
|
|
||||||
save_path += ".snirf"
|
|
||||||
|
|
||||||
try:
|
successful_files: list[str] = []
|
||||||
self.update_optode_positions(file_a=file_a, file_b=file_b, save_path=save_path)
|
failed_files: list[str] = []
|
||||||
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}")
|
for file_a in self.selected_snirf_files:
|
||||||
|
input_path = Path(file_a)
|
||||||
|
|
||||||
def update_optode_positions(self, file_a, file_b, save_path):
|
# Keep original filename and independently add suffix
|
||||||
|
save_path = output_path / f"{input_path.stem}_{suffix}.snirf"
|
||||||
|
|
||||||
|
try:
|
||||||
|
self.update_optode_positions(
|
||||||
|
file_a=file_a,
|
||||||
|
file_b=file_b,
|
||||||
|
save_path=save_path
|
||||||
|
)
|
||||||
|
|
||||||
|
successful_files.append(save_path.name)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
failed_files.append(
|
||||||
|
f"{input_path.name}: {e}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Build summary
|
||||||
|
message_parts: list[str] = []
|
||||||
|
|
||||||
|
if successful_files:
|
||||||
|
message_parts.append(
|
||||||
|
f"Successfully processed {len(successful_files)} "
|
||||||
|
f"SNIRF file(s):\n\n"
|
||||||
|
+ "\n".join(successful_files)
|
||||||
|
)
|
||||||
|
|
||||||
|
if failed_files:
|
||||||
|
message_parts.append(
|
||||||
|
f"Failed to process {len(failed_files)} "
|
||||||
|
f"SNIRF file(s):\n\n"
|
||||||
|
+ "\n".join(failed_files)
|
||||||
|
)
|
||||||
|
|
||||||
|
if failed_files:
|
||||||
|
QMessageBox.warning(
|
||||||
|
self,
|
||||||
|
"Processing Complete",
|
||||||
|
"\n\n".join(message_parts)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
QMessageBox.information(
|
||||||
|
self,
|
||||||
|
"Files Saved",
|
||||||
|
"\n\n".join(message_parts)
|
||||||
|
)
|
||||||
|
|
||||||
|
def update_optode_positions(
|
||||||
|
self,
|
||||||
|
file_a: Union[str, Path],
|
||||||
|
file_b: Union[str, Path],
|
||||||
|
save_path: Union[str, Path]
|
||||||
|
) -> None:
|
||||||
|
|
||||||
fiducials = {}
|
fiducials = {}
|
||||||
ch_positions = {}
|
ch_positions = {}
|
||||||
@@ -247,16 +301,22 @@ class UpdateOptodesWindow(QWidget):
|
|||||||
elif extension == '.xlsx':
|
elif extension == '.xlsx':
|
||||||
|
|
||||||
# TODO: Bad! Why assume sheet1 has the contents?
|
# TODO: Bad! Why assume sheet1 has the contents?
|
||||||
df = pd.read_excel(file_b, sheet_name='Sheet1')
|
df = pd.read_excel(file_b, sheet_name='Sheet1') # type: ignore
|
||||||
|
|
||||||
|
def _get_block_data(
|
||||||
|
target_df: pd.DataFrame,
|
||||||
|
block_id: int,
|
||||||
|
row_mapping: Union[Dict[int, str], str],
|
||||||
|
scale: float = 0.001
|
||||||
|
) -> Dict[str, npt.NDArray[np.float64]]:
|
||||||
|
|
||||||
def _get_block_data(df, block_id, row_mapping, scale=0.001):
|
|
||||||
"""Isolates a block, cleans numeric data, and returns a scaled dictionary."""
|
"""Isolates a block, cleans numeric data, and returns a scaled dictionary."""
|
||||||
# 1. Isolate and clean
|
# 1. Isolate and clean
|
||||||
block = df[df['block_id'] == block_id].iloc[:, [1, 2, 3]].copy()
|
block = target_df[target_df['block_id'] == block_id].iloc[:, [1, 2, 3]].copy()
|
||||||
block = block.apply(pd.to_numeric, errors='coerce')
|
block = block.apply(pd.to_numeric, errors='coerce')
|
||||||
|
|
||||||
# 2. Extract into dictionary based on mapping
|
# 2. Extract into dictionary based on mapping
|
||||||
result = {}
|
result: Dict[str, npt.NDArray[np.float64]] = {}
|
||||||
|
|
||||||
# If row_mapping is a dict (like {0: 'nz'}), use it directly
|
# If row_mapping is a dict (like {0: 'nz'}), use it directly
|
||||||
if isinstance(row_mapping, dict):
|
if isinstance(row_mapping, dict):
|
||||||
@@ -265,7 +325,7 @@ class UpdateOptodesWindow(QWidget):
|
|||||||
result[key] = block.iloc[row_idx].to_numpy(dtype=float) * scale
|
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
|
# If row_mapping is a string prefix (like 'D' or 'S'), auto-generate keys
|
||||||
elif isinstance(row_mapping, str):
|
else:
|
||||||
for i in range(len(block)):
|
for i in range(len(block)):
|
||||||
result[f"{row_mapping}{i+1}"] = block.iloc[i].to_numpy(dtype=float) * scale
|
result[f"{row_mapping}{i+1}"] = block.iloc[i].to_numpy(dtype=float) * scale
|
||||||
|
|
||||||
@@ -291,6 +351,6 @@ class UpdateOptodesWindow(QWidget):
|
|||||||
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
|
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
|
# Read the SNIRF file, set the montage, and write it back
|
||||||
raw = read_raw_snirf(file_a, preload=True)
|
raw = read_raw_snirf(str(file_a), preload=True)
|
||||||
raw.set_montage(initial_montage)
|
raw.set_montage(initial_montage) # type: ignore
|
||||||
write_raw_snirf(raw, save_path)
|
write_raw_snirf(raw, save_path)
|
||||||
@@ -1,21 +1,30 @@
|
|||||||
"""
|
"""
|
||||||
Filename: viewerlauncher.py
|
Filename: viewerlauncher.py
|
||||||
Description: Viewer launcher window
|
Description: Viewer launcher window
|
||||||
|
Note: Compliant with pylance strict type checking
|
||||||
|
|
||||||
Author: Tyler de Zeeuw
|
Author: Tyler de Zeeuw
|
||||||
License: GPL-3.0
|
License: GPL-3.0
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
# Built-in imports
|
||||||
|
from typing import Any, Callable, Type
|
||||||
|
|
||||||
# External library imports
|
# External library imports
|
||||||
|
from pandas import DataFrame
|
||||||
|
|
||||||
from PySide6.QtWidgets import QPushButton, QWidget, QVBoxLayout
|
from PySide6.QtWidgets import QPushButton, QWidget, QVBoxLayout
|
||||||
from PySide6.QtCore import QTimer
|
from PySide6.QtCore import QTimer
|
||||||
|
|
||||||
|
from mne import Epochs
|
||||||
|
from mne.io.base import BaseRaw
|
||||||
|
|
||||||
from src.analysis.exporttocsv import ExportToCSVWidget
|
from src.analysis.exporttocsv import ExportToCSVWidget
|
||||||
|
from src.analysis.intragroupbrainimage import IntraGroupBrainImageWidget
|
||||||
from src.analysis.intergroupbrainimage import InterGroupBrainImageWidget
|
from src.analysis.intergroupbrainimage import InterGroupBrainImageWidget
|
||||||
from src.analysis.crossgroupbrainimage import CrossGroupBrainImageWidget
|
from src.analysis.intragroupfunctionalconnectivity import IntraGroupFunctionalConnectivityWidget
|
||||||
from src.analysis.intergroupfunctionalconnectivity import InterGroupFunctionalConnectivityWidget
|
from src.analysis.intragroupstats import IntraGroupStatsWidget
|
||||||
from src.analysis.intergroupstats import InterGroupStatsWidget
|
from src.analysis.intergroupstats import InterGroupStatsWidget
|
||||||
from src.analysis.crossgroupstats import CrossGroupStatsWidget
|
|
||||||
from src.analysis.participantimage import ParticipantImageViewerWidget
|
from src.analysis.participantimage import ParticipantImageViewerWidget
|
||||||
from src.analysis.participantbrain import ParticipantBrainViewerWidget
|
from src.analysis.participantbrain import ParticipantBrainViewerWidget
|
||||||
from src.analysis.participantfoldchannels import ParticipantFoldChannelsWidget
|
from src.analysis.participantfoldchannels import ParticipantFoldChannelsWidget
|
||||||
@@ -24,44 +33,81 @@ from src.shared.shareddata import APP_NAME
|
|||||||
|
|
||||||
|
|
||||||
class ViewerLauncherWidget(QWidget):
|
class ViewerLauncherWidget(QWidget):
|
||||||
def __init__(self, haemo_dict, epochs_dict, cha_dict, df_ind_dict, design_matrix_dict, config_dict, fig_bytes_dict, contrast_results_dict, roi_channel_map_dict, folding_bypass):
|
def __init__(
|
||||||
|
self,
|
||||||
|
haemo_dict: dict[str, BaseRaw],
|
||||||
|
epochs_dict: dict[str, Epochs],
|
||||||
|
cha_dict: dict[str, DataFrame],
|
||||||
|
df_ind_dict: dict[str, DataFrame],
|
||||||
|
design_matrix_dict: dict[str, DataFrame],
|
||||||
|
config_dict: dict[str, dict[str, Any]],
|
||||||
|
fig_bytes_dict: dict[str, dict[str, bytes]],
|
||||||
|
contrast_results_dict: dict[str, dict[str, Any]],
|
||||||
|
roi_channel_map_dict: dict[str, dict[str, str]],
|
||||||
|
fir_feature_dict: dict[str, dict[str, Any]],
|
||||||
|
qc_dict: dict[str, dict[str, Any]],
|
||||||
|
folding_bypass: bool,
|
||||||
|
) -> None:
|
||||||
|
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self.setWindowTitle(f"Viewer Launcher - {APP_NAME.upper()}")
|
self.setWindowTitle(f"Viewer Launcher - {APP_NAME.upper()}")
|
||||||
|
|
||||||
group_dict = {f: c.get("GROUP", "Unknown") for f, c in config_dict.items()}
|
group_dict = {f: c.get("GROUP", "Unknown") for f, c in config_dict.items()}
|
||||||
|
|
||||||
btn_data = [
|
btn_data: list[tuple[str, Type[QWidget], list[Any], bool]] = [
|
||||||
("Participant Image Viewer", ParticipantImageViewerWidget, [haemo_dict, fig_bytes_dict], True),
|
("Participant Image Viewer", ParticipantImageViewerWidget, [haemo_dict, fig_bytes_dict], True),
|
||||||
("Participant Brain Viewer", ParticipantBrainViewerWidget, [haemo_dict, cha_dict], True),
|
("Participant Brain Viewer", ParticipantBrainViewerWidget, [haemo_dict, cha_dict], True),
|
||||||
("Participant Fold Channels Viewer", ParticipantFoldChannelsWidget, [haemo_dict, cha_dict], False),
|
("Participant Fold Channels Viewer", ParticipantFoldChannelsWidget, [haemo_dict, cha_dict], False),
|
||||||
("Participant Functional Connectivity Viewer [BETA]", ParticipantFunctionalConnectivityWidget, [haemo_dict, epochs_dict], True),
|
("Participant Functional Connectivity Viewer [BETA]", ParticipantFunctionalConnectivityWidget, [haemo_dict, epochs_dict], True),
|
||||||
("Inter-Group Functional Connectivity Viewer [BETA]", InterGroupFunctionalConnectivityWidget, [haemo_dict, group_dict, config_dict], True),
|
("Intra-Group Functional Connectivity Viewer [BETA]", IntraGroupFunctionalConnectivityWidget, [haemo_dict, epochs_dict, group_dict], True),
|
||||||
|
("Intra-Group Stats Viewer", IntraGroupStatsWidget, [haemo_dict, cha_dict, df_ind_dict, design_matrix_dict, contrast_results_dict, roi_channel_map_dict, group_dict], True),
|
||||||
("Inter-Group Stats Viewer", InterGroupStatsWidget, [haemo_dict, cha_dict, df_ind_dict, design_matrix_dict, contrast_results_dict, roi_channel_map_dict, group_dict], True),
|
("Inter-Group Stats Viewer", InterGroupStatsWidget, [haemo_dict, cha_dict, df_ind_dict, design_matrix_dict, contrast_results_dict, roi_channel_map_dict, group_dict], True),
|
||||||
("Cross-Group Stats Viewer", CrossGroupStatsWidget, [haemo_dict, cha_dict, df_ind_dict, design_matrix_dict, contrast_results_dict, roi_channel_map_dict, group_dict], True),
|
("Intra-Group Brain and Image Viewer", IntraGroupBrainImageWidget, [haemo_dict, cha_dict, df_ind_dict, design_matrix_dict, contrast_results_dict, group_dict], True),
|
||||||
("Inter-Group Brain and Image Viewer", InterGroupBrainImageWidget, [haemo_dict, cha_dict, df_ind_dict, design_matrix_dict, contrast_results_dict, group_dict], True),
|
("Inter-Group Brain and Image Viewer", InterGroupBrainImageWidget, [haemo_dict, df_ind_dict, design_matrix_dict, contrast_results_dict, group_dict], True),
|
||||||
("Cross-Group Brain and Image Viewer", CrossGroupBrainImageWidget, [haemo_dict, df_ind_dict, design_matrix_dict, contrast_results_dict, group_dict], True),
|
("Export To CSV Viewer", ExportToCSVWidget, [haemo_dict, cha_dict, df_ind_dict, design_matrix_dict, contrast_results_dict, group_dict, config_dict, fir_feature_dict, qc_dict], True)
|
||||||
("Export To CSV Viewer", ExportToCSVWidget, [haemo_dict, cha_dict, df_ind_dict, design_matrix_dict, contrast_results_dict, group_dict, config_dict], True)
|
|
||||||
]
|
]
|
||||||
|
|
||||||
layout = QVBoxLayout(self)
|
layout = QVBoxLayout(self)
|
||||||
for label, widget_class, args, requires_bypass in btn_data:
|
for label, widget_class, args, requires_bypass in btn_data:
|
||||||
btn = QPushButton(f"Open {label}")
|
btn = QPushButton(f"Open {label}")
|
||||||
# Connect directly to the generic opener
|
# Connect directly to the generic opener
|
||||||
btn.clicked.connect(lambda _, c=widget_class, b=btn, a=args: self._open_viewer(c, b, *a))
|
btn.clicked.connect(self._make_viewer_callback(widget_class, btn, args))
|
||||||
btn.setEnabled(not (requires_bypass and folding_bypass))
|
btn.setEnabled(not (requires_bypass and folding_bypass))
|
||||||
layout.addWidget(btn)
|
layout.addWidget(btn)
|
||||||
|
|
||||||
def _open_viewer(self, widget_class, btn, *args):
|
def _make_viewer_callback(
|
||||||
|
self,
|
||||||
|
widget_class: Type[QWidget],
|
||||||
|
btn: QPushButton,
|
||||||
|
args: list[Any],
|
||||||
|
) -> Callable[[bool], None]:
|
||||||
|
def callback(_checked: bool = False) -> None:
|
||||||
|
self._open_viewer(widget_class, btn, *args)
|
||||||
|
return callback
|
||||||
|
|
||||||
|
def _open_viewer(
|
||||||
|
self,
|
||||||
|
widget_class: Type[QWidget],
|
||||||
|
btn: QPushButton,
|
||||||
|
*args: Any
|
||||||
|
) -> None:
|
||||||
|
|
||||||
# Instantiate and show dynamically
|
# Instantiate and show dynamically
|
||||||
self.active_viewer = widget_class(*args)
|
self.active_viewer = widget_class(*args)
|
||||||
self.active_viewer.show()
|
self.active_viewer.show()
|
||||||
self._trigger_success(btn)
|
self._trigger_success(btn)
|
||||||
|
|
||||||
def _launch(self, func, btn, *args):
|
def _launch(
|
||||||
|
self,
|
||||||
|
func: Callable[..., Any],
|
||||||
|
btn: QPushButton,
|
||||||
|
*args: Any
|
||||||
|
|
||||||
|
) -> None:
|
||||||
func(*args)
|
func(*args)
|
||||||
self._trigger_success(btn)
|
self._trigger_success(btn)
|
||||||
|
|
||||||
def _trigger_success(self, button):
|
def _trigger_success(self, button: QPushButton) -> None:
|
||||||
"""Temporarily adds a green checkmark to the button text."""
|
"""Temporarily adds a green checkmark to the button text."""
|
||||||
original_text = button.text()
|
original_text = button.text()
|
||||||
button.setText(f"{original_text} ✔")
|
button.setText(f"{original_text} ✔")
|
||||||
@@ -70,6 +116,6 @@ class ViewerLauncherWidget(QWidget):
|
|||||||
# Revert after 1 second
|
# Revert after 1 second
|
||||||
QTimer.singleShot(1000, lambda: self._revert_button(button, original_text))
|
QTimer.singleShot(1000, lambda: self._revert_button(button, original_text))
|
||||||
|
|
||||||
def _revert_button(self, button, original_text):
|
def _revert_button(self, button: QPushButton, original_text: str) -> None:
|
||||||
button.setText(original_text)
|
button.setText(original_text)
|
||||||
button.setStyleSheet("")
|
button.setStyleSheet("")
|
||||||
@@ -7,6 +7,7 @@ Author: Tyler de Zeeuw
|
|||||||
License: GPL-3.0
|
License: GPL-3.0
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
# Built-in imports
|
||||||
import os
|
import os
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
update
|
|
||||||
+228
-179
@@ -1,6 +1,7 @@
|
|||||||
"""
|
"""
|
||||||
Filename: updater.py
|
Filename: updater.py
|
||||||
Description: Generic updater file
|
Description: Generic updater file
|
||||||
|
Note: Compliant with pylance strict type checking
|
||||||
|
|
||||||
Author: Tyler de Zeeuw
|
Author: Tyler de Zeeuw
|
||||||
License: GPL-3.0
|
License: GPL-3.0
|
||||||
@@ -17,14 +18,16 @@ import zipfile
|
|||||||
import traceback
|
import traceback
|
||||||
import subprocess
|
import subprocess
|
||||||
import configparser
|
import configparser
|
||||||
|
from typing import List, Tuple, Optional, Callable, Any
|
||||||
|
|
||||||
|
|
||||||
# External library imports
|
# External library imports
|
||||||
import psutil
|
import psutil
|
||||||
import requests
|
import requests
|
||||||
|
|
||||||
from PySide6.QtWidgets import QMessageBox
|
|
||||||
from PySide6.QtCore import QThread, Signal, QObject
|
from PySide6.QtCore import QThread, Signal, QObject
|
||||||
|
from PySide6.QtWidgets import QMainWindow, QMessageBox
|
||||||
|
from src.shared.shareddata import get_app_dir
|
||||||
|
|
||||||
class UpdateDownloadThread(QThread):
|
class UpdateDownloadThread(QThread):
|
||||||
"""
|
"""
|
||||||
@@ -38,14 +41,21 @@ class UpdateDownloadThread(QThread):
|
|||||||
update_ready = Signal(str, str)
|
update_ready = Signal(str, str)
|
||||||
error_occurred = Signal(str)
|
error_occurred = Signal(str)
|
||||||
|
|
||||||
def __init__(self, download_url, latest_version, platform_name, app_name):
|
def __init__(
|
||||||
|
self,
|
||||||
|
download_url: str,
|
||||||
|
latest_version: str,
|
||||||
|
platform_name: str,
|
||||||
|
app_name: str,
|
||||||
|
) -> None:
|
||||||
|
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self.download_url = download_url
|
self.download_url = download_url
|
||||||
self.latest_version = latest_version
|
self.latest_version = latest_version
|
||||||
self.platform_name = platform_name
|
self.platform_name = platform_name
|
||||||
self.app_name = app_name
|
self.app_name = app_name
|
||||||
|
|
||||||
def run(self):
|
def run(self) -> None:
|
||||||
try:
|
try:
|
||||||
local_filename = os.path.basename(self.download_url)
|
local_filename = os.path.basename(self.download_url)
|
||||||
|
|
||||||
@@ -54,7 +64,8 @@ class UpdateDownloadThread(QThread):
|
|||||||
os.makedirs(tmp_dir, exist_ok=True)
|
os.makedirs(tmp_dir, exist_ok=True)
|
||||||
local_path = os.path.join(tmp_dir, local_filename)
|
local_path = os.path.join(tmp_dir, local_filename)
|
||||||
else:
|
else:
|
||||||
local_path = os.path.join(os.getcwd(), local_filename)
|
tmp_dir = get_app_dir()
|
||||||
|
local_path = os.path.join(tmp_dir, local_filename)
|
||||||
|
|
||||||
# Download the file
|
# Download the file
|
||||||
with requests.get(self.download_url, stream=True, timeout=15) as r:
|
with requests.get(self.download_url, stream=True, timeout=15) as r:
|
||||||
@@ -68,10 +79,9 @@ class UpdateDownloadThread(QThread):
|
|||||||
if self.platform_name == 'darwin':
|
if self.platform_name == 'darwin':
|
||||||
extract_folder = os.path.splitext(local_filename)[0]
|
extract_folder = os.path.splitext(local_filename)[0]
|
||||||
extract_path = os.path.join(tmp_dir, extract_folder)
|
extract_path = os.path.join(tmp_dir, extract_folder)
|
||||||
|
|
||||||
else:
|
else:
|
||||||
extract_folder = os.path.splitext(local_filename)[0]
|
extract_folder = os.path.splitext(local_filename)[0]
|
||||||
extract_path = os.path.join(os.getcwd(), extract_folder)
|
extract_path = os.path.join(get_app_dir(), extract_folder)
|
||||||
|
|
||||||
# Create the folder if not exists
|
# Create the folder if not exists
|
||||||
os.makedirs(extract_path, exist_ok=True)
|
os.makedirs(extract_path, exist_ok=True)
|
||||||
@@ -92,7 +102,6 @@ class UpdateDownloadThread(QThread):
|
|||||||
self.error_occurred.emit(str(e))
|
self.error_occurred.emit(str(e))
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
class UpdateCheckThread(QThread):
|
class UpdateCheckThread(QThread):
|
||||||
"""
|
"""
|
||||||
Thread that checks for updates by querying the API and emits a signal based on the result.
|
Thread that checks for updates by querying the API and emits a signal based on the result.
|
||||||
@@ -107,7 +116,15 @@ class UpdateCheckThread(QThread):
|
|||||||
no_update_available = Signal()
|
no_update_available = Signal()
|
||||||
error_occurred = Signal(str)
|
error_occurred = Signal(str)
|
||||||
|
|
||||||
def __init__(self, api_url, api_url_sec, current_version, platform_name, app_name):
|
def __init__(
|
||||||
|
self,
|
||||||
|
api_url: str,
|
||||||
|
api_url_sec: str,
|
||||||
|
current_version: str,
|
||||||
|
platform_name: str,
|
||||||
|
app_name: str,
|
||||||
|
) -> None:
|
||||||
|
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self.api_url = api_url
|
self.api_url = api_url
|
||||||
self.api_url_sec = api_url_sec
|
self.api_url_sec = api_url_sec
|
||||||
@@ -115,10 +132,7 @@ class UpdateCheckThread(QThread):
|
|||||||
self.platform_name = platform_name
|
self.platform_name = platform_name
|
||||||
self.app_name = app_name
|
self.app_name = app_name
|
||||||
|
|
||||||
def run(self):
|
def run(self) -> None:
|
||||||
# if not getattr(sys, 'frozen', False):
|
|
||||||
# self.error_occurred.emit("Application is not frozen (Development mode).")
|
|
||||||
# return
|
|
||||||
try:
|
try:
|
||||||
latest_version, download_url = self.get_latest_release_for_platform()
|
latest_version, download_url = self.get_latest_release_for_platform()
|
||||||
if not latest_version:
|
if not latest_version:
|
||||||
@@ -137,15 +151,15 @@ class UpdateCheckThread(QThread):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.error_occurred.emit(f"Update check failed: {e}")
|
self.error_occurred.emit(f"Update check failed: {e}")
|
||||||
|
|
||||||
def version_compare(self, v1, v2):
|
def version_compare(self, v1: str, v2: str) -> int:
|
||||||
def normalize(v): return [int(x) for x in v.split(".")]
|
def normalize(v: str) -> List[int]:
|
||||||
|
return [int(x) for x in v.split(".")]
|
||||||
return (normalize(v1) > normalize(v2)) - (normalize(v1) < normalize(v2))
|
return (normalize(v1) > normalize(v2)) - (normalize(v1) < normalize(v2))
|
||||||
|
|
||||||
def get_latest_release_for_platform(self):
|
def get_latest_release_for_platform(self) -> Tuple[Optional[str], Optional[str]]:
|
||||||
urls = [self.api_url, self.api_url_sec]
|
urls = [self.api_url, self.api_url_sec]
|
||||||
for url in urls:
|
for url in urls:
|
||||||
try:
|
try:
|
||||||
|
|
||||||
response = requests.get(url, timeout=5)
|
response = requests.get(url, timeout=5)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
releases = response.json()
|
releases = response.json()
|
||||||
@@ -165,7 +179,7 @@ class UpdateCheckThread(QThread):
|
|||||||
return tag, asset["browser_download_url"]
|
return tag, asset["browser_download_url"]
|
||||||
|
|
||||||
return tag, None
|
return tag, None
|
||||||
except (requests.RequestException, ValueError) as e:
|
except (requests.RequestException, ValueError, KeyError):
|
||||||
continue
|
continue
|
||||||
return None, None
|
return None, None
|
||||||
|
|
||||||
@@ -182,22 +196,30 @@ class LocalPendingUpdateCheckThread(QThread):
|
|||||||
pending_update_found = Signal(str, str)
|
pending_update_found = Signal(str, str)
|
||||||
no_pending_update = Signal()
|
no_pending_update = Signal()
|
||||||
|
|
||||||
def __init__(self, current_version, platform_suffix, platform_name, app_name):
|
def __init__(
|
||||||
|
self,
|
||||||
|
current_version: str,
|
||||||
|
platform_suffix: str,
|
||||||
|
platform_name: str,
|
||||||
|
app_name: str,
|
||||||
|
) -> None:
|
||||||
|
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self.current_version = current_version
|
self.current_version = current_version
|
||||||
self.platform_suffix = platform_suffix
|
self.platform_suffix = platform_suffix
|
||||||
self.platform_name = platform_name
|
self.platform_name = platform_name
|
||||||
self.app_name = app_name
|
self.app_name = app_name
|
||||||
|
|
||||||
def version_compare(self, v1, v2):
|
def version_compare(self, v1: str, v2: str) -> int:
|
||||||
def normalize(v): return [int(x) for x in v.split(".")]
|
def normalize(v: str) -> List[int]:
|
||||||
|
return [int(x) for x in v.split(".")]
|
||||||
return (normalize(v1) > normalize(v2)) - (normalize(v1) < normalize(v2))
|
return (normalize(v1) > normalize(v2)) - (normalize(v1) < normalize(v2))
|
||||||
|
|
||||||
def run(self):
|
def run(self) -> None:
|
||||||
if self.platform_name == 'darwin':
|
if self.platform_name == 'darwin':
|
||||||
cwd = f'/tmp/{self.app_name}tempupdate'
|
cwd = f'/tmp/{self.app_name}tempupdate'
|
||||||
else:
|
else:
|
||||||
cwd = os.getcwd()
|
cwd = get_app_dir()
|
||||||
|
|
||||||
pattern = re.compile(r".*-(\d+\.\d+\.\d+)" + re.escape(self.platform_suffix) + r"$")
|
pattern = re.compile(r".*-(\d+\.\d+\.\d+)" + re.escape(self.platform_suffix) + r"$")
|
||||||
found = False
|
found = False
|
||||||
@@ -213,25 +235,32 @@ class LocalPendingUpdateCheckThread(QThread):
|
|||||||
self.pending_update_found.emit(folder_version, folder_path)
|
self.pending_update_found.emit(folder_version, folder_path)
|
||||||
found = True
|
found = True
|
||||||
break
|
break
|
||||||
except:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
if not found:
|
if not found:
|
||||||
self.no_pending_update.emit()
|
self.no_pending_update.emit()
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
class UpdateManager(QObject):
|
class UpdateManager(QObject):
|
||||||
"""
|
"""
|
||||||
Orchestrates the update process.
|
Orchestrates the update process.
|
||||||
Main apps should instantiate this and call check_for_updates().
|
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):
|
def __init__(
|
||||||
super().__init__()
|
self,
|
||||||
self.parent = main_window
|
main_window: QMainWindow,
|
||||||
|
api_url: str,
|
||||||
|
api_url_sec: str,
|
||||||
|
current_version: str,
|
||||||
|
platform_name: str,
|
||||||
|
platform_suffix: str,
|
||||||
|
app_name: str,
|
||||||
|
) -> None:
|
||||||
|
|
||||||
|
super().__init__(main_window)
|
||||||
|
self.main_window: QMainWindow = main_window
|
||||||
self.api_url = api_url
|
self.api_url = api_url
|
||||||
self.api_url_sec = api_url_sec
|
self.api_url_sec = api_url_sec
|
||||||
self.current_version = current_version
|
self.current_version = current_version
|
||||||
@@ -239,63 +268,69 @@ class UpdateManager(QObject):
|
|||||||
self.platform_suffix = platform_suffix
|
self.platform_suffix = platform_suffix
|
||||||
self.app_name = app_name
|
self.app_name = app_name
|
||||||
|
|
||||||
self.pending_update_version = None
|
self.pending_update_version: Optional[str] = None
|
||||||
self.pending_update_path = None
|
self.pending_update_path: Optional[str] = None
|
||||||
|
|
||||||
|
def manual_check_for_updates(self) -> 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 = 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.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.no_pending_update.connect(self.on_no_pending_update)
|
||||||
self.local_check_thread.start()
|
self.local_check_thread.start()
|
||||||
|
|
||||||
def on_pending_update_found(self, version, folder_path):
|
def on_pending_update_found(self, version: str, folder_path: str) -> None:
|
||||||
self.parent.statusBar().showMessage(f"Pending update found: version {version}")
|
if self.main_window.statusBar():
|
||||||
|
self.main_window.statusBar().showMessage(f"Pending update found: version {version}")
|
||||||
self.pending_update_version = version
|
self.pending_update_version = version
|
||||||
self.pending_update_path = folder_path
|
self.pending_update_path = folder_path
|
||||||
self.show_pending_update_popup()
|
self.show_pending_update_popup()
|
||||||
|
|
||||||
def on_no_pending_update(self):
|
def on_no_pending_update(self) -> None:
|
||||||
# No pending update found locally, start server check directly
|
if self.main_window.statusBar():
|
||||||
self.parent.statusBar().showMessage("No pending local update found. Checking server...")
|
self.main_window.statusBar().showMessage("No pending local update found. Checking server...")
|
||||||
self.start_update_check_thread()
|
self.start_update_check_thread()
|
||||||
|
|
||||||
def show_pending_update_popup(self):
|
def show_pending_update_popup(self) -> None:
|
||||||
msg_box = QMessageBox(self.parent)
|
msg_box = QMessageBox(self.main_window)
|
||||||
msg_box.setWindowTitle("Pending Update Found")
|
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?")
|
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_now_button = msg_box.addButton("Install Now", QMessageBox.ButtonRole.AcceptRole)
|
||||||
install_later_button = msg_box.addButton("Install Later", QMessageBox.ButtonRole.RejectRole)
|
msg_box.addButton("Install Later", QMessageBox.ButtonRole.RejectRole)
|
||||||
msg_box.exec()
|
msg_box.exec()
|
||||||
|
|
||||||
if msg_box.clickedButton() == install_now_button:
|
if msg_box.clickedButton() == install_now_button and self.pending_update_path:
|
||||||
self.install_update(self.pending_update_path)
|
self.install_update(self.pending_update_path)
|
||||||
else:
|
else:
|
||||||
self.parent.statusBar().showMessage("Pending update available. Install later.")
|
if self.main_window.statusBar():
|
||||||
# After user dismisses, still check the server for new updates
|
self.main_window.statusBar().showMessage("Pending update available. Install later.")
|
||||||
self.start_update_check_thread()
|
self.start_update_check_thread()
|
||||||
|
|
||||||
def start_update_check_thread(self):
|
def start_update_check_thread(self) -> None:
|
||||||
self.check_thread = UpdateCheckThread(self.api_url, self.api_url_sec, self.current_version, self.platform_name, self.app_name)
|
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.download_requested.connect(self.on_server_update_requested)
|
||||||
self.check_thread.no_update_available.connect(self.on_server_no_update)
|
self.check_thread.no_update_available.connect(self.on_server_no_update)
|
||||||
self.check_thread.error_occurred.connect(self.on_error)
|
self.check_thread.error_occurred.connect(self.on_error)
|
||||||
self.check_thread.start()
|
self.check_thread.start()
|
||||||
|
|
||||||
def on_server_no_update(self):
|
def on_server_no_update(self) -> None:
|
||||||
self.parent.statusBar().showMessage("No new updates found on server.", 5000)
|
if self.main_window.statusBar():
|
||||||
|
self.main_window.statusBar().showMessage("No new updates found on server.", 5000)
|
||||||
|
|
||||||
def on_server_update_requested(self, download_url, latest_version):
|
def on_server_update_requested(self, download_url: str, latest_version: str) -> None:
|
||||||
if self.pending_update_version:
|
pending_path = self.pending_update_path
|
||||||
cmp = self.version_compare(latest_version, self.pending_update_version)
|
pending_version = self.pending_update_version
|
||||||
|
|
||||||
|
if pending_version and pending_path:
|
||||||
|
cmp = self.version_compare(latest_version, pending_version)
|
||||||
if cmp > 0:
|
if cmp > 0:
|
||||||
# Server version is newer than pending update
|
if self.main_window.statusBar():
|
||||||
self.parent.statusBar().showMessage(f"Newer version {latest_version} available on server. Removing old pending update...")
|
self.main_window.statusBar().showMessage(f"Newer version {latest_version} available on server. Removing old pending update...")
|
||||||
try:
|
try:
|
||||||
shutil.rmtree(self.pending_update_path)
|
shutil.rmtree(pending_path)
|
||||||
self.parent.statusBar().showMessage(f"Deleted old update folder: {self.pending_update_path}")
|
if self.main_window.statusBar():
|
||||||
|
self.main_window.statusBar().showMessage(f"Deleted old update folder: {pending_path}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.parent.statusBar().showMessage(f"Failed to delete old update folder: {e}")
|
if self.main_window.statusBar():
|
||||||
|
self.main_window.statusBar().showMessage(f"Failed to delete old update folder: {e}")
|
||||||
|
|
||||||
# Clear pending update info so new download proceeds
|
# Clear pending update info so new download proceeds
|
||||||
self.pending_update_version = None
|
self.pending_update_version = None
|
||||||
@@ -304,63 +339,66 @@ class UpdateManager(QObject):
|
|||||||
# Download the new update
|
# Download the new update
|
||||||
self.download_update(download_url, latest_version)
|
self.download_update(download_url, latest_version)
|
||||||
elif cmp == 0:
|
elif cmp == 0:
|
||||||
# Versions equal, no download needed
|
if self.main_window.statusBar():
|
||||||
self.parent.statusBar().showMessage(f"Pending update version {self.pending_update_version} is already latest. No download needed.")
|
self.main_window.statusBar().showMessage(f"Pending update version {self.pending_update_version} is already latest. No download needed.")
|
||||||
else:
|
else:
|
||||||
# Server version older than pending? Unlikely but just keep pending update
|
# 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.")
|
self.main_window.statusBar().showMessage(f"Pending update version {self.pending_update_version} is newer than server version. No action.")
|
||||||
else:
|
else:
|
||||||
# No pending update, just download
|
# No pending update, just download
|
||||||
self.download_update(download_url, latest_version)
|
self.download_update(download_url, latest_version)
|
||||||
|
|
||||||
def download_update(self, download_url, latest_version):
|
def download_update(self, download_url: str, latest_version: str) -> None:
|
||||||
self.parent.statusBar().showMessage("Downloading update...")
|
if self.main_window.statusBar():
|
||||||
|
self.main_window.statusBar().showMessage("Downloading update...")
|
||||||
self.download_thread = UpdateDownloadThread(download_url, latest_version, self.platform_name, self.app_name)
|
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.update_ready.connect(self.on_update_ready)
|
||||||
self.download_thread.error_occurred.connect(self.on_error)
|
self.download_thread.error_occurred.connect(self.on_error)
|
||||||
self.download_thread.start()
|
self.download_thread.start()
|
||||||
|
|
||||||
def on_update_ready(self, latest_version, extract_folder):
|
def on_update_ready(self, latest_version: str, extract_folder: str) -> None:
|
||||||
self.parent.statusBar().showMessage("Update downloaded and extracted.")
|
if self.main_window.statusBar():
|
||||||
|
self.main_window.statusBar().showMessage("Update downloaded and extracted.")
|
||||||
|
|
||||||
msg_box = QMessageBox(self.parent)
|
msg_box = QMessageBox(self.main_window)
|
||||||
msg_box.setWindowTitle("Update Ready")
|
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?")
|
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_now_button = msg_box.addButton("Install Now", QMessageBox.ButtonRole.AcceptRole)
|
||||||
install_later_button = msg_box.addButton("Install Later", QMessageBox.ButtonRole.RejectRole)
|
msg_box.addButton("Install Later", QMessageBox.ButtonRole.RejectRole)
|
||||||
|
|
||||||
msg_box.exec()
|
msg_box.exec()
|
||||||
|
|
||||||
if msg_box.clickedButton() == install_now_button:
|
if msg_box.clickedButton() == install_now_button:
|
||||||
self.install_update(extract_folder)
|
self.install_update(extract_folder)
|
||||||
else:
|
else:
|
||||||
self.parent.statusBar().showMessage("Update ready. Install later.")
|
if self.main_window.statusBar():
|
||||||
|
self.main_window.statusBar().showMessage("Update ready. Install later.")
|
||||||
|
|
||||||
|
def install_update(self, extract_folder: str) -> None:
|
||||||
|
base_dir = get_app_dir()
|
||||||
|
|
||||||
def install_update(self, extract_folder):
|
|
||||||
# Path to updater executable
|
# Path to updater executable
|
||||||
|
|
||||||
if self.platform_name == 'windows':
|
if self.platform_name == 'windows':
|
||||||
updater_path = os.path.join(os.getcwd(), f"{self.app_name}_updater.exe")
|
updater_path = os.path.join(base_dir, f"{self.app_name}_updater.exe")
|
||||||
elif self.platform_name == 'darwin':
|
elif self.platform_name == 'darwin':
|
||||||
if getattr(sys, 'frozen', False):
|
if getattr(sys, 'frozen', False):
|
||||||
updater_path = os.path.join(os.path.dirname(sys.executable), f"../../../{self.app_name}_updater.app")
|
updater_path = os.path.join(base_dir, f"../../../{self.app_name}_updater.app")
|
||||||
else:
|
else:
|
||||||
updater_path = os.path.join(os.getcwd(), f"../{self.app_name}_updater.app")
|
updater_path = os.path.join(base_dir, f"../{self.app_name}_updater.app")
|
||||||
|
|
||||||
elif self.platform_name == 'linux':
|
elif self.platform_name == 'linux':
|
||||||
updater_path = os.path.join(os.getcwd(), f"{self.app_name}_updater")
|
updater_path = os.path.join(base_dir, f"{self.app_name}_updater")
|
||||||
else:
|
else:
|
||||||
updater_path = os.getcwd()
|
updater_path = base_dir
|
||||||
|
|
||||||
|
updater_path = os.path.abspath(updater_path)
|
||||||
|
|
||||||
if not os.path.exists(updater_path):
|
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)}")
|
QMessageBox.critical(self.main_window, "Error", f"Updater not found at:\n{updater_path}. The absolute path was {os.path.abspath(updater_path)}")
|
||||||
return
|
return
|
||||||
|
|
||||||
# Launch updater with extracted folder path as argument
|
# Launch updater with extracted folder path as argument
|
||||||
try:
|
try:
|
||||||
# Pass current app's executable path for updater to relaunch
|
main_app_executable = sys.executable if getattr(sys, 'frozen', False) else os.path.abspath(sys.argv[0])
|
||||||
main_app_executable = os.path.abspath(sys.argv[0])
|
|
||||||
|
|
||||||
print(f'Launching updater with: "{updater_path}" "{extract_folder}" "{main_app_executable}"')
|
print(f'Launching updater with: "{updater_path}" "{extract_folder}" "{main_app_executable}"')
|
||||||
|
|
||||||
@@ -373,18 +411,19 @@ class UpdateManager(QObject):
|
|||||||
sys.exit(0)
|
sys.exit(0)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
QMessageBox.critical(self.parent, "Error", f"[Updater Launch Failed]\n{str(e)}\n{traceback.format_exc()}")
|
QMessageBox.critical(self.main_window, "Error", f"[Updater Launch Failed]\n{str(e)}\n{traceback.format_exc()}")
|
||||||
|
|
||||||
def on_error(self, message):
|
def on_error(self, message: str) -> None:
|
||||||
# print(f"Error: {message}")
|
if self.main_window.statusBar():
|
||||||
self.parent.statusBar().showMessage(f"Error occurred during update process. {message}")
|
self.main_window.statusBar().showMessage(f"Error occurred during update process. {message}")
|
||||||
|
|
||||||
def version_compare(self, v1, v2):
|
def version_compare(self, v1: str, v2: str) -> int:
|
||||||
def normalize(v): return [int(x) for x in v.split(".")]
|
def normalize(v: str) -> List[int]:
|
||||||
|
return [int(x) for x in v.split(".")]
|
||||||
return (normalize(v1) > normalize(v2)) - (normalize(v1) < normalize(v2))
|
return (normalize(v1) > normalize(v2)) - (normalize(v1) < normalize(v2))
|
||||||
|
|
||||||
|
|
||||||
def wait_for_process_to_exit(process_name, timeout=10):
|
def wait_for_process_to_exit(process_name: str, timeout: int = 10) -> bool:
|
||||||
"""
|
"""
|
||||||
Waits for a process with the specified name to exit within a timeout period.
|
Waits for a process with the specified name to exit within a timeout period.
|
||||||
|
|
||||||
@@ -416,125 +455,135 @@ def wait_for_process_to_exit(process_name, timeout=10):
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
def finish_update_if_needed(platform_name, app_name, cfg_path, finish_update):
|
def _remove_readonly(func: Callable[[str], Any], path: str, exc: Any) -> None:
|
||||||
|
"""Error handler for shutil.rmtree to clear read-only files on Windows."""
|
||||||
|
import stat
|
||||||
|
os.chmod(path, stat.S_IWRITE)
|
||||||
|
func(path)
|
||||||
|
|
||||||
|
|
||||||
|
def finish_update_if_needed(platform_name: str, app_name: str, cfg_path: str, finish_update: bool) -> None:
|
||||||
"""
|
"""
|
||||||
Completes a pending application update if '--finish-update' is present in the command-line arguments.
|
Completes a pending application update if '--finish-update' is present in the command-line arguments.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
if finish_update:
|
if not finish_update:
|
||||||
print("Finishing update...")
|
return
|
||||||
|
|
||||||
update_cfg = configparser.ConfigParser()
|
print("Finishing update...")
|
||||||
try:
|
|
||||||
|
# 1. Reset welcome dialog config flag
|
||||||
|
update_cfg = configparser.ConfigParser()
|
||||||
|
try:
|
||||||
|
if os.path.exists(cfg_path):
|
||||||
update_cfg.read(cfg_path)
|
update_cfg.read(cfg_path)
|
||||||
|
if not update_cfg.has_section("Options"):
|
||||||
|
update_cfg.add_section("Options")
|
||||||
update_cfg.set("Options", "show_welcome_dialog", "true")
|
update_cfg.set("Options", "show_welcome_dialog", "true")
|
||||||
|
|
||||||
with open(cfg_path, "w") as f:
|
with open(cfg_path, "w") as f:
|
||||||
update_cfg.write(f)
|
update_cfg.write(f)
|
||||||
print("Welcome dialog flag successfully reset to 'true' for next run.")
|
print("Welcome dialog flag successfully reset to 'true' for next run.")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Warning: Could not update welcome dialog preference flag: {e}")
|
print(f"Warning: Could not update welcome dialog preference flag: {e}")
|
||||||
|
|
||||||
if platform_name == 'darwin':
|
app_dir = f'/tmp/{app_name}tempupdate' if platform_name == 'darwin' else get_app_dir()
|
||||||
app_dir = f'/tmp/{app_name}tempupdate'
|
|
||||||
else:
|
|
||||||
app_dir = os.getcwd()
|
|
||||||
|
|
||||||
# 1. Find update folder
|
if not os.path.exists(app_dir):
|
||||||
update_folder = None
|
print(f"App directory does not exist: {app_dir}. Skipping cleanup.")
|
||||||
|
return
|
||||||
|
|
||||||
|
# 2. Find update folder(s)
|
||||||
|
update_folders: List[str] = []
|
||||||
|
try:
|
||||||
for entry in os.listdir(app_dir):
|
for entry in os.listdir(app_dir):
|
||||||
entry_path = os.path.join(app_dir, entry)
|
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):
|
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)
|
update_folders.append(entry_path)
|
||||||
break
|
except Exception as e:
|
||||||
|
print(f"Error scanning app directory for update folders: {e}")
|
||||||
|
|
||||||
if update_folder is None:
|
if not update_folders:
|
||||||
print("No update folder found. Skipping update steps.")
|
print("No update folder found. Skipping update steps.")
|
||||||
return
|
return
|
||||||
|
|
||||||
if platform_name == 'darwin':
|
primary_update_folder = update_folders[0]
|
||||||
update_folder = os.path.join(update_folder, f"{app_name}-darwin")
|
target_updater_folder = os.path.join(primary_update_folder, f"{app_name}-darwin") if platform_name == 'darwin' else primary_update_folder
|
||||||
|
|
||||||
# 2. Wait for updater to exit
|
# 3. Wait for updater process to exit
|
||||||
print(f"Waiting for {app_name}_updater to exit...")
|
print(f"Waiting for {app_name}_updater to exit...")
|
||||||
for proc in psutil.process_iter(['pid', 'name']):
|
updater_bin_name = f"{app_name}_updater"
|
||||||
if proc.info['name'] and f"{app_name}_updater" in proc.info['name'].lower():
|
for proc in psutil.process_iter(['pid', 'name']):
|
||||||
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:
|
try:
|
||||||
if platform_name == 'darwin':
|
if proc.info['name'] and updater_bin_name.lower() in proc.info['name'].lower():
|
||||||
shutil.rmtree(app_dir)
|
proc.wait(timeout=5)
|
||||||
else:
|
except (psutil.NoSuchProcess, psutil.AccessDenied):
|
||||||
shutil.rmtree(update_folder)
|
pass
|
||||||
except Exception as e:
|
except psutil.TimeoutExpired:
|
||||||
print(f"Failed to delete update folder: {e}")
|
print(f"Force killing lingering {app_name}_updater")
|
||||||
|
try:
|
||||||
|
proc.kill()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# 4. Replace the updater executable
|
||||||
|
target_base = get_app_dir()
|
||||||
|
if platform_name == 'windows':
|
||||||
|
new_updater = os.path.join(target_updater_folder, f"{app_name}_updater.exe")
|
||||||
|
dest_updater = os.path.join(target_base, f"{app_name}_updater.exe")
|
||||||
|
elif platform_name == 'darwin':
|
||||||
|
new_updater = os.path.join(target_updater_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(target_updater_folder, f"{app_name}_updater")
|
||||||
|
dest_updater = os.path.join(target_base, f"{app_name}_updater")
|
||||||
|
else:
|
||||||
|
new_updater = target_base
|
||||||
|
dest_updater = target_base
|
||||||
|
|
||||||
|
if os.path.exists(new_updater):
|
||||||
|
try:
|
||||||
|
if os.path.exists(dest_updater):
|
||||||
|
if platform_name == 'darwin' and os.path.isdir(dest_updater):
|
||||||
|
shutil.rmtree(dest_updater, onexc=_remove_readonly)
|
||||||
|
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 successfully.")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Failed to replace {app_name}_updater: {e}")
|
||||||
|
|
||||||
|
# 5. Clean up all temporary update folders (with retries for Windows file locks)
|
||||||
|
for folder in update_folders:
|
||||||
|
for attempt in range(3):
|
||||||
|
try:
|
||||||
|
if os.path.exists(folder):
|
||||||
|
shutil.rmtree(folder, onexc=_remove_readonly)
|
||||||
|
print(f"Successfully deleted update folder: {folder}")
|
||||||
|
break
|
||||||
|
except Exception as e:
|
||||||
|
if attempt < 2:
|
||||||
|
time.sleep(1.0)
|
||||||
|
else:
|
||||||
|
print(f"Failed to delete update folder '{folder}' after 3 attempts: {e}")
|
||||||
|
|
||||||
|
if "--finish-update" in sys.argv:
|
||||||
sys.argv.remove("--finish-update")
|
sys.argv.remove("--finish-update")
|
||||||
|
|
||||||
|
|
||||||
def remove_quarantine(app_path, app_name):
|
def remove_quarantine(app_path: str, app_name: str) -> None:
|
||||||
"""
|
"""
|
||||||
Removes the macOS quarantine attribute from the specified application path.
|
Removes the macOS quarantine attribute from the specified application path.
|
||||||
"""
|
"""
|
||||||
|
|||||||
Reference in New Issue
Block a user