Compare commits
16
Commits
v1.7.0
...
d1c9346e57
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d1c9346e57 | ||
|
|
63785d3a55 | ||
|
|
3a8ffbdf70 | ||
|
|
4059a3ef93 | ||
|
|
66242c1a5c | ||
|
|
2a5e3f5606 | ||
|
|
613d2b9103 | ||
|
|
cd0b55df34 | ||
|
|
5d46d3b3e1 | ||
|
|
05dc9203ba | ||
|
|
4d00ef86e4 | ||
|
|
e9ef78c872 | ||
|
|
ca203fcb56 | ||
|
|
815f342ead | ||
|
|
57492a479b | ||
|
|
8ef352add3 |
No files matched your search
@@ -175,6 +175,7 @@ cython_debug/
|
|||||||
.pypirc
|
.pypirc
|
||||||
|
|
||||||
/individual_images
|
/individual_images
|
||||||
|
/plugins
|
||||||
*.xlsx
|
*.xlsx
|
||||||
*.csv
|
*.csv
|
||||||
*.snirf
|
*.snirf
|
||||||
|
|||||||
@@ -1,3 +1,40 @@
|
|||||||
|
# Version 1.8.0
|
||||||
|
|
||||||
|
- Plugin Manager now supports checking and downloading plugin updates to ensure plugins can be updated
|
||||||
|
- Plugin Manager now properly uninstalls plugins when requested rather than doing nothing
|
||||||
|
- Installing plugins now displays a progress bar when downloading and no longer hangs the application when downloading a big plugin
|
||||||
|
- Project loading now displays a loading message when loading big projects and no longer hangs the application when loading
|
||||||
|
- New preferences options and menus are now available! All existing preferences have been moved into new sub categories
|
||||||
|
- The UI preference category now has the Show Advanced Parameters preference
|
||||||
|
- The Updates preference category has two ney options to prevent the automatic checking of application or plugin updates
|
||||||
|
- The Theme category has remain untouched and all other preferences have been moved to the Processing category
|
||||||
|
- Added a new preference in the Processing category called "Separate Events". This allows events to have their own values, and not be collapsed into a single value
|
||||||
|
- Export To CSV Viewer export options now perform the actions they claim rather than having no functionality
|
||||||
|
- Logs on a per participant basis are now available to be viewed from within the application
|
||||||
|
- They can be accessed by right clicking a participant bubble and selecting "Show Logs". Logs will be empty if they have not been processed
|
||||||
|
- Fixed a regression where opening a folder would no longer load all the files in that folder
|
||||||
|
- Improved code stability and functionality
|
||||||
|
|
||||||
|
|
||||||
|
# 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
|
# 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
|
- This is potentially a save-changing release due to adding more data into the save file. Please update your project files to ensure compatibility
|
||||||
@@ -12,6 +49,7 @@
|
|||||||
- 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 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 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)
|
- 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
|
- 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 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 help messages for parameters in the SCI and PSP areas to better reflect how the parameters are used
|
||||||
@@ -32,6 +70,7 @@
|
|||||||
- Fixed an issue where certain parameters would not enable or disable depending on other parameters when they should've
|
- 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 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 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
|
||||||
|
|||||||
+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
|
||||||
+52
-32
@@ -20,21 +20,24 @@ from pathlib import Path
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
# External library imports
|
# External library imports
|
||||||
from src.shared.shareddata import APP_NAME, PLATFORM_NAME
|
from src.shared.shareddata import APP_NAME, PLATFORM_NAME, get_app_dir
|
||||||
|
|
||||||
|
|
||||||
if PLATFORM_NAME == 'darwin':
|
if PLATFORM_NAME == 'darwin':
|
||||||
_log_path = 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_path = 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
|
LOG_FILE = _log_path
|
||||||
|
|
||||||
|
|
||||||
def log(msg: str) -> None:
|
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: Union[str, Path]) -> bool:
|
def kill_all_processes_by_executable(exe_path: Union[str, Path]) -> bool:
|
||||||
@@ -45,22 +48,28 @@ def kill_all_processes_by_executable(exe_path: Union[str, Path]) -> bool:
|
|||||||
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}")
|
||||||
|
|
||||||
@@ -82,6 +91,9 @@ def _terminate_process(proc: psutil.Process) -> None:
|
|||||||
|
|
||||||
|
|
||||||
def wait_for_unlock(path: Union[str, Path], timeout: Union[int, float] = 100) -> None:
|
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:
|
||||||
@@ -95,7 +107,7 @@ def wait_for_unlock(path: Union[str, Path], timeout: Union[int, float] = 100) ->
|
|||||||
log(f"Still locked: {path} - {e}")
|
log(f"Still locked: {path} - {e}")
|
||||||
time.sleep(1)
|
time.sleep(1)
|
||||||
log(f"Failed to delete after wait: {path}")
|
log(f"Failed to delete after wait: {path}")
|
||||||
|
|
||||||
|
|
||||||
def delete_path(path: Union[str, Path]) -> None:
|
def delete_path(path: Union[str, Path]) -> None:
|
||||||
if os.path.exists(path):
|
if os.path.exists(path):
|
||||||
@@ -130,8 +142,8 @@ def copy_update_files(src_folder: Union[str, Path], dest_folder: Union[str, Path
|
|||||||
|
|
||||||
|
|
||||||
def copy_update_files_darwin(src_folder: Union[str, Path], dest_folder: Union[str, Path], updater_name: str) -> None:
|
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():
|
||||||
@@ -152,10 +164,7 @@ def copy_update_files_darwin(src_folder: Union[str, Path], dest_folder: Union[st
|
|||||||
|
|
||||||
|
|
||||||
def remove_quarantine(app_path: Union[str, Path]) -> bool:
|
def remove_quarantine(app_path: Union[str, Path]) -> bool:
|
||||||
"""Removes the macOS quarantine extended attribute from an application bundle using osascript.
|
"""Removes the macOS quarantine extended attribute from an application bundle using osascript."""
|
||||||
Returns True on success, False on error or cancellation.
|
|
||||||
"""
|
|
||||||
|
|
||||||
clean_path: str = str(app_path)
|
clean_path: str = str(app_path)
|
||||||
escaped_path: str = shlex.quote(clean_path)
|
escaped_path: str = shlex.quote(clean_path)
|
||||||
|
|
||||||
@@ -172,7 +181,7 @@ def remove_quarantine(app_path: Union[str, Path]) -> bool:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main() -> None:
|
||||||
main_exe: str = ""
|
main_exe: str = ""
|
||||||
app_dir: Path = Path()
|
app_dir: Path = Path()
|
||||||
bundle_dir: Path = Path()
|
bundle_dir: Path = Path()
|
||||||
@@ -189,17 +198,24 @@ def main():
|
|||||||
main_exe = sys.argv[2]
|
main_exe = sys.argv[2]
|
||||||
|
|
||||||
main_exe_path = Path(main_exe).resolve()
|
main_exe_path = Path(main_exe).resolve()
|
||||||
app_dir = main_exe_path.parent
|
app_dir = main_exe_path.parent
|
||||||
bundle_dir = main_exe_path.parents[2]
|
|
||||||
parent_bundle_dir = main_exe_path.parents[3]
|
|
||||||
|
|
||||||
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: {bundle_dir}")
|
log(f"Main App Folder: {bundle_dir}")
|
||||||
|
|
||||||
# Kill all instances of main app
|
# Kill all instances of main app
|
||||||
@@ -212,18 +228,22 @@ def main():
|
|||||||
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)
|
||||||
@@ -231,11 +251,11 @@ 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 {bundle_dir}')
|
log(f'Attempting to delete {bundle_dir}')
|
||||||
delete_path(str(bundle_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, str(parent_bundle_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)
|
||||||
@@ -252,8 +272,8 @@ def main():
|
|||||||
if PLATFORM_NAME == 'linux':
|
if PLATFORM_NAME == 'linux':
|
||||||
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(str(bundle_dir), 0o755)
|
os.chmod(str(bundle_dir), 0o755)
|
||||||
log("Added executable bit")
|
log("Added executable bit")
|
||||||
remove_quarantine(str(bundle_dir))
|
remove_quarantine(str(bundle_dir))
|
||||||
|
|||||||
+5
-5
@@ -25,11 +25,11 @@ main_test.py::test_save_project_actions_pass_correct_ask_parameter
|
|||||||
main_test.py::test_main_window_opens
|
main_test.py::test_main_window_opens
|
||||||
main_test.py::test_file_recent_submenus_exist
|
main_test.py::test_file_recent_submenus_exist
|
||||||
main_test.py::test_view_reset_layout
|
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[2D Data Bypass-2d_data_bypass]
|
||||||
main_test.py::test_preferences_actions[Incompatible Save Bypass-incompatible_save_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[Missing Events Bypass-missing_events_bypass]
|
||||||
main_test.py::test_preferences_actions[Analysis Clearing Bypass-analysis_clearing_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[Folding Bypass-folding_bypass]
|
||||||
main_test.py::test_preferences_actions[Show Advanced Parameters-advanced_parameters]
|
main_test.py::test_preferences_actions[Show Advanced Parameters-advanced_parameters]
|
||||||
'''
|
'''
|
||||||
|
|
||||||
|
|||||||
@@ -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,426 @@
|
|||||||
|
"""
|
||||||
|
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, QThread, 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
|
||||||
|
|
||||||
|
def check_for_updates(self, repo_urls: list[str]) -> list[dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
Fetches remote repositories and compares server plugin versions
|
||||||
|
against installed plugins, returning the installed plugins list
|
||||||
|
enriched with update metadata.
|
||||||
|
"""
|
||||||
|
installed_plugins = self.get_installed_plugins_info()
|
||||||
|
remote_data, _ = self.fetch_remote_repositories(repo_urls)
|
||||||
|
|
||||||
|
# Index remote plugins by their ID (fallback to name if id is missing)
|
||||||
|
remote_map: dict[str, dict[str, Any]] = {}
|
||||||
|
for remote_plugin in remote_data:
|
||||||
|
plugin_id = remote_plugin.get("id") or remote_plugin.get("name")
|
||||||
|
if plugin_id:
|
||||||
|
remote_map[str(plugin_id)] = remote_plugin
|
||||||
|
|
||||||
|
# Compare versions and annotate each installed plugin dictionary
|
||||||
|
for plugin in installed_plugins:
|
||||||
|
plugin_id = str(plugin.get("id", ""))
|
||||||
|
local_version_str = str(plugin.get("version", "0.0.0"))
|
||||||
|
local_tuple = parse_version(local_version_str)
|
||||||
|
|
||||||
|
remote_plugin = remote_map.get(plugin_id)
|
||||||
|
if remote_plugin:
|
||||||
|
remote_version_str = str(remote_plugin.get("version", "0.0.0"))
|
||||||
|
remote_tuple = parse_version(remote_version_str)
|
||||||
|
|
||||||
|
if remote_tuple > local_tuple:
|
||||||
|
plugin["update_available"] = True
|
||||||
|
plugin["remote_version"] = remote_version_str
|
||||||
|
plugin["download_url"] = str(remote_plugin.get("download_url", ""))
|
||||||
|
else:
|
||||||
|
plugin["update_available"] = False
|
||||||
|
plugin["remote_version"] = remote_version_str
|
||||||
|
plugin["download_url"] = ""
|
||||||
|
else:
|
||||||
|
plugin["update_available"] = False
|
||||||
|
plugin["remote_version"] = None
|
||||||
|
plugin["download_url"] = ""
|
||||||
|
|
||||||
|
return installed_plugins
|
||||||
|
|
||||||
|
|
||||||
|
class StartupUpdateWorker(QThread):
|
||||||
|
updates_checked = Signal(list)
|
||||||
|
|
||||||
|
def __init__(self, manager: Any, repo_urls: list[str]) -> None:
|
||||||
|
super().__init__()
|
||||||
|
self.manager = manager
|
||||||
|
self.repo_urls = repo_urls
|
||||||
|
|
||||||
|
def run(self) -> None:
|
||||||
|
try:
|
||||||
|
print("[StartupWorker] Starting background update check...")
|
||||||
|
installed_plugins = self.manager.get_installed_plugins_info()
|
||||||
|
print(f"[StartupWorker] Found {len(installed_plugins)} installed local plugins.")
|
||||||
|
|
||||||
|
remote_data, all_failed = self.manager.fetch_remote_repositories(self.repo_urls)
|
||||||
|
print(f"[StartupWorker] Fetch remote complete. Failed: {all_failed}, Items fetched: {len(remote_data)}")
|
||||||
|
|
||||||
|
if all_failed or not remote_data:
|
||||||
|
print("[StartupWorker] Aborting: Remote fetch failed or returned empty data.")
|
||||||
|
return
|
||||||
|
|
||||||
|
remote_map: dict[str, dict[str, Any]] = {}
|
||||||
|
for raw_plugin in remote_data:
|
||||||
|
if isinstance(raw_plugin, dict):
|
||||||
|
remote_plugin = cast(dict[str, Any], raw_plugin)
|
||||||
|
|
||||||
|
plugin_id = remote_plugin.get("id") or remote_plugin.get("name")
|
||||||
|
if isinstance(plugin_id, str):
|
||||||
|
remote_map[plugin_id] = remote_plugin
|
||||||
|
|
||||||
|
outdated_plugins: list[dict[str, Any]] = []
|
||||||
|
for plugin in installed_plugins:
|
||||||
|
plugin_id = str(plugin.get("id", ""))
|
||||||
|
local_version_str = str(plugin.get("version", "0.0.0"))
|
||||||
|
local_tuple = parse_version(local_version_str)
|
||||||
|
|
||||||
|
remote_plugin = remote_map.get(plugin_id)
|
||||||
|
if remote_plugin:
|
||||||
|
remote_version_str = str(remote_plugin.get("version", "0.0.0"))
|
||||||
|
remote_tuple = parse_version(remote_version_str)
|
||||||
|
print(f"[StartupWorker] Comparing '{plugin.get('name')}': Local v{local_version_str} vs Remote v{remote_version_str}")
|
||||||
|
|
||||||
|
if remote_tuple > local_tuple:
|
||||||
|
plugin["remote_version"] = remote_version_str
|
||||||
|
plugin["download_url"] = str(remote_plugin.get("download_url", ""))
|
||||||
|
outdated_plugins.append(plugin)
|
||||||
|
|
||||||
|
print(f"[StartupWorker] Outdated plugins found: {len(outdated_plugins)}")
|
||||||
|
self.updates_checked.emit(outdated_plugins)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[StartupWorker] Error during startup update check: {e}")
|
||||||
+179
-22
@@ -15,6 +15,7 @@ import copy
|
|||||||
import pickle
|
import pickle
|
||||||
import concurrent
|
import concurrent
|
||||||
import configparser
|
import configparser
|
||||||
|
import concurrent.futures
|
||||||
from pathlib import Path, PurePosixPath
|
from pathlib import Path, PurePosixPath
|
||||||
from typing import TYPE_CHECKING, Any, List, Optional, Union
|
from typing import TYPE_CHECKING, Any, List, Optional, Union
|
||||||
|
|
||||||
@@ -37,6 +38,23 @@ if TYPE_CHECKING:
|
|||||||
from main import MainApplication
|
from main import MainApplication
|
||||||
|
|
||||||
|
|
||||||
|
class LoadProjectThread(QThread):
|
||||||
|
finished_signal = Signal(object) # Emits the loaded project data dictionary
|
||||||
|
error_signal = Signal(str) # Emits the error message string
|
||||||
|
|
||||||
|
def __init__(self, filename: str) -> None:
|
||||||
|
super().__init__()
|
||||||
|
self.filename = filename
|
||||||
|
|
||||||
|
def run(self) -> None:
|
||||||
|
try:
|
||||||
|
with open(self.filename, "rb") as f:
|
||||||
|
project_data = pickle.load(f)
|
||||||
|
self.finished_signal.emit(project_data)
|
||||||
|
except Exception as e:
|
||||||
|
self.error_signal.emit(str(e))
|
||||||
|
|
||||||
|
|
||||||
class SaveProjectThread(QThread):
|
class SaveProjectThread(QThread):
|
||||||
finished_signal = Signal(str)
|
finished_signal = Signal(str)
|
||||||
error_signal = Signal(str)
|
error_signal = Signal(str)
|
||||||
@@ -55,6 +73,25 @@ class SaveProjectThread(QThread):
|
|||||||
self.error_signal.emit(str(e))
|
self.error_signal.emit(str(e))
|
||||||
|
|
||||||
|
|
||||||
|
class LoadingOverlay(QDialog):
|
||||||
|
def __init__(self, parent: Optional[QWidget] = None) -> None:
|
||||||
|
super().__init__(parent)
|
||||||
|
self.setWindowFlags(Qt.WindowType.Dialog | Qt.WindowType.FramelessWindowHint)
|
||||||
|
self.setModal(True)
|
||||||
|
self.setWindowModality(Qt.WindowModality.ApplicationModal)
|
||||||
|
self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground)
|
||||||
|
|
||||||
|
layout = QVBoxLayout()
|
||||||
|
layout.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||||
|
|
||||||
|
label = QLabel("Loading Project…")
|
||||||
|
label.setStyleSheet(
|
||||||
|
"font-size: 18px; color: white; background-color: rgba(0,0,0,150);"
|
||||||
|
" padding: 20px; border-radius: 10px;"
|
||||||
|
)
|
||||||
|
layout.addWidget(label)
|
||||||
|
self.setLayout(layout)
|
||||||
|
|
||||||
|
|
||||||
class SavingOverlay(QDialog):
|
class SavingOverlay(QDialog):
|
||||||
def __init__(self, parent: Optional[QWidget] = None) -> None:
|
def __init__(self, parent: Optional[QWidget] = None) -> None:
|
||||||
@@ -63,7 +100,7 @@ class SavingOverlay(QDialog):
|
|||||||
self.setModal(True)
|
self.setModal(True)
|
||||||
self.setWindowModality(Qt.WindowModality.ApplicationModal)
|
self.setWindowModality(Qt.WindowModality.ApplicationModal)
|
||||||
self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground)
|
self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground)
|
||||||
|
|
||||||
layout = QVBoxLayout()
|
layout = QVBoxLayout()
|
||||||
layout.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
layout.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||||
|
|
||||||
@@ -71,7 +108,7 @@ class SavingOverlay(QDialog):
|
|||||||
label.setStyleSheet("font-size: 18px; color: white; background-color: rgba(0,0,0,150); padding: 20px; border-radius: 10px;")
|
label.setStyleSheet("font-size: 18px; color: white; background-color: rgba(0,0,0,150); padding: 20px; border-radius: 10px;")
|
||||||
layout.addWidget(label)
|
layout.addWidget(label)
|
||||||
self.setLayout(layout)
|
self.setLayout(layout)
|
||||||
|
|
||||||
|
|
||||||
class ProjectManager:
|
class ProjectManager:
|
||||||
"""
|
"""
|
||||||
@@ -107,20 +144,117 @@ class ProjectManager:
|
|||||||
# =========================================================================
|
# =========================================================================
|
||||||
# File & Folder Opening Dialogs
|
# File & Folder Opening Dialogs
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
|
|
||||||
|
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:
|
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
|
||||||
|
|
||||||
|
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:
|
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)
|
|
||||||
|
# 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
|
||||||
|
|
||||||
|
self._load_files_into_pipeline(snirf_files)
|
||||||
|
|
||||||
|
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:
|
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."""
|
||||||
@@ -135,7 +269,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
|
||||||
@@ -146,8 +279,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
|
||||||
|
|
||||||
@@ -241,12 +377,31 @@ class ProjectManager:
|
|||||||
self.load_project(filename)
|
self.load_project(filename)
|
||||||
|
|
||||||
def load_project(self, filename: str) -> None:
|
def load_project(self, filename: str) -> None:
|
||||||
"""Loads a .flare project file into the application."""
|
"""Triggers asynchronous loading of a .flare project file."""
|
||||||
|
app = self.app
|
||||||
|
|
||||||
|
# 1. Show the custom loading overlay matching your saver design
|
||||||
|
self.loading_overlay = LoadingOverlay(app)
|
||||||
|
self.loading_overlay.resize(app.size())
|
||||||
|
self.loading_overlay.move(app.pos())
|
||||||
|
self.loading_overlay.show()
|
||||||
|
|
||||||
|
# 2. Fire up the background thread for pickle loading
|
||||||
|
self.loader_thread = LoadProjectThread(filename)
|
||||||
|
self.loader_thread.finished_signal.connect(
|
||||||
|
lambda data: self._handle_loaded_data(filename, data)
|
||||||
|
)
|
||||||
|
self.loader_thread.error_signal.connect(self._handle_load_error)
|
||||||
|
self.loader_thread.start()
|
||||||
|
|
||||||
|
def _handle_loaded_data(self, filename: str, data: dict) -> None:
|
||||||
|
# Close overlay
|
||||||
|
if hasattr(self, "loading_overlay"):
|
||||||
|
self.loading_overlay.close()
|
||||||
|
self.loading_overlay.deleteLater()
|
||||||
|
|
||||||
app = self.app
|
app = self.app
|
||||||
try:
|
try:
|
||||||
with open(filename, "rb") as f:
|
|
||||||
data = pickle.load(f)
|
|
||||||
|
|
||||||
checks = [
|
checks = [
|
||||||
("version", "<=1.1.7"),
|
("version", "<=1.1.7"),
|
||||||
("file_metadata", "<=1.2.2"),
|
("file_metadata", "<=1.2.2"),
|
||||||
@@ -260,7 +415,7 @@ class ProjectManager:
|
|||||||
f"This project was saved in an earlier version of {APP_NAME.upper()} ({ver_str}) "
|
f"This project was saved in an earlier version of {APP_NAME.upper()} ({ver_str}) "
|
||||||
"and is potentially not compatible with this version. "
|
"and is potentially not compatible with this version. "
|
||||||
)
|
)
|
||||||
if getattr(app, "incompatible_save_bypass", False):
|
if getattr(app, "processing_incompatible_save_bypass", False):
|
||||||
QMessageBox.warning(
|
QMessageBox.warning(
|
||||||
app, f"Warning - {APP_NAME.upper()}", msg + "Attempting load."
|
app, f"Warning - {APP_NAME.upper()}", msg + "Attempting load."
|
||||||
)
|
)
|
||||||
@@ -340,16 +495,19 @@ class ProjectManager:
|
|||||||
if hasattr(app, "button3"):
|
if hasattr(app, "button3"):
|
||||||
app.button3.setVisible(has_data)
|
app.button3.setVisible(has_data)
|
||||||
|
|
||||||
|
|
||||||
self.add_to_recent_projects(os.path.normpath(filename))
|
self.add_to_recent_projects(os.path.normpath(filename))
|
||||||
|
|
||||||
# Reset baselines cleanly
|
|
||||||
self.reset_all_dirty_states()
|
self.reset_all_dirty_states()
|
||||||
|
|
||||||
QMessageBox.information(app, "Loaded", f"Project loaded from:\n{filename}")
|
QMessageBox.information(app, "Loaded", f"Project loaded from:\n{filename}")
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
QMessageBox.critical(app, "Error", f"Failed to load project:\n{e}")
|
QMessageBox.critical(app, "Error", f"Failed to process loaded project data:\n{e}")
|
||||||
|
|
||||||
|
def _handle_load_error(self, error_msg: str) -> None:
|
||||||
|
if hasattr(self, "loading_overlay"):
|
||||||
|
self.loading_overlay.close()
|
||||||
|
self.loading_overlay.deleteLater()
|
||||||
|
QMessageBox.critical(self.app, "Error", f"Failed to read project file:\n{error_msg}")
|
||||||
|
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
# Project Saving (Save / Save As)
|
# Project Saving (Save / Save As)
|
||||||
@@ -719,7 +877,7 @@ def _get_bids_demographics(snirf_path: str) -> dict[str, str]:
|
|||||||
return {}
|
return {}
|
||||||
|
|
||||||
def _row_to_dict(row: pd.Series) -> 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
|
||||||
@@ -765,7 +923,6 @@ def _get_bids_demographics(snirf_path: str) -> dict[str, str]:
|
|||||||
return {}
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def extract_metadata_worker(file_name: str) -> dict[str, Any]:
|
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."""
|
||||||
|
|
||||||
@@ -823,7 +980,7 @@ def extract_metadata_worker(file_name: str) -> dict[str, Any]:
|
|||||||
if "hand" in demographics:
|
if "hand" in demographics:
|
||||||
snirf_info["BIDS - Handedness"] = demographics["hand"]
|
snirf_info["BIDS - Handedness"] = demographics["hand"]
|
||||||
return snirf_info
|
return snirf_info
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Worker safely caught failure on {file_name}: {str(e)}")
|
print(f"Worker safely caught failure on {file_name}: {str(e)}")
|
||||||
return {'status': 'error', 'reason': str(e)}
|
return {'status': 'error', 'reason': str(e)}
|
||||||
|
|||||||
+6
-7
@@ -1,7 +1,6 @@
|
|||||||
src\analysis\participantfoldchannels.py 158
|
src\analysis\participantfoldchannels.py 84
|
||||||
src\shared\flaresbasewidget.py 1001+
|
src\shared\flaresbasewidget.py 1155
|
||||||
src\window\updateevents.py 83
|
flares.py 2900
|
||||||
flares.py 1001+
|
main_unit_tests.py 152
|
||||||
main_unit_tests.py 153
|
main.py 732
|
||||||
main.py 691
|
project_manager.py 109
|
||||||
project_manager.py 113
|
|
||||||
Binary file not shown.
+80
-78
@@ -12,6 +12,7 @@ import os
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
# External library imports
|
# External library imports
|
||||||
|
from mne import Epochs
|
||||||
from pandas import DataFrame
|
from pandas import DataFrame
|
||||||
|
|
||||||
from mne.io.base import BaseRaw
|
from mne.io.base import BaseRaw
|
||||||
@@ -25,8 +26,9 @@ 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, BaseRaw],
|
haemo_dict: dict[str, BaseRaw],
|
||||||
|
epochs_dict: dict[str, Epochs],
|
||||||
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],
|
||||||
@@ -36,10 +38,11 @@ class ExportToCSVWidget(CSVUIMixin, FlaresBaseWidget):
|
|||||||
fir_feature_dict: dict[str, dict[str, Any]],
|
fir_feature_dict: dict[str, dict[str, Any]],
|
||||||
qc_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.epochs_dict = epochs_dict
|
||||||
self.cha_dict = cha_dict
|
self.cha_dict = cha_dict
|
||||||
self.df_ind_dict = df_ind_dict
|
self.df_ind_dict = df_ind_dict
|
||||||
self.design_matrix = design_matrix_dict
|
self.design_matrix = design_matrix_dict
|
||||||
@@ -48,20 +51,19 @@ class ExportToCSVWidget(CSVUIMixin, FlaresBaseWidget):
|
|||||||
self.config_dict = config_dict
|
self.config_dict = config_dict
|
||||||
self.fir_feature_dict = fir_feature_dict
|
self.fir_feature_dict = fir_feature_dict
|
||||||
self.qc_dict = qc_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)", "4 (Export FIR Waveform Features to CSV)",
|
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)",
|
"5 (Export Quality Control Metrics to CSV)", "6 (Export Master Consolidated Matrix [All Participants into 1 CSV])"
|
||||||
"6 (Export Master Consolidated Matrix [All Participants into 1 CSV])"
|
|
||||||
])
|
])
|
||||||
|
|
||||||
|
|
||||||
def process_request(self):
|
def process_request(self):
|
||||||
selected_display_names = self._get_checked_items(self.participant_dropdown)
|
selected_display_names = self._get_checked_items(self.participant_dropdown)
|
||||||
selected_file_paths: list[str] = []
|
selected_file_paths: list[str] = []
|
||||||
for display_name in selected_display_names:
|
for display_name in selected_display_names:
|
||||||
for fp, short_label in self.participant_map.items():
|
for fp, short_label in self.participant_map.items():
|
||||||
expected_display = f"{short_label} ({os.path.basename(fp)})"
|
expected_display = f"{short_label} ({os.path.basename(fp)})"
|
||||||
if display_name == expected_display:
|
if display_name == expected_display:
|
||||||
selected_file_paths.append(fp)
|
selected_file_paths.append(fp)
|
||||||
break
|
break
|
||||||
|
|
||||||
@@ -74,7 +76,7 @@ class ExportToCSVWidget(CSVUIMixin, FlaresBaseWidget):
|
|||||||
return
|
return
|
||||||
|
|
||||||
output_dir = QFileDialog.getExistingDirectory(self, "Select Output Folder for CSV Exports")
|
output_dir = QFileDialog.getExistingDirectory(self, "Select Output Folder for CSV Exports")
|
||||||
|
|
||||||
if not output_dir:
|
if not output_dir:
|
||||||
print("Export cancelled: No folder selected.")
|
print("Export cancelled: No folder selected.")
|
||||||
return
|
return
|
||||||
@@ -120,78 +122,78 @@ 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:
|
elif idx == 4:
|
||||||
# # FIR Waveform Features Export
|
# FIR Waveform Features Export
|
||||||
# fir_data = self.fir_feature_dict.get(file_path)
|
fir_data = self.fir_feature_dict.get(file_path)
|
||||||
# if fir_data and isinstance(fir_data, dict):
|
if fir_data:
|
||||||
# names = fir_data.get("feature_names", [])
|
names = fir_data.get("feature_names", [])
|
||||||
# vals = fir_data.get("features", [])
|
vals = fir_data.get("features", [])
|
||||||
# chans = fir_data.get("feature_channels", [])
|
chans = fir_data.get("feature_channels", [])
|
||||||
|
|
||||||
# fir_df = DataFrame({
|
fir_df = DataFrame({
|
||||||
# "Feature_Name": names,
|
"Feature_Name": names,
|
||||||
# "Channel": chans if len(chans) == len(names) else ["N/A"] * len(names),
|
"Channel": chans if len(chans) == len(names) else ["N/A"] * len(names),
|
||||||
# "Value": vals
|
"Value": vals
|
||||||
# })
|
})
|
||||||
# save_path = os.path.join(output_dir, f"{base_filename}_fir_features.csv")
|
save_path = os.path.join(output_dir, f"{base_filename}_fir_features.csv")
|
||||||
# fir_df.to_csv(save_path, index=False)
|
fir_df.to_csv(save_path, index=False)
|
||||||
# success_count += 1
|
success_count += 1
|
||||||
|
|
||||||
# elif idx == 5:
|
elif idx == 5:
|
||||||
# # Quality Control (QC) Metrics Export
|
# Quality Control (QC) Metrics Export
|
||||||
# qc_data = self.qc_dict.get(file_path)
|
qc_data = self.qc_dict.get(file_path)
|
||||||
# if qc_data and isinstance(qc_data, dict):
|
if qc_data and isinstance(qc_data, dict):
|
||||||
# qc_df = DataFrame(list(qc_data.items()), columns=["Metric", "Value"])
|
qc_df = DataFrame(list(qc_data.items()), columns=["Metric", "Value"])
|
||||||
# save_path = os.path.join(output_dir, f"{base_filename}_qc_metrics.csv")
|
save_path = os.path.join(output_dir, f"{base_filename}_qc_metrics.csv")
|
||||||
# qc_df.to_csv(save_path, index=False)
|
qc_df.to_csv(save_path, index=False)
|
||||||
# success_count += 1
|
success_count += 1
|
||||||
|
|
||||||
# elif idx == 6:
|
elif idx == 6:
|
||||||
# # Master Consolidated Matrix (1 single CSV combining all selected participants)
|
# Master Consolidated Matrix (1 single CSV combining all selected participants)
|
||||||
# save_path = os.path.join(output_dir, f"{APP_NAME}_master_consolidated.csv")
|
save_path = os.path.join(output_dir, f"{APP_NAME}_master_consolidated.csv")
|
||||||
# if not os.path.exists(save_path):
|
if not os.path.exists(save_path):
|
||||||
# master_rows: list[dict[str, Any]] = []
|
master_rows: list[dict[str, Any]] = []
|
||||||
# for fp in selected_file_paths:
|
for fp in selected_file_paths:
|
||||||
# abs_path = os.path.abspath(fp)
|
abs_path = os.path.abspath(fp)
|
||||||
# grp = self.group_dict.get(fp, "Unknown")
|
grp = self.group_dict.get(fp, "Unknown")
|
||||||
# row: dict[str, Any] = {"Participant": abs_path, "Group": grp}
|
row: dict[str, Any] = {"Participant": abs_path, "Group": grp}
|
||||||
|
|
||||||
# # QC Metrics
|
# QC Metrics
|
||||||
# qc_info = self.qc_dict.get(fp, {})
|
qc_info = self.qc_dict.get(fp, {})
|
||||||
# if isinstance(qc_info, dict):
|
if isinstance(qc_info, dict):
|
||||||
# for mk, mv in qc_info.items():
|
for mk, mv in qc_info.items():
|
||||||
# row[f"QC_{mk}"] = mv
|
row[f"QC_{mk}"] = mv
|
||||||
|
|
||||||
# # FIR Features
|
# FIR Features
|
||||||
# fir_info = self.fir_feature_dict.get(fp, {})
|
fir_info = self.fir_feature_dict.get(fp, {})
|
||||||
# print("1")
|
print("1")
|
||||||
# if isinstance(fir_info, dict):
|
if isinstance(fir_info, dict):
|
||||||
# print("2")
|
print("2")
|
||||||
# f_names = fir_info.get("feature_names", [])
|
f_names = fir_info.get("feature_names", [])
|
||||||
# f_vals = fir_info.get("features", [])
|
f_vals = fir_info.get("features", [])
|
||||||
# print("3")
|
print("3")
|
||||||
# if len(f_names) == len(f_vals):
|
if len(f_names) == len(f_vals):
|
||||||
# print("4")
|
print("4")
|
||||||
# for fn, fv in zip(f_names, f_vals):
|
for fn, fv in zip(f_names, f_vals):
|
||||||
# row[f"FIR_{fn}"] = fv
|
row[f"FIR_{fn}"] = fv
|
||||||
|
|
||||||
# master_rows.append(row)
|
master_rows.append(row)
|
||||||
|
|
||||||
# if master_rows:
|
if master_rows:
|
||||||
# df_master = DataFrame(master_rows)
|
df_master = DataFrame(master_rows)
|
||||||
# df_master.to_csv(save_path, index=False)
|
df_master.to_csv(save_path, index=False)
|
||||||
# success_count += 1
|
success_count += 1
|
||||||
|
|
||||||
else:
|
else:
|
||||||
print(f"No method defined for index {idx}")
|
print(f"No method defined for index {idx}")
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Failed to export {file_path} (Type {idx}): {e}")
|
print(f"Failed to export {file_path} (Type {idx}): {e}")
|
||||||
|
|
||||||
# 4. Final Notification
|
# 4. Final Notification
|
||||||
if success_count > 0:
|
if success_count > 0:
|
||||||
QMessageBox.information(self, "Export Complete", f"Successfully saved {success_count} CSV files to:\n{output_dir}")
|
QMessageBox.information(self, "Export Complete", f"Successfully saved {success_count} CSV files to:\n{output_dir}")
|
||||||
|
|
||||||
# # If SPARKS export was included, show the Event Window once at the end
|
# # If SPARKS export was included, show the Event Window once at the end
|
||||||
# if 1 in selected_indexes:
|
# if 1 in selected_indexes:
|
||||||
# win = UpdateEventsWindow(
|
# win = UpdateEventsWindow(
|
||||||
@@ -203,62 +205,62 @@ class ExportToCSVWidget(CSVUIMixin, FlaresBaseWidget):
|
|||||||
|
|
||||||
|
|
||||||
def gen_magic_str(self, all_params: dict[str, str]) -> str:
|
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']:
|
||||||
magic_str += f"The data was downsampled to {all_params['DOWNSAMPLE_FREQUENCY']}hz. "
|
magic_str += f"The data was downsampled to {all_params['DOWNSAMPLE_FREQUENCY']}hz. "
|
||||||
else:
|
else:
|
||||||
magic_str += "The data was not downsampled and was retained at its original sampling frequency. "
|
magic_str += "The data was not downsampled and was retained at its original sampling frequency. "
|
||||||
|
|
||||||
if all_params['TRIM']:
|
if all_params['TRIM']:
|
||||||
magic_str += f"The data was then trimmed to only keep {all_params['SECONDS_TO_KEEP']} seconds before the first event, as the earlier data with no events would not be needed. "
|
magic_str += f"The data was then trimmed to only keep {all_params['SECONDS_TO_KEEP']} seconds before the first event, as the earlier data with no events would not be needed. "
|
||||||
else:
|
else:
|
||||||
magic_str += "The data was not trimmed and was retained at its original length. "
|
magic_str += "The data was not trimmed and was retained at its original length. "
|
||||||
|
|
||||||
if all_params['OPTODE_PLACEMENT']:
|
if all_params['OPTODE_PLACEMENT']:
|
||||||
magic_str += f"A visualization check of the optode locations in 3D space was performed to ensure that they were at the correct location. "
|
magic_str += f"A visualization check of the optode locations in 3D space was performed to ensure that they were at the correct location. "
|
||||||
|
|
||||||
if all_params['SHORT_CHANNELS']:
|
if all_params['SHORT_CHANNELS']:
|
||||||
magic_str += f"A short channel(s) was specified with the maximum length of a short channel being {all_params['SHORT_CHANNELS_THRESHOLD']} metres. Channels that were over this distance but under {all_params['LONG_CHANNELS_THRESHOLD']} metres were retained. "
|
magic_str += f"A short channel(s) was specified with the maximum length of a short channel being {all_params['SHORT_CHANNELS_THRESHOLD']} metres. Channels that were over this distance but under {all_params['LONG_CHANNELS_THRESHOLD']} metres were retained. "
|
||||||
else:
|
else:
|
||||||
magic_str += f"No short channel was specified. Channels between the lengths of {all_params['SHORT_CHANNELS_THRESHOLD']} and {all_params['LONG_CHANNELS_THRESHOLD']} meters were used with all other channles being discarded. "
|
magic_str += f"No short channel was specified. Channels between the lengths of {all_params['SHORT_CHANNELS_THRESHOLD']} and {all_params['LONG_CHANNELS_THRESHOLD']} meters were used with all other channles being discarded. "
|
||||||
|
|
||||||
if all_params['HEART_RATE']:
|
if all_params['HEART_RATE']:
|
||||||
magic_str += f"The heart rate of the participant was attempted to be calculated. {all_params['SECONDS_TO_STRIP_HR']} seconds were ignored from the start of the file to avoid motion artifacts. "
|
magic_str += f"The heart rate of the participant was attempted to be calculated. {all_params['SECONDS_TO_STRIP_HR']} seconds were ignored from the start of the file to avoid motion artifacts. "
|
||||||
magic_str += f"The heart rate had hard limits of {all_params['MAX_LOW_HR']} to {all_params['MAX_HIGH_HR']} and was smoothed across {all_params['SMOOTHING_WINDOW_HR']} samples. "
|
magic_str += f"The heart rate had hard limits of {all_params['MAX_LOW_HR']} to {all_params['MAX_HIGH_HR']} and was smoothed across {all_params['SMOOTHING_WINDOW_HR']} samples. "
|
||||||
else:
|
else:
|
||||||
magic_str += "The heart rate of the participants was not attempted to be calculated. "
|
magic_str += "The heart rate of the participants was not attempted to be calculated. "
|
||||||
|
|
||||||
if all_params['SCI']:
|
if all_params['SCI']:
|
||||||
magic_str += f"The Scalp Coupling Index was calculated over {all_params['SCI_TIME_WINDOW']} second windows. The threshold value was {all_params['SCI_THRESHOLD']}. "
|
magic_str += f"The Scalp Coupling Index was calculated over {all_params['SCI_TIME_WINDOW']} second windows. The threshold value was {all_params['SCI_THRESHOLD']}. "
|
||||||
else:
|
else:
|
||||||
magic_str += "Scalp Coupling Index was not used. "
|
magic_str += "Scalp Coupling Index was not used. "
|
||||||
|
|
||||||
if all_params['SNR']:
|
if all_params['SNR']:
|
||||||
magic_str += f"The Signal to Noise Ratio was calculated. The threshold value was set to {all_params['SNR_THRESHOLD']}db. "
|
magic_str += f"The Signal to Noise Ratio was calculated. The threshold value was set to {all_params['SNR_THRESHOLD']}db. "
|
||||||
else:
|
else:
|
||||||
magic_str += "The Signal to Noise Ratio was not used. "
|
magic_str += "The Signal to Noise Ratio was not used. "
|
||||||
|
|
||||||
if all_params['PSP']:
|
if all_params['PSP']:
|
||||||
magic_str += f"Peak Spectral Power was calculated over {all_params['PSP_TIME_WINDOW']} second windows. The threshold value was {all_params['PSP_THRESHOLD']}. "
|
magic_str += f"Peak Spectral Power was calculated over {all_params['PSP_TIME_WINDOW']} second windows. The threshold value was {all_params['PSP_THRESHOLD']}. "
|
||||||
else:
|
else:
|
||||||
magic_str += "Peak Spectral Power was not used. "
|
magic_str += "Peak Spectral Power was not used. "
|
||||||
|
|
||||||
if all_params['COEFF_VAR']:
|
if all_params['COEFF_VAR']:
|
||||||
magic_str += f"The Coefficient of Variation was calculated. The threshold value was set to {all_params['COEFF_VAR_THRESHOLD']}. "
|
magic_str += f"The Coefficient of Variation was calculated. The threshold value was set to {all_params['COEFF_VAR_THRESHOLD']}. "
|
||||||
else:
|
else:
|
||||||
magic_str += "The Coefficient of Variation was not used. "
|
magic_str += "The Coefficient of Variation was not used. "
|
||||||
|
|
||||||
if all_params['MAD']:
|
if all_params['MAD']:
|
||||||
magic_str += f"Median Absolute Deviation was calculated. The threshold value was set to {all_params['MAD_THRESHOLD']}. "
|
magic_str += f"Median Absolute Deviation was calculated. The threshold value was set to {all_params['MAD_THRESHOLD']}. "
|
||||||
else:
|
else:
|
||||||
magic_str += "Median Absolute Deviation was not used. "
|
magic_str += "Median Absolute Deviation was not used. "
|
||||||
|
|
||||||
if all_params['PSD_NOISE']:
|
if all_params['PSD_NOISE']:
|
||||||
magic_str += f"Power Spectral Density Noise was calculated with a target frequency divisor of {all_params['TARGET_FREQ_DIV']} and a decibal limit of {all_params['DB_LIMIT']}. "
|
magic_str += f"Power Spectral Density Noise was calculated with a target frequency divisor of {all_params['TARGET_FREQ_DIV']} and a decibal limit of {all_params['DB_LIMIT']}. "
|
||||||
else:
|
else:
|
||||||
magic_str += f"Power Spectral Density Noise was not used. "
|
magic_str += f"Power Spectral Density Noise was not used. "
|
||||||
|
|
||||||
magic_str += "More coming soon."
|
magic_str += "More coming soon."
|
||||||
|
|
||||||
return magic_str
|
return magic_str
|
||||||
@@ -68,13 +68,13 @@ DESCRIPTION = """\n1. Group Contrast 2D/3D (plot_2d_3d_contrasts_between_groups)
|
|||||||
class InterGroupBrainImageWidget(InterGroupUIMixin, FlaresBaseWidget):
|
class InterGroupBrainImageWidget(InterGroupUIMixin, FlaresBaseWidget):
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
haemo_dict: dict[str, BaseRaw],
|
haemo_dict: dict[str, BaseRaw],
|
||||||
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
|
||||||
@@ -87,7 +87,7 @@ class InterGroupBrainImageWidget(InterGroupUIMixin, FlaresBaseWidget):
|
|||||||
|
|
||||||
|
|
||||||
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
|
||||||
@@ -122,7 +122,7 @@ class InterGroupBrainImageWidget(InterGroupUIMixin, FlaresBaseWidget):
|
|||||||
print("contrast_df_b empty?", contrast_df_b.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)]
|
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:
|
if len(all_raw_objs) > 1:
|
||||||
processed_raw = aggregate_fnirs_group_geometry(all_raw_objs)
|
processed_raw = aggregate_fnirs_group_geometry(all_raw_objs)
|
||||||
elif len(all_raw_objs) == 1 and all_raw_objs[0] is not None:
|
elif len(all_raw_objs) == 1 and all_raw_objs[0] is not None:
|
||||||
|
|||||||
@@ -125,7 +125,7 @@ PARAMETERIZED_INDEXES: dict[int, list[dict[str, Any]]] = {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
DESCRIPTION = """0. Raw ROI Comparison (run_inter_group_second_level_analysis)
|
DESCRIPTION = """0. Raw ROI Comparison (run_inter_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.
|
\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.
|
||||||
@@ -145,8 +145,8 @@ DESCRIPTION = """0. Raw ROI Comparison (run_inter_group_second_level_analysis)
|
|||||||
class InterGroupStatsWidget(InterGroupUIMixin, FlaresBaseWidget):
|
class InterGroupStatsWidget(InterGroupUIMixin, FlaresBaseWidget):
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
haemo_dict: dict[str, 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],
|
||||||
@@ -154,7 +154,7 @@ class InterGroupStatsWidget(InterGroupUIMixin, FlaresBaseWidget):
|
|||||||
roi_channel_map_dict: dict[str, dict[str, str]],
|
roi_channel_map_dict: dict[str, dict[str, str]],
|
||||||
group_dict: dict[str, str],
|
group_dict: dict[str, str],
|
||||||
) -> None:
|
) -> None:
|
||||||
|
|
||||||
super().__init__("InterGroupStats")
|
super().__init__("InterGroupStats")
|
||||||
self.setWindowTitle(f"Inter-Group Stats Viewer - {APP_NAME.upper()}")
|
self.setWindowTitle(f"Inter-Group Stats Viewer - {APP_NAME.upper()}")
|
||||||
self.haemo_dict = haemo_dict
|
self.haemo_dict = haemo_dict
|
||||||
@@ -166,7 +166,7 @@ class InterGroupStatsWidget(InterGroupUIMixin, FlaresBaseWidget):
|
|||||||
self.group_dict = group_dict
|
self.group_dict = group_dict
|
||||||
|
|
||||||
self.setup_inter_group_ui(["0 (Raw ROI Comparison)", "1 (Laterality Comparison)", "2 (Contrast Comparison)",], 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):
|
||||||
request = self.get_common_request_data(PARAMETERIZED_INDEXES, self.df_ind_dict, self.contrast_results_dict)
|
request = self.get_common_request_data(PARAMETERIZED_INDEXES, self.df_ind_dict, self.contrast_results_dict)
|
||||||
@@ -185,7 +185,7 @@ class InterGroupStatsWidget(InterGroupUIMixin, FlaresBaseWidget):
|
|||||||
|
|
||||||
valid_chas = [df for df in self.cha_dict.values() if not df.empty]
|
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()
|
cha_combined = pd.concat(valid_chas, ignore_index=True) if valid_chas else pd.DataFrame()
|
||||||
|
|
||||||
sample_path = file_paths_a[0]
|
sample_path = file_paths_a[0]
|
||||||
p_haemo = self.haemo_dict.get(sample_path)
|
p_haemo = self.haemo_dict.get(sample_path)
|
||||||
|
|
||||||
@@ -226,7 +226,7 @@ class InterGroupStatsWidget(InterGroupUIMixin, FlaresBaseWidget):
|
|||||||
print("Laterality comparison requires a specific event/condition "
|
print("Laterality comparison requires a specific event/condition "
|
||||||
"to be selected first.")
|
"to be selected first.")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
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", 3)
|
min_subjects = params.get("min_subjects", 3)
|
||||||
@@ -234,36 +234,36 @@ class InterGroupStatsWidget(InterGroupUIMixin, FlaresBaseWidget):
|
|||||||
target_chroma = params.get("target_chroma", "hbo")
|
target_chroma = params.get("target_chroma", "hbo")
|
||||||
roi_a: str = params.get("roi_a", "").strip()
|
roi_a: str = params.get("roi_a", "").strip()
|
||||||
roi_b: str = params.get("roi_b", "").strip()
|
roi_b: str = params.get("roi_b", "").strip()
|
||||||
|
|
||||||
if not roi_a or not roi_b:
|
if not roi_a or not roi_b:
|
||||||
print("Both a contralateral and ipsilateral ROI name must be specified.")
|
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
|
||||||
|
|
||||||
# Build each group's dataframe directly from the dict using
|
# Build each group's dataframe directly from the dict using
|
||||||
# the file-path lists as keys - no ID cleaning/matching needed.
|
# the file-path lists as keys - no ID cleaning/matching needed.
|
||||||
def _build_group_df(
|
def _build_group_df(
|
||||||
file_paths: list[str],
|
file_paths: list[str],
|
||||||
dict_source: dict[str, DataFrame]
|
dict_source: dict[str, DataFrame]
|
||||||
) -> DataFrame:
|
) -> DataFrame:
|
||||||
|
|
||||||
valid_dfs = [
|
valid_dfs = [
|
||||||
dict_source[fp] for fp in file_paths
|
dict_source[fp] for fp in file_paths
|
||||||
if fp in dict_source and not dict_source[fp].empty
|
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()
|
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_a = _build_group_df(file_paths_a, self.df_ind_dict)
|
||||||
df_roi_b = _build_group_df(file_paths_b, 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:
|
if df_roi_a.empty or df_roi_b.empty:
|
||||||
print("No ROI data (df_ind) found for one or both groups.")
|
print("No ROI data (df_ind) found for one or both groups.")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
run_inter_group_laterality_analysis(
|
run_inter_group_laterality_analysis(
|
||||||
df_roi_all_a=df_roi_a,
|
df_roi_all_a=df_roi_a,
|
||||||
df_roi_all_b=df_roi_b,
|
df_roi_all_b=df_roi_b,
|
||||||
@@ -278,7 +278,7 @@ class InterGroupStatsWidget(InterGroupUIMixin, FlaresBaseWidget):
|
|||||||
roi_contra_label=roi_a,
|
roi_contra_label=roi_a,
|
||||||
roi_ipsi_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)
|
||||||
@@ -286,11 +286,11 @@ class InterGroupStatsWidget(InterGroupUIMixin, FlaresBaseWidget):
|
|||||||
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", "")
|
||||||
|
|
||||||
if not contrast_name:
|
if not contrast_name:
|
||||||
print("A contrast name must be specified.")
|
print("A contrast name must be specified.")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Build each group's channel-level contrast dataframe
|
# Build each group's channel-level contrast dataframe
|
||||||
# directly from contrast_results_dict, keyed by file path -
|
# directly from contrast_results_dict, keyed by file path -
|
||||||
# same dict-key approach as the laterality patch, avoids
|
# same dict-key approach as the laterality patch, avoids
|
||||||
@@ -300,7 +300,7 @@ class InterGroupStatsWidget(InterGroupUIMixin, FlaresBaseWidget):
|
|||||||
contrast_dict: dict[str, dict[str, pd.DataFrame]],
|
contrast_dict: dict[str, dict[str, pd.DataFrame]],
|
||||||
name: str,
|
name: str,
|
||||||
) -> pd.DataFrame:
|
) -> pd.DataFrame:
|
||||||
|
|
||||||
all_rows: list[DataFrame] = []
|
all_rows: list[DataFrame] = []
|
||||||
for fp in file_paths:
|
for fp in file_paths:
|
||||||
condition_dfs = contrast_dict.get(fp)
|
condition_dfs = contrast_dict.get(fp)
|
||||||
@@ -315,10 +315,10 @@ class InterGroupStatsWidget(InterGroupUIMixin, FlaresBaseWidget):
|
|||||||
else:
|
else:
|
||||||
print(f" [MISSING CONTRAST] '{name}' not available for '{fp}'.")
|
print(f" [MISSING CONTRAST] '{name}' not available for '{fp}'.")
|
||||||
return pd.concat(all_rows, ignore_index=True) if all_rows else pd.DataFrame()
|
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_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)
|
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:
|
if df_contrasts_a.empty or df_contrasts_b.empty:
|
||||||
print("No contrast data found for one or both groups.")
|
print("No contrast data found for one or both groups.")
|
||||||
continue
|
continue
|
||||||
@@ -350,6 +350,6 @@ class InterGroupStatsWidget(InterGroupUIMixin, FlaresBaseWidget):
|
|||||||
p_threshold=p_threshold,
|
p_threshold=p_threshold,
|
||||||
correction_method=correction_method,
|
correction_method=correction_method,
|
||||||
)
|
)
|
||||||
|
|
||||||
else:
|
else:
|
||||||
print("no")
|
print("no")
|
||||||
@@ -78,7 +78,7 @@ DESCRIPTION = """0. FIR Model Results (plot_fir_model_results)
|
|||||||
class IntraGroupBrainImageWidget(IntraGroupUIMixin, FlaresBaseWidget):
|
class IntraGroupBrainImageWidget(IntraGroupUIMixin, FlaresBaseWidget):
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
haemo_dict: dict[str, 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],
|
||||||
@@ -94,7 +94,7 @@ class IntraGroupBrainImageWidget(IntraGroupUIMixin, FlaresBaseWidget):
|
|||||||
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_intra_group_ui(["0 (GLM Results)", "1 (Brain Activity Visualization)"], placeholder_text=DESCRIPTION)
|
self.setup_intra_group_ui(["0 (GLM Results)", "1 (Brain Activity Visualization)"], placeholder_text=DESCRIPTION)
|
||||||
|
|
||||||
|
|
||||||
@@ -116,18 +116,18 @@ class IntraGroupBrainImageWidget(IntraGroupUIMixin, FlaresBaseWidget):
|
|||||||
|
|
||||||
if selected_event:
|
if selected_event:
|
||||||
raw_annotations = getattr(haemo_obj, "annotations", None)
|
raw_annotations = getattr(haemo_obj, "annotations", None)
|
||||||
|
|
||||||
if raw_annotations is not None:
|
if raw_annotations is not None:
|
||||||
annotations = cast(Annotations, raw_annotations)
|
annotations = cast(Annotations, raw_annotations)
|
||||||
descriptions = cast(list[str], list(annotations.description))
|
descriptions = cast(list[str], list(annotations.description))
|
||||||
participant_events: set[str] = set(descriptions)
|
participant_events: set[str] = set(descriptions)
|
||||||
else:
|
else:
|
||||||
participant_events: set[str] = set()
|
participant_events: set[str] = set()
|
||||||
|
|
||||||
if selected_event not in participant_events:
|
if selected_event not in participant_events:
|
||||||
print(f"Skipping {self.participant_map[file_path]}: Event '{selected_event}' not found.")
|
print(f"Skipping {self.participant_map[file_path]}: Event '{selected_event}' not found.")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
cha_df = self.cha_dict.get(file_path)
|
cha_df = self.cha_dict.get(file_path)
|
||||||
if cha_df is not None:
|
if cha_df is not None:
|
||||||
all_cha = pd.concat([all_cha, cha_df], ignore_index=True)
|
all_cha = pd.concat([all_cha, cha_df], ignore_index=True)
|
||||||
@@ -144,7 +144,7 @@ class IntraGroupBrainImageWidget(IntraGroupUIMixin, FlaresBaseWidget):
|
|||||||
df = self.df_ind_dict.get(file_path)
|
df = self.df_ind_dict.get(file_path)
|
||||||
if df is not None:
|
if df is not None:
|
||||||
df_group = pd.concat([df_group, df], ignore_index=True)
|
df_group = pd.concat([df_group, df], ignore_index=True)
|
||||||
|
|
||||||
|
|
||||||
for idx in selected_indexes:
|
for idx in selected_indexes:
|
||||||
if idx == 0:
|
if idx == 0:
|
||||||
@@ -170,7 +170,7 @@ class IntraGroupBrainImageWidget(IntraGroupUIMixin, FlaresBaseWidget):
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
all_raw_objs = [self.haemo_dict.get(fp) for fp in selected_file_paths if self.haemo_dict.get(fp)]
|
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:
|
if len(all_raw_objs) > 1:
|
||||||
processed_raw = aggregate_fnirs_group_geometry(all_raw_objs)
|
processed_raw = aggregate_fnirs_group_geometry(all_raw_objs)
|
||||||
elif len(all_raw_objs) == 1 and all_raw_objs[0] is not None:
|
elif len(all_raw_objs) == 1 and all_raw_objs[0] is not None:
|
||||||
@@ -180,6 +180,6 @@ class IntraGroupBrainImageWidget(IntraGroupUIMixin, FlaresBaseWidget):
|
|||||||
processed_raw = None
|
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)
|
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:
|
else:
|
||||||
print(f"No method defined for index {idx}")
|
print(f"No method defined for index {idx}")
|
||||||
@@ -64,7 +64,7 @@ class IntraGroupFunctionalConnectivityWidget(IntraGroupUIMixin, FlaresBaseWidget
|
|||||||
epochs_dict: dict[str, Epochs],
|
epochs_dict: dict[str, Epochs],
|
||||||
group_dict: dict[str, str],
|
group_dict: dict[str, str],
|
||||||
) -> None:
|
) -> None:
|
||||||
|
|
||||||
super().__init__("IntraGroupFunctionalConnectivity")
|
super().__init__("IntraGroupFunctionalConnectivity")
|
||||||
self.setWindowTitle(f"Intra-Group Functional Connectivity Viewer [BETA] - {APP_NAME.upper()}")
|
self.setWindowTitle(f"Intra-Group Functional Connectivity Viewer [BETA] - {APP_NAME.upper()}")
|
||||||
self.haemo_dict = haemo_dict
|
self.haemo_dict = haemo_dict
|
||||||
|
|||||||
@@ -157,8 +157,8 @@ DESCRIPTION = """0. ROI vs. Zero (run_roi_second_level_analysis)
|
|||||||
|
|
||||||
class IntraGroupStatsWidget(IntraGroupUIMixin, FlaresBaseWidget):
|
class IntraGroupStatsWidget(IntraGroupUIMixin, FlaresBaseWidget):
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
haemo_dict: dict[str, 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],
|
||||||
@@ -166,7 +166,7 @@ class IntraGroupStatsWidget(IntraGroupUIMixin, FlaresBaseWidget):
|
|||||||
roi_channel_map_dict: dict[str, dict[str, str]],
|
roi_channel_map_dict: dict[str, dict[str, str]],
|
||||||
group_dict: dict[str, str],
|
group_dict: dict[str, str],
|
||||||
) -> None:
|
) -> None:
|
||||||
|
|
||||||
super().__init__("IntraGroupStats")
|
super().__init__("IntraGroupStats")
|
||||||
self.setWindowTitle(f"Intra-Group Stats Viewer - {APP_NAME.upper()}")
|
self.setWindowTitle(f"Intra-Group Stats Viewer - {APP_NAME.upper()}")
|
||||||
self.haemo_dict = haemo_dict
|
self.haemo_dict = haemo_dict
|
||||||
@@ -185,7 +185,7 @@ class IntraGroupStatsWidget(IntraGroupUIMixin, FlaresBaseWidget):
|
|||||||
if request is None:
|
if request is None:
|
||||||
return
|
return
|
||||||
|
|
||||||
(selected_event, selected_file_paths, selected_indexes, raw_params) = request
|
(selected_event, selected_file_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)
|
||||||
|
|
||||||
@@ -198,20 +198,19 @@ class IntraGroupStatsWidget(IntraGroupUIMixin, FlaresBaseWidget):
|
|||||||
|
|
||||||
if selected_event:
|
if selected_event:
|
||||||
raw_annotations = getattr(haemo_obj, "annotations", None)
|
raw_annotations = getattr(haemo_obj, "annotations", None)
|
||||||
|
|
||||||
if raw_annotations is not None:
|
if raw_annotations is not None:
|
||||||
annotations = cast(Annotations, raw_annotations)
|
annotations = cast(Annotations, raw_annotations)
|
||||||
descriptions = cast(list[str], list(annotations.description))
|
descriptions = cast(list[str], list(annotations.description))
|
||||||
participant_events: set[str] = set(descriptions)
|
participant_events: set[str] = set(descriptions)
|
||||||
else:
|
else:
|
||||||
participant_events: set[str] = set()
|
participant_events: set[str] = set()
|
||||||
|
|
||||||
if selected_event not in participant_events:
|
if selected_event not in participant_events:
|
||||||
print(f"Skipping {self.participant_map[file_path]}: Event '{selected_event}' not found.")
|
print(f"Skipping {self.participant_map[file_path]}: Event '{selected_event}' not found.")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
cha_df = self.cha_dict.get(file_path)
|
cha_df = self.cha_dict.get(file_path)
|
||||||
if cha_df is not None:
|
if cha_df is not None:
|
||||||
all_cha = pd.concat([all_cha, cha_df], ignore_index=True)
|
all_cha = pd.concat([all_cha, cha_df], ignore_index=True)
|
||||||
@@ -288,23 +287,23 @@ class IntraGroupStatsWidget(IntraGroupUIMixin, FlaresBaseWidget):
|
|||||||
target_chroma = params.get("target_chroma", "hbo")
|
target_chroma = params.get("target_chroma", "hbo")
|
||||||
roi_a = params.get("roi_a", "").strip()
|
roi_a = params.get("roi_a", "").strip()
|
||||||
roi_b = params.get("roi_b", "").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("Paired ROI contrast requires a specific event/condition "
|
||||||
"to be selected - pick one from the Event dropdown first.")
|
"to be selected - pick one from the Event dropdown first.")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
if df_group.empty:
|
if df_group.empty:
|
||||||
print("No ROI data (df_ind) found for selected participants.")
|
print("No ROI data (df_ind) found for selected participants.")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
if correction_method == "None":
|
if correction_method == "None":
|
||||||
correction_method = None
|
correction_method = None
|
||||||
|
|
||||||
if not roi_a or not roi_b:
|
if not roi_a or not roi_b:
|
||||||
print("Both ROI A and ROI B must be specified.")
|
print("Both ROI A and ROI B must be specified.")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
run_roi_paired_contrast_analysis(
|
run_roi_paired_contrast_analysis(
|
||||||
df_roi_all=df_group,
|
df_roi_all=df_group,
|
||||||
roi_pairs=(roi_a, roi_b),
|
roi_pairs=(roi_a, roi_b),
|
||||||
@@ -351,12 +350,12 @@ class IntraGroupStatsWidget(IntraGroupUIMixin, FlaresBaseWidget):
|
|||||||
else:
|
else:
|
||||||
print(f" [MISSING CONTRAST] '{contrast_name}' not "
|
print(f" [MISSING CONTRAST] '{contrast_name}' not "
|
||||||
f"available for {self.participant_map.get(fp, fp)}.")
|
f"available for {self.participant_map.get(fp, fp)}.")
|
||||||
|
|
||||||
if not all_contrasts:
|
if not all_contrasts:
|
||||||
print(f"No contrast data found for '{contrast_name}' "
|
print(f"No contrast data found for '{contrast_name}' "
|
||||||
f"across selected participants.")
|
f"across selected participants.")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
df_contrasts = pd.concat(all_contrasts, ignore_index=True)
|
df_contrasts = pd.concat(all_contrasts, ignore_index=True)
|
||||||
|
|
||||||
selected_roi_maps = {
|
selected_roi_maps = {
|
||||||
@@ -378,12 +377,12 @@ class IntraGroupStatsWidget(IntraGroupUIMixin, FlaresBaseWidget):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Failed to aggregate contrasts to ROI: {e}")
|
print(f"Failed to aggregate contrasts to ROI: {e}")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
if roi_theta.empty:
|
if roi_theta.empty:
|
||||||
print("No ROI-level contrast values could be computed "
|
print("No ROI-level contrast values could be computed "
|
||||||
"(check regions.json channel names against this montage).")
|
"(check regions.json channel names against this montage).")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
run_roi_second_level_analysis(
|
run_roi_second_level_analysis(
|
||||||
df_roi_all=roi_theta,
|
df_roi_all=roi_theta,
|
||||||
condition=contrast_name,
|
condition=contrast_name,
|
||||||
@@ -395,6 +394,6 @@ class IntraGroupStatsWidget(IntraGroupUIMixin, FlaresBaseWidget):
|
|||||||
target_chroma=target_chroma,
|
target_chroma=target_chroma,
|
||||||
graph_bounds=graph_bounds if graph_bounds > 0.0 else None,
|
graph_bounds=graph_bounds if graph_bounds > 0.0 else None,
|
||||||
)
|
)
|
||||||
|
|
||||||
else:
|
else:
|
||||||
print(f"No method defined for index {idx}")
|
print(f"No method defined for index {idx}")
|
||||||
@@ -67,11 +67,11 @@ 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, BaseRaw],
|
haemo_dict: dict[str, BaseRaw],
|
||||||
cha_dict: dict[str, DataFrame],
|
cha_dict: dict[str, DataFrame],
|
||||||
) -> None:
|
) -> None:
|
||||||
|
|
||||||
super().__init__("ParticipantBrain")
|
super().__init__("ParticipantBrain")
|
||||||
self.setWindowTitle(f"Participant Brain Viewer - {APP_NAME.upper()}")
|
self.setWindowTitle(f"Participant Brain Viewer - {APP_NAME.upper()}")
|
||||||
self.haemo_dict = haemo_dict
|
self.haemo_dict = haemo_dict
|
||||||
@@ -86,7 +86,7 @@ class ParticipantBrainViewerWidget(ParticipantUIMixin, FlaresBaseWidget):
|
|||||||
if request is None:
|
if request is None:
|
||||||
return
|
return
|
||||||
|
|
||||||
(selected_event, selected_file_paths, selected_indexes, raw_params) = request
|
(selected_event, selected_file_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)
|
||||||
|
|
||||||
@@ -99,14 +99,14 @@ class ParticipantBrainViewerWidget(ParticipantUIMixin, FlaresBaseWidget):
|
|||||||
|
|
||||||
if selected_event:
|
if selected_event:
|
||||||
raw_annotations = getattr(haemo_obj, "annotations", None)
|
raw_annotations = getattr(haemo_obj, "annotations", None)
|
||||||
|
|
||||||
if raw_annotations is not None:
|
if raw_annotations is not None:
|
||||||
annotations = cast(Annotations, raw_annotations)
|
annotations = cast(Annotations, raw_annotations)
|
||||||
descriptions = cast(list[str], list(annotations.description))
|
descriptions = cast(list[str], list(annotations.description))
|
||||||
participant_events: set[str] = set(descriptions)
|
participant_events: set[str] = set(descriptions)
|
||||||
else:
|
else:
|
||||||
participant_events: set[str] = set()
|
participant_events: set[str] = set()
|
||||||
|
|
||||||
if selected_event not in participant_events:
|
if selected_event not in participant_events:
|
||||||
print(f"Skipping {self.participant_map[file_path]}: Event '{selected_event}' not found.")
|
print(f"Skipping {self.participant_map[file_path]}: Event '{selected_event}' not found.")
|
||||||
continue
|
continue
|
||||||
|
|||||||
@@ -8,29 +8,31 @@ License: GPL-3.0
|
|||||||
|
|
||||||
# Built-in Imports
|
# Built-in Imports
|
||||||
import os
|
import os
|
||||||
from pathlib import Path
|
|
||||||
import time
|
import time
|
||||||
import traceback
|
import traceback
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
from multiprocessing import Process, current_process, Manager
|
from multiprocessing import Process, current_process, Manager
|
||||||
from typing import Any, Dict, List, Optional, Tuple, Union
|
from typing import Any, Callable, Dict, List, Optional, Tuple, Union
|
||||||
|
|
||||||
# External library imports
|
# External library imports
|
||||||
from matplotlib.backend_bases import Event
|
|
||||||
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, QLayout, 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, QObject, Signal
|
from PySide6.QtCore import QThread, Qt, QSize, QTimer, QObject, Signal
|
||||||
from PySide6.QtGui import QCloseEvent, QMouseEvent, QPixmap, QImage
|
from PySide6.QtGui import QCloseEvent, QMouseEvent, QPixmap, QImage
|
||||||
from pandas import DataFrame
|
|
||||||
|
|
||||||
from mne.io.base import BaseRaw
|
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):
|
||||||
@@ -38,7 +40,7 @@ class MultiProgressDialog(QDialog):
|
|||||||
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.main_layout = QVBoxLayout(self)
|
self.main_layout = QVBoxLayout(self)
|
||||||
self.bars: Dict[str, QProgressBar] = {}
|
self.bars: Dict[str, QProgressBar] = {}
|
||||||
self.allow_closing = False
|
self.allow_closing = False
|
||||||
@@ -50,7 +52,7 @@ class MultiProgressDialog(QDialog):
|
|||||||
pbar.setMinimum(0)
|
pbar.setMinimum(0)
|
||||||
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.main_layout.addWidget(label_widget)
|
self.main_layout.addWidget(label_widget)
|
||||||
self.main_layout.addWidget(pbar)
|
self.main_layout.addWidget(pbar)
|
||||||
self.bars[clean_key] = pbar
|
self.bars[clean_key] = pbar
|
||||||
@@ -70,7 +72,7 @@ class MultiProgressDialog(QDialog):
|
|||||||
def force_close(self):
|
def force_close(self):
|
||||||
self.allow_closing = True
|
self.allow_closing = True
|
||||||
self.close()
|
self.close()
|
||||||
|
|
||||||
|
|
||||||
def single_participant_worker(
|
def single_participant_worker(
|
||||||
file_path: str,
|
file_path: str,
|
||||||
@@ -82,10 +84,9 @@ def single_participant_worker(
|
|||||||
""" 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)
|
||||||
|
|
||||||
# Hand back results and signal completion
|
# Hand back results and signal completion
|
||||||
result_queue.put({file_path: channel_results})
|
result_queue.put({file_path: channel_results})
|
||||||
progress_queue.put(p_name)
|
progress_queue.put(p_name)
|
||||||
@@ -116,11 +117,11 @@ def get_landmark_color_map() -> Dict[str, Tuple[float, float, float, float]]:
|
|||||||
]
|
]
|
||||||
# Sort logically
|
# Sort logically
|
||||||
landmarks.sort(key=lambda x: (int(x.split(" - ")[0]) if x.split(" - ")[0].isdigit() else float('inf')))
|
landmarks.sort(key=lambda x: (int(x.split(" - ")[0]) if x.split(" - ")[0].isdigit() else float('inf')))
|
||||||
|
|
||||||
cmap1 = plt.get_cmap('tab20')
|
cmap1 = plt.get_cmap('tab20')
|
||||||
cmap2 = plt.get_cmap('tab20b')
|
cmap2 = plt.get_cmap('tab20b')
|
||||||
colors = [cmap1(i) for i in range(20)] + [cmap2(i) for i in range(20)]
|
colors = [cmap1(i) for i in range(20)] + [cmap2(i) for i in range(20)]
|
||||||
|
|
||||||
return {landmark: colors[i % len(colors)] for i, landmark in enumerate(landmarks)}
|
return {landmark: colors[i % len(colors)] for i, landmark in enumerate(landmarks)}
|
||||||
|
|
||||||
|
|
||||||
@@ -136,16 +137,16 @@ class StaticChannelCanvas(FigureCanvas):
|
|||||||
image_path: Optional[str] = None,
|
image_path: Optional[str] = None,
|
||||||
parent: Optional[QWidget] = None,
|
parent: Optional[QWidget] = None,
|
||||||
) -> 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)
|
||||||
|
|
||||||
super().__init__(self.fig)
|
super().__init__(self.fig)
|
||||||
self.setParent(parent)
|
self.setParent(parent)
|
||||||
|
|
||||||
self.setMouseTracking(True)
|
self.setMouseTracking(True)
|
||||||
|
|
||||||
# --- 1. DATA PREPARATION ---
|
# --- 1. DATA PREPARATION ---
|
||||||
self.wedge_data_list = list(data_list)
|
self.wedge_data_list = list(data_list)
|
||||||
total_specificity = sum(d['Specificity'] for d in self.wedge_data_list)
|
total_specificity = sum(d['Specificity'] for d in self.wedge_data_list)
|
||||||
@@ -156,7 +157,7 @@ class StaticChannelCanvas(FigureCanvas):
|
|||||||
'Landmark': 'Other / Unclassified Regions',
|
'Landmark': 'Other / Unclassified Regions',
|
||||||
'Specificity': remainder
|
'Specificity': remainder
|
||||||
})
|
})
|
||||||
|
|
||||||
self.specificities = [d['Specificity'] for d in self.wedge_data_list]
|
self.specificities = [d['Specificity'] for d in self.wedge_data_list]
|
||||||
self.landmarks = [d['Landmark'] for d in self.wedge_data_list]
|
self.landmarks = [d['Landmark'] for d in self.wedge_data_list]
|
||||||
self.colors = [color_map.get(lm, '#ccc') if 'Other' not in lm else '#d3d3d3' for lm in self.landmarks]
|
self.colors = [color_map.get(lm, '#ccc') if 'Other' not in lm else '#d3d3d3' for lm in self.landmarks]
|
||||||
@@ -174,7 +175,7 @@ class StaticChannelCanvas(FigureCanvas):
|
|||||||
labeldistance=1.1
|
labeldistance=1.1
|
||||||
)
|
)
|
||||||
self.ax[0].axis('equal')
|
self.ax[0].axis('equal')
|
||||||
|
|
||||||
# --- 3. RIGHT SUBPLOT: PNG IMAGE DISPLAY ---
|
# --- 3. RIGHT SUBPLOT: PNG IMAGE DISPLAY ---
|
||||||
# Note we explicitly target self.ax[1] now
|
# Note we explicitly target self.ax[1] now
|
||||||
if image_path:
|
if image_path:
|
||||||
@@ -188,14 +189,14 @@ class StaticChannelCanvas(FigureCanvas):
|
|||||||
# Fallback message if no image path is passed down
|
# Fallback message if no image path is passed down
|
||||||
self.ax[1].text(0.5, 0.5, "No Reference Image\nProvided",
|
self.ax[1].text(0.5, 0.5, "No Reference Image\nProvided",
|
||||||
ha='center', va='center', fontsize=12, fontweight='bold', color='#777')
|
ha='center', va='center', fontsize=12, fontweight='bold', color='#777')
|
||||||
|
|
||||||
# Completely hide the background grid, spines, and axis lines for the image box
|
# Completely hide the background grid, spines, and axis lines for the image box
|
||||||
self.ax[1].axis('off')
|
self.ax[1].axis('off')
|
||||||
|
|
||||||
# --- 4. CANVAS TEXT OVERLAY ---
|
# --- 4. CANVAS TEXT OVERLAY ---
|
||||||
# Main Title centered globally over both subplots
|
# Main Title centered globally over both subplots
|
||||||
self.fig.suptitle(channel_name, fontsize=16, fontweight='bold', y=0.97)
|
self.fig.suptitle(channel_name, fontsize=16, fontweight='bold', y=0.97)
|
||||||
|
|
||||||
# Shared info box text overlay centered horizontally across the whole window figure
|
# Shared info box text overlay centered horizontally across the whole window figure
|
||||||
self.info_text = self.ax[0].text(
|
self.info_text = self.ax[0].text(
|
||||||
0.5, 0.04, "",
|
0.5, 0.04, "",
|
||||||
@@ -205,9 +206,9 @@ class StaticChannelCanvas(FigureCanvas):
|
|||||||
bbox=dict(boxstyle="round,pad=0.5", facecolor="#fdfdfd", edgecolor="#bbb", alpha=0.95)
|
bbox=dict(boxstyle="round,pad=0.5", facecolor="#fdfdfd", edgecolor="#bbb", alpha=0.95)
|
||||||
)
|
)
|
||||||
self.info_text.set_visible(False)
|
self.info_text.set_visible(False)
|
||||||
|
|
||||||
self.currently_exploded_idx = None
|
self.currently_exploded_idx = None
|
||||||
|
|
||||||
# Layout space optimization
|
# Layout space optimization
|
||||||
self.fig.subplots_adjust(left=0.05, bottom=0.1, right=0.95, top=0.85, wspace=0.2)
|
self.fig.subplots_adjust(left=0.05, bottom=0.1, right=0.95, top=0.85, wspace=0.2)
|
||||||
self.draw()
|
self.draw()
|
||||||
@@ -236,10 +237,10 @@ class StaticChannelCanvas(FigureCanvas):
|
|||||||
if self.currently_exploded_idx != hovered_index:
|
if self.currently_exploded_idx != hovered_index:
|
||||||
self.currently_exploded_idx = hovered_index
|
self.currently_exploded_idx = hovered_index
|
||||||
self._explode_wedge(hovered_index)
|
self._explode_wedge(hovered_index)
|
||||||
|
|
||||||
displayed_pct = self.autotexts[hovered_index].get_text()
|
displayed_pct = self.autotexts[hovered_index].get_text()
|
||||||
full_desc = self.landmarks[hovered_index]
|
full_desc = self.landmarks[hovered_index]
|
||||||
|
|
||||||
self.info_text.set_text(f"{full_desc} | {displayed_pct}")
|
self.info_text.set_text(f"{full_desc} | {displayed_pct}")
|
||||||
self.info_text.set_visible(True)
|
self.info_text.set_visible(True)
|
||||||
self.draw_idle()
|
self.draw_idle()
|
||||||
@@ -249,7 +250,7 @@ class StaticChannelCanvas(FigureCanvas):
|
|||||||
self.info_text.set_visible(False)
|
self.info_text.set_visible(False)
|
||||||
self.currently_exploded_idx = None
|
self.currently_exploded_idx = None
|
||||||
self.draw_idle()
|
self.draw_idle()
|
||||||
|
|
||||||
except Exception as err:
|
except Exception as err:
|
||||||
print(f"[ERROR] Internal failure inside _on_hover loop: {err}")
|
print(f"[ERROR] Internal failure inside _on_hover loop: {err}")
|
||||||
traceback.print_exc()
|
traceback.print_exc()
|
||||||
@@ -280,19 +281,18 @@ class StaticChannelCanvas(FigureCanvas):
|
|||||||
changed = True
|
changed = True
|
||||||
if changed:
|
if changed:
|
||||||
self.draw_idle()
|
self.draw_idle()
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
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)
|
||||||
self.resize(500, 900)
|
self.resize(500, 900)
|
||||||
|
|
||||||
layout = QVBoxLayout(self)
|
layout = QVBoxLayout(self)
|
||||||
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)
|
legend_card = canvas_engine.create_legend_card(title_prefix)
|
||||||
layout.addWidget(legend_card)
|
layout.addWidget(legend_card)
|
||||||
@@ -302,34 +302,41 @@ 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
|
||||||
|
|
||||||
num_channels = len(channels_data)
|
num_channels = len(channels_data)
|
||||||
|
|
||||||
# --- FIX: DYNAMICALLY CALCULATE OPTIMAL 16:9 COLUMNS ---
|
# Dynamically calculate best ratio for 16:9
|
||||||
target_ratio = 16 / 9
|
target_ratio = 16 / 9
|
||||||
best_cols = 4
|
best_cols = 4
|
||||||
min_ratio_error = float('inf')
|
min_ratio_error = float('inf')
|
||||||
|
|
||||||
# Test configurations from 4 columns up to the total number of channels
|
# Test configurations from 4 columns up to the total number of channels
|
||||||
for test_cols in range(4, num_channels + 1):
|
for test_cols in range(4, num_channels + 1):
|
||||||
test_rows = (num_channels + test_cols - 1) // test_cols
|
test_rows = (num_channels + test_cols - 1) // test_cols
|
||||||
|
|
||||||
# Approximate the visual aspect ratio based on cell dimensions
|
# Approximate the visual aspect ratio based on cell dimensions
|
||||||
# Mini charts are slightly wider than tall, roughly 1.15 to 1.0 factor
|
# Mini charts are slightly wider than tall, roughly 1.15 to 1.0 factor
|
||||||
current_ratio = (test_cols * 1.15) / (test_rows * 1.0)
|
current_ratio = (test_cols * 1.15) / (test_rows * 1.0)
|
||||||
error = abs(current_ratio - target_ratio)
|
error = abs(current_ratio - target_ratio)
|
||||||
|
|
||||||
if error < min_ratio_error:
|
if error < min_ratio_error:
|
||||||
min_ratio_error = error
|
min_ratio_error = error
|
||||||
best_cols = test_cols
|
best_cols = test_cols
|
||||||
|
|
||||||
cols = best_cols
|
cols = best_cols
|
||||||
rows = (num_channels + cols - 1) // cols
|
rows = (num_channels + cols - 1) // cols
|
||||||
|
|
||||||
# Base figure sizing dynamically scales off the optimal matrix constraints
|
# Base figure sizing dynamically scales off the optimal matrix constraints
|
||||||
if is_fullscreen_copy:
|
if is_fullscreen_copy:
|
||||||
# Maximized views stretch cleanly across standard display panels
|
# Maximized views stretch cleanly across standard display panels
|
||||||
@@ -337,17 +344,17 @@ class InteractiveParticipantGridCanvas(FigureCanvas):
|
|||||||
else:
|
else:
|
||||||
# Standard thumbnail views scaled down for participant cards
|
# Standard thumbnail views scaled down for participant cards
|
||||||
figsize = (7.5, 7.5 / target_ratio)
|
figsize = (7.5, 7.5 / target_ratio)
|
||||||
|
|
||||||
self.fig = Figure(figsize=figsize)
|
self.fig = Figure(figsize=figsize)
|
||||||
|
|
||||||
super().__init__(self.fig)
|
super().__init__(self.fig)
|
||||||
self.setParent(parent)
|
self.setParent(parent)
|
||||||
|
|
||||||
self.axes_data_registry = {}
|
self.axes_data_registry = {}
|
||||||
|
|
||||||
for idx, (channel_name, data_list) in enumerate(channels_data.items()):
|
for idx, (channel_name, data_list) in enumerate(channels_data.items()):
|
||||||
ax = self.fig.add_subplot(rows, cols, idx + 1)
|
ax = self.fig.add_subplot(rows, cols, idx + 1)
|
||||||
|
|
||||||
padded_data_list = list(data_list)
|
padded_data_list = list(data_list)
|
||||||
total_specificity = sum(d['Specificity'] for d in padded_data_list)
|
total_specificity = sum(d['Specificity'] for d in padded_data_list)
|
||||||
if total_specificity < 100.0:
|
if total_specificity < 100.0:
|
||||||
@@ -373,15 +380,15 @@ class InteractiveParticipantGridCanvas(FigureCanvas):
|
|||||||
title_sz = 6 if num_channels > 30 else (9 if is_fullscreen_copy else 7)
|
title_sz = 6 if num_channels > 30 else (9 if is_fullscreen_copy else 7)
|
||||||
|
|
||||||
ax.pie(
|
ax.pie(
|
||||||
specificities,
|
specificities,
|
||||||
startangle=90,
|
startangle=90,
|
||||||
colors=colors,
|
colors=colors,
|
||||||
labels=labels,
|
labels=labels,
|
||||||
textprops={'fontsize': font_sz, 'fontweight': 'bold'},
|
textprops={'fontsize': font_sz, 'fontweight': 'bold'},
|
||||||
labeldistance=1.05,
|
labeldistance=1.05,
|
||||||
radius=0.75
|
radius=0.75
|
||||||
)
|
)
|
||||||
|
|
||||||
ax.set_title(channel_name, fontsize=title_sz, fontweight='bold', pad=0, y=1.04)
|
ax.set_title(channel_name, fontsize=title_sz, fontweight='bold', pad=0, y=1.04)
|
||||||
ax.axis('equal')
|
ax.axis('equal')
|
||||||
|
|
||||||
@@ -389,12 +396,12 @@ class InteractiveParticipantGridCanvas(FigureCanvas):
|
|||||||
# Large multi-column plots require less spacing overhead to prevent clipping label masks
|
# Large multi-column plots require less spacing overhead to prevent clipping label masks
|
||||||
h_sp = 0.35 if num_channels > 30 else 0.18
|
h_sp = 0.35 if num_channels > 30 else 0.18
|
||||||
w_sp = 0.25 if num_channels > 30 else 0.10
|
w_sp = 0.25 if num_channels > 30 else 0.10
|
||||||
|
|
||||||
if is_fullscreen_copy:
|
if is_fullscreen_copy:
|
||||||
self.fig.subplots_adjust(left=0.02, bottom=0.02, right=0.98, top=0.95, hspace=h_sp, wspace=w_sp)
|
self.fig.subplots_adjust(left=0.02, bottom=0.02, right=0.98, top=0.95, hspace=h_sp, wspace=w_sp)
|
||||||
else:
|
else:
|
||||||
self.fig.set_layout_engine('constrained')
|
self.fig.set_layout_engine('constrained')
|
||||||
|
|
||||||
self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
|
self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
|
||||||
|
|
||||||
self.draw()
|
self.draw()
|
||||||
@@ -417,28 +424,28 @@ class InteractiveParticipantGridCanvas(FigureCanvas):
|
|||||||
background-color: #f8f9fa;
|
background-color: #f8f9fa;
|
||||||
}
|
}
|
||||||
""")
|
""")
|
||||||
|
|
||||||
card_layout = QVBoxLayout(card_frame)
|
card_layout = QVBoxLayout(card_frame)
|
||||||
card_layout.setContentsMargins(6, 6, 6, 6)
|
card_layout.setContentsMargins(6, 6, 6, 6)
|
||||||
card_layout.setSpacing(4)
|
card_layout.setSpacing(4)
|
||||||
|
|
||||||
# 2. Add header matching the summary card type architecture
|
# 2. Add header matching the summary card type architecture
|
||||||
header = QLabel(f"{title_prefix} - Channels Matrix")
|
header = QLabel(f"{title_prefix} - Channels Matrix")
|
||||||
header.setStyleSheet("font-weight: bold; font-size: 10pt; border: none; color: #212529; background: transparent;")
|
header.setStyleSheet("font-weight: bold; font-size: 10pt; border: none; color: #212529; background: transparent;")
|
||||||
header.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
header.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||||
card_layout.addWidget(header)
|
card_layout.addWidget(header)
|
||||||
|
|
||||||
# 3. Nest this canvas instance cleanly inside the card frame layout
|
# 3. Nest this canvas instance cleanly inside the card frame layout
|
||||||
self.setParent(card_frame)
|
self.setParent(card_frame)
|
||||||
card_layout.addWidget(self)
|
card_layout.addWidget(self)
|
||||||
card_layout.addStretch(0)
|
card_layout.addStretch(0)
|
||||||
|
|
||||||
# 4. Make the remaining empty whitespace frame areas trigger the maximization loop
|
# 4. Make the remaining empty whitespace frame areas trigger the maximization loop
|
||||||
card_frame.mouseReleaseEvent = lambda event: self._open_fullscreen_grid() if event.button() == Qt.MouseButton.LeftButton else None
|
card_frame.mouseReleaseEvent = lambda event: self._open_fullscreen_grid() if event.button() == Qt.MouseButton.LeftButton else None
|
||||||
|
|
||||||
# Ensure underlying child mouse hits tunnel downstream properly to our parent container frame
|
# Ensure underlying child mouse hits tunnel downstream properly to our parent container frame
|
||||||
header.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents, True)
|
header.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents, True)
|
||||||
|
|
||||||
layout_to_attach_to.addWidget(card_frame)
|
layout_to_attach_to.addWidget(card_frame)
|
||||||
return card_frame
|
return card_frame
|
||||||
|
|
||||||
@@ -452,7 +459,7 @@ class InteractiveParticipantGridCanvas(FigureCanvas):
|
|||||||
clicked_subplot_data = self.axes_data_registry.get(event.inaxes)
|
clicked_subplot_data = self.axes_data_registry.get(event.inaxes)
|
||||||
if clicked_subplot_data:
|
if clicked_subplot_data:
|
||||||
self._open_expanded_view(
|
self._open_expanded_view(
|
||||||
clicked_subplot_data['channel_name'],
|
clicked_subplot_data['channel_name'],
|
||||||
clicked_subplot_data['data_list']
|
clicked_subplot_data['data_list']
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -463,30 +470,30 @@ class InteractiveParticipantGridCanvas(FigureCanvas):
|
|||||||
fullscreen_window = QWidget(None)
|
fullscreen_window = QWidget(None)
|
||||||
fullscreen_window.setWindowTitle("Participant Grid Monitor - Maximized View")
|
fullscreen_window.setWindowTitle("Participant Grid Monitor - Maximized View")
|
||||||
fullscreen_window.setWindowFlags(
|
fullscreen_window.setWindowFlags(
|
||||||
Qt.WindowType.Window |
|
Qt.WindowType.Window |
|
||||||
Qt.WindowType.WindowMinMaxButtonsHint |
|
Qt.WindowType.WindowMinMaxButtonsHint |
|
||||||
Qt.WindowType.WindowCloseButtonHint
|
Qt.WindowType.WindowCloseButtonHint
|
||||||
)
|
)
|
||||||
|
|
||||||
layout = QVBoxLayout(fullscreen_window)
|
layout = QVBoxLayout(fullscreen_window)
|
||||||
layout.setContentsMargins(0, 0, 0, 0)
|
layout.setContentsMargins(0, 0, 0, 0)
|
||||||
|
|
||||||
# Instantiate the copy
|
# Instantiate the copy
|
||||||
large_grid_canvas = InteractiveParticipantGridCanvas(
|
large_grid_canvas = InteractiveParticipantGridCanvas(
|
||||||
self.channels_data,
|
self.channels_data,
|
||||||
self.color_map,
|
self.color_map,
|
||||||
is_fullscreen_copy=True,
|
is_fullscreen_copy=True,
|
||||||
parent=fullscreen_window
|
parent=fullscreen_window
|
||||||
)
|
)
|
||||||
|
|
||||||
# Explicitly tag the new canvas object internally to block further clicks
|
# Explicitly tag the new canvas object internally to block further clicks
|
||||||
large_grid_canvas._is_fullscreen_flag_set = True
|
large_grid_canvas._is_fullscreen_flag_set = True
|
||||||
|
|
||||||
layout.addWidget(large_grid_canvas)
|
layout.addWidget(large_grid_canvas)
|
||||||
|
|
||||||
# Open non-modally so it populates the taskbar and matches OS window behaviors
|
# Open non-modally so it populates the taskbar and matches OS window behaviors
|
||||||
fullscreen_window.showMaximized()
|
fullscreen_window.showMaximized()
|
||||||
|
|
||||||
# Keep a reference alive on the source canvas so Python doesn't garbage collect the window
|
# Keep a reference alive on the source canvas so Python doesn't garbage collect the window
|
||||||
if not hasattr(self, '_fullscreen_refs'):
|
if not hasattr(self, '_fullscreen_refs'):
|
||||||
self._fullscreen_refs = []
|
self._fullscreen_refs = []
|
||||||
@@ -494,16 +501,16 @@ class InteractiveParticipantGridCanvas(FigureCanvas):
|
|||||||
self._fullscreen_refs.append(fullscreen_window)
|
self._fullscreen_refs.append(fullscreen_window)
|
||||||
|
|
||||||
|
|
||||||
def _calculate_total_brodmann_profile(self, channels_data: Dict[str, Any]):
|
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)
|
||||||
|
|
||||||
if num_channels == 0:
|
if num_channels == 0:
|
||||||
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']
|
||||||
@@ -532,19 +539,19 @@ 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)
|
||||||
popup.setWindowTitle(f"Channel Specificity Detail - {channel_name}")
|
popup.setWindowTitle(f"Channel Specificity Detail - {channel_name}")
|
||||||
|
|
||||||
# 2. Add standard window control behaviors
|
# 2. Add standard window control behaviors
|
||||||
popup.setWindowFlags(
|
popup.setWindowFlags(
|
||||||
Qt.WindowType.Window |
|
Qt.WindowType.Window |
|
||||||
Qt.WindowType.WindowMinMaxButtonsHint |
|
Qt.WindowType.WindowMinMaxButtonsHint |
|
||||||
Qt.WindowType.WindowCloseButtonHint
|
Qt.WindowType.WindowCloseButtonHint
|
||||||
)
|
)
|
||||||
|
|
||||||
# 3. Build layout out exactly as before
|
# 3. Build layout out exactly as before
|
||||||
layout = QVBoxLayout(popup)
|
layout = QVBoxLayout(popup)
|
||||||
layout.setContentsMargins(0, 0, 0, 0) # Strip extra outer layout spacing
|
layout.setContentsMargins(0, 0, 0, 0) # Strip extra outer layout spacing
|
||||||
@@ -552,23 +559,23 @@ class InteractiveParticipantGridCanvas(FigureCanvas):
|
|||||||
target_png_path = resource_path("images/brain.png")
|
target_png_path = resource_path("images/brain.png")
|
||||||
|
|
||||||
expanded_canvas = StaticChannelCanvas(
|
expanded_canvas = StaticChannelCanvas(
|
||||||
channel_name,
|
channel_name,
|
||||||
data_list,
|
data_list,
|
||||||
self.color_map,
|
self.color_map,
|
||||||
image_path=target_png_path,
|
image_path=target_png_path,
|
||||||
parent=popup
|
parent=popup
|
||||||
)
|
)
|
||||||
|
|
||||||
layout.addWidget(expanded_canvas)
|
layout.addWidget(expanded_canvas)
|
||||||
popup.resize(900, 520)
|
popup.resize(900, 520)
|
||||||
|
|
||||||
# 4. Display non-modally
|
# 4. Display non-modally
|
||||||
popup.show()
|
popup.show()
|
||||||
|
|
||||||
# 5. Keep the reference alive so Python doesn't garbage collect it
|
# 5. Keep the reference alive so Python doesn't garbage collect it
|
||||||
if not hasattr(self, '_open_popups'):
|
if not hasattr(self, '_open_popups'):
|
||||||
self._open_popups = []
|
self._open_popups = []
|
||||||
|
|
||||||
# Clean up closed windows from our tracking list to save memory
|
# Clean up closed windows from our tracking list to save memory
|
||||||
self._open_popups = [w for w in self._open_popups if w.isVisible()]
|
self._open_popups = [w for w in self._open_popups if w.isVisible()]
|
||||||
self._open_popups.append(popup)
|
self._open_popups.append(popup)
|
||||||
@@ -578,7 +585,7 @@ class InteractiveParticipantGridCanvas(FigureCanvas):
|
|||||||
"""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)
|
||||||
|
|
||||||
# 2. Create a styled container card frame
|
# 2. Create a styled container card frame
|
||||||
card_frame = QFrame()
|
card_frame = QFrame()
|
||||||
card_frame.setFrameShape(QFrame.Shape.StyledPanel)
|
card_frame.setFrameShape(QFrame.Shape.StyledPanel)
|
||||||
@@ -593,19 +600,19 @@ class InteractiveParticipantGridCanvas(FigureCanvas):
|
|||||||
background-color: #f8f9fa; /* Slightly shifts background color on hover */
|
background-color: #f8f9fa; /* Slightly shifts background color on hover */
|
||||||
}
|
}
|
||||||
""")
|
""")
|
||||||
|
|
||||||
card_layout = QVBoxLayout(card_frame)
|
card_layout = QVBoxLayout(card_frame)
|
||||||
card_layout.setContentsMargins(4, 4, 4, 4)
|
card_layout.setContentsMargins(4, 4, 4, 4)
|
||||||
card_layout.setSpacing(2)
|
card_layout.setSpacing(2)
|
||||||
|
|
||||||
# Add a clear section header label containing the specific participant identity
|
# Add a clear section header label containing the specific participant identity
|
||||||
header = QLabel(f"{title_prefix} - Total Profile")
|
header = QLabel(f"{title_prefix} - Total Profile")
|
||||||
header.setStyleSheet("font-weight: bold; font-size: 10pt; border: none; color: #212529;")
|
header.setStyleSheet("font-weight: bold; font-size: 10pt; border: none; color: #212529;")
|
||||||
header.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
header.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||||
card_layout.addWidget(header)
|
card_layout.addWidget(header)
|
||||||
|
|
||||||
target_png_path = resource_path("images/brain.png")
|
target_png_path = resource_path("images/brain.png")
|
||||||
|
|
||||||
# 3. Instantiate the canvas with a custom size flag or constraint
|
# 3. Instantiate the canvas with a custom size flag or constraint
|
||||||
# Adjust your StaticChannelCanvas __init__ to check if it should render in 'compact' mode
|
# Adjust your StaticChannelCanvas __init__ to check if it should render in 'compact' mode
|
||||||
summary_canvas = StaticChannelCanvas(
|
summary_canvas = StaticChannelCanvas(
|
||||||
@@ -615,7 +622,7 @@ class InteractiveParticipantGridCanvas(FigureCanvas):
|
|||||||
image_path=target_png_path,
|
image_path=target_png_path,
|
||||||
parent=card_frame,
|
parent=card_frame,
|
||||||
)
|
)
|
||||||
|
|
||||||
# --- CRITICAL: SHRINK MATPLOTLIB FIGURE ELEMENTS FOR THE EMBEDDED VIEWER ---
|
# --- CRITICAL: SHRINK MATPLOTLIB FIGURE ELEMENTS FOR THE EMBEDDED VIEWER ---
|
||||||
# Scale down the underlying canvas container so it doesn't balloon the layout grid
|
# Scale down the underlying canvas container so it doesn't balloon the layout grid
|
||||||
if hasattr(summary_canvas, 'fig'):
|
if hasattr(summary_canvas, 'fig'):
|
||||||
@@ -625,7 +632,7 @@ class InteractiveParticipantGridCanvas(FigureCanvas):
|
|||||||
for text in ax.texts:
|
for text in ax.texts:
|
||||||
text.set_fontsize(6)
|
text.set_fontsize(6)
|
||||||
summary_canvas.draw()
|
summary_canvas.draw()
|
||||||
|
|
||||||
summary_canvas.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred)
|
summary_canvas.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred)
|
||||||
card_layout.addWidget(summary_canvas)
|
card_layout.addWidget(summary_canvas)
|
||||||
card_layout.addStretch(0)
|
card_layout.addStretch(0)
|
||||||
@@ -634,9 +641,9 @@ class InteractiveParticipantGridCanvas(FigureCanvas):
|
|||||||
# 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)
|
||||||
|
|
||||||
card_frame.mouseReleaseEvent = handle_card_click
|
card_frame.mouseReleaseEvent = handle_card_click
|
||||||
|
|
||||||
# Prevent clicks on the text/child elements from being swallowed up instead of passing to frame
|
# Prevent clicks on the text/child elements from being swallowed up instead of passing to frame
|
||||||
header.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents, True)
|
header.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents, True)
|
||||||
summary_canvas.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents, True)
|
summary_canvas.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents, True)
|
||||||
@@ -646,21 +653,21 @@ class InteractiveParticipantGridCanvas(FigureCanvas):
|
|||||||
return card_frame
|
return card_frame
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def create_legend_card(self, title_prefix: str) -> QFrame:
|
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; }")
|
||||||
|
|
||||||
layout = QVBoxLayout(card)
|
layout = QVBoxLayout(card)
|
||||||
layout.setContentsMargins(20, 20, 20, 20)
|
layout.setContentsMargins(20, 20, 20, 20)
|
||||||
layout.setSpacing(10)
|
layout.setSpacing(10)
|
||||||
|
|
||||||
header_label = QLabel(f"{title_prefix}\nLandmarks")
|
header_label = QLabel(f"{title_prefix}\nLandmarks")
|
||||||
header_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
header_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||||
header_label.setStyleSheet("font-size: 14px; font-weight: bold; color: #1a252f; border: none;")
|
header_label.setStyleSheet("font-size: 14px; font-weight: bold; color: #1a252f; border: none;")
|
||||||
layout.addWidget(header_label)
|
layout.addWidget(header_label)
|
||||||
layout.addSpacing(10)
|
layout.addSpacing(10)
|
||||||
|
|
||||||
scroll_area = QScrollArea()
|
scroll_area = QScrollArea()
|
||||||
scroll_area.setWidgetResizable(True)
|
scroll_area.setWidgetResizable(True)
|
||||||
scroll_area.setStyleSheet("QScrollArea { border: none; background: transparent; }")
|
scroll_area.setStyleSheet("QScrollArea { border: none; background: transparent; }")
|
||||||
@@ -669,76 +676,75 @@ class InteractiveParticipantGridCanvas(FigureCanvas):
|
|||||||
scroll_layout = QVBoxLayout(scroll_content)
|
scroll_layout = QVBoxLayout(scroll_content)
|
||||||
scroll_layout.setSpacing(6)
|
scroll_layout.setSpacing(6)
|
||||||
scroll_layout.setContentsMargins(0, 0, 0, 0)
|
scroll_layout.setContentsMargins(0, 0, 0, 0)
|
||||||
|
|
||||||
|
|
||||||
true_color_map = get_landmark_color_map()
|
true_color_map = get_landmark_color_map()
|
||||||
|
|
||||||
# Iterate over the sorted keys directly from your method
|
# Iterate over the sorted keys directly from your method
|
||||||
for landmark_text in true_color_map.keys():
|
for landmark_text in true_color_map.keys():
|
||||||
item_row = QHBoxLayout()
|
item_row = QHBoxLayout()
|
||||||
item_row.setSpacing(12)
|
item_row.setSpacing(12)
|
||||||
|
|
||||||
# Extract the RGBA tuple value assigned by matplotlib
|
# Extract the RGBA tuple value assigned by matplotlib
|
||||||
rgba = true_color_map[landmark_text]
|
rgba = true_color_map[landmark_text]
|
||||||
# Convert float tuple components (0.0 - 1.0) to standard CSS integer scales (0 - 255)
|
# Convert float tuple components (0.0 - 1.0) to standard CSS integer scales (0 - 255)
|
||||||
r, g, b = int(rgba[0] * 255), int(rgba[1] * 255), int(rgba[2] * 255)
|
r, g, b = int(rgba[0] * 255), int(rgba[1] * 255), int(rgba[2] * 255)
|
||||||
color_hex = f"rgb({r}, {g}, {b})"
|
color_hex = f"rgb({r}, {g}, {b})"
|
||||||
|
|
||||||
# Format display string nicely: "1 — Primary Somatosensory Cortex"
|
# Format display string nicely: "1 — Primary Somatosensory Cortex"
|
||||||
if " - " in landmark_text:
|
if " - " in landmark_text:
|
||||||
num, name = landmark_text.split(" - ", 1)
|
num, name = landmark_text.split(" - ", 1)
|
||||||
display_string = f"<b>{num}</b> — {name}"
|
display_string = f"<b>{num}</b> — {name}"
|
||||||
else:
|
else:
|
||||||
display_string = f"<b>{landmark_text}</b>"
|
display_string = f"<b>{landmark_text}</b>"
|
||||||
|
|
||||||
dot = QLabel()
|
dot = QLabel()
|
||||||
dot.setFixedSize(14, 14)
|
dot.setFixedSize(14, 14)
|
||||||
dot.setStyleSheet(f"background-color: {color_hex}; border-radius: 7px; border: none;")
|
dot.setStyleSheet(f"background-color: {color_hex}; border-radius: 7px; border: none;")
|
||||||
|
|
||||||
label = QLabel(display_string)
|
label = QLabel(display_string)
|
||||||
label.setStyleSheet("font-size: 12px; color: #343a40; border: none;")
|
label.setStyleSheet("font-size: 12px; color: #343a40; border: none;")
|
||||||
|
|
||||||
item_row.addWidget(dot)
|
item_row.addWidget(dot)
|
||||||
item_row.addWidget(label, 1)
|
item_row.addWidget(label, 1)
|
||||||
scroll_layout.addLayout(item_row)
|
scroll_layout.addLayout(item_row)
|
||||||
|
|
||||||
scroll_area.setWidget(scroll_content)
|
scroll_area.setWidget(scroll_content)
|
||||||
layout.addWidget(scroll_area)
|
layout.addWidget(scroll_area)
|
||||||
return card
|
return card
|
||||||
|
|
||||||
|
|
||||||
def _open_expanded_summary_window(self, title_prefix: str, summary_data: List[Any]) -> None:
|
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}")
|
||||||
popup.setWindowFlags(
|
popup.setWindowFlags(
|
||||||
Qt.WindowType.Window |
|
Qt.WindowType.Window |
|
||||||
Qt.WindowType.WindowMinMaxButtonsHint |
|
Qt.WindowType.WindowMinMaxButtonsHint |
|
||||||
Qt.WindowType.WindowCloseButtonHint
|
Qt.WindowType.WindowCloseButtonHint
|
||||||
)
|
)
|
||||||
|
|
||||||
layout = QVBoxLayout(popup)
|
layout = QVBoxLayout(popup)
|
||||||
layout.setContentsMargins(10, 10, 10, 10)
|
layout.setContentsMargins(10, 10, 10, 10)
|
||||||
|
|
||||||
target_png_path = resource_path("images/brain.png")
|
target_png_path = resource_path("images/brain.png")
|
||||||
|
|
||||||
# This one renders full size (900x520) for analytical reading
|
# This one renders full size (900x520) for analytical reading
|
||||||
expanded_canvas = StaticChannelCanvas(
|
expanded_canvas = StaticChannelCanvas(
|
||||||
f"{title_prefix} - All Channels Aggregated",
|
f"{title_prefix} - All Channels Aggregated",
|
||||||
summary_data,
|
summary_data,
|
||||||
self.color_map,
|
self.color_map,
|
||||||
image_path=target_png_path,
|
image_path=target_png_path,
|
||||||
parent=popup,
|
parent=popup,
|
||||||
)
|
)
|
||||||
|
|
||||||
layout.addWidget(expanded_canvas)
|
layout.addWidget(expanded_canvas)
|
||||||
popup.resize(950, 550)
|
popup.resize(950, 550)
|
||||||
popup.show()
|
popup.show()
|
||||||
|
|
||||||
if not hasattr(self, '_summary_popups'):
|
if not hasattr(self, '_summary_popups'):
|
||||||
self._summary_popups = []
|
self._summary_popups = []
|
||||||
self._summary_popups.append(popup)
|
self._summary_popups.append(popup)
|
||||||
|
|
||||||
|
|
||||||
class ProcessOrchestrator(QObject):
|
class ProcessOrchestrator(QObject):
|
||||||
# Fires when Manager + Processes are completely ready
|
# Fires when Manager + Processes are completely ready
|
||||||
@@ -746,12 +752,13 @@ class ProcessOrchestrator(QObject):
|
|||||||
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,
|
def __init__(
|
||||||
selected_files,
|
self,
|
||||||
haemo_dict: dict[str, BaseRaw],
|
selected_files: list[str],
|
||||||
worker_func
|
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
|
||||||
@@ -786,7 +793,7 @@ class ParticipantFoldChannelsWidget(FlaresBaseWidget):
|
|||||||
haemo_dict: dict[str | Path, BaseRaw],
|
haemo_dict: dict[str | Path, BaseRaw],
|
||||||
cha_dict: dict[str, DataFrame]
|
cha_dict: dict[str, DataFrame]
|
||||||
) -> None:
|
) -> 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
|
||||||
@@ -807,12 +814,9 @@ class ParticipantFoldChannelsWidget(FlaresBaseWidget):
|
|||||||
|
|
||||||
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)
|
||||||
@@ -835,7 +839,7 @@ class ParticipantFoldChannelsWidget(FlaresBaseWidget):
|
|||||||
|
|
||||||
# 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()
|
||||||
|
|
||||||
# 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)
|
||||||
self.grid_layout.setContentsMargins(12, 12, 12, 12)
|
self.grid_layout.setContentsMargins(12, 12, 12, 12)
|
||||||
@@ -844,7 +848,7 @@ class ParticipantFoldChannelsWidget(FlaresBaseWidget):
|
|||||||
self.grid_layout.setColumnStretch(0, 1)
|
self.grid_layout.setColumnStretch(0, 1)
|
||||||
self.grid_layout.setColumnStretch(1, 1)
|
self.grid_layout.setColumnStretch(1, 1)
|
||||||
self.grid_layout.setColumnStretch(2, 1)
|
self.grid_layout.setColumnStretch(2, 1)
|
||||||
|
|
||||||
# 2. Force a uniform structural minimum width per column
|
# 2. Force a uniform structural minimum width per column
|
||||||
# This blocks the dense matrices from hogging space and compressing the summary cards
|
# This blocks the dense matrices from hogging space and compressing the summary cards
|
||||||
self.grid_layout.setColumnMinimumWidth(0, 400)
|
self.grid_layout.setColumnMinimumWidth(0, 400)
|
||||||
@@ -854,7 +858,7 @@ class ParticipantFoldChannelsWidget(FlaresBaseWidget):
|
|||||||
|
|
||||||
# Bind them together
|
# Bind them together
|
||||||
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.main_layout.addWidget(self.scroll_area)
|
self.main_layout.addWidget(self.scroll_area)
|
||||||
|
|
||||||
@@ -865,12 +869,12 @@ class ParticipantFoldChannelsWidget(FlaresBaseWidget):
|
|||||||
def show_fold_images(self):
|
def show_fold_images(self):
|
||||||
selected_display_names = self._get_checked_items(self.participant_dropdown)
|
selected_display_names = self._get_checked_items(self.participant_dropdown)
|
||||||
selected_indexes = [int(s.split(" ")[0]) for s in self._get_checked_items(self.image_index_dropdown)]
|
selected_indexes = [int(s.split(" ")[0]) for s in self._get_checked_items(self.image_index_dropdown)]
|
||||||
|
|
||||||
if not selected_display_names or 0 not in selected_indexes:
|
if not selected_display_names or 0 not in selected_indexes:
|
||||||
return
|
return
|
||||||
selected_files = [path for path, label in self.participant_map.items()
|
selected_files = [path for path, label in self.participant_map.items()
|
||||||
if f"{label} ({os.path.basename(path)})" in selected_display_names]
|
if f"{label} ({os.path.basename(path)})" in selected_display_names]
|
||||||
|
|
||||||
while self.grid_layout.count():
|
while self.grid_layout.count():
|
||||||
item = self.grid_layout.takeAt(0)
|
item = self.grid_layout.takeAt(0)
|
||||||
widget = item.widget()
|
widget = item.widget()
|
||||||
@@ -885,16 +889,11 @@ class ParticipantFoldChannelsWidget(FlaresBaseWidget):
|
|||||||
# Dig out the exact channels list length matching your loop engine logic
|
# Dig out the exact channels list length matching your loop engine logic
|
||||||
hbo_channels = getattr(raw_data.copy().pick(picks='hbo'), "ch_names", [])
|
hbo_channels = getattr(raw_data.copy().pick(picks='hbo'), "ch_names", [])
|
||||||
total_channels = len(hbo_channels) if hbo_channels else 1
|
total_channels = len(hbo_channels) if hbo_channels else 1
|
||||||
|
|
||||||
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()
|
||||||
@@ -914,16 +913,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(
|
def on_orchestration_success(
|
||||||
self,
|
self,
|
||||||
manager: Any,
|
manager: Any,
|
||||||
result_queue: Any,
|
result_queue: Any,
|
||||||
progress_queue: Any,
|
progress_queue: Any,
|
||||||
active_processes: List[Any]
|
active_processes: List[Any]
|
||||||
) -> None:
|
) -> 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
|
||||||
@@ -949,12 +947,12 @@ class ParticipantFoldChannelsWidget(FlaresBaseWidget):
|
|||||||
|
|
||||||
while not self.progress_queue.empty():
|
while not self.progress_queue.empty():
|
||||||
msg = self.progress_queue.get()
|
msg = self.progress_queue.get()
|
||||||
|
|
||||||
# CASE 1: Micro-step channel increment (Tuple tracking)
|
# CASE 1: Micro-step channel increment (Tuple tracking)
|
||||||
if isinstance(msg, tuple):
|
if isinstance(msg, tuple):
|
||||||
p_name, completed_channels = msg
|
p_name, completed_channels = msg
|
||||||
clean_key = str(p_name).strip()
|
clean_key = str(p_name).strip()
|
||||||
|
|
||||||
if hasattr(self, 'multi_progress') and clean_key in self.multi_progress.bars:
|
if hasattr(self, 'multi_progress') and clean_key in self.multi_progress.bars:
|
||||||
print(completed_channels)
|
print(completed_channels)
|
||||||
self.multi_progress.update_bar(clean_key, completed_channels)
|
self.multi_progress.update_bar(clean_key, completed_channels)
|
||||||
@@ -962,12 +960,12 @@ class ParticipantFoldChannelsWidget(FlaresBaseWidget):
|
|||||||
# DEBUG LOG: This tells us exactly why a bar isn't moving
|
# DEBUG LOG: This tells us exactly why a bar isn't moving
|
||||||
print(f"[DEBUG WARNING] Progress received for '{clean_key}' but no matching bar was found. Existing bars: {list(self.multi_progress.bars.keys())}")
|
print(f"[DEBUG WARNING] Progress received for '{clean_key}' but no matching bar was found. Existing bars: {list(self.multi_progress.bars.keys())}")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# CASE 2: Worker process crashed with an error string
|
# CASE 2: Worker process crashed with an error string
|
||||||
if isinstance(msg, str) and msg.startswith("ERROR"):
|
if isinstance(msg, str) and msg.startswith("ERROR"):
|
||||||
print(f"Worker Error: {msg}")
|
print(f"Worker Error: {msg}")
|
||||||
#self.completed_count += 1 # Count as finished so the UI doesn't hang
|
#self.completed_count += 1 # Count as finished so the UI doesn't hang
|
||||||
|
|
||||||
# CASE 3: Final clean text string signal indicating complete file closure
|
# CASE 3: Final clean text string signal indicating complete file closure
|
||||||
elif isinstance(msg, str):
|
elif isinstance(msg, str):
|
||||||
# Max out the progress bar visually on completion
|
# Max out the progress bar visually on completion
|
||||||
@@ -975,7 +973,7 @@ class ParticipantFoldChannelsWidget(FlaresBaseWidget):
|
|||||||
if msg in self.multi_progress.bars:
|
if msg in self.multi_progress.bars:
|
||||||
max_val = self.multi_progress.bars[msg].maximum()
|
max_val = self.multi_progress.bars[msg].maximum()
|
||||||
self.multi_progress.update_bar(msg, max_val)
|
self.multi_progress.update_bar(msg, max_val)
|
||||||
|
|
||||||
self.completed_count += 1 # Increment the master task tracker
|
self.completed_count += 1 # Increment the master task tracker
|
||||||
print(self.completed_count, time.time())
|
print(self.completed_count, time.time())
|
||||||
|
|
||||||
@@ -987,36 +985,36 @@ class ParticipantFoldChannelsWidget(FlaresBaseWidget):
|
|||||||
# Clean up when all processes are done
|
# Clean up when all processes are done
|
||||||
if self.completed_count >= len(self.active_processes):
|
if self.completed_count >= len(self.active_processes):
|
||||||
self.result_timer.stop()
|
self.result_timer.stop()
|
||||||
|
|
||||||
# Close the custom multi-progress window
|
# Close the custom multi-progress window
|
||||||
if hasattr(self, 'multi_progress'):
|
if hasattr(self, 'multi_progress'):
|
||||||
self.multi_progress.force_close()
|
self.multi_progress.force_close()
|
||||||
|
|
||||||
# Clean up processes
|
# Clean up processes
|
||||||
for p in self.active_processes:
|
for p in self.active_processes:
|
||||||
if p.is_alive():
|
if p.is_alive():
|
||||||
p.join(timeout=1) # Give it a second to wrap up
|
p.join(timeout=1) # Give it a second to wrap up
|
||||||
p.close() # Explicitly close the process object
|
p.close() # Explicitly close the process object
|
||||||
|
|
||||||
# Shut down the Manager process (the source of the 'rogue' process)
|
# Shut down the Manager process (the source of the 'rogue' process)
|
||||||
if hasattr(self, 'manager'):
|
if hasattr(self, 'manager'):
|
||||||
self.manager.shutdown()
|
self.manager.shutdown()
|
||||||
|
|
||||||
self.active_processes = []
|
self.active_processes = []
|
||||||
print("Processing fully complete. All resources released.")
|
print("Processing fully complete. All resources released.")
|
||||||
|
|
||||||
if hasattr(self, 'global_channels_data') and self.global_channels_data:
|
if hasattr(self, 'global_channels_data') and self.global_channels_data:
|
||||||
color_map = get_landmark_color_map()
|
color_map = get_landmark_color_map()
|
||||||
|
|
||||||
# We feed the entire channel pool directly to your existing canvas engine class
|
# We feed the entire channel pool directly to your existing canvas engine class
|
||||||
global_canvas = InteractiveParticipantGridCanvas(self.global_channels_data, color_map)
|
global_canvas = InteractiveParticipantGridCanvas(self.global_channels_data, color_map)
|
||||||
|
|
||||||
# Create the summary card using your exact visual method
|
# Create the summary card using your exact visual method
|
||||||
global_card = global_canvas.create_total_summary_card(
|
global_card = global_canvas.create_total_summary_card(
|
||||||
title_prefix="Grand Global Layout",
|
title_prefix="Grand Global Layout",
|
||||||
layout_to_attach_to=self.scroll_content_widget.layout()
|
layout_to_attach_to=self.scroll_content_widget.layout()
|
||||||
)
|
)
|
||||||
|
|
||||||
# Match your exact layout positioning logic to place it next in the grid
|
# Match your exact layout positioning logic to place it next in the grid
|
||||||
count = self.grid_layout.count() - 1
|
count = self.grid_layout.count() - 1
|
||||||
row = count // 3
|
row = count // 3
|
||||||
@@ -1031,9 +1029,9 @@ class ParticipantFoldChannelsWidget(FlaresBaseWidget):
|
|||||||
def handle_legend_click(event):
|
def handle_legend_click(event):
|
||||||
self.active_legend_window = StandaloneLegendDialog(global_canvas, legend_title, self)
|
self.active_legend_window = StandaloneLegendDialog(global_canvas, legend_title, self)
|
||||||
self.active_legend_window.show()
|
self.active_legend_window.show()
|
||||||
|
|
||||||
legend_card.mousePressEvent = handle_legend_click
|
legend_card.mousePressEvent = handle_legend_click
|
||||||
|
|
||||||
count = self.grid_layout.count()
|
count = self.grid_layout.count()
|
||||||
row = count // 3
|
row = count // 3
|
||||||
col = count % 3
|
col = count % 3
|
||||||
@@ -1045,7 +1043,7 @@ class ParticipantFoldChannelsWidget(FlaresBaseWidget):
|
|||||||
|
|
||||||
for file_path, channels_data in result_dict.items():
|
for file_path, channels_data in result_dict.items():
|
||||||
participant_label = self.participant_map.get(file_path, os.path.basename(file_path))
|
participant_label = self.participant_map.get(file_path, os.path.basename(file_path))
|
||||||
|
|
||||||
if hasattr(self, 'global_channels_data'):
|
if hasattr(self, 'global_channels_data'):
|
||||||
for ch_name, ch_data in channels_data.items():
|
for ch_name, ch_data in channels_data.items():
|
||||||
unique_key = f"{participant_label}_{ch_name}"
|
unique_key = f"{participant_label}_{ch_name}"
|
||||||
@@ -1060,19 +1058,19 @@ class ParticipantFoldChannelsWidget(FlaresBaseWidget):
|
|||||||
title_prefix=participant_label,
|
title_prefix=participant_label,
|
||||||
layout_to_attach_to=self.scroll_content_widget.layout() # Maps directly to your grid layout
|
layout_to_attach_to=self.scroll_content_widget.layout() # Maps directly to your grid layout
|
||||||
)
|
)
|
||||||
|
|
||||||
# Pin Card A to the sequential grid coordinate tracker layout
|
# Pin Card A to the sequential grid coordinate tracker layout
|
||||||
count = self.grid_layout.count() - 1 # Subtract 1 because widget registration steps index values forward
|
count = self.grid_layout.count() - 1 # Subtract 1 because widget registration steps index values forward
|
||||||
row = count // 3
|
row = count // 3
|
||||||
col = count % 3
|
col = count % 3
|
||||||
self.grid_layout.addWidget(matrix_card, row, col)
|
self.grid_layout.addWidget(matrix_card, row, col)
|
||||||
|
|
||||||
# 3. Build Card B (Total Summary Profile Frame Layout)
|
# 3. Build Card B (Total Summary Profile Frame Layout)
|
||||||
summary_card = participant_grid_canvas.create_total_summary_card(
|
summary_card = participant_grid_canvas.create_total_summary_card(
|
||||||
title_prefix=participant_label,
|
title_prefix=participant_label,
|
||||||
layout_to_attach_to=self.scroll_content_widget.layout()
|
layout_to_attach_to=self.scroll_content_widget.layout()
|
||||||
)
|
)
|
||||||
|
|
||||||
# Pin Card B directly next into the 3-column processing loop matrix layout tracker
|
# Pin Card B directly next into the 3-column processing loop matrix layout tracker
|
||||||
count = self.grid_layout.count() - 1
|
count = self.grid_layout.count() - 1
|
||||||
row = count // 3
|
row = count // 3
|
||||||
@@ -1100,21 +1098,21 @@ class ParticipantFoldChannelsWidget(FlaresBaseWidget):
|
|||||||
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)
|
||||||
legend_label = QLabel()
|
legend_label = QLabel()
|
||||||
# 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)
|
||||||
self.grid_layout.addWidget(container, 0, 0)
|
self.grid_layout.addWidget(container, 0, 0)
|
||||||
@@ -83,7 +83,7 @@ class ParticipantFunctionalConnectivityWidget(ParticipantUIMixin, FlaresBaseWidg
|
|||||||
haemo_dict: dict[str, BaseRaw],
|
haemo_dict: dict[str, BaseRaw],
|
||||||
epochs_dict: dict[str, Epochs],
|
epochs_dict: dict[str, Epochs],
|
||||||
) -> None:
|
) -> None:
|
||||||
|
|
||||||
super().__init__("ParticipantFunctionalConnectivity")
|
super().__init__("ParticipantFunctionalConnectivity")
|
||||||
self.setWindowTitle(f"Participant Functional Connectivity Viewer [BETA] - {APP_NAME.upper()}")
|
self.setWindowTitle(f"Participant Functional Connectivity Viewer [BETA] - {APP_NAME.upper()}")
|
||||||
self.haemo_dict = haemo_dict
|
self.haemo_dict = haemo_dict
|
||||||
@@ -101,28 +101,28 @@ class ParticipantFunctionalConnectivityWidget(ParticipantUIMixin, FlaresBaseWidg
|
|||||||
if request is None:
|
if request is None:
|
||||||
return
|
return
|
||||||
|
|
||||||
(selected_event, selected_file_paths, selected_indexes, raw_params) = request
|
(selected_event, selected_file_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)
|
||||||
|
|
||||||
# Pass the necessary arguments to each method
|
# Pass the necessary arguments to each method
|
||||||
for file_path in selected_file_paths:
|
for file_path in selected_file_paths:
|
||||||
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 or epochs_obj is None:
|
if haemo_obj is None or epochs_obj is None:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
if selected_event:
|
if selected_event:
|
||||||
raw_annotations = getattr(haemo_obj, "annotations", None)
|
raw_annotations = getattr(haemo_obj, "annotations", None)
|
||||||
|
|
||||||
if raw_annotations is not None:
|
if raw_annotations is not None:
|
||||||
annotations = cast(Annotations, raw_annotations)
|
annotations = cast(Annotations, raw_annotations)
|
||||||
descriptions = cast(list[str], list(annotations.description))
|
descriptions = cast(list[str], list(annotations.description))
|
||||||
participant_events: set[str] = set(descriptions)
|
participant_events: set[str] = set(descriptions)
|
||||||
else:
|
else:
|
||||||
participant_events: set[str] = set()
|
participant_events: set[str] = set()
|
||||||
|
|
||||||
if selected_event not in participant_events:
|
if selected_event not in participant_events:
|
||||||
print(f"Skipping {self.participant_map[file_path]}: Event '{selected_event}' not found.")
|
print(f"Skipping {self.participant_map[file_path]}: Event '{selected_event}' not found.")
|
||||||
continue
|
continue
|
||||||
@@ -135,7 +135,7 @@ class ParticipantFunctionalConnectivityWidget(ParticipantUIMixin, FlaresBaseWidg
|
|||||||
vmin = params.get("vmin", 0.9)
|
vmin = params.get("vmin", 0.9)
|
||||||
fmin = params.get("fmin", 0.04)
|
fmin = params.get("fmin", 0.04)
|
||||||
fmax = params.get("fmax", 0.2)
|
fmax = params.get("fmax", 0.2)
|
||||||
|
|
||||||
functional_connectivity_spectral_epochs(epochs=epochs_obj, n_lines=n_lines, vmin=vmin, fmin=fmin, fmax=fmax, method=method)
|
functional_connectivity_spectral_epochs(epochs=epochs_obj, n_lines=n_lines, vmin=vmin, fmin=fmin, fmax=fmax, method=method)
|
||||||
|
|
||||||
elif idx == 1:
|
elif idx == 1:
|
||||||
|
|||||||
@@ -25,11 +25,11 @@ 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],
|
||||||
fig_bytes_dict: dict[str, dict[str, bytes]]
|
fig_bytes_dict: dict[str, dict[str, bytes]]
|
||||||
) -> None:
|
) -> None:
|
||||||
|
|
||||||
super().__init__("ParticipantImage")
|
super().__init__("ParticipantImage")
|
||||||
self.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose)
|
self.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose)
|
||||||
self.setWindowTitle(f"Participant Image Viewer - {APP_NAME.upper()}")
|
self.setWindowTitle(f"Participant Image Viewer - {APP_NAME.upper()}")
|
||||||
@@ -52,7 +52,7 @@ class ParticipantImageViewerWidget(FlaresBaseWidget):
|
|||||||
|
|
||||||
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)
|
||||||
|
|
||||||
first_fig_dict = next(iter(self.fig_bytes_dict.values()))
|
first_fig_dict = next(iter(self.fig_bytes_dict.values()))
|
||||||
image_label_items = list(first_fig_dict.keys())
|
image_label_items = list(first_fig_dict.keys())
|
||||||
|
|
||||||
|
|||||||
@@ -14,11 +14,12 @@ 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 QPalette, QStandardItemModel, QStandardItem, QPixmap, QIntValidator, QDoubleValidator
|
from PySide6.QtGui import 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
|
||||||
|
|
||||||
|
|
||||||
class FullClickComboBox(QComboBox):
|
class FullClickComboBox(QComboBox):
|
||||||
def __init__(self, parent=None):
|
def __init__(self, parent=None):
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
@@ -28,16 +29,16 @@ class FullClickComboBox(QComboBox):
|
|||||||
|
|
||||||
def eventFilter(self, obj, event):
|
def eventFilter(self, obj, event):
|
||||||
if obj == self.lineEdit():
|
if obj == self.lineEdit():
|
||||||
|
|
||||||
if event.type() == QEvent.MouseButtonPress:
|
if event.type() == QEvent.MouseButtonPress:
|
||||||
return True
|
return True
|
||||||
|
|
||||||
if event.type() == QEvent.MouseButtonRelease:
|
if event.type() == QEvent.MouseButtonRelease:
|
||||||
self.showPopup()
|
self.showPopup()
|
||||||
return True
|
return True
|
||||||
|
|
||||||
return super().eventFilter(obj, event)
|
return super().eventFilter(obj, event)
|
||||||
|
|
||||||
|
|
||||||
class ClickableLabel(QLabel):
|
class ClickableLabel(QLabel):
|
||||||
def __init__(self, full_pixmap: QPixmap, thumbnail_pixmap: QPixmap):
|
def __init__(self, full_pixmap: QPixmap, thumbnail_pixmap: QPixmap):
|
||||||
@@ -47,7 +48,7 @@ class ClickableLabel(QLabel):
|
|||||||
self.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
self.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||||
self.setFixedSize(thumbnail_pixmap.size())
|
self.setFixedSize(thumbnail_pixmap.size())
|
||||||
self.setStyleSheet("border: 1px solid gray; margin: 2px;")
|
self.setStyleSheet("border: 1px solid gray; margin: 2px;")
|
||||||
|
|
||||||
def mousePressEvent(self, event):
|
def mousePressEvent(self, event):
|
||||||
viewer = QWidget()
|
viewer = QWidget()
|
||||||
viewer.setWindowTitle(f"Expanded View - {APP_NAME.upper()}")
|
viewer.setWindowTitle(f"Expanded View - {APP_NAME.upper()}")
|
||||||
@@ -100,12 +101,12 @@ class ParameterInputDialog(QDialog):
|
|||||||
self.scroll.setMaximumHeight(800)
|
self.scroll.setMaximumHeight(800)
|
||||||
self.scroll.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded)
|
self.scroll.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded)
|
||||||
self.scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
|
self.scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
|
||||||
|
|
||||||
self.scroll_content = QWidget()
|
self.scroll_content = QWidget()
|
||||||
self.scroll_layout = QVBoxLayout(self.scroll_content)
|
self.scroll_layout = QVBoxLayout(self.scroll_content)
|
||||||
self.scroll_layout.setContentsMargins(10, 10, 10, 10)
|
self.scroll_layout.setContentsMargins(10, 10, 10, 10)
|
||||||
self.scroll.setWidget(self.scroll_content)
|
self.scroll.setWidget(self.scroll_content)
|
||||||
|
|
||||||
main_layout.addWidget(self.scroll)
|
main_layout.addWidget(self.scroll)
|
||||||
|
|
||||||
for idx, param_list in params_dict.items():
|
for idx, param_list in params_dict.items():
|
||||||
@@ -123,7 +124,7 @@ class ParameterInputDialog(QDialog):
|
|||||||
# Convert options to string just in case they aren't
|
# Convert options to string just in case they aren't
|
||||||
options = [str(opt) for opt in param_info.get("options", [])]
|
options = [str(opt) for opt in param_info.get("options", [])]
|
||||||
widget.addItems(options)
|
widget.addItems(options)
|
||||||
|
|
||||||
# Set default choice if it exists in the options list
|
# Set default choice if it exists in the options list
|
||||||
default_val = str(param_info.get("default", ""))
|
default_val = str(param_info.get("default", ""))
|
||||||
if default_val in options:
|
if default_val in options:
|
||||||
@@ -231,14 +232,14 @@ class FullClickComboBox(QComboBox):
|
|||||||
|
|
||||||
def eventFilter(self, obj, event):
|
def eventFilter(self, obj, event):
|
||||||
if obj == self.lineEdit():
|
if obj == self.lineEdit():
|
||||||
|
|
||||||
if event.type() == QEvent.MouseButtonPress:
|
if event.type() == QEvent.MouseButtonPress:
|
||||||
return True
|
return True
|
||||||
|
|
||||||
if event.type() == QEvent.MouseButtonRelease:
|
if event.type() == QEvent.MouseButtonRelease:
|
||||||
self.showPopup()
|
self.showPopup()
|
||||||
return True
|
return True
|
||||||
|
|
||||||
return super().eventFilter(obj, event)
|
return super().eventFilter(obj, event)
|
||||||
|
|
||||||
|
|
||||||
@@ -249,32 +250,32 @@ class FilePickerWidget(QWidget):
|
|||||||
|
|
||||||
def __init__(self, default_val="", file_filter="JSON Files (*.json);;All Files (*)", parent=None):
|
def __init__(self, default_val="", file_filter="JSON Files (*.json);;All Files (*)", parent=None):
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
|
|
||||||
# Inline layout to hold line edit and button side-by-side
|
# Inline layout to hold line edit and button side-by-side
|
||||||
layout = QHBoxLayout(self)
|
layout = QHBoxLayout(self)
|
||||||
layout.setContentsMargins(0, 0, 0, 0)
|
layout.setContentsMargins(0, 0, 0, 0)
|
||||||
layout.setSpacing(5)
|
layout.setSpacing(5)
|
||||||
|
|
||||||
self.line_edit = QLineEdit()
|
self.line_edit = QLineEdit()
|
||||||
self.line_edit.setText(str(default_val))
|
self.line_edit.setText(str(default_val))
|
||||||
self.line_edit.setPlaceholderText("Select file path...")
|
self.line_edit.setPlaceholderText("Select file path...")
|
||||||
# Forward internal text updates out through our custom component signal
|
# Forward internal text updates out through our custom component signal
|
||||||
self.line_edit.textChanged.connect(self.textChanged.emit)
|
self.line_edit.textChanged.connect(self.textChanged.emit)
|
||||||
|
|
||||||
self.browse_btn = QPushButton("Browse...")
|
self.browse_btn = QPushButton("Browse...")
|
||||||
self.browse_btn.clicked.connect(self.open_file_dialog)
|
self.browse_btn.clicked.connect(self.open_file_dialog)
|
||||||
|
|
||||||
layout.addWidget(self.line_edit)
|
layout.addWidget(self.line_edit)
|
||||||
layout.addWidget(self.browse_btn)
|
layout.addWidget(self.browse_btn)
|
||||||
|
|
||||||
self.file_filter = file_filter
|
self.file_filter = file_filter
|
||||||
|
|
||||||
def open_file_dialog(self):
|
def open_file_dialog(self):
|
||||||
# Open PySide6 native file browser
|
# Open PySide6 native file browser
|
||||||
file_path, _ = QFileDialog.getOpenFileName(
|
file_path, _ = QFileDialog.getOpenFileName(
|
||||||
self,
|
self,
|
||||||
"Select Configuration File",
|
"Select Configuration File",
|
||||||
self.line_edit.text(),
|
self.line_edit.text(),
|
||||||
self.file_filter
|
self.file_filter
|
||||||
)
|
)
|
||||||
if file_path:
|
if file_path:
|
||||||
@@ -308,7 +309,7 @@ class ParamSection(QWidget):
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
dirty_state_changed = Signal(bool)
|
dirty_state_changed = Signal(bool)
|
||||||
|
|
||||||
def __init__(self, section_data, global_widgets):
|
def __init__(self, section_data, global_widgets):
|
||||||
super().__init__()
|
super().__init__()
|
||||||
layout = QVBoxLayout()
|
layout = QVBoxLayout()
|
||||||
@@ -317,7 +318,7 @@ class ParamSection(QWidget):
|
|||||||
self.dependencies = []
|
self.dependencies = []
|
||||||
self.selected_path = None
|
self.selected_path = None
|
||||||
|
|
||||||
self.param_rows = []
|
self.param_rows = []
|
||||||
self.header_widgets = []
|
self.header_widgets = []
|
||||||
self.dirty_params = {}
|
self.dirty_params = {}
|
||||||
self._updating_checkstates = False
|
self._updating_checkstates = False
|
||||||
@@ -344,7 +345,7 @@ class ParamSection(QWidget):
|
|||||||
# Build label text and tooltips
|
# Build label text and tooltips
|
||||||
label_text = f"⚠️ {param_name}" if is_advanced else param_name
|
label_text = f"⚠️ {param_name}" if is_advanced else param_name
|
||||||
label = QLabel(label_text)
|
label = QLabel(label_text)
|
||||||
|
|
||||||
if is_advanced:
|
if is_advanced:
|
||||||
label.setToolTip(f"ADVANCED: {help_text}")
|
label.setToolTip(f"ADVANCED: {help_text}")
|
||||||
else:
|
else:
|
||||||
@@ -371,19 +372,19 @@ class ParamSection(QWidget):
|
|||||||
widget.setCurrentText(str(default_val))
|
widget.setCurrentText(str(default_val))
|
||||||
widget.currentTextChanged.connect(lambda _, p=param_name: self.check_if_changed(p))
|
widget.currentTextChanged.connect(lambda _, p=param_name: self.check_if_changed(p))
|
||||||
widget.currentTextChanged.connect(self.notify_global_update)
|
widget.currentTextChanged.connect(self.notify_global_update)
|
||||||
|
|
||||||
elif param_type == int or "int" in type_str:
|
elif param_type == int or "int" in type_str:
|
||||||
widget = QLineEdit()
|
widget = QLineEdit()
|
||||||
widget.setValidator(QIntValidator())
|
widget.setValidator(QIntValidator())
|
||||||
widget.setText(str(default_val))
|
widget.setText(str(default_val))
|
||||||
widget.textChanged.connect(lambda _, p=param_name: self.check_if_changed(p))
|
widget.textChanged.connect(lambda _, p=param_name: self.check_if_changed(p))
|
||||||
|
|
||||||
elif param_type == float or "float" in type_str:
|
elif param_type == float or "float" in type_str:
|
||||||
widget = QLineEdit()
|
widget = QLineEdit()
|
||||||
widget.setValidator(QDoubleValidator())
|
widget.setValidator(QDoubleValidator())
|
||||||
widget.setText(str(default_val))
|
widget.setText(str(default_val))
|
||||||
widget.textChanged.connect(lambda _, p=param_name: self.check_if_changed(p))
|
widget.textChanged.connect(lambda _, p=param_name: self.check_if_changed(p))
|
||||||
|
|
||||||
elif param_type == list or "list" in type_str:
|
elif param_type == list or "list" in type_str:
|
||||||
options = param.get("options", [])
|
options = param.get("options", [])
|
||||||
if param.get("exclusive", True):
|
if param.get("exclusive", True):
|
||||||
@@ -406,12 +407,12 @@ class ParamSection(QWidget):
|
|||||||
else:
|
else:
|
||||||
widget.setValue(15)
|
widget.setValue(15)
|
||||||
widget.valueChanged.connect(lambda _, p=param_name: self.check_if_changed(p))
|
widget.valueChanged.connect(lambda _, p=param_name: self.check_if_changed(p))
|
||||||
|
|
||||||
elif param_type == "json_file":
|
elif param_type == "json_file":
|
||||||
widget = FilePickerWidget(default_val=default_val, file_filter="JSON Files (*.json)")
|
widget = FilePickerWidget(default_val=default_val, file_filter="JSON Files (*.json)")
|
||||||
widget.textChanged.connect(lambda _, p=param_name: self.check_if_changed(p))
|
widget.textChanged.connect(lambda _, p=param_name: self.check_if_changed(p))
|
||||||
|
|
||||||
else:
|
else:
|
||||||
widget = QLineEdit()
|
widget = QLineEdit()
|
||||||
widget.setText(str(default_val))
|
widget.setText(str(default_val))
|
||||||
widget.textChanged.connect(lambda _, p=param_name: self.check_if_changed(p))
|
widget.textChanged.connect(lambda _, p=param_name: self.check_if_changed(p))
|
||||||
@@ -447,7 +448,7 @@ class ParamSection(QWidget):
|
|||||||
def update_theme_colors(self):
|
def update_theme_colors(self):
|
||||||
self.title_label.style().unpolish(self.title_label)
|
self.title_label.style().unpolish(self.title_label)
|
||||||
self.title_label.style().polish(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()
|
||||||
@@ -566,7 +567,7 @@ class ParamSection(QWidget):
|
|||||||
normal_items.append(item)
|
normal_items.append(item)
|
||||||
state = Qt.Checked if item.text() in defaults else Qt.Unchecked
|
state = Qt.Checked if item.text() in defaults else Qt.Unchecked
|
||||||
item.setCheckState(state)
|
item.setCheckState(state)
|
||||||
|
|
||||||
# Sync toggle select all item
|
# Sync toggle select all item
|
||||||
toggle_item = model.item(1)
|
toggle_item = model.item(1)
|
||||||
if toggle_item and normal_items:
|
if toggle_item and normal_items:
|
||||||
@@ -637,7 +638,7 @@ class ParamSection(QWidget):
|
|||||||
|
|
||||||
for hw in self.header_widgets:
|
for hw in self.header_widgets:
|
||||||
hw.setVisible(has_visible_rows)
|
hw.setVisible(has_visible_rows)
|
||||||
|
|
||||||
self.setVisible(has_visible_rows)
|
self.setVisible(has_visible_rows)
|
||||||
|
|
||||||
def has_any_changes(self):
|
def has_any_changes(self):
|
||||||
@@ -663,7 +664,7 @@ class ParamSection(QWidget):
|
|||||||
for cond in dep["conditions"]:
|
for cond in dep["conditions"]:
|
||||||
parent_name = cond.get("parent_name") or cond.get("parent")
|
parent_name = cond.get("parent_name") or cond.get("parent")
|
||||||
required_val = str(cond.get("depends_value") if "depends_value" in cond else cond.get("value", "True"))
|
required_val = str(cond.get("depends_value") if "depends_value" in cond else cond.get("value", "True"))
|
||||||
|
|
||||||
parent_info = self.widgets.get(parent_name)
|
parent_info = self.widgets.get(parent_name)
|
||||||
if not parent_info:
|
if not parent_info:
|
||||||
all_conditions_met = False
|
all_conditions_met = False
|
||||||
@@ -745,7 +746,7 @@ class ParamSection(QWidget):
|
|||||||
toggle_item.setCheckState(Qt.Checked if all_checked else Qt.Unchecked)
|
toggle_item.setCheckState(Qt.Checked if all_checked else Qt.Unchecked)
|
||||||
|
|
||||||
self._updating_checkstates = False
|
self._updating_checkstates = False
|
||||||
|
|
||||||
if param_name:
|
if param_name:
|
||||||
self.update_dropdown_label(param_name)
|
self.update_dropdown_label(param_name)
|
||||||
self.check_if_changed(param_name)
|
self.check_if_changed(param_name)
|
||||||
@@ -813,7 +814,7 @@ class ParamSection(QWidget):
|
|||||||
|
|
||||||
selected = self._get_checked_items(widget)
|
selected = self._get_checked_items(widget)
|
||||||
widget.lineEdit().setText(", ".join(selected) if selected else "<None Selected>")
|
widget.lineEdit().setText(", ".join(selected) if selected else "<None Selected>")
|
||||||
|
|
||||||
# def update_annotation_dropdown_from_loaded_files(self, bubble_widgets, button1):
|
# def update_annotation_dropdown_from_loaded_files(self, bubble_widgets, button1):
|
||||||
# file_paths = [bubble.file_path for bubble in bubble_widgets.values()]
|
# file_paths = [bubble.file_path for bubble in bubble_widgets.values()]
|
||||||
# if not file_paths:
|
# if not file_paths:
|
||||||
@@ -822,12 +823,12 @@ class ParamSection(QWidget):
|
|||||||
# # 1. Start the UI immediately
|
# # 1. Start the UI immediately
|
||||||
# progress = QProgressDialog("Accessing Workers...", "Cancel", 0, len(file_paths), self)
|
# progress = QProgressDialog("Accessing Workers...", "Cancel", 0, len(file_paths), self)
|
||||||
# progress.setWindowModality(Qt.WindowModality.WindowModal)
|
# progress.setWindowModality(Qt.WindowModality.WindowModal)
|
||||||
# progress.setMinimumDuration(0)
|
# progress.setMinimumDuration(0)
|
||||||
# progress.setValue(0)
|
# progress.setValue(0)
|
||||||
|
|
||||||
# # Force the UI to draw the window NOW before we start the loop
|
# # Force the UI to draw the window NOW before we start the loop
|
||||||
# progress.show()
|
# progress.show()
|
||||||
# QApplication.processEvents()
|
# QApplication.processEvents()
|
||||||
|
|
||||||
# annotation_sets = []
|
# annotation_sets = []
|
||||||
|
|
||||||
@@ -843,7 +844,7 @@ class ParamSection(QWidget):
|
|||||||
# # This call is now nearly instant because the process is already warm
|
# # This call is now nearly instant because the process is already warm
|
||||||
# future = self.file_executor.submit(_extract_annotations, path)
|
# future = self.file_executor.submit(_extract_annotations, path)
|
||||||
# try:
|
# try:
|
||||||
# labels_list = future.result()
|
# labels_list = future.result()
|
||||||
# if labels_list:
|
# if labels_list:
|
||||||
# annotation_sets.append(set(labels_list))
|
# annotation_sets.append(set(labels_list))
|
||||||
# except Exception as e:
|
# except Exception as e:
|
||||||
@@ -870,10 +871,10 @@ class ProgressBubble(QWidget):
|
|||||||
file_path (str): Associated file path stored with the bubble.
|
file_path (str): Associated file path stored with the bubble.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
clicked = Signal(object)
|
clicked = Signal(object)
|
||||||
rightClicked = Signal(object, QPoint)
|
rightClicked = Signal(object, QPoint)
|
||||||
|
|
||||||
def __init__(self, display_name, file_path):
|
def __init__(self, display_name, file_path):
|
||||||
super().__init__()
|
super().__init__()
|
||||||
|
|
||||||
@@ -964,7 +965,7 @@ class ProgressBubble(QWidget):
|
|||||||
else:
|
else:
|
||||||
rect.setStyleSheet("background-color: white; border: 1px solid gray;")
|
rect.setStyleSheet("background-color: white; border: 1px solid gray;")
|
||||||
|
|
||||||
|
|
||||||
def mark_cancelled(self):
|
def mark_cancelled(self):
|
||||||
for i, rect in enumerate(self.rects):
|
for i, rect in enumerate(self.rects):
|
||||||
if i < self.current_step:
|
if i < self.current_step:
|
||||||
@@ -1074,7 +1075,7 @@ class FlaresBaseWidget(QWidget):
|
|||||||
model.itemChanged.connect(on_item_changed)
|
model.itemChanged.connect(on_item_changed)
|
||||||
combo.setInsertPolicy(QComboBox.NoInsert)
|
combo.setInsertPolicy(QComboBox.NoInsert)
|
||||||
return combo
|
return combo
|
||||||
|
|
||||||
|
|
||||||
# def _get_checked_items(self, combo):
|
# def _get_checked_items(self, combo):
|
||||||
# model = combo.model()
|
# model = combo.model()
|
||||||
@@ -1084,20 +1085,20 @@ class FlaresBaseWidget(QWidget):
|
|||||||
# if item.checkState() == Qt.Checked:
|
# if item.checkState() == Qt.Checked:
|
||||||
# checked.append(item.text())
|
# checked.append(item.text())
|
||||||
# return checked
|
# return checked
|
||||||
|
|
||||||
def _get_checked_items(
|
def _get_checked_items(
|
||||||
self,
|
self,
|
||||||
combo: QComboBox | None = None
|
combo: QComboBox | None = None
|
||||||
) -> list[str]:
|
) -> list[str]:
|
||||||
|
|
||||||
target = combo if combo is not None else getattr(self, 'participant_dropdown', None)
|
target = combo if combo is not None else getattr(self, 'participant_dropdown', None)
|
||||||
|
|
||||||
if target is None or target.model() is None:
|
if target is None or target.model() is None:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
model = target.model()
|
model = target.model()
|
||||||
checked_items = []
|
checked_items = []
|
||||||
|
|
||||||
# Exclusion list: any item text that should never be treated as data
|
# Exclusion list: any item text that should never be treated as data
|
||||||
forbidden = {"Toggle All", "Select All", "<None Selected>", "Toggle"}
|
forbidden = {"Toggle All", "Select All", "<None Selected>", "Toggle"}
|
||||||
|
|
||||||
@@ -1108,7 +1109,7 @@ class FlaresBaseWidget(QWidget):
|
|||||||
# Only add if it's not a 'UI control' item
|
# Only add if it's not a 'UI control' item
|
||||||
if text not in forbidden and not text.startswith("Toggle"):
|
if text not in forbidden and not text.startswith("Toggle"):
|
||||||
checked_items.append(text)
|
checked_items.append(text)
|
||||||
|
|
||||||
return checked_items
|
return checked_items
|
||||||
|
|
||||||
|
|
||||||
@@ -1125,7 +1126,7 @@ class FlaresBaseWidget(QWidget):
|
|||||||
|
|
||||||
# 1. Figure out which dropdown we are talking to
|
# 1. Figure out which dropdown we are talking to
|
||||||
target_combo = combo if combo is not None else getattr(self, "participant_dropdown", None)
|
target_combo = combo if combo is not None else getattr(self, "participant_dropdown", None)
|
||||||
|
|
||||||
if target_combo is None:
|
if target_combo is None:
|
||||||
return # Safety check: nothing to update
|
return # Safety check: nothing to update
|
||||||
|
|
||||||
@@ -1141,11 +1142,11 @@ class FlaresBaseWidget(QWidget):
|
|||||||
# 3. Conditional trigger for event updates
|
# 3. Conditional trigger for event updates
|
||||||
# We only update events if we aren't in one of the excluded viewers
|
# We only update events if we aren't in one of the excluded viewers
|
||||||
excluded_viewers = {
|
excluded_viewers = {
|
||||||
"ParticipantImage",
|
"ParticipantImage",
|
||||||
"ParticipantFoldChannels",
|
"ParticipantFoldChannels",
|
||||||
"ExportToCSV",
|
"ExportToCSV",
|
||||||
}
|
}
|
||||||
|
|
||||||
if getattr(self, "caller", None) not in excluded_viewers:
|
if getattr(self, "caller", None) not in excluded_viewers:
|
||||||
self._update_event_dropdown()
|
self._update_event_dropdown()
|
||||||
|
|
||||||
@@ -1162,7 +1163,7 @@ class FlaresBaseWidget(QWidget):
|
|||||||
|
|
||||||
def _update_event_dropdown(self):
|
def _update_event_dropdown(self):
|
||||||
is_split_group = hasattr(self, 'participant_dropdown_a') and hasattr(self, 'participant_dropdown_b')
|
is_split_group = hasattr(self, 'participant_dropdown_a') and hasattr(self, 'participant_dropdown_b')
|
||||||
|
|
||||||
bypass = False
|
bypass = False
|
||||||
main_win = next((w for w in QApplication.topLevelWidgets()
|
main_win = next((w for w in QApplication.topLevelWidgets()
|
||||||
if w.objectName() == "MainApplication" or hasattr(w, "missing_events_bypass")), None)
|
if w.objectName() == "MainApplication" or hasattr(w, "missing_events_bypass")), None)
|
||||||
@@ -1199,7 +1200,7 @@ class FlaresBaseWidget(QWidget):
|
|||||||
|
|
||||||
if not bypass:
|
if not bypass:
|
||||||
final_annotations = set.intersection(*(sets_a + sets_b))
|
final_annotations = set.intersection(*(sets_a + sets_b))
|
||||||
else:
|
else:
|
||||||
all_events_a = {event for s in sets_a for event in s}
|
all_events_a = {event for s in sets_a for event in s}
|
||||||
all_events_b = {event for s in sets_b for event in s}
|
all_events_b = {event for s in sets_b for event in s}
|
||||||
|
|
||||||
@@ -1216,7 +1217,7 @@ class FlaresBaseWidget(QWidget):
|
|||||||
valid_b.add(event)
|
valid_b.add(event)
|
||||||
|
|
||||||
final_annotations = valid_a.intersection(valid_b)
|
final_annotations = valid_a.intersection(valid_b)
|
||||||
|
|
||||||
else:
|
else:
|
||||||
names = self._get_checked_items(self.participant_dropdown)
|
names = self._get_checked_items(self.participant_dropdown)
|
||||||
if not names:
|
if not names:
|
||||||
@@ -1288,8 +1289,7 @@ class FlaresBaseWidget(QWidget):
|
|||||||
self._updating_checkstates = False
|
self._updating_checkstates = False
|
||||||
|
|
||||||
model.itemChanged.connect(on_item_changed)
|
model.itemChanged.connect(on_item_changed)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def update_participant_list_for_group(self, group_name=None, combo=None):
|
def update_participant_list_for_group(self, group_name=None, combo=None):
|
||||||
|
|
||||||
@@ -1323,7 +1323,7 @@ class FlaresBaseWidget(QWidget):
|
|||||||
# 4. Refresh the Model
|
# 4. Refresh the Model
|
||||||
model = target_combo.model()
|
model = target_combo.model()
|
||||||
model.clear()
|
model.clear()
|
||||||
|
|
||||||
for text in ["<None Selected>", "Toggle Select All"]:
|
for text in ["<None Selected>", "Toggle Select All"]:
|
||||||
item = QStandardItem(str(text))
|
item = QStandardItem(str(text))
|
||||||
if text == "Toggle Select All":
|
if text == "Toggle Select All":
|
||||||
@@ -1343,7 +1343,7 @@ class FlaresBaseWidget(QWidget):
|
|||||||
target_combo.setEnabled(True)
|
target_combo.setEnabled(True)
|
||||||
# Get file paths (handles target_group as int or str)
|
# Get file paths (handles target_group as int or str)
|
||||||
group_file_paths = self.group_to_paths.get(target_group, [])
|
group_file_paths = self.group_to_paths.get(target_group, [])
|
||||||
|
|
||||||
for i, file_path in enumerate(group_file_paths, start=1):
|
for i, file_path in enumerate(group_file_paths, start=1):
|
||||||
short_label = f"Participant {i}"
|
short_label = f"Participant {i}"
|
||||||
display_label = f"{short_label} ({os.path.basename(file_path)})"
|
display_label = f"{short_label} ({os.path.basename(file_path)})"
|
||||||
@@ -1363,8 +1363,8 @@ class InterGroupUIMixin:
|
|||||||
participant_map: dict[str, str]
|
participant_map: dict[str, str]
|
||||||
|
|
||||||
def setup_inter_group_ui(
|
def setup_inter_group_ui(
|
||||||
self,
|
self,
|
||||||
index_texts: Sequence[str],
|
index_texts: Sequence[str],
|
||||||
placeholder_text: str = ""
|
placeholder_text: str = ""
|
||||||
) -> None:
|
) -> None:
|
||||||
|
|
||||||
@@ -1394,7 +1394,7 @@ class InterGroupUIMixin:
|
|||||||
self.event_dropdown = QComboBox()
|
self.event_dropdown = QComboBox()
|
||||||
self.event_dropdown.addItem("<None Selected>")
|
self.event_dropdown.addItem("<None Selected>")
|
||||||
|
|
||||||
|
|
||||||
self.participant_dropdown_a = self._create_multiselect_dropdown([])
|
self.participant_dropdown_a = self._create_multiselect_dropdown([])
|
||||||
line_edit = self.participant_dropdown_a.lineEdit()
|
line_edit = self.participant_dropdown_a.lineEdit()
|
||||||
assert line_edit is not None, "Dropdown A must be editable to have a lineEdit"
|
assert line_edit is not None, "Dropdown A must be editable to have a lineEdit"
|
||||||
@@ -1453,10 +1453,10 @@ class InterGroupUIMixin:
|
|||||||
def _update_group_b_options(self):
|
def _update_group_b_options(self):
|
||||||
"""Triggered when Group B changes: Update Group A to exclude B's choice"""
|
"""Triggered when Group B changes: Update Group A to exclude B's choice"""
|
||||||
selected_b = self.group_b_dropdown.currentText()
|
selected_b = self.group_b_dropdown.currentText()
|
||||||
|
|
||||||
# Refresh Group A and exclude what was just picked in Group B
|
# Refresh Group A and exclude what was just picked in Group B
|
||||||
self._refresh_group_dropdown(self.group_a_dropdown, exclude=selected_b)
|
self._refresh_group_dropdown(self.group_a_dropdown, exclude=selected_b)
|
||||||
|
|
||||||
# Update the participants for Group B
|
# Update the participants for Group B
|
||||||
self.update_participant_list_for_group(selected_b, self.participant_dropdown_b)
|
self.update_participant_list_for_group(selected_b, self.participant_dropdown_b)
|
||||||
self._update_event_dropdown()
|
self._update_event_dropdown()
|
||||||
@@ -1464,10 +1464,10 @@ class InterGroupUIMixin:
|
|||||||
def _update_group_a_options(self):
|
def _update_group_a_options(self):
|
||||||
"""Triggered when Group A changes: Update Group B to exclude A's choice"""
|
"""Triggered when Group A changes: Update Group B to exclude A's choice"""
|
||||||
selected_a = self.group_a_dropdown.currentText()
|
selected_a = self.group_a_dropdown.currentText()
|
||||||
|
|
||||||
# Refresh Group B and exclude what was just picked in Group A
|
# Refresh Group B and exclude what was just picked in Group A
|
||||||
self._refresh_group_dropdown(self.group_b_dropdown, exclude=selected_a)
|
self._refresh_group_dropdown(self.group_b_dropdown, exclude=selected_a)
|
||||||
|
|
||||||
# Update the participants for Group A
|
# Update the participants for Group A
|
||||||
self.update_participant_list_for_group(selected_a, self.participant_dropdown_a)
|
self.update_participant_list_for_group(selected_a, self.participant_dropdown_a)
|
||||||
self._update_event_dropdown()
|
self._update_event_dropdown()
|
||||||
@@ -1491,8 +1491,6 @@ class InterGroupUIMixin:
|
|||||||
dropdown.setCurrentIndex(0) # Reset to "<None Selected>"
|
dropdown.setCurrentIndex(0) # Reset to "<None Selected>"
|
||||||
dropdown.blockSignals(False)
|
dropdown.blockSignals(False)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def _get_file_paths_from_labels(self, labels, group_name):
|
def _get_file_paths_from_labels(self, labels, group_name):
|
||||||
file_paths = []
|
file_paths = []
|
||||||
@@ -1516,14 +1514,14 @@ class InterGroupUIMixin:
|
|||||||
file_paths.append(file_path)
|
file_paths.append(file_path)
|
||||||
|
|
||||||
return file_paths
|
return file_paths
|
||||||
|
|
||||||
def get_common_request_data(
|
def get_common_request_data(
|
||||||
self,
|
self,
|
||||||
parameterized_indexes: dict[int, list[dict[str, Any]]],
|
parameterized_indexes: dict[int, list[dict[str, Any]]],
|
||||||
df_ind_dict: dict[str, DataFrame] | None = None,
|
df_ind_dict: dict[str, DataFrame] | None = None,
|
||||||
contrast_dfs: dict[str, dict[str, Any]] | None = None,
|
contrast_dfs: dict[str, dict[str, Any]] | None = None,
|
||||||
) -> tuple[str | None, list[str], list[str], list[str], list[int], dict[str, Any]] | None:
|
) -> tuple[str | None, list[str], list[str], list[str], list[int], dict[str, Any]] | None:
|
||||||
|
|
||||||
selected_event = self.event_dropdown.currentText()
|
selected_event = self.event_dropdown.currentText()
|
||||||
if selected_event == "<None Selected>":
|
if selected_event == "<None Selected>":
|
||||||
selected_event = None
|
selected_event = None
|
||||||
@@ -1562,7 +1560,7 @@ class InterGroupUIMixin:
|
|||||||
}
|
}
|
||||||
|
|
||||||
dynamic_rois = []
|
dynamic_rois = []
|
||||||
|
|
||||||
if df_ind_dict:
|
if df_ind_dict:
|
||||||
roi_set = set()
|
roi_set = set()
|
||||||
for fp in all_selected_paths:
|
for fp in all_selected_paths:
|
||||||
@@ -1572,7 +1570,7 @@ class InterGroupUIMixin:
|
|||||||
|
|
||||||
if roi_set:
|
if roi_set:
|
||||||
dynamic_rois = sorted(list(roi_set))
|
dynamic_rois = sorted(list(roi_set))
|
||||||
|
|
||||||
# Fallback to prevent UI crashes if JSON file doesn't exist or is empty
|
# Fallback to prevent UI crashes if JSON file doesn't exist or is empty
|
||||||
if not dynamic_rois:
|
if not dynamic_rois:
|
||||||
dynamic_rois = ["Option 1", "Option 2"]
|
dynamic_rois = ["Option 1", "Option 2"]
|
||||||
@@ -1588,7 +1586,7 @@ class InterGroupUIMixin:
|
|||||||
# If an event is selected, only keep contrasts containing the event name as a substring.
|
# If an event is selected, only keep contrasts containing the event name as a substring.
|
||||||
if selected_event is None or selected_event in contrast_name:
|
if selected_event is None or selected_event in contrast_name:
|
||||||
contrast_set.add(contrast_name)
|
contrast_set.add(contrast_name)
|
||||||
|
|
||||||
# Sort them cleanly for the UI
|
# Sort them cleanly for the UI
|
||||||
dynamic_contrasts = sorted(list(contrast_set))
|
dynamic_contrasts = sorted(list(contrast_set))
|
||||||
|
|
||||||
@@ -1634,15 +1632,15 @@ class InterGroupUIMixin:
|
|||||||
selected_indexes,
|
selected_indexes,
|
||||||
param_values,
|
param_values,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class CSVUIMixin:
|
class CSVUIMixin:
|
||||||
|
|
||||||
def setup_csv_ui(
|
def setup_csv_ui(
|
||||||
self,
|
self,
|
||||||
index_texts: Sequence[str]
|
index_texts: Sequence[str]
|
||||||
) -> None:
|
) -> None:
|
||||||
|
|
||||||
# Create mappings: file_path -> participant label and dropdown display text
|
# Create mappings: file_path -> participant label and dropdown display text
|
||||||
self.participant_map: dict[str, str] = {} # file_path -> "Participant 1"
|
self.participant_map: dict[str, str] = {} # file_path -> "Participant 1"
|
||||||
self.participant_dropdown_items: list[str] = [] # "Participant 1 (filename)"
|
self.participant_dropdown_items: list[str] = [] # "Participant 1 (filename)"
|
||||||
@@ -1659,7 +1657,7 @@ class CSVUIMixin:
|
|||||||
|
|
||||||
self.participant_dropdown: FullClickComboBox = self._create_multiselect_dropdown(self.participant_dropdown_items)
|
self.participant_dropdown: FullClickComboBox = 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 = index_texts
|
self.index_texts = index_texts
|
||||||
|
|
||||||
self.image_index_dropdown: FullClickComboBox = self._create_multiselect_dropdown(self.index_texts)
|
self.image_index_dropdown: FullClickComboBox = self._create_multiselect_dropdown(self.index_texts)
|
||||||
@@ -1693,14 +1691,14 @@ class CSVUIMixin:
|
|||||||
class IntraGroupUIMixin:
|
class IntraGroupUIMixin:
|
||||||
|
|
||||||
def setup_intra_group_ui(
|
def setup_intra_group_ui(
|
||||||
self,
|
self,
|
||||||
index_texts: Sequence[str],
|
index_texts: Sequence[str],
|
||||||
placeholder_text: str = ""
|
placeholder_text: str = ""
|
||||||
) -> None:
|
) -> None:
|
||||||
|
|
||||||
self.show_all_events = True
|
self.show_all_events = True
|
||||||
self._updating_checkstates = False
|
self._updating_checkstates = False
|
||||||
|
|
||||||
# Create mappings: file_path -> participant label and dropdown display text
|
# Create mappings: file_path -> participant label and dropdown display text
|
||||||
self.participant_map: dict[str, str] = {} # file_path -> "Participant 1"
|
self.participant_map: dict[str, str] = {} # file_path -> "Participant 1"
|
||||||
self.participant_dropdown_items = [] # "Participant 1 (filename)"
|
self.participant_dropdown_items = [] # "Participant 1 (filename)"
|
||||||
@@ -1771,7 +1769,7 @@ class IntraGroupUIMixin:
|
|||||||
df_ind_dict: dict[str, DataFrame] | None = None,
|
df_ind_dict: dict[str, DataFrame] | None = None,
|
||||||
contrast_dfs: dict[str, dict[str, Any]] | None = None,
|
contrast_dfs: dict[str, dict[str, Any]] | None = None,
|
||||||
) -> tuple[str | None, list[str], list[int], dict[str, Any]] | None:
|
) -> tuple[str | None, list[str], list[int], dict[str, Any]] | None:
|
||||||
|
|
||||||
selected_event = self.event_dropdown.currentText()
|
selected_event = self.event_dropdown.currentText()
|
||||||
if selected_event == "<None Selected>":
|
if selected_event == "<None Selected>":
|
||||||
selected_event = None
|
selected_event = None
|
||||||
@@ -1793,7 +1791,7 @@ class IntraGroupUIMixin:
|
|||||||
if raw is not None and hasattr(raw, "annotations"):
|
if raw is not None and hasattr(raw, "annotations"):
|
||||||
if selected_event in raw.annotations.description:
|
if selected_event in raw.annotations.description:
|
||||||
valid_paths.append(fp)
|
valid_paths.append(fp)
|
||||||
|
|
||||||
selected_file_paths = valid_paths
|
selected_file_paths = valid_paths
|
||||||
|
|
||||||
selected_indexes = [
|
selected_indexes = [
|
||||||
@@ -1803,7 +1801,7 @@ class IntraGroupUIMixin:
|
|||||||
if not selected_file_paths:
|
if not selected_file_paths:
|
||||||
print("No participants selected.")
|
print("No participants selected.")
|
||||||
return
|
return
|
||||||
|
|
||||||
|
|
||||||
# Inject full_text from index_texts
|
# Inject full_text from index_texts
|
||||||
for idx, params_list in parameterized_indexes.items():
|
for idx, params_list in parameterized_indexes.items():
|
||||||
@@ -1814,7 +1812,7 @@ class IntraGroupUIMixin:
|
|||||||
indexes_needing_params = {idx: parameterized_indexes[idx] for idx in selected_indexes if idx in parameterized_indexes}
|
indexes_needing_params = {idx: parameterized_indexes[idx] for idx in selected_indexes if idx in parameterized_indexes}
|
||||||
|
|
||||||
dynamic_rois = []
|
dynamic_rois = []
|
||||||
|
|
||||||
if df_ind_dict:
|
if df_ind_dict:
|
||||||
roi_set = set()
|
roi_set = set()
|
||||||
for fp in selected_file_paths:
|
for fp in selected_file_paths:
|
||||||
@@ -1840,7 +1838,7 @@ class IntraGroupUIMixin:
|
|||||||
# If an event is selected, only keep contrasts containing the event name as a substring.
|
# If an event is selected, only keep contrasts containing the event name as a substring.
|
||||||
if selected_event is None or selected_event in contrast_name:
|
if selected_event is None or selected_event in contrast_name:
|
||||||
contrast_set.add(contrast_name)
|
contrast_set.add(contrast_name)
|
||||||
|
|
||||||
# Sort them cleanly for the UI
|
# Sort them cleanly for the UI
|
||||||
dynamic_contrasts = sorted(list(contrast_set))
|
dynamic_contrasts = sorted(list(contrast_set))
|
||||||
|
|
||||||
@@ -1868,7 +1866,6 @@ class IntraGroupUIMixin:
|
|||||||
param_info["options"] = dynamic_contrasts
|
param_info["options"] = dynamic_contrasts
|
||||||
param_info["default"] = dynamic_contrasts[0] if dynamic_contrasts else ""
|
param_info["default"] = dynamic_contrasts[0] if dynamic_contrasts else ""
|
||||||
|
|
||||||
|
|
||||||
param_values = {}
|
param_values = {}
|
||||||
if indexes_needing_params:
|
if indexes_needing_params:
|
||||||
dialog = ParameterInputDialog(indexes_needing_params, parent=self)
|
dialog = ParameterInputDialog(indexes_needing_params, parent=self)
|
||||||
@@ -1878,21 +1875,22 @@ class IntraGroupUIMixin:
|
|||||||
return
|
return
|
||||||
else:
|
else:
|
||||||
return
|
return
|
||||||
|
|
||||||
return (
|
return (
|
||||||
selected_event,
|
selected_event,
|
||||||
selected_file_paths,
|
selected_file_paths,
|
||||||
selected_indexes,
|
selected_indexes,
|
||||||
param_values,
|
param_values,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
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 = ""
|
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
|
||||||
self.participant_map: dict[str, str] = {} # file_path -> "Participant 1"
|
self.participant_map: dict[str, str] = {} # file_path -> "Participant 1"
|
||||||
self.participant_dropdown_items = [] # "Participant 1 (filename)"
|
self.participant_dropdown_items = [] # "Participant 1 (filename)"
|
||||||
@@ -1913,7 +1911,6 @@ class ParticipantUIMixin:
|
|||||||
self.event_dropdown = QComboBox()
|
self.event_dropdown = QComboBox()
|
||||||
self.event_dropdown.addItem("<None Selected>")
|
self.event_dropdown.addItem("<None Selected>")
|
||||||
|
|
||||||
|
|
||||||
self.index_texts = index_texts
|
self.index_texts = index_texts
|
||||||
|
|
||||||
self.image_index_dropdown = self._create_multiselect_dropdown(self.index_texts)
|
self.image_index_dropdown = self._create_multiselect_dropdown(self.index_texts)
|
||||||
@@ -1968,8 +1965,6 @@ class ParticipantUIMixin:
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
# Inject full_text from index_texts
|
# Inject full_text from index_texts
|
||||||
for idx, params_list in parameterized_indexes.items():
|
for idx, params_list in parameterized_indexes.items():
|
||||||
full_text = self.index_texts[idx] if idx < len(self.index_texts) else f"{idx} (No label found)"
|
full_text = self.index_texts[idx] if idx < len(self.index_texts) else f"{idx} (No label found)"
|
||||||
@@ -1987,10 +1982,10 @@ class ParticipantUIMixin:
|
|||||||
return
|
return
|
||||||
else:
|
else:
|
||||||
return
|
return
|
||||||
|
|
||||||
return (
|
return (
|
||||||
selected_event,
|
selected_event,
|
||||||
selected_file_paths,
|
selected_file_paths,
|
||||||
selected_indexes,
|
selected_indexes,
|
||||||
param_values,
|
param_values,
|
||||||
)
|
)
|
||||||
@@ -13,7 +13,7 @@ import sys
|
|||||||
import platform
|
import platform
|
||||||
|
|
||||||
|
|
||||||
CURRENT_VERSION = "1.6.1"
|
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 = [
|
||||||
@@ -72,9 +73,13 @@ DATA_SCHEMA = [
|
|||||||
|
|
||||||
|
|
||||||
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()
|
||||||
+1
-1
@@ -32,7 +32,7 @@ class AboutWindow(QWidget):
|
|||||||
label2 = QLabel(f"{APP_NAME_EXPANDED}", self)
|
label2 = QLabel(f"{APP_NAME_EXPANDED}", self)
|
||||||
label3 = QLabel(f"{APP_NAME.upper()} is licensed under the GPL-3.0 licence. For more information, visit https://www.gnu.org/licenses/gpl-3.0.en.html", self)
|
label3 = QLabel(f"{APP_NAME.upper()} is licensed under the GPL-3.0 licence. For more information, visit https://www.gnu.org/licenses/gpl-3.0.en.html", self)
|
||||||
label4 = QLabel(f"Version v{CURRENT_VERSION}")
|
label4 = QLabel(f"Version v{CURRENT_VERSION}")
|
||||||
|
|
||||||
layout.addWidget(label)
|
layout.addWidget(label)
|
||||||
layout.addWidget(label2)
|
layout.addWidget(label2)
|
||||||
layout.addWidget(label3)
|
layout.addWidget(label3)
|
||||||
|
|||||||
@@ -0,0 +1,658 @@
|
|||||||
|
"""
|
||||||
|
Filename: plugins.py
|
||||||
|
Description: Plugins window
|
||||||
|
Note: Compliant with pylance strict type checking
|
||||||
|
|
||||||
|
Author: Tyler de Zeeuw
|
||||||
|
License: GPL-3.0
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Built-in imports
|
||||||
|
import io
|
||||||
|
import shutil
|
||||||
|
import urllib.request
|
||||||
|
import zipfile
|
||||||
|
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, QProgressDialog
|
||||||
|
)
|
||||||
|
|
||||||
|
from plugin_manager import PluginManager, get_ssl_context, 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, str) # (plugins_data, all_failed, fetch_type)
|
||||||
|
|
||||||
|
def __init__(self, manager: PluginManager, urls: list[str], fetch_type: str = "browser") -> None:
|
||||||
|
super().__init__()
|
||||||
|
self.manager = manager
|
||||||
|
self.urls = urls
|
||||||
|
self.fetch_type = fetch_type
|
||||||
|
|
||||||
|
def run(self) -> None:
|
||||||
|
plugins, all_failed = self.manager.fetch_remote_repositories(self.urls)
|
||||||
|
self.fetched.emit(plugins, all_failed, self.fetch_type)
|
||||||
|
|
||||||
|
|
||||||
|
class PluginInstallWorker(QThread):
|
||||||
|
"""Background worker to download, unpack, and install/update plugins with progress reporting."""
|
||||||
|
progress = Signal(int, int) # (downloaded_bytes, total_bytes)
|
||||||
|
status_message = Signal(str)
|
||||||
|
finished = Signal(bool, str) # (success, message_or_path)
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
manager: PluginManager,
|
||||||
|
download_url: str,
|
||||||
|
plugin_id: str,
|
||||||
|
is_update: bool = False,
|
||||||
|
plugin_path_to_remove: Path | None = None
|
||||||
|
) -> None:
|
||||||
|
super().__init__()
|
||||||
|
self.manager = manager
|
||||||
|
self.download_url = download_url
|
||||||
|
self.plugin_id = plugin_id
|
||||||
|
self.is_update = is_update
|
||||||
|
self.plugin_path_to_remove = plugin_path_to_remove
|
||||||
|
|
||||||
|
def run(self) -> None:
|
||||||
|
try:
|
||||||
|
self.status_message.emit("Connecting to server...")
|
||||||
|
req = urllib.request.Request(
|
||||||
|
self.download_url,
|
||||||
|
headers={"User-Agent": f"{APP_NAME}-PluginManager"},
|
||||||
|
)
|
||||||
|
|
||||||
|
with urllib.request.urlopen(req, timeout=30, context=get_ssl_context()) as response:
|
||||||
|
if response.status != 200:
|
||||||
|
self.finished.emit(False, f"Download failed with HTTP status code {response.status}")
|
||||||
|
return
|
||||||
|
|
||||||
|
total_size_str = response.headers.get("Content-Length")
|
||||||
|
total_size = int(total_size_str) if total_size_str and total_size_str.isdigit() else 0
|
||||||
|
|
||||||
|
self.status_message.emit("Downloading plugin archive...")
|
||||||
|
chunk_size = 8192
|
||||||
|
downloaded = 0
|
||||||
|
buffer = io.BytesIO()
|
||||||
|
|
||||||
|
while True:
|
||||||
|
chunk = response.read(chunk_size)
|
||||||
|
if not chunk:
|
||||||
|
break
|
||||||
|
buffer.write(chunk)
|
||||||
|
downloaded += len(chunk)
|
||||||
|
self.progress.emit(downloaded, total_size)
|
||||||
|
|
||||||
|
zip_bytes = buffer.getvalue()
|
||||||
|
|
||||||
|
if self.is_update and self.plugin_path_to_remove and self.plugin_path_to_remove.exists():
|
||||||
|
self.status_message.emit("Removing old version...")
|
||||||
|
if self.plugin_path_to_remove.is_dir():
|
||||||
|
shutil.rmtree(self.plugin_path_to_remove)
|
||||||
|
else:
|
||||||
|
self.plugin_path_to_remove.unlink()
|
||||||
|
|
||||||
|
self.status_message.emit("Unpacking and extracting files...")
|
||||||
|
with zipfile.ZipFile(io.BytesIO(zip_bytes)) as zip_ref:
|
||||||
|
zip_ref.extractall(self.manager.plugins_dir)
|
||||||
|
|
||||||
|
self.finished.emit(True, str(self.manager.plugins_dir))
|
||||||
|
except Exception as e:
|
||||||
|
self.finished.emit(False, str(e))
|
||||||
|
|
||||||
|
|
||||||
|
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
|
||||||
|
self._install_worker: PluginInstallWorker | None = None
|
||||||
|
self._progress_dialog: QProgressDialog | 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()
|
||||||
|
self.check_installed_updates()
|
||||||
|
|
||||||
|
def check_installed_updates(self) -> None:
|
||||||
|
"""Asynchronously checks remote repositories for installed plugin updates."""
|
||||||
|
if self._fetch_worker is not None and self._fetch_worker.isRunning():
|
||||||
|
return
|
||||||
|
|
||||||
|
self._fetch_worker = RemoteFetchWorker(self.manager, self.repository_urls, fetch_type="updates")
|
||||||
|
self._fetch_worker.fetched.connect(self._on_remote_fetched)
|
||||||
|
self._fetch_worker.start()
|
||||||
|
|
||||||
|
def refresh_installed_plugins(self) -> None:
|
||||||
|
"""Refreshes installed list using metadata from PluginManager and attaches update info."""
|
||||||
|
self.installed_list.clear()
|
||||||
|
plugins_info = self.manager.get_installed_plugins_info()
|
||||||
|
|
||||||
|
remote_map: dict[str, dict[str, Any]] = {}
|
||||||
|
if self._has_fetched_remote and self.remote_plugins_data:
|
||||||
|
for p in self.remote_plugins_data:
|
||||||
|
p_id = p.get("id") or p.get("name")
|
||||||
|
if p_id:
|
||||||
|
remote_map[str(p_id)] = p
|
||||||
|
|
||||||
|
for info in plugins_info:
|
||||||
|
p_id = str(info.get("id", ""))
|
||||||
|
local_tuple = parse_version(str(info.get("version", "0.0.0")))
|
||||||
|
|
||||||
|
remote_plugin = remote_map.get(p_id)
|
||||||
|
if remote_plugin:
|
||||||
|
remote_ver = str(remote_plugin.get("version", "0.0.0"))
|
||||||
|
if parse_version(remote_ver) > local_tuple:
|
||||||
|
info["update_available"] = True
|
||||||
|
info["remote_version"] = remote_ver
|
||||||
|
info["download_url"] = str(remote_plugin.get("download_url", ""))
|
||||||
|
|
||||||
|
display_text = f"{info['name']} (v{info.get('version', '1.0.0')})"
|
||||||
|
if info.get("update_available"):
|
||||||
|
display_text += f" [Update Available: v{info.get('remote_version')}]"
|
||||||
|
if info["is_disabled"]:
|
||||||
|
display_text += " [Disabled]"
|
||||||
|
|
||||||
|
item = QListWidgetItem(display_text, self.installed_list)
|
||||||
|
item.setData(Qt.ItemDataRole.UserRole, info)
|
||||||
|
|
||||||
|
if info.get("update_available"):
|
||||||
|
item.setForeground(QColor("#1976d2"))
|
||||||
|
elif info["is_disabled"]:
|
||||||
|
item.setForeground(QColor("#757575"))
|
||||||
|
|
||||||
|
if self.installed_list.count() == 0:
|
||||||
|
self.installed_list.addItem("No plugins installed.")
|
||||||
|
self._clear_details_panel()
|
||||||
|
|
||||||
|
if not self._has_fetched_remote:
|
||||||
|
self.check_installed_updates()
|
||||||
|
elif 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()
|
||||||
|
self.btn_update_plugin.setEnabled(False)
|
||||||
|
return
|
||||||
|
|
||||||
|
info = current.data(Qt.ItemDataRole.UserRole)
|
||||||
|
if not isinstance(info, dict):
|
||||||
|
self._clear_details_panel()
|
||||||
|
self.btn_update_plugin.setEnabled(False)
|
||||||
|
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", ""))
|
||||||
|
update_available = bool(plugin_info.get("update_available", False))
|
||||||
|
|
||||||
|
self.lbl_plugin_title.setText(name)
|
||||||
|
self.lbl_plugin_meta.setText(f"<b>Version:</b> {version} | <b>Author:</b> {author}")
|
||||||
|
|
||||||
|
if update_available:
|
||||||
|
rem_ver = plugin_info.get("remote_version", "")
|
||||||
|
self.lbl_plugin_status.setText(f"<font color='#1976d2'><b>Status: Update v{rem_ver} Available!</b></font>")
|
||||||
|
elif 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>")
|
||||||
|
self.btn_update_plugin.setEnabled(update_available)
|
||||||
|
|
||||||
|
def execute_plugin_installation(self, download_url: str, plugin_id: str, plugin_name: str, is_update: bool, plugin_path: Path | None) -> None:
|
||||||
|
"""Sets up the progress dialog and starts the background installation/update worker."""
|
||||||
|
if self._install_worker is not None and self._install_worker.isRunning():
|
||||||
|
return
|
||||||
|
|
||||||
|
self._installation_finished_flag = False
|
||||||
|
action_label = "Updating" if is_update else "Installing"
|
||||||
|
|
||||||
|
self._progress_dialog = QProgressDialog(f"{action_label} '{plugin_name}'...", "Cancel", 0, 100, self)
|
||||||
|
self._progress_dialog.setMinimumDuration(0) # Show the progress dialog instantly
|
||||||
|
self._progress_dialog.setWindowTitle(f"{action_label} Plugin")
|
||||||
|
self._progress_dialog.setWindowModality(Qt.WindowModality.WindowModal)
|
||||||
|
self._progress_dialog.setAutoClose(False)
|
||||||
|
self._progress_dialog.setAutoReset(False)
|
||||||
|
self._progress_dialog.canceled.connect(self._cancel_installation)
|
||||||
|
self._progress_dialog.show()
|
||||||
|
|
||||||
|
self._install_worker = PluginInstallWorker(self.manager, download_url, plugin_id, is_update, plugin_path)
|
||||||
|
self._install_worker.progress.connect(self._on_install_progress)
|
||||||
|
self._install_worker.status_message.connect(self._on_install_status)
|
||||||
|
self._install_worker.finished.connect(lambda success, msg: self._on_install_finished(success, msg, plugin_name, is_update))
|
||||||
|
self._install_worker.start()
|
||||||
|
|
||||||
|
|
||||||
|
def _cancel_installation(self) -> None:
|
||||||
|
"""Handles manual user cancellation of the download/install progress."""
|
||||||
|
if getattr(self, "_installation_finished_flag", False):
|
||||||
|
return # Ignore if triggered by closing the dialog programmatically on success
|
||||||
|
|
||||||
|
if self._install_worker and self._install_worker.isRunning():
|
||||||
|
self._install_worker.terminate()
|
||||||
|
self._install_worker.wait()
|
||||||
|
QMessageBox.warning(self, "Cancelled", "Plugin installation was cancelled.")
|
||||||
|
|
||||||
|
def _on_install_finished(self, success: bool, message: str, plugin_name: str, is_update: bool) -> None:
|
||||||
|
self._installation_finished_flag = True # Prevent false cancellation triggers
|
||||||
|
|
||||||
|
if self._progress_dialog:
|
||||||
|
self._progress_dialog.close()
|
||||||
|
self._progress_dialog.deleteLater()
|
||||||
|
self._progress_dialog = None
|
||||||
|
|
||||||
|
action_word = "updated" if is_update else "installed"
|
||||||
|
action_verb = "update" if is_update else "install"
|
||||||
|
|
||||||
|
if success:
|
||||||
|
self.manager.reload_plugins()
|
||||||
|
QMessageBox.information(self, "Success", f"Successfully {action_word} '{plugin_name}'!")
|
||||||
|
self.refresh_installed_plugins()
|
||||||
|
if self._has_fetched_remote:
|
||||||
|
self._populate_browser_list()
|
||||||
|
else:
|
||||||
|
QMessageBox.critical(self, "Installation Failed", f"Could not {action_verb} plugin:\n{message}")
|
||||||
|
|
||||||
|
def _on_install_progress(self, downloaded: int, total: int) -> None:
|
||||||
|
if self._progress_dialog:
|
||||||
|
if total > 0:
|
||||||
|
self._progress_dialog.setMaximum(total)
|
||||||
|
self._progress_dialog.setValue(downloaded)
|
||||||
|
mb_down = downloaded / (1024 * 1024)
|
||||||
|
mb_total = total / (1024 * 1024)
|
||||||
|
self._progress_dialog.setLabelText(f"Downloading... ({mb_down:.2f} MB / {mb_total:.2f} MB)")
|
||||||
|
else:
|
||||||
|
self._progress_dialog.setMaximum(0) # Infinite marquee progress if size is unknown
|
||||||
|
mb_down = downloaded / (1024 * 1024)
|
||||||
|
self._progress_dialog.setLabelText(f"Downloading... ({mb_down:.2f} MB downloaded)")
|
||||||
|
|
||||||
|
def _on_install_status(self, message: str) -> None:
|
||||||
|
if self._progress_dialog:
|
||||||
|
self._progress_dialog.setLabelText(message)
|
||||||
|
|
||||||
|
|
||||||
|
def update_selected_plugin(self) -> None:
|
||||||
|
"""Triggers the background update process for the selected installed plugin."""
|
||||||
|
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)
|
||||||
|
download_url = info.get("download_url", "")
|
||||||
|
plugin_id = info.get("id", "unnamed")
|
||||||
|
plugin_name = info.get("name", "Plugin")
|
||||||
|
plugin_path = info.get("path")
|
||||||
|
|
||||||
|
if not download_url:
|
||||||
|
QMessageBox.warning(self, "Update Failed", "No download URL provided for this update.")
|
||||||
|
return
|
||||||
|
|
||||||
|
path_obj = Path(plugin_path) if isinstance(plugin_path, str) and plugin_path else (plugin_path if isinstance(plugin_path, Path) else None)
|
||||||
|
self.execute_plugin_installation(download_url, plugin_id, plugin_name, is_update=True, plugin_path=path_obj)
|
||||||
|
|
||||||
|
def install_selected_plugin(self) -> None:
|
||||||
|
"""Triggers the background installation/update process from the browser tab."""
|
||||||
|
selected = self.browser_list.currentItem()
|
||||||
|
if not selected:
|
||||||
|
return
|
||||||
|
data = selected.data(Qt.ItemDataRole.UserRole)
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
return
|
||||||
|
|
||||||
|
plugin_data = cast(dict[str, Any], data)
|
||||||
|
|
||||||
|
update_available = bool(selected.data(Qt.ItemDataRole.UserRole + 3))
|
||||||
|
plugin_id = str(plugin_data.get("id", "unnamed"))
|
||||||
|
plugin_name = str(plugin_data.get("name", "Plugin"))
|
||||||
|
download_url = str(plugin_data.get("download_url", ""))
|
||||||
|
|
||||||
|
if not download_url:
|
||||||
|
QMessageBox.warning(self, "Error", "No download URL provided for this plugin.")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Find existing local path if updating an installed plugin
|
||||||
|
existing_path: Path | None = None
|
||||||
|
installed_info = self.manager.get_installed_plugins_info()
|
||||||
|
for info in installed_info:
|
||||||
|
if info.get("id") == plugin_id or info.get("name") == plugin_name:
|
||||||
|
p = info.get("path")
|
||||||
|
if isinstance(p, Path):
|
||||||
|
existing_path = p
|
||||||
|
elif isinstance(p, str):
|
||||||
|
existing_path = Path(p)
|
||||||
|
break
|
||||||
|
|
||||||
|
self.execute_plugin_installation(download_url, plugin_id, plugin_name, is_update=update_available, plugin_path=existing_path)
|
||||||
|
|
||||||
|
def _on_remote_fetched(self, plugins: list[dict[str, Any]], all_failed: bool, fetch_type: str) -> 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 fetch_type == "updates":
|
||||||
|
self.refresh_installed_plugins()
|
||||||
|
return
|
||||||
|
|
||||||
|
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 fetch_remote_plugins(self) -> None:
|
||||||
|
"""Asynchronously fetches remote plugins on a background thread for the browser."""
|
||||||
|
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)
|
||||||
|
|
||||||
|
self._fetch_worker = RemoteFetchWorker(self.manager, self.repository_urls, fetch_type="browser")
|
||||||
|
self._fetch_worker.fetched.connect(self._on_remote_fetched)
|
||||||
|
self._fetch_worker.start()
|
||||||
|
|
||||||
|
def _populate_browser_list(self) -> None:
|
||||||
|
"""Populates the browser tab list and evaluates compatibility, installation, and updates."""
|
||||||
|
self.browser_list.clear()
|
||||||
|
|
||||||
|
installed_info = self.manager.get_installed_plugins_info()
|
||||||
|
installed_map: dict[str, dict[str, Any]] = {}
|
||||||
|
for p in installed_info:
|
||||||
|
p_id = p.get("id")
|
||||||
|
p_name = p.get("name")
|
||||||
|
if p_id:
|
||||||
|
installed_map[str(p_id)] = p
|
||||||
|
if p_name:
|
||||||
|
installed_map[str(p_name)] = p
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
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}+")
|
||||||
|
|
||||||
|
installed_match = installed_map.get(str(p_id)) or installed_map.get(str(name))
|
||||||
|
is_installed = installed_match is not None
|
||||||
|
|
||||||
|
update_available = False
|
||||||
|
remote_ver_str = str(plugin.get("version", "0.0.0"))
|
||||||
|
if is_installed:
|
||||||
|
local_ver_str = str(installed_match.get("version", "0.0.0"))
|
||||||
|
if parse_version(remote_ver_str) > parse_version(local_ver_str):
|
||||||
|
update_available = True
|
||||||
|
|
||||||
|
display_text = f"{name} (v{version}) - {desc}"
|
||||||
|
if update_available:
|
||||||
|
display_text += f" [Update Available: v{remote_ver_str}]"
|
||||||
|
elif 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)
|
||||||
|
item.setData(Qt.ItemDataRole.UserRole + 3, update_available)
|
||||||
|
|
||||||
|
if update_available:
|
||||||
|
item.setForeground(QColor("#1976d2"))
|
||||||
|
elif is_installed:
|
||||||
|
item.setForeground(QColor("#2e7d32"))
|
||||||
|
elif not is_compatible:
|
||||||
|
item.setForeground(QColor("#d32f2f"))
|
||||||
|
|
||||||
|
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))
|
||||||
|
update_available = bool(current.data(Qt.ItemDataRole.UserRole + 3))
|
||||||
|
|
||||||
|
if update_available:
|
||||||
|
self.btn_install.setText("Update Plugin")
|
||||||
|
self.btn_install.setEnabled(is_compatible)
|
||||||
|
elif is_installed:
|
||||||
|
self.btn_install.setText("Install Plugin")
|
||||||
|
self.btn_install.setEnabled(False)
|
||||||
|
else:
|
||||||
|
self.btn_install.setText("Install Plugin")
|
||||||
|
self.btn_install.setEnabled(is_compatible)
|
||||||
|
else:
|
||||||
|
self.btn_install.setText("Install Plugin")
|
||||||
|
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)
|
||||||
|
self.btn_update_plugin = QPushButton("Update Plugin", tab)
|
||||||
|
self.btn_update_plugin.setEnabled(False)
|
||||||
|
self.btn_update_plugin.setStyleSheet("font-weight: bold; color: #1976d2;")
|
||||||
|
btn_enable = QPushButton("Enable / Disable", tab)
|
||||||
|
btn_uninstall = QPushButton("Uninstall", tab)
|
||||||
|
|
||||||
|
btn_refresh.clicked.connect(self.refresh_installed_plugins)
|
||||||
|
self.btn_update_plugin.clicked.connect(self.update_selected_plugin)
|
||||||
|
btn_enable.clicked.connect(self.toggle_enable_disable)
|
||||||
|
btn_uninstall.clicked.connect(self.uninstall_plugin)
|
||||||
|
|
||||||
|
btn_layout.addWidget(btn_refresh)
|
||||||
|
btn_layout.addWidget(self.btn_update_plugin)
|
||||||
|
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
|
||||||
|
|
||||||
|
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))
|
||||||
|
elif isinstance(plugin_path, Path):
|
||||||
|
self.manager.toggle_plugin_state(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 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:
|
||||||
|
if isinstance(plugin_path, str):
|
||||||
|
self.manager.uninstall_plugin(Path(plugin_path))
|
||||||
|
elif isinstance(plugin_path, Path):
|
||||||
|
self.manager.uninstall_plugin(plugin_path)
|
||||||
|
|
||||||
|
|
||||||
|
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("")
|
||||||
+23
-15
@@ -17,8 +17,10 @@ from PySide6.QtWidgets import QWidget, QVBoxLayout, QTextEdit, QLineEdit, QMainW
|
|||||||
from PySide6.QtCore import QProcess, 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
|
||||||
|
|
||||||
|
|
||||||
@@ -31,18 +33,18 @@ class _AssocWorker(QThread):
|
|||||||
window. Used for the non-elevated path too for consistency.
|
window. Used for the non-elevated path too for consistency.
|
||||||
"""
|
"""
|
||||||
result_ready = Signal(bool, str)
|
result_ready = Signal(bool, str)
|
||||||
|
|
||||||
def __init__(self, force_admin: bool, parent: QWidget | None = None) -> None:
|
def __init__(self, force_admin: bool, parent: QWidget | None = None) -> None:
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
self._force_admin = force_admin
|
self._force_admin = force_admin
|
||||||
|
|
||||||
def run(self) -> None:
|
def run(self) -> None:
|
||||||
ok, msg = register_file_association(force_admin=self._force_admin)
|
ok, msg = register_file_association(force_admin=self._force_admin)
|
||||||
self.result_ready.emit(ok, msg)
|
self.result_ready.emit(ok, msg)
|
||||||
|
|
||||||
|
|
||||||
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)
|
||||||
@@ -58,6 +60,7 @@ class TerminalWindow(QWidget):
|
|||||||
self.setLayout(layout)
|
self.setLayout(layout)
|
||||||
|
|
||||||
self._process: QProcess | None = None
|
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,
|
||||||
@@ -66,6 +69,7 @@ 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,
|
"utest": self.cmd_utest,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -88,7 +92,7 @@ class TerminalWindow(QWidget):
|
|||||||
self._pending_assoc_confirmation = False
|
self._pending_assoc_confirmation = False
|
||||||
self._handle_assoc_confirmation(command_text.strip().lower())
|
self._handle_assoc_confirmation(command_text.strip().lower())
|
||||||
return
|
return
|
||||||
|
|
||||||
parts = command_text.strip().split()
|
parts = command_text.strip().split()
|
||||||
if not parts:
|
if not parts:
|
||||||
return
|
return
|
||||||
@@ -114,19 +118,23 @@ class TerminalWindow(QWidget):
|
|||||||
def cmd_help(self, *args: Any) -> str:
|
def cmd_help(self, *args: Any) -> str:
|
||||||
available_cmds = [cmd for cmd in self.commands.keys() if cmd != "utest"]
|
available_cmds = [cmd for cmd in self.commands.keys() if cmd != "utest"]
|
||||||
return f"Available commands: {', '.join(available_cmds)}"
|
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}."
|
||||||
|
|
||||||
def cmd_about(self, *args: Any) -> None:
|
def cmd_about(self, *args: Any) -> None:
|
||||||
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, QMainWindow):
|
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(
|
||||||
main_window=main_win,
|
main_window=main_win,
|
||||||
api_url=API_URL,
|
api_url=API_URL,
|
||||||
@@ -137,7 +145,7 @@ class TerminalWindow(QWidget):
|
|||||||
app_name=APP_NAME
|
app_name=APP_NAME
|
||||||
)
|
)
|
||||||
self.output_area.append("Checking for updates...")
|
self.output_area.append("Checking for updates...")
|
||||||
|
|
||||||
self.updater.manual_check_for_updates()
|
self.updater.manual_check_for_updates()
|
||||||
return "See status bar for update information."
|
return "See status bar for update information."
|
||||||
|
|
||||||
@@ -147,10 +155,10 @@ class TerminalWindow(QWidget):
|
|||||||
if PLATFORM_NAME != "windows" or is_windows_admin():
|
if PLATFORM_NAME != "windows" or is_windows_admin():
|
||||||
self._run_assoc(force_admin=False)
|
self._run_assoc(force_admin=False)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
self._pending_assoc_confirmation = True
|
self._pending_assoc_confirmation = True
|
||||||
return "Not running as admin. Register system-wide via UAC elevation? (y/n)"
|
return "Not running as admin. Register system-wide via UAC elevation? (y/n)"
|
||||||
|
|
||||||
def _handle_assoc_confirmation(self, answer: str) -> None:
|
def _handle_assoc_confirmation(self, answer: str) -> None:
|
||||||
if answer in ("y", "yes"):
|
if answer in ("y", "yes"):
|
||||||
self._run_assoc(force_admin=True)
|
self._run_assoc(force_admin=True)
|
||||||
@@ -158,15 +166,15 @@ class TerminalWindow(QWidget):
|
|||||||
self._run_assoc(force_admin=False)
|
self._run_assoc(force_admin=False)
|
||||||
else:
|
else:
|
||||||
self.output_area.append("Please answer 'y' or 'n'. Run 'assoc' again to retry.")
|
self.output_area.append("Please answer 'y' or 'n'. Run 'assoc' again to retry.")
|
||||||
|
|
||||||
def _run_assoc(self, force_admin: bool) -> None:
|
def _run_assoc(self, force_admin: bool) -> None:
|
||||||
if force_admin:
|
if force_admin:
|
||||||
self.output_area.append("Requesting elevation. Check for a UAC prompt...")
|
self.output_area.append("Requesting elevation. Check for a UAC prompt...")
|
||||||
|
|
||||||
self._assoc_worker = _AssocWorker(force_admin=force_admin, parent=self)
|
self._assoc_worker = _AssocWorker(force_admin=force_admin, parent=self)
|
||||||
self._assoc_worker.result_ready.connect(self._on_assoc_result)
|
self._assoc_worker.result_ready.connect(self._on_assoc_result)
|
||||||
self._assoc_worker.start()
|
self._assoc_worker.start()
|
||||||
|
|
||||||
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
|
||||||
@@ -182,7 +190,7 @@ class TerminalWindow(QWidget):
|
|||||||
return f"[Error] Target script not found at: {target_script}"
|
return f"[Error] Target script not found at: {target_script}"
|
||||||
|
|
||||||
self._process = QProcess(self)
|
self._process = QProcess(self)
|
||||||
|
|
||||||
# Stream stdout and stderr live to output_area
|
# Stream stdout and stderr live to output_area
|
||||||
self._process.readyReadStandardOutput.connect(self._handle_stdout)
|
self._process.readyReadStandardOutput.connect(self._handle_stdout)
|
||||||
self._process.readyReadStandardError.connect(self._handle_stderr)
|
self._process.readyReadStandardError.connect(self._handle_stderr)
|
||||||
@@ -190,7 +198,7 @@ class TerminalWindow(QWidget):
|
|||||||
|
|
||||||
# Use current Python interpreter executable. Works when packaged?
|
# Use current Python interpreter executable. Works when packaged?
|
||||||
python_executable = sys.executable
|
python_executable = sys.executable
|
||||||
|
|
||||||
self.output_area.append(f"Starting {target_script.name}...")
|
self.output_area.append(f"Starting {target_script.name}...")
|
||||||
self._process.start(python_executable, [str(target_script)])
|
self._process.start(python_executable, [str(target_script)])
|
||||||
return None
|
return None
|
||||||
|
|||||||
+229
-198
@@ -1,6 +1,7 @@
|
|||||||
"""
|
"""
|
||||||
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
|
||||||
@@ -9,27 +10,184 @@ License: GPL-3.0
|
|||||||
# Built-in imports
|
# 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 typing import Any, List, Optional, cast
|
from typing import Any, List, Optional, Sequence, Union, cast
|
||||||
|
|
||||||
# External library imports
|
# 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 #type: ignore
|
from mne.io import read_raw_snirf #type: ignore
|
||||||
from mne_nirs.io import write_raw_snirf #type: ignore
|
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: Optional[QWidget]=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
|
||||||
@@ -59,7 +217,7 @@ class UpdateEventsWindow(QWidget):
|
|||||||
self.label_events = QLabel("Events in selected observation:")
|
self.label_events = QLabel("Events in selected observation:")
|
||||||
self.combo_events = QComboBox()
|
self.combo_events = QComboBox()
|
||||||
self.combo_events.setEnabled(False)
|
self.combo_events.setEnabled(False)
|
||||||
|
|
||||||
self.label_snirf_events = QLabel("Events in SNIRF file:")
|
self.label_snirf_events = QLabel("Events in SNIRF file:")
|
||||||
self.combo_snirf_events = QComboBox()
|
self.combo_snirf_events = QComboBox()
|
||||||
self.combo_snirf_events.setEnabled(False)
|
self.combo_snirf_events.setEnabled(False)
|
||||||
@@ -69,13 +227,13 @@ class UpdateEventsWindow(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)
|
||||||
self.description.setTextInteractionFlags(Qt.TextInteractionFlag.TextBrowserInteraction)
|
self.description.setTextInteractionFlags(Qt.TextInteractionFlag.TextBrowserInteraction)
|
||||||
self.description.setOpenExternalLinks(True)
|
self.description.setOpenExternalLinks(True)
|
||||||
|
|
||||||
self.description.setText("The events that are present in a snirf file may not be the events that are to be studied and examined.<br>"
|
self.description.setText("The events that are present in a snirf file may not be the events that are to be studied and examined.<br>"
|
||||||
"Utilizing different software and video recordings, it is easy enough to see when an action actually occured in a file.<br>"
|
"Utilizing different software and video recordings, it is easy enough to see when an action actually occured in a file.<br>"
|
||||||
"The software <a href='https://www.boris.unito.it/'>BORIS</a> is used to add these events to video files, and these events can be applied to the snirf file <br>"
|
"The software <a href='https://www.boris.unito.it/'>BORIS</a> is used to add these events to video files, and these events can be applied to the snirf file <br>"
|
||||||
@@ -159,7 +317,7 @@ class UpdateEventsWindow(QWidget):
|
|||||||
help_btn_suffix.setToolTip(help_text_suffix)
|
help_btn_suffix.setToolTip(help_text_suffix)
|
||||||
help_btn_suffix.clicked.connect(lambda: self.show_help_popup(help_text_suffix))
|
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()
|
||||||
suffix2_container_layout = QHBoxLayout()
|
suffix2_container_layout = QHBoxLayout()
|
||||||
suffix2_container_layout.setContentsMargins(0, 0, 0, 0)
|
suffix2_container_layout.setContentsMargins(0, 0, 0, 0)
|
||||||
@@ -170,7 +328,7 @@ class UpdateEventsWindow(QWidget):
|
|||||||
|
|
||||||
suffix2_layout.addWidget(suffix2_container)
|
suffix2_layout.addWidget(suffix2_container)
|
||||||
layout.addLayout(suffix2_layout)
|
layout.addLayout(suffix2_layout)
|
||||||
|
|
||||||
snirf_events_layout = QHBoxLayout()
|
snirf_events_layout = QHBoxLayout()
|
||||||
|
|
||||||
help_text_snirf_events = "The event markers extracted from the SNIRF file."
|
help_text_snirf_events = "The event markers extracted from the SNIRF file."
|
||||||
@@ -210,12 +368,13 @@ class UpdateEventsWindow(QWidget):
|
|||||||
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)
|
||||||
@@ -239,7 +398,7 @@ class UpdateEventsWindow(QWidget):
|
|||||||
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)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
with open(file_path, 'r', encoding='utf-8') as f:
|
with open(file_path, 'r', encoding='utf-8') as f:
|
||||||
data = json.load(f)
|
data = json.load(f)
|
||||||
@@ -251,7 +410,8 @@ 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: dict[str, Any]) -> List[str]:
|
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.")
|
||||||
@@ -259,7 +419,7 @@ class UpdateEventsWindow(QWidget):
|
|||||||
observations = cast(dict[str, Any], data["observations"])
|
observations = cast(dict[str, Any], data["observations"])
|
||||||
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'):
|
||||||
@@ -274,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)
|
||||||
|
|
||||||
@@ -289,12 +454,12 @@ class UpdateEventsWindow(QWidget):
|
|||||||
def clear_files(self) -> None:
|
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) -> None:
|
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"
|
||||||
|
|
||||||
if not hasattr(self, "boris_data") or self.combo_events.count() == 0 or self.combo_snirf_events.count() == 0:
|
if not hasattr(self, "boris_data") or self.combo_events.count() == 0 or self.combo_snirf_events.count() == 0:
|
||||||
QMessageBox.warning(self, "Missing data", "Please make sure a BORIS and SNIRF event are selected.")
|
QMessageBox.warning(self, "Missing data", "Please make sure a BORIS and SNIRF event are selected.")
|
||||||
return
|
return
|
||||||
@@ -363,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}")
|
||||||
@@ -466,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: Optional[QWidget]=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
|
||||||
@@ -561,7 +600,7 @@ class UpdateEventsBlazesWindow(QWidget):
|
|||||||
self.label_events = QLabel("Events in selected blazes file:")
|
self.label_events = QLabel("Events in selected blazes file:")
|
||||||
self.combo_events = QComboBox()
|
self.combo_events = QComboBox()
|
||||||
self.combo_events.setEnabled(False)
|
self.combo_events.setEnabled(False)
|
||||||
|
|
||||||
self.label_snirf_events = QLabel("Events in SNIRF file:")
|
self.label_snirf_events = QLabel("Events in SNIRF file:")
|
||||||
self.combo_snirf_events = QComboBox()
|
self.combo_snirf_events = QComboBox()
|
||||||
self.combo_snirf_events.setEnabled(False)
|
self.combo_snirf_events.setEnabled(False)
|
||||||
@@ -571,13 +610,13 @@ class UpdateEventsBlazesWindow(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)
|
||||||
self.description.setTextInteractionFlags(Qt.TextInteractionFlag.TextBrowserInteraction)
|
self.description.setTextInteractionFlags(Qt.TextInteractionFlag.TextBrowserInteraction)
|
||||||
self.description.setOpenExternalLinks(True)
|
self.description.setOpenExternalLinks(True)
|
||||||
|
|
||||||
self.description.setText("The events that are present in a snirf file may not be the events that are to be studied and examined.<br>"
|
self.description.setText("The events that are present in a snirf file may not be the events that are to be studied and examined.<br>"
|
||||||
"Utilizing different software and video recordings, it is easy enough to see when an action actually occured in a file.<br>"
|
"Utilizing different software and video recordings, it is easy enough to see when an action actually occured in a file.<br>"
|
||||||
"The software <a href='https://git.research.dezeeuw.ca/tyler/blazes/'>BLAZES</a> is used to create these events in video files, and these events can be applied to the snirf file <br>"
|
"The software <a href='https://git.research.dezeeuw.ca/tyler/blazes/'>BLAZES</a> is used to create these events in video files, and these events can be applied to the snirf file <br>"
|
||||||
@@ -640,7 +679,7 @@ class UpdateEventsBlazesWindow(QWidget):
|
|||||||
help_btn_suffix.setToolTip(help_text_suffix)
|
help_btn_suffix.setToolTip(help_text_suffix)
|
||||||
help_btn_suffix.clicked.connect(lambda: self.show_help_popup(help_text_suffix))
|
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()
|
||||||
suffix2_container_layout = QHBoxLayout()
|
suffix2_container_layout = QHBoxLayout()
|
||||||
suffix2_container_layout.setContentsMargins(0, 0, 0, 0)
|
suffix2_container_layout.setContentsMargins(0, 0, 0, 0)
|
||||||
@@ -651,7 +690,7 @@ class UpdateEventsBlazesWindow(QWidget):
|
|||||||
|
|
||||||
suffix2_layout.addWidget(suffix2_container)
|
suffix2_layout.addWidget(suffix2_container)
|
||||||
layout.addLayout(suffix2_layout)
|
layout.addLayout(suffix2_layout)
|
||||||
|
|
||||||
snirf_events_layout = QHBoxLayout()
|
snirf_events_layout = QHBoxLayout()
|
||||||
|
|
||||||
help_text_snirf_events = "The event markers extracted from the SNIRF file."
|
help_text_snirf_events = "The event markers extracted from the SNIRF file."
|
||||||
@@ -692,12 +731,13 @@ class UpdateEventsBlazesWindow(QWidget):
|
|||||||
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)
|
||||||
@@ -721,7 +761,7 @@ class UpdateEventsBlazesWindow(QWidget):
|
|||||||
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)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
with open(file_path, 'r', encoding='utf-8') as f:
|
with open(file_path, 'r', encoding='utf-8') as f:
|
||||||
data = json.load(f)
|
data = json.load(f)
|
||||||
@@ -740,7 +780,7 @@ class UpdateEventsBlazesWindow(QWidget):
|
|||||||
QMessageBox.warning(self, "Error", f"Failed to parse JSON file:\n{e}")
|
QMessageBox.warning(self, "Error", f"Failed to parse JSON file:\n{e}")
|
||||||
self.combo_events.clear()
|
self.combo_events.clear()
|
||||||
self.combo_events.setEnabled(False)
|
self.combo_events.setEnabled(False)
|
||||||
|
|
||||||
|
|
||||||
def extract_json_observation_strings(self, data: dict[str, Any]) -> List[str]:
|
def extract_json_observation_strings(self, data: dict[str, Any]) -> List[str]:
|
||||||
if "events" not in data:
|
if "events" not in data:
|
||||||
@@ -748,28 +788,27 @@ class UpdateEventsBlazesWindow(QWidget):
|
|||||||
|
|
||||||
event_strings: List[str] = []
|
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) -> None:
|
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) -> None:
|
def go_action(self) -> None:
|
||||||
file_a = self.line_edit_file_a.text()
|
file_a = self.line_edit_file_a.text()
|
||||||
_ = 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:
|
||||||
QMessageBox.warning(self, "Missing data", "Please make sure a JSON and SNIRF event are selected.")
|
QMessageBox.warning(self, "Missing data", "Please make sure a JSON and SNIRF event are selected.")
|
||||||
return
|
return
|
||||||
@@ -792,21 +831,23 @@ 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:
|
||||||
events_list = self.json_data.get("events", [])
|
events_list = self.json_data.get("events", [])
|
||||||
|
|
||||||
for event in events_list:
|
for event in events_list:
|
||||||
track_name = event.get("track_name", "Unknown")
|
track_name = event.get("track_name", "Unknown")
|
||||||
clean_name = track_name.replace("AI: ", "").strip()
|
clean_name = track_name.replace("AI: ", "").strip()
|
||||||
|
|
||||||
original_start = event.get("start_sec", 0.0)
|
original_start = event.get("start_sec", 0.0)
|
||||||
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
|
||||||
@@ -829,24 +870,14 @@ class UpdateEventsBlazesWindow(QWidget):
|
|||||||
if self.mode == EventUpdateMode.WRITE_SNIRF:
|
if self.mode == EventUpdateMode.WRITE_SNIRF:
|
||||||
suggested_name = f"{os.path.splitext(os.path.basename(file_a))[0]}_{suffix}.snirf"
|
suggested_name = f"{os.path.splitext(os.path.basename(file_a))[0]}_{suffix}.snirf"
|
||||||
save_path, _ = QFileDialog.getSaveFileName(self, "Save SNIRF", suggested_name, "SNIRF Files (*.snirf)")
|
save_path, _ = QFileDialog.getSaveFileName(self, "Save SNIRF", suggested_name, "SNIRF Files (*.snirf)")
|
||||||
|
|
||||||
if not save_path: return
|
if not save_path: return
|
||||||
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:
|
||||||
|
|||||||
+15
-16
@@ -1,6 +1,6 @@
|
|||||||
"""
|
"""
|
||||||
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
|
Note: Compliant with pylance strict type checking
|
||||||
|
|
||||||
Author: Tyler de Zeeuw
|
Author: Tyler de Zeeuw
|
||||||
@@ -8,7 +8,6 @@ License: GPL-3.0
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
# Built-in imports
|
# Built-in imports
|
||||||
import os
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Dict, Optional, Union
|
from typing import Dict, Optional, Union
|
||||||
|
|
||||||
@@ -62,7 +61,7 @@ class UpdateOptodesWindow(QWidget):
|
|||||||
self.description.setTextFormat(Qt.TextFormat.RichText)
|
self.description.setTextFormat(Qt.TextFormat.RichText)
|
||||||
self.description.setTextInteractionFlags(Qt.TextInteractionFlag.TextBrowserInteraction)
|
self.description.setTextInteractionFlags(Qt.TextInteractionFlag.TextBrowserInteraction)
|
||||||
self.description.setOpenExternalLinks(False) # Handle the click internally
|
self.description.setOpenExternalLinks(False) # Handle the click internally
|
||||||
|
|
||||||
self.description.setText("Some software when creating snirf files will insert a template of optode positions as the correct position of the optodes for the participant.<br>"
|
self.description.setText("Some software when creating snirf files will insert a template of optode positions as the correct position of the optodes for the participant.<br>"
|
||||||
"This is rarely correct as each head differs slightly in shape or size, and a lot of calculations require the optodes to be in the correct location.<br>"
|
"This is rarely correct as each head differs slightly in shape or size, and a lot of calculations require the optodes to be in the correct location.<br>"
|
||||||
"Using a .txt or .xlsx file, we can update the positions in the snirf file to match those of a digitization system such as one from Polhemus or elsewhere.<br>"
|
"Using a .txt or .xlsx file, we can update the positions in the snirf file to match those of a digitization system such as one from Polhemus or elsewhere.<br>"
|
||||||
@@ -271,12 +270,12 @@ class UpdateOptodesWindow(QWidget):
|
|||||||
)
|
)
|
||||||
|
|
||||||
def update_optode_positions(
|
def update_optode_positions(
|
||||||
self,
|
self,
|
||||||
file_a: Union[str, Path],
|
file_a: Union[str, Path],
|
||||||
file_b: Union[str, Path],
|
file_b: Union[str, Path],
|
||||||
save_path: Union[str, Path]
|
save_path: Union[str, Path]
|
||||||
) -> None:
|
) -> None:
|
||||||
|
|
||||||
fiducials = {}
|
fiducials = {}
|
||||||
ch_positions = {}
|
ch_positions = {}
|
||||||
|
|
||||||
@@ -305,33 +304,33 @@ class UpdateOptodesWindow(QWidget):
|
|||||||
df = pd.read_excel(file_b, sheet_name='Sheet1') # type: ignore
|
df = pd.read_excel(file_b, sheet_name='Sheet1') # type: ignore
|
||||||
|
|
||||||
def _get_block_data(
|
def _get_block_data(
|
||||||
target_df: pd.DataFrame,
|
target_df: pd.DataFrame,
|
||||||
block_id: int,
|
block_id: int,
|
||||||
row_mapping: Union[Dict[int, str], str],
|
row_mapping: Union[Dict[int, str], str],
|
||||||
scale: float = 0.001
|
scale: float = 0.001
|
||||||
) -> Dict[str, npt.NDArray[np.float64]]:
|
) -> Dict[str, npt.NDArray[np.float64]]:
|
||||||
|
|
||||||
"""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 = target_df[target_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: Dict[str, npt.NDArray[np.float64]] = {}
|
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):
|
||||||
for row_idx, key in row_mapping.items():
|
for row_idx, key in row_mapping.items():
|
||||||
if row_idx < len(block):
|
if row_idx < len(block):
|
||||||
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
|
||||||
else:
|
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
|
||||||
|
|
||||||
return result
|
return result
|
||||||
|
|
||||||
# Identify blocks
|
# Identify blocks
|
||||||
is_empty = df.isnull().all(axis=1)
|
is_empty = df.isnull().all(axis=1)
|
||||||
df['block_id'] = is_empty.cumsum()
|
df['block_id'] = is_empty.cumsum()
|
||||||
|
|||||||
@@ -48,12 +48,12 @@ class ViewerLauncherWidget(QWidget):
|
|||||||
qc_dict: dict[str, dict[str, Any]],
|
qc_dict: dict[str, dict[str, Any]],
|
||||||
folding_bypass: bool,
|
folding_bypass: bool,
|
||||||
) -> None:
|
) -> 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: list[tuple[str, Type[QWidget], list[Any], bool]] = [
|
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),
|
||||||
@@ -64,7 +64,7 @@ class ViewerLauncherWidget(QWidget):
|
|||||||
("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),
|
||||||
("Intra-Group Brain and Image Viewer", IntraGroupBrainImageWidget, [haemo_dict, cha_dict, df_ind_dict, design_matrix_dict, contrast_results_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, 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),
|
||||||
("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, epochs_dict, cha_dict, df_ind_dict, design_matrix_dict, contrast_results_dict, group_dict, config_dict, fir_feature_dict, qc_dict], True)
|
||||||
]
|
]
|
||||||
|
|
||||||
layout = QVBoxLayout(self)
|
layout = QVBoxLayout(self)
|
||||||
@@ -84,23 +84,23 @@ class ViewerLauncherWidget(QWidget):
|
|||||||
def callback(_checked: bool = False) -> None:
|
def callback(_checked: bool = False) -> None:
|
||||||
self._open_viewer(widget_class, btn, *args)
|
self._open_viewer(widget_class, btn, *args)
|
||||||
return callback
|
return callback
|
||||||
|
|
||||||
def _open_viewer(
|
def _open_viewer(
|
||||||
self,
|
self,
|
||||||
widget_class: Type[QWidget],
|
widget_class: Type[QWidget],
|
||||||
btn: QPushButton,
|
btn: QPushButton,
|
||||||
*args: Any
|
*args: Any
|
||||||
) -> None:
|
) -> 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(
|
def _launch(
|
||||||
self,
|
self,
|
||||||
func: Callable[..., Any],
|
func: Callable[..., Any],
|
||||||
btn: QPushButton,
|
btn: QPushButton,
|
||||||
*args: Any
|
*args: Any
|
||||||
|
|
||||||
) -> None:
|
) -> None:
|
||||||
@@ -112,7 +112,7 @@ class ViewerLauncherWidget(QWidget):
|
|||||||
original_text = button.text()
|
original_text = button.text()
|
||||||
button.setText(f"{original_text} ✔")
|
button.setText(f"{original_text} ✔")
|
||||||
button.setStyleSheet("color: green; font-weight: bold;")
|
button.setStyleSheet("color: green; font-weight: bold;")
|
||||||
|
|
||||||
# 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))
|
||||||
|
|
||||||
|
|||||||
@@ -22,9 +22,9 @@ class WelcomeDialog(QDialog):
|
|||||||
self.setWindowTitle(f"What's New - {APP_NAME.upper()}")
|
self.setWindowTitle(f"What's New - {APP_NAME.upper()}")
|
||||||
self.setMinimumSize(550, 450)
|
self.setMinimumSize(550, 450)
|
||||||
self.resize(800, 500)
|
self.resize(800, 500)
|
||||||
|
|
||||||
layout = QVBoxLayout(self)
|
layout = QVBoxLayout(self)
|
||||||
|
|
||||||
header_layout = QHBoxLayout()
|
header_layout = QHBoxLayout()
|
||||||
logo_label = QLabel(self)
|
logo_label = QLabel(self)
|
||||||
|
|
||||||
@@ -41,7 +41,7 @@ class WelcomeDialog(QDialog):
|
|||||||
header_layout.addWidget(title_label)
|
header_layout.addWidget(title_label)
|
||||||
header_layout.addStretch()
|
header_layout.addStretch()
|
||||||
layout.addLayout(header_layout)
|
layout.addLayout(header_layout)
|
||||||
|
|
||||||
self.text_browser = QTextBrowser(self)
|
self.text_browser = QTextBrowser(self)
|
||||||
self.text_browser.setHtml("<p style='color: gray;'>Loading latest updates from server...</p>")
|
self.text_browser.setHtml("<p style='color: gray;'>Loading latest updates from server...</p>")
|
||||||
|
|
||||||
@@ -49,17 +49,17 @@ class WelcomeDialog(QDialog):
|
|||||||
self.text_browser.setOpenLinks(False)
|
self.text_browser.setOpenLinks(False)
|
||||||
self.text_browser.anchorClicked.connect(QDesktopServices.openUrl)
|
self.text_browser.anchorClicked.connect(QDesktopServices.openUrl)
|
||||||
layout.addWidget(self.text_browser)
|
layout.addWidget(self.text_browser)
|
||||||
|
|
||||||
footer_layout = QHBoxLayout()
|
footer_layout = QHBoxLayout()
|
||||||
|
|
||||||
ok_button = QPushButton("OK", self)
|
ok_button = QPushButton("OK", self)
|
||||||
ok_button.setDefault(True)
|
ok_button.setDefault(True)
|
||||||
ok_button.clicked.connect(self.accept)
|
ok_button.clicked.connect(self.accept)
|
||||||
|
|
||||||
footer_layout.addStretch()
|
footer_layout.addStretch()
|
||||||
footer_layout.addWidget(ok_button)
|
footer_layout.addWidget(ok_button)
|
||||||
layout.addLayout(footer_layout)
|
layout.addLayout(footer_layout)
|
||||||
|
|
||||||
# Fetch markdown from the web asynchronously
|
# Fetch markdown from the web asynchronously
|
||||||
self.network_manager = QNetworkAccessManager(self)
|
self.network_manager = QNetworkAccessManager(self)
|
||||||
self.network_manager.finished.connect(self._on_download_complete)
|
self.network_manager.finished.connect(self._on_download_complete)
|
||||||
|
|||||||
+1
-1
@@ -30,7 +30,7 @@ def parse_startup_args(argv: list[str]) -> StartupArgs:
|
|||||||
a fixed position, so it survives regardless of
|
a fixed position, so it survives regardless of
|
||||||
whether --finish-update precedes it.
|
whether --finish-update precedes it.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
finish_update = "--finish-update" in argv[1:]
|
finish_update = "--finish-update" in argv[1:]
|
||||||
|
|
||||||
initial_file = None
|
initial_file = None
|
||||||
|
|||||||
+153
-145
@@ -18,7 +18,8 @@ import zipfile
|
|||||||
import traceback
|
import traceback
|
||||||
import subprocess
|
import subprocess
|
||||||
import configparser
|
import configparser
|
||||||
from typing import List
|
from typing import List, Tuple, Optional, Callable, Any
|
||||||
|
|
||||||
|
|
||||||
# External library imports
|
# External library imports
|
||||||
import psutil
|
import psutil
|
||||||
@@ -26,7 +27,7 @@ import requests
|
|||||||
|
|
||||||
from PySide6.QtCore import QThread, Signal, QObject
|
from PySide6.QtCore import QThread, Signal, QObject
|
||||||
from PySide6.QtWidgets import QMainWindow, QMessageBox
|
from PySide6.QtWidgets import QMainWindow, QMessageBox
|
||||||
|
from src.shared.shareddata import get_app_dir
|
||||||
|
|
||||||
class UpdateDownloadThread(QThread):
|
class UpdateDownloadThread(QThread):
|
||||||
"""
|
"""
|
||||||
@@ -36,7 +37,7 @@ class UpdateDownloadThread(QThread):
|
|||||||
download_url (str): URL of the update zip file to download.
|
download_url (str): URL of the update zip file to download.
|
||||||
latest_version (str): Version string of the latest update.
|
latest_version (str): Version string of the latest update.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
update_ready = Signal(str, str)
|
update_ready = Signal(str, str)
|
||||||
error_occurred = Signal(str)
|
error_occurred = Signal(str)
|
||||||
|
|
||||||
@@ -47,14 +48,14 @@ class UpdateDownloadThread(QThread):
|
|||||||
platform_name: str,
|
platform_name: str,
|
||||||
app_name: str,
|
app_name: str,
|
||||||
) -> None:
|
) -> 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)
|
||||||
|
|
||||||
@@ -63,8 +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:
|
||||||
tmp_dir = os.getcwd()
|
tmp_dir = get_app_dir()
|
||||||
local_path = os.path.join(os.getcwd(), local_filename)
|
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:
|
||||||
@@ -78,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)
|
||||||
@@ -124,7 +124,7 @@ class UpdateCheckThread(QThread):
|
|||||||
platform_name: str,
|
platform_name: str,
|
||||||
app_name: str,
|
app_name: str,
|
||||||
) -> None:
|
) -> 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
|
||||||
@@ -132,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:
|
||||||
@@ -159,11 +156,10 @@ class UpdateCheckThread(QThread):
|
|||||||
return [int(x) for x in v.split(".")]
|
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()
|
||||||
@@ -175,7 +171,7 @@ class UpdateCheckThread(QThread):
|
|||||||
|
|
||||||
if not latest:
|
if not latest:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
tag = latest["tag_name"].lstrip("v")
|
tag = latest["tag_name"].lstrip("v")
|
||||||
|
|
||||||
for asset in latest.get("assets", []):
|
for asset in latest.get("assets", []):
|
||||||
@@ -207,7 +203,7 @@ class LocalPendingUpdateCheckThread(QThread):
|
|||||||
platform_name: str,
|
platform_name: str,
|
||||||
app_name: str,
|
app_name: str,
|
||||||
) -> None:
|
) -> 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
|
||||||
@@ -219,11 +215,11 @@ class LocalPendingUpdateCheckThread(QThread):
|
|||||||
return [int(x) for x in v.split(".")]
|
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
|
||||||
@@ -239,9 +235,9 @@ 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()
|
||||||
|
|
||||||
@@ -251,7 +247,7 @@ 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__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
main_window: QMainWindow,
|
main_window: QMainWindow,
|
||||||
@@ -262,7 +258,7 @@ class UpdateManager(QObject):
|
|||||||
platform_suffix: str,
|
platform_suffix: str,
|
||||||
app_name: str,
|
app_name: str,
|
||||||
) -> None:
|
) -> None:
|
||||||
|
|
||||||
super().__init__(main_window)
|
super().__init__(main_window)
|
||||||
self.main_window: QMainWindow = main_window
|
self.main_window: QMainWindow = main_window
|
||||||
self.api_url = api_url
|
self.api_url = api_url
|
||||||
@@ -271,10 +267,9 @@ class UpdateManager(QObject):
|
|||||||
self.platform_name = platform_name
|
self.platform_name = platform_name
|
||||||
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_path = None
|
|
||||||
|
|
||||||
|
self.pending_update_version: Optional[str] = None
|
||||||
|
self.pending_update_path: Optional[str] = None
|
||||||
|
|
||||||
def manual_check_for_updates(self) -> None:
|
def manual_check_for_updates(self) -> None:
|
||||||
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)
|
||||||
@@ -283,14 +278,15 @@ class UpdateManager(QObject):
|
|||||||
self.local_check_thread.start()
|
self.local_check_thread.start()
|
||||||
|
|
||||||
def on_pending_update_found(self, version: str, folder_path: str) -> None:
|
def on_pending_update_found(self, version: str, folder_path: str) -> None:
|
||||||
self.main_window.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) -> None:
|
def on_no_pending_update(self) -> None:
|
||||||
# No pending update found locally, start server check directly
|
if self.main_window.statusBar():
|
||||||
self.main_window.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) -> None:
|
def show_pending_update_popup(self) -> None:
|
||||||
@@ -306,7 +302,6 @@ class UpdateManager(QObject):
|
|||||||
else:
|
else:
|
||||||
if self.main_window.statusBar():
|
if self.main_window.statusBar():
|
||||||
self.main_window.statusBar().showMessage("Pending update available. Install later.")
|
self.main_window.statusBar().showMessage("Pending update available. Install later.")
|
||||||
# After user dismisses, still check the server for new updates
|
|
||||||
self.start_update_check_thread()
|
self.start_update_check_thread()
|
||||||
|
|
||||||
def start_update_check_thread(self) -> None:
|
def start_update_check_thread(self) -> None:
|
||||||
@@ -327,14 +322,16 @@ class UpdateManager(QObject):
|
|||||||
if pending_version and pending_path:
|
if pending_version and pending_path:
|
||||||
cmp = self.version_compare(latest_version, pending_version)
|
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.main_window.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(pending_path)
|
shutil.rmtree(pending_path)
|
||||||
self.main_window.statusBar().showMessage(f"Deleted old update folder: {pending_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.main_window.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
|
||||||
self.pending_update_path = None
|
self.pending_update_path = None
|
||||||
@@ -342,8 +339,8 @@ 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.main_window.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.main_window.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.")
|
||||||
@@ -374,24 +371,26 @@ class UpdateManager(QObject):
|
|||||||
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.main_window.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:
|
def install_update(self, extract_folder: str) -> None:
|
||||||
# Path to updater executable
|
base_dir = get_app_dir()
|
||||||
|
|
||||||
|
# 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.main_window, "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)}")
|
||||||
@@ -399,8 +398,7 @@ class UpdateManager(QObject):
|
|||||||
|
|
||||||
# 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}"')
|
||||||
|
|
||||||
@@ -457,121 +455,131 @@ def wait_for_process_to_exit(process_name: str, timeout: int = 10) -> bool:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
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:
|
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:
|
|
||||||
update_cfg.read(cfg_path)
|
# 1. Reset welcome dialog config flag
|
||||||
|
update_cfg = configparser.ConfigParser()
|
||||||
|
try:
|
||||||
|
if os.path.exists(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':
|
|
||||||
update_folder = os.path.join(update_folder, f"{app_name}-darwin")
|
|
||||||
|
|
||||||
# 2. Wait for updater to exit
|
primary_update_folder = update_folders[0]
|
||||||
print(f"Waiting for {app_name}_updater to exit...")
|
target_updater_folder = os.path.join(primary_update_folder, f"{app_name}-darwin") if platform_name == 'darwin' else primary_update_folder
|
||||||
for proc in psutil.process_iter(['pid', 'name']):
|
|
||||||
if proc.info['name'] and f"{app_name}_updater" in proc.info['name'].lower():
|
|
||||||
try:
|
|
||||||
proc.wait(timeout=5)
|
|
||||||
except psutil.TimeoutExpired:
|
|
||||||
print(f"Force killing lingering {app_name}_updater")
|
|
||||||
proc.kill()
|
|
||||||
|
|
||||||
# 3. Replace the updater
|
# 3. Wait for updater process to exit
|
||||||
if platform_name == 'windows':
|
print(f"Waiting for {app_name}_updater to exit...")
|
||||||
new_updater = os.path.join(update_folder, f"{app_name}_updater.exe")
|
updater_bin_name = f"{app_name}_updater"
|
||||||
dest_updater = os.path.join(app_dir, f"{app_name}_updater.exe")
|
for proc in psutil.process_iter(['pid', 'name']):
|
||||||
|
|
||||||
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")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user