93 Commits
Author SHA1 Message Date
tyler d2a595d0d0 release for 1.6.0 2026-08-10 18:00:22 -07:00
tyler 1e7fca49f2 fixes before release 2026-08-10 16:47:32 -07:00
tyler 074a0681b9 typos, standardization, and file association for project extensions 2026-08-06 02:00:36 -07:00
tyler 68e34484ee late and too many typos to ignore 2026-08-03 22:39:57 -07:00
tyler a0199737f5 improved project saving capabilities 2026-08-03 22:34:41 -07:00
tyler 0b52d2b0bc more metadata 2026-08-03 00:46:18 -07:00
tyler 7c456e31e7 fix to stats when no json file is defined 2026-08-03 00:01:21 -07:00
tyler 61a9ea2f34 fix rare bug 2026-08-01 19:20:49 -07:00
tyler defd4ec1c8 1.5.2 time 2026-07-31 01:17:29 -07:00
tyler 3e0d83ca9b implement the new preferences in the backend 2026-07-30 16:15:33 -07:00
tyler ebd13927e2 new preferences and bug fixes 2026-07-30 14:18:26 -07:00
tyler f15a5d9433 improvements for 1.5.2 2026-07-29 17:20:23 -07:00
tyler 8b017005c5 pylance standardization 2026-07-21 00:54:51 -07:00
tyler 2b019c1bc0 preparation for 1.5.1 2026-07-17 23:59:42 -07:00
tyler 0680718398 unhardcoding and general cleanup 2026-07-17 00:07:56 -07:00
tyler 2ff7cda93a more stats 2026-07-15 22:45:07 -07:00
tyler d300dfb7f7 continuation of the rewrite 2026-07-15 21:43:22 -07:00
tyler 12afc5d3bc small rewrite to impose dry principles 2026-07-15 21:21:35 -07:00
tyler ffa14693b3 start to figure out stats 2026-07-15 16:24:13 -07:00
tyler afde775b17 almost done 1.5.0 2026-06-28 17:17:35 -07:00
tyler 2708c78f8e fix for packed images 2026-06-28 13:31:50 -07:00
tyler b5de8709de testing changes 2026-06-28 12:58:38 -07:00
tyler ddb30d98f2 mostly finish rewrite 2026-06-28 10:10:27 -07:00
tyler f10eb64332 cleanup what I just did 2026-06-28 09:09:06 -07:00
tyler 617daa0e9b partial rewrite into multiple files 2026-06-28 08:57:38 -07:00
tyler 766bf75dd7 massive changes for 1.5.0 2026-06-28 08:17:36 -07:00
tyler 57b7082564 added second changelog that just contains lines from most recent major update 2026-06-27 21:43:01 -07:00
tyler e1c7e1a0e6 changelog 1.4.3 2026-05-01 11:42:04 -07:00
tyler 69f3df8921 imptovements for blazes and fix fold again 2026-05-01 11:40:34 -07:00
tyler d74be5cdec note for updating optode placements 2026-04-15 16:17:01 -07:00
tyler 0f6434121f qol improvements + optode location fix 2026-04-15 16:15:15 -07:00
tyler 8d922ecae9 fix for quick update that occured 2026-04-14 08:01:23 -07:00
tyler 8655115cca fix to too many processes 2026-03-30 14:12:35 -07:00
tyler 74ce2eeb2e final changes for new release 2026-03-28 14:30:20 -07:00
tyler 70c4c1e779 fix zombie process & added more quality metrics 2026-03-27 08:10:34 -07:00
tyler 94be2fb921 delete unneeded method and comment out unused params 2026-03-24 16:51:55 -07:00
tyler 7ef07678ae no more zombie + additional bad channel detection underneath 2026-03-24 16:50:30 -07:00
tyler f2c236bec5 fix logic errors 2026-03-24 12:47:10 -07:00
tyler 46255e6aa3 quick changelog improvements 2026-03-24 11:06:46 -07:00
tyler 3a0eaef30d new saving format again 2026-03-24 11:04:14 -07:00
tyler 868a67b44b raised issue fixes 2026-03-23 23:44:34 -07:00
tyler 9a32c8e301 issue fixes 2026-03-23 22:18:22 -07:00
tyler 3afcb54b77 mac + windows standalone craching fixes 2026-03-23 16:49:02 -07:00
tyler 9fe4a4dc30 changes to ignore 2026-03-23 13:35:08 -07:00
tyler 8d893f846e fold fix 2026-03-21 22:22:01 -07:00
tyler 540793d150 too many fixes to document just read changelog 2026-03-21 15:26:59 -07:00
tyler 4922405138 fix to design matrix parameters 2026-03-17 12:50:43 -07:00
tyler 1b23f2f81a Merge branch 'main' of https://git.research.dezeeuw.ca/tyler/flares 2026-03-17 12:48:44 -07:00
tyler 3f1161a03a fix to support both xlsx and txt in same dropdown, update to git ignore 2026-03-17 12:48:42 -07:00
tyler 1b5c31e4ef add pyinstaller to dependcies 2026-03-16 14:20:39 -07:00
tyler 276e1b1876 fix for xlsx and upgrade dependencies 2026-03-16 14:08:21 -07:00
tyler 3cfc6f01a2 typo 2026-03-13 16:57:51 -07:00
tyler 91fb9b38d4 copyright 2026-03-13 16:55:49 -07:00
tyler 1e346f8ef2 generalization and status bar fix 2026-03-13 14:07:40 -07:00
tyler 8c207b17ad more generalization 2026-03-13 13:40:49 -07:00
tyler f794b98d18 changelog and generalization of updater exe 2026-03-13 13:14:00 -07:00
tyler 06972bee28 plz 2026-03-13 13:01:47 -07:00
tyler c279f269da update logic seperated and blazes import 2026-03-13 13:00:57 -07:00
tyler c7d044beed group fc 2026-02-03 17:29:34 -08:00
tyler 76df19f332 further issue fixes 2026-02-02 13:08:00 -08:00
tyler 22695a2281 functions instead of repeating 6 times 2026-02-01 14:12:46 -08:00
tyler f1dd9bd184 release worthy? 2026-01-31 23:42:49 -08:00
tyler dd2ac058af temp fc 2026-01-30 20:16:55 -08:00
tyler 98c749477c preferences 2026-01-30 15:38:12 -08:00
tyler 92973da658 fix to fold and memory leaks 2026-01-29 22:30:28 -08:00
tyler f82978e2e8 changes and improvements 2026-01-29 17:23:52 -08:00
tyler 7007478c3b update ignore 2026-01-28 10:10:26 -08:00
tyler fb728d5033 added support updating optode positions from .xlsx 2026-01-28 10:09:06 -08:00
tyler 1b78f1904d further variable changes 2026-01-23 11:25:01 -08:00
tyler 9779a63a9c crash and documentation fixes 2026-01-15 12:04:55 -08:00
tyler 2ecd357aca fix bad dependency 2026-01-14 23:57:54 -08:00
tyler fe4e8904b4 improvements 2026-01-14 23:54:03 -08:00
tyler 473c945563 fix for desktop windows 2025-11-30 15:42:56 -08:00
tyler 64ed6d2e87 more parameters 2025-11-03 16:56:05 -08:00
tyler 1aa2402d09 updates to hr 2025-10-31 21:21:10 -07:00
tyler 45c6176dba quick bug fixes 2025-10-21 18:05:30 -07:00
tyler a4bbdb90c8 update to changelog for build 1.1.5 2025-10-20 16:08:34 -07:00
tyler 953ea90c67 fix to bandpass filter 2025-10-20 16:07:18 -07:00
tyler 20b255321b improvements 2025-10-20 09:33:50 -07:00
tyler b5afcec37d fixes to cross platform saves 2025-10-15 17:09:51 -07:00
tyler 5361f6ea21 changed the changelog 2025-10-15 16:12:51 -07:00
tyler ee023c26c1 changelog fix 2025-10-15 16:10:48 -07:00
tyler 06c9ff0ecf update changelog 2025-10-15 15:59:26 -07:00
tyler 542dd85a78 general fixes 2025-10-15 15:51:02 -07:00
tyler 3e0f70ea49 fixes to build version 1.1.3 2025-10-15 12:59:24 -07:00
tyler d6c71e0ab2 changelog fixes and further updates to cancel running process 2025-10-15 10:48:07 -07:00
tyler 87073fb218 more boris implementation 2025-10-15 10:00:44 -07:00
tyler 3d0fbd5c5e fix to boris events 2025-10-03 16:58:49 -07:00
tyler 3f38f5a978 updates for boris support 2025-09-26 14:01:32 -07:00
tyler 0607ced61e fixes 2025-09-12 16:22:12 -07:00
tyler b1e5405f7b holy rewrite 2025-08-25 14:34:42 -07:00
tyler e3c21cfb10 update for macos again 2025-08-19 15:17:50 -07:00
tyler 41c507e721 changes to macos misbehaving 2025-08-19 14:59:59 -07:00
52 changed files with 17056 additions and 5384 deletions
+8
View File
@@ -174,3 +174,11 @@ cython_debug/
# PyPI configuration file # PyPI configuration file
.pypirc .pypirc
/individual_images
*.xlsx
*.csv
*.snirf
*.json
flares-*
*.flare
*.cfg
+4 -4
View File
@@ -3,13 +3,13 @@ FLARES (fNIRS Lightweight Analysis, Research, & Evaluation Suite)
FLARES is a lightweight standalone application to extract meaningful data out of .snirf files. FLARES is a lightweight standalone application to extract meaningful data out of .snirf files.
FLARES is free and open-source software that runs on Windows, MacOS, and Linux. Please read the information regarding each operating system below. FLARES is free and open-source software that runs on Windows, macOS, and Linux. Please read the information regarding each operating system below.
Visit the official [FLARES web site](https://research.dezeeuw.ca/flares). Visit the official [FLARES web site](https://research.dezeeuw.ca/flares).
[![Python web site](https://img.shields.io/badge/Made%20with-Python-1f425f.svg)](https://www.python.org) [![Python web site](https://img.shields.io/badge/Made%20with-Python-1f425f.svg)](https://www.python.org)
# For MacOS Users # For macOS Users
Due to the cost of an Apple Developer account, the application is not certified by Apple. Once the application is extracted and attempted to be launched for the first time you will get a popup stating: Due to the cost of an Apple Developer account, the application is not certified by Apple. Once the application is extracted and attempted to be launched for the first time you will get a popup stating:
@@ -27,7 +27,7 @@ This only applies for the first time you attempt to run FLARES. Subsequent times
Due to the cost of a code signing certificate, the application is not digitally signed. Once the application is extracted and attempted to be launched for the first time you will get a popup stating: Due to the cost of a code signing certificate, the application is not digitally signed. Once the application is extracted and attempted to be launched for the first time you will get a popup stating:
"Windows protected your PC - Microsoft Defender SmartScreen prevented an unrecognized app from starting. Running this app might put your PC at risk.", with the options of" More info" or "Don't run". "Windows protected your PC - Microsoft Defender SmartScreen prevented an unrecognized app from starting. Running this app might put your PC at risk.", with the options of "More info" or "Don't run".
The solution around this is to click "More info" and then select "Run anyway". The solution around this is to click "More info" and then select "Run anyway".
@@ -41,4 +41,4 @@ There are no conditions for Linux users at this time.
FLARES is distributed under the GPL-3.0 license. FLARES is distributed under the GPL-3.0 license.
Copyright (C) 2025 Tyler de Zeeuw Copyright (C) 2025-2026 Tyler de Zeeuw
+353 -1
View File
@@ -1,7 +1,359 @@
# Version 1.6.0
- This is potentially a save-changing release due to adding more data into the save file, renaming existing data, and changing what part of the code saves data. Please update your project files to ensure compatibility
- It is still possible to load older saves by enabling 'Incompatible Save Bypass' from the Preferences menu, but your mileage may vary
- Optimized calculations being performed when calculating the Heart Rate to speed up step 5 by up to ~35% on a per-file basis
- Optimized calculations being performed when running the General Linear Model to speed up step 5 by up to ~35% on a per-file basis
- The Group Stats Viewer windows will now properly load the Right/Left or Front/Back fallback ROIs if JSON_LOCATION is not set
- Changed the warning message for lots of short channels and changed when it shows to be percentage based (35%) instead of numerical based (6)
- Renames GENDER to SEX to better represent the values that the field expects and to be more BIDS compliant
- Changed the Assign Groups by AGE window to now be the Assign Groups by Metadata window and added support for grouping by other metadata values
- Added a new metadata value of HAND to allow for grouping participants based on their handiness
- Updated the layout of the participant metadata area to better accommodate current and future metadata values
- Updated the message displayed when "Why are these useful?" is clicked to better represent what the values are used for and to accommodate the new metadata values
- Changed the application title to now show what project you are working on. If you are not working on a project, it will display 'Untitled'
- Tracking of current save states is now present and an asterisk will now appear in the title bar when a project has not been saved
- Currently tracking is only supported on loaded files, parameters on the right side of the screen, and when processing has completed. Per-file metadata or processing stages are not currently implemented
- Clicking close on the Main Window will now properly close all sub windows when closing the application and closing the application with unsaved changes will now prompt to save a project or discard changes. Fixes [Issue 87](https://git.research.dezeeuw.ca/tyler/flares/issues/87)
- The Save button under the File menu no longer asks for a destination if a saved project was already loaded. Fixes [Issue 72](https://git.research.dezeeuw.ca/tyler/flares/issues/72)
- Added an icon to the 'Toggle Status Bar' view option
- Status bar visibility and window layout sizes are now stored between application sessions. Fixes [Issue 88](https://git.research.dezeeuw.ca/tyler/flares/issues/88)
- The popup that appeared when a project was saved has now been moved to a message displayed from the status bar
- Moved all loading of files, loading of folders, loading of projects, and saving of projects to a new file for easier future development
- Changed the working of some messages on the status bar to better reflect what has occurred
- Added a new terminal command 'assoc' to associate .flare files to the application. This is undergoing testing and may not function correctly.
- Removed the debug flag from the application package, which may speed up the application. Fixes [Issue 11](https://git.research.dezeeuw.ca/tyler/flares/issues/11)
- Fixed an issue where the two Group Stats Viewer windows would crash the application once attempted to be opened if JSON_LOCATION was not set
- Fixed an issue where participants could be skipped when processing multiple participants at once which could prevent overall processing from completing
- Fixed an issue where when loading a save file some list dropdowns could go blue and bold even if the value was default
- Fixed an issue where the Analysis button would not be clickable if the user had previously pushed Clear. Fixes [Issue 83](https://git.research.dezeeuw.ca/tyler/flares/issues/83)
- Fixed an issue where removing all files via their right click feature would cause the Process button to still be visible. Fixes [Issue 91](https://git.research.dezeeuw.ca/tyler/flares/issues/91)
- Fixed an issue where some popup windows would not properly display the application name when they would open
- Fixed an issue where the welcome dialog image would not display correctly on macOS
- Fixed an issue where the Linux version would fail at 0 seconds when attempting to process files
- Fixed an issue where fOLDing channels could cause the same channels to be repeated misaligning the labels
- Fixed all instances of the word MacOS to now read macOS to match Apple branding
# Version 1.5.2
- Opening both a file or a folder now contains support for reading some metadata from the BIDS structure. Both options will still function if this metadata is not present
- Currently only the AGE metadata is grabbed and is auto populated into the participants AGE field and displayed on their bubble
- If the participants have metadata, a popup will be displayed asking if the user wants to group the participants by their metadata
- If the user wants to create groups, a dialog box is presented allowing them to do so. Once completed, the GROUP value will be automatically applied to all applicable participants
- A new options menu item has been added: Regroup Files from Metadata. This will allow the dialog to be opened at a later time with a popup appearing if there is no metadata present
- Added two new options to the Export to CSV Viewer: Export Configuration to CSV and Paragraph of Configuration
- Export Configuration to CSV will export the parameters utilized for each file into a CSV formatted file to provide additional validation of what parameters were used
- Paragraph of Configuration will generate a ready-to-go paragraph explaining in a more friendly and easy to follow manner of what parameters were used
- Chunked the loading of folders to provide more feedback to the user instead of hanging the application waiting for all files to load
- New parameters have been added to the right side of the screen! This allows for more flexibility and customizability when processing
- A new Preference Menu option has been added: Show Advanced Parameters. This keeps some of the parameters hidden when not checked. Since this is a preference, it will be saved when reopening the application
- Advanced parameters should only be changed if you know what you are doing, and will have a yellow warning symbol next to them to avoid potential confusion on what parameters are advanced
- Optimized some of the calculations in Scalp Coupling Index to speed up Step 6 by up to ~25% on a per-file basis
- Removed duplicate/redundant calculations in Peak Spectral Power to speed up Step 8 by up to ~50% on a per-file basis
- Changed how the figures are generated when processing to speed up Step 28 by up to ~85% on a per-file basis
- Removed unused methods inside the processing file to slightly speed up application load time
- Fixed an issue with the build script not properly updating the version string causing the application to falsely think that an update was always available
- Fixed an issue where parameters that were dependent on SHORT_CHANNELS were not properly being updated
- Fixed an issue where files that failed processing were not having their progress bar turn red at the step that failed
- Fixed an issue where progress bars were not updating when MAX_WORKERS was set to a value higher than default
- Fixed an issue where a high value of MAX_WORKERS was never hit the targeted value due to not being able to spawn the workers fast enough
- Fixed an issue causing numerous 'Invalid File' popups to appear upon closing the application while files were still having their initial metadata being grabbed
- Fixed an issue where both 'Update events in snirf file...' windows could not be open at the same time
- Fixed an issue where externally updating a participant's metadata when they were selected would cause the application to crash
- Fixed an issue where there was a redundant checking of dependencies when the parameters on the right side of the screen were being calculated
- Fixed an issue in which clicking menu options too fast would cause the application to hard crash
- Added a time elapsed message to the status bar denoting how long processing has been going on for. When processing is complete, it will change to be the time elapsed
- Added a speedup multiplier to show how much time was saved by having MAX_WORKERS not set to 1. This multiplier is calculated using a naive approach and may not be accurate
- Removed redundant font styling when parameters were changed from their default values
# Version 1.5.1
- Opening a folder is now minimally BIDS compatible. It will recursively go through all folders, but not load external metadata
- Renamed options in the Viewer Launcher window to better denote what actions they can perform
- 2 new analysis options have been added in the viewer launcher window: Inter-Group Stats and Cross-Group Stats
- Each of these analysis options have lots of text explaining what they do when their respective windows are opened
- The parameter input dialog in the analysis windows will now be able to scroll down when lots of parameters are required
- When a project is saved, it automatically will populate into the Recent Projects menu. Fixes [Issue 86](https://git.research.dezeeuw.ca/tyler/flares/issues/86)
- The description when clicking "Why are these useful?" underneath the per-file parameters is no longer placeholder text. Fixes [Issue 85](https://git.research.dezeeuw.ca/tyler/flares/issues/85)
- Added a new parameter section "Region of Interest". It has one parameter of "JSON_LOCATION", a json file containing Region of Interest data
- Temporarily removed the parameter REMOVE_EVENTS due to its functionality being removed because of a memory leak
- Fixed participant metadata remaining in the background when the participant was removed. Fixes [Issue 82](https://git.research.dezeeuw.ca/tyler/flares/issues/82)
- Fixed processing remaining active hidden in the background when the "Clear" button was pushed. Fixes [Issue 81](https://git.research.dezeeuw.ca/tyler/flares/issues/81)
- Now when "Clear" is pushed while data is processing, a popup will appear ensuring that the user understands that pushing "Clear" will stop processing
- Reset to Default Configuration will now properly reset all of the parameters to their default values. Fixes [Issue 90](https://git.research.dezeeuw.ca/tyler/flares/issues/90)
- A confirmation popup will now display once the application has been reset to default in addition to the status bar message
- Changed the improper display text of "Cross Validation" to now properly read as "Coefficient of Variation"
- Changed the parameters "CV" and "CV_THRESHOLD" to now be "COEFF_VAR and COEFF_VAR_THRESHOLD"
- Changed the improper display text of "Mean Absolute Deviation" to now properly read as "Median Absolute Deviation"
- Changed the parameters "SHORT_CHANNEL", "SHORT_CHANNEL_THRESH", and "LONG_CHANNEL_THRESH" to now be "SHORT_CHANNELS", "SHORT_CHANNELS_THRESHOLD", and "LONG_CHANNELS_THRESHOLD"
- Changed the parameter section "Channel Variance" to now be "Sensor Dropout" to better reflect the action it performs, not the method employed to calculate it
- Changed the parameters "CHANNEL_VAR" and "CHANNEL_THRESH" to now be "SENSOR_DROPOUT" and "SENSOR_DROPOUT_VARIANCE_THRESHOLD"
- Changed the messaged displayed when the application crashes to better reflect what occurred
- Changed the processing stages 25-27 and updates their messages to better reflect their actions that they now perform
- Changed stage 25 from "Generate Channel Significance" to "Generate Channel Results"
- Changed stage 26 from "Generate Channel, Region of Interest, and Contrast Results" to "Generate Region of Interest Results"
- Changed stage 27 from "Compute Contrast Results" to "Generate Contrast Results"
- Changed backend code to only load required methods and not load all methods every time
- Fixed string parameters not going blue and bold when their value was different than default
- Fixed all parameters not going blue and bold when a save file is loaded containing values that differ from default
- Fixed a crucial bug where short channels were not being processed and filtered the same way as long channels before being used as regressors
- Fixed a crucial bug where short channels were being presented to the design matrix as normal long channels
- Fixed a crucial bug where long channels could be interpolated from short channels. Short channels are still potentially interpolated from long channels. See [this link](https://git.research.dezeeuw.ca/tyler/flares/issues/80) for more information regarding this issue.
- Decreased unnecessary processing time when fOLDing channels by an order of magnitude. Fixes [Issue 84](https://git.research.dezeeuw.ca/tyler/flares/issues/84)
- Added a welcome message when the terminal is opened, resized the terminal, and added more commands
# Version 1.5.0
- This release introduces a new configuration file that may break existing installs. If your application does not update correctly, please download fresh from [this link.](https://git.research.dezeeuw.ca/tyler/flares/releases/)
- This release features an almost complete rewrite of backend files. If you encounter any problems, please raise an issue at [this link.](https://git.research.dezeeuw.ca/tyler/flares/issues/new)
- New configuration file has been added! Now your choices of preferences will be saved when the application is closed and re-opened. If the configuration file is missing, a new one will be generated
- Recent files and recent projects are now saved and appear under the File menu for quick resuming
- The new option "Reset to Default Configuration" under the Options menu will reset the configuration file back to its default values and remove any recent files
- A welcome dialog will now display the changelog on first startup and after every update. This popup will only appear once but can be reopened under the Options menu through the button "Show Update Changelog"
- Changed the hotkey for "Update optodes in snirf file..." to be F9 instead of F6
- Revamped the fOLD channels window. Images containing the pie charts are now interactable! Click whitespace to expand the whole image and click a chart to expand it.
- fOLD progress bar when processing now updates the percentages live. Fixes [Issue 76](https://git.research.dezeeuw.ca/tyler/flares/issues/76)
- Overall pie charts on an individual and global basis are now generated. Fixes [Issue 78](https://git.research.dezeeuw.ca/tyler/flares/issues/78)
- Brodmann images are now available when examining a pie chart to understand which area is being reported. Fixes [Issue 77](https://git.research.dezeeuw.ca/tyler/flares/issues/77)
- Added a new option 'Folding Bypass' to the Preferences Menu. This skips most processing steps and the only analysis option available will be to fold. Parameters on the right will be ignored. Fixes [Issue 75](https://git.research.dezeeuw.ca/tyler/flares/issues/75)
- Fixed an issue where the fOLD analysis progress window would go unresponsive before processing participants. Fixes [Issue 45](https://git.research.dezeeuw.ca/tyler/flares/issues/45), Fixes [Issue 34](https://git.research.dezeeuw.ca/tyler/flares/issues/34)
- Added a feature to hover over the 28-stage progress bar and see which state the progress bar is at. Fixes [Issue 74](https://git.research.dezeeuw.ca/tyler/flares/issues/74)
- Loading a broken snirf file no longer hangs its processing and can now be removed from the list. Fixes [Issue 73](https://git.research.dezeeuw.ca/tyler/flares/issues/73)
# Version 1.4.3
- Fixed an issue where the fOLD files could not be located
- Added better support for updating events from external software
# Version 1.4.2
- Fixed AGE, GENDER, GROUP not visually appearing on a bubble after the metadata has been set. Fixes [Issue 42](https://git.research.dezeeuw.ca/tyler/flares/issues/42)
- Fixed first stage of progress bar going yellow after loading from an unprocessed save
- Fixed AGE, GENDER, GROUP not visually appearing on a bubble when loading from a save
- Group images involving an optode template will now be the average of all participants selected in the group and not the last processed participant. Fixes [Issue 62](https://git.research.dezeeuw.ca/tyler/flares/issues/62)
- Group images will no longer crash if being made with participants that have a different number of channels
- Changed CSV files to now save to the same folder rather than asking each time for each file. Fixes [Issue 39](https://git.research.dezeeuw.ca/tyler/flares/issues/39)
# Version 1.4.1
- Hotfix to fix a recursive child loop that would cause the macOS version to not open
# Version 1.4.0
- This is potentially a save-changing release due to changes in how file paths and parameters are stored. Please update your project files to ensure compatibility
- It is still possible to load older saves by enabling 'Incompatible Save Bypass' from the Preferences menu, but your mileage may vary
- AGE, GENDER, GROUP, loaded files, and all the parameters on the right side of the screen can now be saved before any data has been processed
- If processing has not been completed, the process button will be visible. If processing has completed, the analysis button will be visible
- If the values fail to load, they will fallback to the previous logic of retrieving these values after processing has occurred. Fixes [Issue 66](https://git.research.dezeeuw.ca/tyler/flares/issues/66)
- Added new parameters to the right side of the screen: MAX_SHIFT, T_MIN, T_MAX, MAX_BAD_CHANNELS. Fixes [Issue 69](https://git.research.dezeeuw.ca/tyler/flares/issues/69)
- Participants that are bad on channels will no longer continue to the GLM stage. Fixes [Issue 18](https://git.research.dezeeuw.ca/tyler/flares/issues/18)
- Added new sections to the right side of the screen to mark bad channels: Cross Validation, Median Absolute Deviation, PSD Noise, & Channel Variance
- These sections come with the new parameters CV, CV_THRESHOLD, MAD, MAD_THRESHOLD, PSD_NOISE, TARGET_FREQ_DIV, DB_LIMIT, CHANNEL_VAR, & CHANNEL_THRESH
- Changed number of rectangles in the progress bar to 28 to account for the new options and updated the User Guide with the new stages
- Added feedback when clicking an analysis option that opens up a new window. Fixes [Issue 20](https://git.research.dezeeuw.ca/tyler/flares/issues/20)
- Fixed an issue where projects can not be saved to a different drive letter on windows. Fixes [Issue 71](https://git.research.dezeeuw.ca/tyler/flares/issues/71)
- Fixed an issue where the fOLD files were not included in the Windows version. Fixes [Issue 60](https://git.research.dezeeuw.ca/tyler/flares/issues/60)
- Fixed an issue where the macOS version would fail to perform some analysis options. Fixes [Issue 63](https://git.research.dezeeuw.ca/tyler/flares/issues/63)
- Fixed an issue where processing too many participants would cause the analysis button to not appear. Fixes [Issue 61](https://git.research.dezeeuw.ca/tyler/flares/issues/61)
- Fixed an issue where the error message when a participant fails would not appear. Fixes [Issue 68](https://git.research.dezeeuw.ca/tyler/flares/issues/68)
- Fixed an issue where changes would not be saved if a project was originally loaded from a save. Fixes [Issue 44](https://git.research.dezeeuw.ca/tyler/flares/issues/44)
- Fixed an issue where the significance image would be empty in the Inter-Group Viewer if only one participant was selected. Fixes [Issue 32](https://git.research.dezeeuw.ca/tyler/flares/issues/32)
- Fixed an issue where pressing the 'Clear' button after loading a save would cause the application to crash. Fixes [Issue 67](https://git.research.dezeeuw.ca/tyler/flares/issues/67)
- Fixed an issue where group dropdowns in the Cross-Group viewer would not be updated correctly based on the other groups selected value. Fixes [Issue 49](https://git.research.dezeeuw.ca/tyler/flares/issues/49)
- Fixed an issue where scrollbars were still present after clearing all data. Fixes [Issue 70](https://git.research.dezeeuw.ca/tyler/flares/issues/70)
- Fixed an issue where 'Missing Events Bypass' did not work on the Cross-Group viewer. Fixes [Issue 64](https://git.research.dezeeuw.ca/tyler/flares/issues/64)
- Fixed an issue where bubbles loaded from a save would not resize correctly. Fixes [Issue 14](https://git.research.dezeeuw.ca/tyler/flares/issues/14)
# Version 1.3.0
- This is potentially a save-changing release due to a new parameter being saved. Please update your project files to ensure compatibility
- It is still possible to load older saves by enabling 'Incompatible Save Bypass' from the Preferences menu, but your mileage may vary
- Fixed workers not releasing memory when processing multiple participants. Fixes [Issue 55](https://git.research.dezeeuw.ca/tyler/flares/issues/55)
- Fixed part of an issue where memory could increase over time despite clicking the clear button. There are still some edge cases where this can occur
- Fixed an issue when clearing a bubble, reloading the same file, and clicking it again would cause the app to crash. Fixes [Issue 57](https://git.research.dezeeuw.ca/tyler/flares/issues/57)
- Picking a .txt or .xlsx file now has both in the same file selection instead of having to select which extension was desired
- Added a new parameter to the right side of the screen: EPOCH_HANDLING. Fixes [Issue 58](https://git.research.dezeeuw.ca/tyler/flares/issues/58)
- EPOCH_HANDLING defaults to 'shift' compared to previous versions where the default would have been equivalent to 'strict'
- The label for ENHANCE_NEGATIVE_CORRELATION no longer gets cut off by its dropdown selection
- Loading in files and folders have changes to immediately show their bubbles having a respective loading symbol on each bubble
- Once the file has been completely loaded and processed, the loading symbol will change to a green checkmark and clicking will be enabled
- The metadata in the File information widget is now saved to prevent recalculations every time the bubble is selected
- The status bar will now say loading while the bubbles are being processed, and loaded once the processing has completed
- This new loading method will prevent the application from hanging when loading lots of files at once. Fixes [Issue 59](https://git.research.dezeeuw.ca/tyler/flares/issues/59)
- Fixed text alignment for the first paragraph when a bubble is selected in the 'File information' widget
- The three main widgets are now resizable! All of them have minimum widths to ensure they do not get too squished
- Added a new option 'Reset Window Layout' under the View menu that will resize all widgets back to their default sizes
- Added a new terminal command 'version' that will print the applications current version
# Version 1.2.2
- Added 'Update events in snirf file (BLAZES)...' and renamed 'Update events in snirf file...' to 'Update events in snirf file (BORIS)...' under the Options menu
- The BLAZES option will assign events that are exported directly from the software [BLAZES](https://git.research.dezeeuw.ca/tyler/blazes)
- Moved the updating logic to a separate file for better reusability and generalization
- Fixed 'Toggle Status Bar' having no effect on the visibility of the status bar
- Fixed a bug when updating optode positions that would prevent .txt files from being selected. Fixes [Issue 54](https://git.research.dezeeuw.ca/tyler/flares/issues/54)
- Fixed a missing dependency in the standalone application when attempting to use an .xlsx file to update optode positions
# Version 1.2.1
- Added a requirements.txt file to ensure compatibility
- Added new options 'Missing Events Bypass' and 'Analysis Clearing Bypass' to the Preferences Menu
- Missing Events Bypass allows comparing events in the Group Viewers even if not all participants in the group have the event present. Fixes [Issue 28](https://git.research.dezeeuw.ca/tyler/flares/issues/28)
- Clicking Process after an analysis has been performed will now clear the existing analysis by default with a popup warning that the analysis will be cleared
- Analysis Clearing Bypass will prevent the popup and will not clear the existing analysis data. Fixes [Issue 41](https://git.research.dezeeuw.ca/tyler/flares/issues/41)
- Clicking 'Clear' should now actually properly clear all data. Hopefully fixes [Issue 9](https://git.research.dezeeuw.ca/tyler/flares/issues/9) for good
- Setting SHORT_CHANNEL to False will now grey out SHORT_CHANNEL_REGRESSION, as it is impossible to regress what does not exist. Sets SHORT_CHANNEL_REGRESSION to False under the hood when it is greyed out regardless of what is displayed. Fixes [Issue 47](https://git.research.dezeeuw.ca/tyler/flares/issues/47)
- Projects can now be saves if files have different parent folders. Fixes [Issue 48](https://git.research.dezeeuw.ca/tyler/flares/issues/48)
- It is no longer possible to attempt a save before any data has been processed. A popup will now display if a save is attempted with nothing to save
- Fixed a bug where LONG_CHANNEL_THRESH was not being applied in the processing steps
- Added a new option in the Analysis window for Group Functional Connectivity. Implements [Issue 50](https://git.research.dezeeuw.ca/tyler/flares/issues/50)
- Group Functional connectivity is still in development and the results should currently be taken with a grain of salt
- A warning is displayed when entering the Group Functional Connectivity Viewer disclosing this
- Fixed a bug when updating optode positions that would prevent .txt files from being selected. Fixes [Issue 54](https://git.research.dezeeuw.ca/tyler/flares/issues/54)
- Fixed a bug where the secondary download server would never get contacted if the primary failed
- Automatic downloads will now ignore prerelease versions. Fixes [Issue 52](https://git.research.dezeeuw.ca/tyler/flares/issues/52)
# Version 1.2.0
- This is a save-breaking release due to a new save file format. Please update your project files to ensure compatibility. Fixes [Issue 30](https://git.research.dezeeuw.ca/tyler/flares/issues/30)
- Added new parameters to the right side of the screen
- These parameters include SHOW_OPTODE_NAMES, SECONDS_TO_STRIP_HR, MAX_LOW_HR, MAX_HIGH_HR, SMOOTHING_WINDOW_HR, HEART_RATE_WINDOW, BAD_CHANNELS_HANDLING, MAX_DIST, MIN_NEIGHBORS, L_TRANS_BANDWIDTH, H_TRANS_BANDWIDTH, RESAMPLE, RESAMPLE_FREQ, STIM_DUR, HRF_MODEL, HIGH_PASS, DRIFT_ORDER, FIR_DELAYS, MIN_ONSET, OVERSAMPLING, SHORT_CHANNEL_REGRESSION, NOISE_MODEL, BINS, and VERBOSITY.
- Certain parameters now have dependencies on other parameters and will now grey out if they are not used
- All the new parameters have default values matching the underlying values in version 1.1.7
- The order of the parameters has been changed to match the order that the code runs when the Process button is clicked
- Moved TIME_WINDOW_START and TIME_WINDOW_END to the 'Other' category
- Fixed a bug causing SCI to not work when HEART_RATE was set to False
- Bad channels can now be dealt with by taking no action, removing them completely, or interpolating them based on their neighbours. Interpolation remains the default option
- Fixed an underlying deprecation warning
- Fixed an issue causing some overlay elements to not render on the brain for certain devices
- Fixed a crash when rendering some Inter-Group images with only one participant in a group
- Fixed a crash when attempting to fOLD channels without the fOLD dataset installed
- Lowered the number of rectangles in the progress bar to 24 after combining some actions
- Fixed the User Guide window to properly display information about the 24 stages and added a link to the Git wiki page
- MAX_WORKERS should now properly respect the value set
- Added a new CSV export option to be used by other applications
- Added support for updating optode positions directly from an .xlsx file from a Polhemius system
- Fixed an issue where the dropdowns in the Viewer windows would immediately open and close when using a trackpad
- glover and spm HRF models now function as intended without crashing. Currently, group analysis is still only supported by fir. Fixes [Issue 8](https://git.research.dezeeuw.ca/tyler/flares/issues/8)
- Clicking 'Clear' should now properly clear all data. Fixes [Issue 9](https://git.research.dezeeuw.ca/tyler/flares/issues/9)
- Revamped the fold channels viewer to not hang the application and to better process multiple participants at once. Fixes [Issue 34](https://git.research.dezeeuw.ca/tyler/flares/issues/34), [Issue 31](https://git.research.dezeeuw.ca/tyler/flares/issues/31)
- Added a Preferences menu to the navigation bar
- Two preferences have been added allowing to bypass the warning of 2D data detected and save files being from previous, potentially breaking versions
- Fixed a typo when saving a CSV that stated a SNIRF was being saved
- Loading a save file now properly restores AGE, GENDER, and GROUP. Fixes [Issue 40](https://git.research.dezeeuw.ca/tyler/flares/issues/40)
- Saving a project now no longer makes the main window go not responding. Fixes [Issue 43](https://git.research.dezeeuw.ca/tyler/flares/issues/43)
- Memory usage should no longer grow when generating lots of images multiple times. Fixes [Issue 36](https://git.research.dezeeuw.ca/tyler/flares/issues/36)
- Added a new option in the Analysis window for Functional Connectivity
- Functional connectivity is still in development and the results should currently be taken with a grain of salt
- A warning is displayed when entering the Functional Connectivity Viewer disclosing this
# Version 1.1.7
- Fixed a bug where having both a L_FREQ and H_FREQ would cause only the L_FREQ to be used
- Changed the default H_FREQ from 0.7 to 0.3
- Added a PSD graph, along with 2 heart rate images to the individual participant viewer
- The PSD graph is used to help calculate the heart rate, whereas the other 2 are currently just for show
- SCI is now done using a .6hz window around the calculated heart rate compared to a window around an average heart rate
- Fixed an issue with some epochs figures not showing under the participant analysis
- Removed SECONDS_TO_STRIP from the preprocessing options
- Added new parameters to the right side of the screen
- These parameters include TRIM, SECONDS_TO_KEEP, OPTODE_PLACEMENT, HEART_RATE, WAVELET, IQR, WAVELET_TYPE, WAVELET_LEVEL, ENHANCE_NEGATIVE_CORRELATION, SHORT_CHANNEL_THRESH, LONG_CHANNEL_THRESH, and DRIFT_MODEL
- Changed number of rectangles in the progress bar to 25 to account for the new options
# Version 1.1.6
- Fixed Process button from appearing when no files are selected
- Fixed a bug that would cause an instant child process crash on Windows
- Added L_FREQ and H_FREQ parameters for more user control over low and high pass filtering
# Version 1.1.5
- Fixed Windows saves not being able to be opened by a Mac (hopefully the other way too!)
- Added the option to right click loaded snirf files to reveal them in a file browser or delete them if they are no longer desired
- Changed the way folders are opened to store the files separately rather than the folder as a whole to allow for the removal of files
- Fixed issues with dropdowns and bubbles not populating correctly when opening a single file and temporarily removed the option to open multiple folders
- Improved crash handling and the message that is displayed to the user if the application crashes
- Progress bar will now colour the stage that fails as red if a file fails during processing
- A warning message will be displayed when a file fails to process with information on what went wrong. This message does not halt the rest of the processing of the other files
- Fixed the number of rectangles in the progress bar to 20 (was incorrect in v1.1.1)
- Added validation to ensure loaded files do not have 2-dimensional data when clicking process to prevent inaccurate results from being generated
- Added more metadata information to the top left information panel
- Changed the Status Bar message when processing is complete to state how many were successful and how many were not
- Added a clickable link below the selected file's metadata explaining the independent parameters and why they are useful
- Updated some tooltips to provide better, more accurate information
- Added details about the processing steps and their order into the user guide
- Changed the default bandpass filtering parameters
# Version 1.1.4
- Fixed some display text to now display the correct information
- A new option under Analysis has been added to export the data from a specified participant as a csv file. Fixes [Issue 19](https://git.research.dezeeuw.ca/tyler/flares/issues/19), [Issue 27](https://git.research.dezeeuw.ca/tyler/flares/issues/27)
- Added 2 new parameters - TIME_WINDOW_START and TIME_WINDOW_END. Fixes [Issue 29](https://git.research.dezeeuw.ca/tyler/flares/issues/29)
- These parameters affect the visualization of the significance and contrast images but do not change the total time modeled underneath
- Fixed the duration of annotations edited from a BORIS file from 0 seconds to their proper duration
- Added the annotation information to each participant under their "File information" window
- Fixed Macs not being able to save snirfs attempting to be updated from BORIS files, and in general the updated files not respecting the path chosen by the user
# Version 1.1.3
- Added back the ability to use the fOLD dataset. Fixes [Issue 23](https://git.research.dezeeuw.ca/tyler/flares/issues/23)
- 5th option has been added under Analysis to get to fOLD channels per participant
- Added an option to cancel the running process. Fixes [Issue 15](https://git.research.dezeeuw.ca/tyler/flares/issues/15)
- Prevented graph images from showing when participants are being processed. Fixes [Issue 24](https://git.research.dezeeuw.ca/tyler/flares/issues/24)
- Allow the option to remove all events of a type from all loaded snirfs. Fixes [Issue 25](https://git.research.dezeeuw.ca/tyler/flares/issues/25)
- Added new icons in the menu bar
- Added a terminal to interact with the app in a more command-like form
- Currently the terminal has no functionality but some features for batch operations will be coming soon!
- Inter-Group viewer now has the option to visualize the average response on the brain of all participants in the group. Fixes [Issue 26](https://git.research.dezeeuw.ca/tyler/flares/issues/24)
- Fixed the description under "Update events in snirf file..."
# Version 1.1.2
- Fixed incorrect colormaps being applied
- Added functionality to utilize external event markers from a file. Fixes [Issue 6](https://git.research.dezeeuw.ca/tyler/flares/issues/6)
# Version 1.1.1
- Fixed the number of rectangles in the progress bar to 19
- Fixed a crash when attempting to load a brain image on Windows
- Removed hardcoded event annotations. Fixes [Issue 16](https://git.research.dezeeuw.ca/tyler/flares/issues/16)
# Version 1.1.0
- Revamped the Analysis window
- 4 Options of Participant, Participant Brain, Inter-Group, and Cross Group Brain are available.
- Customization is present to query different participants, images, events, brains, etc.
- Removed preprocessing options and reorganized their order to correlate with the actual order.
- Most preprocessing options removed will be coming back soon
- Added a group option when clicking on a participant's file
- If no group is specified, the participant will be added to the "Default" group
- Added option to update the optode positions in a snirf file from the Options menu (F6)
- Fixed [Issue 3](https://git.research.dezeeuw.ca/tyler/flares/issues/3), [Issue 5](https://git.research.dezeeuw.ca/tyler/flares/issues/5), [Issue 17](https://git.research.dezeeuw.ca/tyler/flares/issues/17), [Issue 21](https://git.research.dezeeuw.ca/tyler/flares/issues/21), [Issue 22](https://git.research.dezeeuw.ca/tyler/flares/issues/22)
# Version 1.0.1 # Version 1.0.1
- Two new options have been added when clicking on a participant's file. - Two new options have been added when clicking on a participant's file.
- First new option is Age which allows the option to specify age on a per-file basis to calculate DPF by using the Scholkmann & Wolf calculation instead of having one DPF for all files. Fixes [Issue 1](https://git.research.dezeeuw.ca/tyler/flares/issues/1). This also allows for proper calculation of DPF for the two seperate wavelengths. - First new option is Age which allows the option to specify age on a per-file basis to calculate DPF by using the Scholkmann & Wolf calculation instead of having one DPF for all files. Fixes [Issue 1](https://git.research.dezeeuw.ca/tyler/flares/issues/1). This also allows for proper calculation of DPF for the two separate wavelengths.
- If no age is set by the user, the code will fall back to the age of 25 - If no age is set by the user, the code will fall back to the age of 25
- Second new option is Gender to be used in future analysis options (currently not implemented). - Second new option is Gender to be used in future analysis options (currently not implemented).
- UI elements have been shuffled to make room for the new parameters. - UI elements have been shuffled to make room for the new parameters.
+35
View File
@@ -0,0 +1,35 @@
# Version 1.6.0
- This is potentially a save-changing release due to adding more data into the save file, renaming existing data, and changing what part of the code saves data. Please update your project files to ensure compatibility
- It is still possible to load older saves by enabling 'Incompatible Save Bypass' from the Preferences menu, but your mileage may vary
- Optimized calculations being performed when calculating the Heart Rate to speed up step 5 by up to ~35% on a per-file basis
- Optimized calculations being performed when running the General Linear Model to speed up step 5 by up to ~35% on a per-file basis
- The Group Stats Viewer windows will now properly load the Right/Left or Front/Back fallback ROIs if JSON_LOCATION is not set
- Changed the warning message for lots of short channels and changed when it shows to be percentage based (35%) instead of numerical based (6)
- Renames GENDER to SEX to better represent the values that the field expects and to be more BIDS compliant
- Changed the Assign Groups by AGE window to now be the Assign Groups by Metadata window and added support for grouping by other metadata values
- Added a new metadata value of HAND to allow for grouping participants based on their handiness
- Updated the layout of the participant metadata area to better accommodate current and future metadata values
- Updated the message displayed when "Why are these useful?" is clicked to better represent what the values are used for and to accommodate the new metadata values
- Changed the application title to now show what project you are working on. If you are not working on a project, it will display 'Untitled'
- Tracking of current save states is now present and an asterisk will now appear in the title bar when a project has not been saved
- Currently tracking is only supported on loaded files, parameters on the right side of the screen, and when processing has completed. Per-file metadata or processing stages are not currently implemented
- Clicking close on the Main Window will now properly close all sub windows when closing the application and closing the application with unsaved changes will now prompt to save a project or discard changes. Fixes [Issue 87](https://git.research.dezeeuw.ca/tyler/flares/issues/87)
- The Save button under the File menu no longer asks for a destination if a saved project was already loaded. Fixes [Issue 72](https://git.research.dezeeuw.ca/tyler/flares/issues/72)
- Added an icon to the 'Toggle Status Bar' view option
- Status bar visibility and window layout sizes are now stored between application sessions. Fixes [Issue 88](https://git.research.dezeeuw.ca/tyler/flares/issues/88)
- The popup that appeared when a project was saved has now been moved to a message displayed from the status bar
- Moved all loading of files, loading of folders, loading of projects, and saving of projects to a new file for easier future development
- Changed the working of some messages on the status bar to better reflect what has occurred
- Added a new terminal command 'assoc' to associate .flare files to the application. This is undergoing testing and may not function correctly.
- Removed the debug flag from the application package, which may speed up the application. Fixes [Issue 11](https://git.research.dezeeuw.ca/tyler/flares/issues/11)
- Fixed an issue where the two Group Stats Viewer windows would crash the application once attempted to be opened if JSON_LOCATION was not set
- Fixed an issue where participants could be skipped when processing multiple participants at once which could prevent overall processing from completing
- Fixed an issue where when loading a save file some list dropdowns could go blue and bold even if the value was default
- Fixed an issue where the Analysis button would not be clickable if the user had previously pushed Clear. Fixes [Issue 83](https://git.research.dezeeuw.ca/tyler/flares/issues/83)
- Fixed an issue where removing all files via their right click feature would cause the Process button to still be visible. Fixes [Issue 91](https://git.research.dezeeuw.ca/tyler/flares/issues/91)
- Fixed an issue where some popup windows would not properly display the application name when they would open
- Fixed an issue where the welcome dialog image would not display correctly on macOS
- Fixed an issue where the Linux version would fail at 0 seconds when attempting to process files
- Fixed an issue where fOLDing channels could cause the same channels to be repeated misaligning the labels
- Fixed all instances of the word MacOS to now read macOS to match Apple branding
-4107
View File
File diff suppressed because it is too large Load Diff
+393
View File
@@ -0,0 +1,393 @@
"""
Filename: file_ext_registration.py
Description: Registers the extension of project files with the application
Note: Compliant with pylance strict type checking
Author: Tyler de Zeeuw
License: GPL-3.0
"""
# Built-in imports
import os
import sys
import plistlib
import subprocess
from typing import Optional, Tuple
# External library imports
from src.shared.shareddata import APP_NAME, PLATFORM_NAME
ELEVATION_FLAG = "--register_file_association_elevated"
def register_file_association(ext: Optional[str] = None,
prog_id: Optional[str] = None,
app_name: Optional[str] = None,
bundle_id: Optional[str] = None,
force_admin: bool = False,
) -> Tuple[bool, str]:
"""
Registers a custom file extension across Windows, Linux, and macOS.
Handles non-admin Windows users by falling back to local user registry.
"""
clean_app = APP_NAME.replace(" ", "").lower()[:-1]
if ext is None:
ext = f".{clean_app}"
if prog_id is None:
prog_id = f"{APP_NAME.upper().replace(' ', '')}.ProjectFile"
if app_name is None:
app_name = APP_NAME
# Ensure extension starts with dot and contains no spaces
ext = f".{ext.lstrip('.').replace(' ', '').lower()}"
if PLATFORM_NAME == "windows":
return _register_windows(ext, prog_id, app_name, force_admin=force_admin)
elif PLATFORM_NAME == "linux":
return _register_linux(ext, prog_id, app_name)
elif PLATFORM_NAME == "darwin": # macOS
if bundle_id is None:
bundle_id = f"com.{clean_app}.app"
return _register_macos(ext, app_name, bundle_id)
else:
return False, f"Unsupported OS: {PLATFORM_NAME}"
def _dev_windowless_executable() -> str:
"""
Windows dev-mode only: returns pythonw.exe alongside the current
interpreter if it exists, otherwise falls back to sys.executable.
"""
if getattr(sys, 'frozen', False):
return sys.executable
exe_dir = os.path.dirname(sys.executable)
windowless = os.path.join(exe_dir, "pythonw.exe")
return windowless if os.path.exists(windowless) else sys.executable
def is_windows_admin() -> bool:
"""
Returns True if currently running elevated on Windows. Always False on
macOS/Linux, and False (rather than raising) if the check itself fails.
"""
if PLATFORM_NAME != "windows":
return False
import ctypes
try:
return ctypes.windll.shell32.IsUserAnAdmin() != 0
except Exception:
return False
def _relaunch_elevated(ext: str, prog_id: str, app_name: str) -> Optional[int]:
"""
Triggers a UAC prompt and re-launches this process elevated with the
registration args.
"""
import ctypes
from ctypes import wintypes
if getattr(sys, 'frozen', False):
exe = sys.executable
params = [ELEVATION_FLAG, ext, prog_id, app_name]
else:
exe = _dev_windowless_executable()
script_path = os.path.abspath(sys.argv[0])
params = [script_path, ELEVATION_FLAG, ext, prog_id, app_name]
param_str = " ".join(f'"{p}"' for p in params)
SEE_MASK_NOCLOSEPROCESS = 0x00000040
SW_SHOWNORMAL = 1
class SHELLEXECUTEINFO(ctypes.Structure):
_fields_ = [
("cbSize", wintypes.DWORD),
("fMask", ctypes.c_ulong),
("hwnd", wintypes.HWND),
("lpVerb", wintypes.LPCWSTR),
("lpFile", wintypes.LPCWSTR),
("lpParameters", wintypes.LPCWSTR),
("lpDirectory", wintypes.LPCWSTR),
("nShow", ctypes.c_int),
("hInstApp", wintypes.HINSTANCE),
("lpIDList", ctypes.c_void_p),
("lpClass", wintypes.LPCWSTR),
("hKeyClass", wintypes.HKEY),
("dwHotKey", wintypes.DWORD),
("hIconOrMonitor", wintypes.HANDLE),
("hProcess", wintypes.HANDLE),
]
sei = SHELLEXECUTEINFO()
sei.cbSize = ctypes.sizeof(sei)
sei.fMask = SEE_MASK_NOCLOSEPROCESS
sei.lpVerb = "runas"
sei.lpFile = exe
sei.lpParameters = param_str
sei.nShow = SW_SHOWNORMAL
if not ctypes.windll.shell32.ShellExecuteExW(ctypes.byref(sei)):
# User clicked "No" on the UAC prompt, or elevation failed outright.
return None
WAIT_INFINITE = 0xFFFFFFFF
ctypes.windll.kernel32.WaitForSingleObject(sei.hProcess, WAIT_INFINITE)
exit_code = wintypes.DWORD()
ctypes.windll.kernel32.GetExitCodeProcess(sei.hProcess, ctypes.byref(exit_code))
ctypes.windll.kernel32.CloseHandle(sei.hProcess)
return exit_code.value
def _delete_key_recursive(root_key: int, path: str) -> None:
"""Recursively deletes a registry key and all its subkeys, if present."""
import winreg
try:
with winreg.OpenKey(root_key, path, 0, winreg.KEY_ALL_ACCESS) as key:
while True:
try:
subkey_name = winreg.EnumKey(key, 0)
except OSError:
break
_delete_key_recursive(root_key, f"{path}\\{subkey_name}")
winreg.DeleteKey(root_key, path)
except FileNotFoundError:
pass
def _cleanup_stale_hkcu_entries(ext: str, prog_id: str) -> None:
"""
Removes any leftover per-user (HKCU) association for this ext/prog_id.
Windows prefers HKCU\\Software\\Classes over HKEY_CLASSES_ROOT for a
given user, so a stale non-admin dev-time registration can silently
keep shadowing a correct system-wide (admin) registration.
"""
import winreg
_delete_key_recursive(winreg.HKEY_CURRENT_USER, f"Software\\Classes\\{ext}")
_delete_key_recursive(winreg.HKEY_CURRENT_USER, f"Software\\Classes\\{prog_id}")
def _register_windows(ext: str, prog_id: str, app_name: str, force_admin: bool = False) -> Tuple[bool, str]:
"""
Registers the file extension with the application on Windows.
"""
import winreg
import ctypes
is_admin = is_windows_admin()
if force_admin and not is_admin:
exit_code = _relaunch_elevated(ext, prog_id, app_name)
if exit_code is None:
return False, "Elevation was cancelled or the UAC prompt could not be shown."
if exit_code != 0:
return False, f"Elevated registration process exited with code {exit_code}."
return True, f"Successfully registered {ext} on Windows (System-wide, via elevated relaunch)!"
# Windows is the one platform where unfrozen invocation is supported
if getattr(sys, 'frozen', False):
command_str = f'"{sys.executable}" "%1"'
else:
script_path = os.path.abspath(sys.argv[0])
command_str = f'"{_dev_windowless_executable()}" "{script_path}" "%1"'
root_key = winreg.HKEY_CLASSES_ROOT if is_admin else winreg.HKEY_CURRENT_USER
base_path = "" if is_admin else "Software\\Classes\\"
try:
# 1. Map extension -> ProgID
ext_path = f"{base_path}{ext}"
with winreg.CreateKey(root_key, ext_path) as key:
winreg.SetValue(key, "", winreg.REG_SZ, prog_id)
# 2. Add to OpenWithProgids (Forces Windows 10/11 to show in 'Open With')
open_with_path = f"{base_path}{ext}\\OpenWithProgids"
with winreg.CreateKey(root_key, open_with_path) as key:
winreg.SetValueEx(key, prog_id, 0, winreg.REG_SZ, "")
# 3. Set friendly type description (shown as "Type" in File Explorer)
type_description = f"{app_name.upper()} Project File"
prog_path = f"{base_path}{prog_id}"
with winreg.CreateKey(root_key, prog_path) as key:
winreg.SetValue(key, "", winreg.REG_SZ, type_description)
# 4. Set launch command
cmd_path = f"{base_path}{prog_id}\\shell\\open\\command"
with winreg.CreateKey(root_key, cmd_path) as key:
winreg.SetValue(key, "", winreg.REG_SZ, command_str)
# 5. Notify Shell (SHCNE_ASSOCCHANGED = 0x08000000, SHCNF_IDLIST = 0x0000)
try:
ctypes.windll.shell32.SHChangeNotify(0x08000000, 0x0000, 0, 0)
except Exception:
pass
# 6. If we just wrote a system-wide entry, clean up any stale per-user entry that would otherwise shadow it.
if is_admin:
try:
_cleanup_stale_hkcu_entries(ext, prog_id)
except Exception:
# Non-fatal: the system-wide write above already succeeded.
pass
scope = "System-wide" if is_admin else "Local User (Non-Admin)"
return True, f"Successfully registered {ext} on Windows ({scope})!"
except Exception as e:
return False, f"Windows Registration failed: {str(e)}"
# TODO: Validate
def _register_linux(ext: str, prog_id: str, app_name: str) -> Tuple[bool, str]:
"""
Registers the file extension with the application on Linux based platforms.
"""
clean_ext = ext.lstrip('.')
mime_type = f"application/x-{clean_ext}"
desktop_file_name = f"{prog_id.lower()}.desktop"
# Linux build is always a frozen PyInstaller executable
exe_path = f'"{sys.executable}"'
apps_dir = os.path.expanduser("~/.local/share/applications")
mime_dir = os.path.expanduser("~/.local/share/mime/packages")
os.makedirs(apps_dir, exist_ok=True)
os.makedirs(mime_dir, exist_ok=True)
try:
# 1. Create XML MIME definition
mime_xml_path = os.path.join(mime_dir, f"{prog_id.lower()}.xml")
xml_content = f"""<?xml version="1.0" encoding="UTF-8"?>
<mime-info xmlns="http://www.freedesktop.org/standards/shared-mime-info">
<mime-type type="{mime_type}">
<comment>{app_name} File</comment>
<glob pattern="*.{clean_ext}"/>
</mime-type>
</mime-info>"""
with open(mime_xml_path, "w") as f:
f.write(xml_content)
# 2. Create .desktop file
desktop_path = os.path.join(apps_dir, desktop_file_name)
desktop_content = f"""[Desktop Entry]
Name={app_name}
Exec={exe_path} %f
Type=Application
MimeType={mime_type};
Terminal=false
"""
with open(desktop_path, "w") as f:
f.write(desktop_content)
# 3. Update the shared-mime-info database and check it actually worked
mime_result = subprocess.run(
["update-mime-database", os.path.expanduser("~/.local/share/mime")],
capture_output=True, text=True,
)
if mime_result.returncode != 0:
return False, f"update-mime-database failed: {mime_result.stderr.strip()}"
# 4. Set as default handler and check it actually worked
xdg_result = subprocess.run(
["xdg-mime", "default", desktop_file_name, mime_type],
capture_output=True, text=True,
)
if xdg_result.returncode != 0:
return False, f"xdg-mime default failed: {xdg_result.stderr.strip()}"
return True, f"Successfully registered {ext} on Linux (Local User)!"
except FileNotFoundError as e:
return False, (
f"Required tool not found ({str(e)}). Is shared-mime-info / "
f"xdg-utils installed on this system?"
)
except Exception as e:
return False, f"Linux Registration failed: {str(e)}"
# TODO: Validate
def _register_macos(ext: str, app_name: str, bundle_id: str) -> Tuple[bool, str]:
"""
Registers the file extension with the application on macOS.
"""
if not getattr(sys, 'frozen', False):
return False, "macOS file association requires app to be packaged as a .app bundle."
exe_path = sys.executable
app_bundle_path = os.path.abspath(os.path.join(exe_path, "../../../"))
if not app_bundle_path.endswith(".app"):
return False, "Could not locate outer .app bundle."
info_plist_path = os.path.join(app_bundle_path, "Contents", "Info.plist")
if not os.path.exists(info_plist_path):
return False, f"Info.plist not found at {info_plist_path}"
clean_ext = ext.lstrip('.')
uti = f"{bundle_id}.{clean_ext}"
try:
with open(info_plist_path, "rb") as f:
plist = plistlib.load(f)
# Tell macOS this app can open files with our UTI
doc_types = plist.get("CFBundleDocumentTypes", [])
if not any(uti in dt.get("LSItemContentTypes", []) for dt in doc_types):
doc_types.append({
"CFBundleTypeName": f"{app_name} Project File",
"CFBundleTypeRole": "Editor",
"LSHandlerRank": "Owner",
"LSItemContentTypes": [uti],
})
plist["CFBundleDocumentTypes"] = doc_types
# Declare the UTI itself (required — without this the type is unknown to LS)
exported_types = plist.get("UTExportedTypeDeclarations", [])
if not any(t.get("UTTypeIdentifier") == uti for t in exported_types):
exported_types.append({
"UTTypeIdentifier": uti,
"UTTypeDescription": f"{app_name} File",
"UTTypeConformsTo": ["public.data"],
"UTTypeTagSpecification": {"public.filename-extension": [clean_ext]},
})
plist["UTExportedTypeDeclarations"] = exported_types
with open(info_plist_path, "wb") as f:
plistlib.dump(plist, f)
except Exception as e:
return False, f"Failed to update Info.plist: {str(e)}"
try:
lsregister_path = (
"/System/Library/Frameworks/CoreServices.framework/Frameworks/"
"LaunchServices.framework/Support/lsregister"
)
subprocess.run(
[lsregister_path, "-f", app_bundle_path],
check=True, capture_output=True,
)
return True, f"Registered {ext} with {app_bundle_path} via macOS Launch Services!"
except subprocess.CalledProcessError as e:
stderr = e.stderr.decode(errors="ignore") if e.stderr else str(e)
return False, f"macOS Launch Services registration failed: {stderr}"
except Exception as e:
return False, f"macOS Registration failed: {str(e)}"
+6102
View File
File diff suppressed because it is too large Load Diff
+6 -5
View File
@@ -18,11 +18,12 @@ import subprocess
from datetime import datetime from datetime import datetime
PLATFORM_NAME = platform.system().lower() PLATFORM_NAME = platform.system().lower()
APP_NAME = "flares"
if PLATFORM_NAME == 'darwin': if PLATFORM_NAME == 'darwin':
LOG_FILE = os.path.join(os.path.dirname(sys.executable), "../../../flares_updater.log") LOG_FILE = os.path.join(os.path.dirname(sys.executable), f"../../../{APP_NAME}_updater.log")
else: else:
LOG_FILE = os.path.join(os.getcwd(), "flares_updater.log") LOG_FILE = os.path.join(os.getcwd(), f"{APP_NAME}_updater.log")
def log(msg): def log(msg):
@@ -147,7 +148,7 @@ def copy_update_files_darwin(src_folder, dest_folder, updater_name):
def remove_quarantine(app_path): def remove_quarantine(app_path):
script = f''' script = f'''
do shell script "xattr -d -r com.apple.quarantine {shlex.quote(app_path)}" with administrator privileges with prompt "FLARES needs privileges to finish the update. (1/2)" do shell script "xattr -d -r com.apple.quarantine {shlex.quote(app_path)}" with administrator privileges with prompt "{APP_NAME} needs privileges to finish the update. (1/2)"
''' '''
try: try:
subprocess.run(['osascript', '-e', script], check=True) subprocess.run(['osascript', '-e', script], check=True)
@@ -162,7 +163,7 @@ def main():
log(f"[Updater] sys.argv: {sys.argv}") log(f"[Updater] sys.argv: {sys.argv}")
if len(sys.argv) != 3: if len(sys.argv) != 3:
log("Invalid arguments. Usage: flares_updater <update_folder> <main_app_executable>") log(f"Invalid arguments. Usage: {APP_NAME}_updater <update_folder> <main_app_executable>")
sys.exit(1) sys.exit(1)
update_folder = sys.argv[1] update_folder = sys.argv[1]
@@ -215,7 +216,7 @@ def main():
if PLATFORM_NAME == 'darwin': if PLATFORM_NAME == 'darwin':
log(f'Attempting to delete {ppparent_dir}') log(f'Attempting to delete {ppparent_dir}')
delete_path(ppparent_dir) delete_path(ppparent_dir)
update_folder = os.path.join(sys.argv[1], "flares-darwin") update_folder = os.path.join(sys.argv[1], f"{APP_NAME}-darwin")
copy_update_files_darwin(update_folder, pppparent_dir, updater_name) copy_update_files_darwin(update_folder, pppparent_dir, updater_name)
else: else:
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="#1f1f1f"><path d="M400-280h160v-80H400v80Zm0-160h280v-80H400v80ZM280-600h400v-80H280v80Zm200 120ZM265-80q-79 0-134.5-55.5T75-270q0-57 29.5-102t77.5-68H80v-80h240v240h-80v-97q-37 8-61 38t-24 69q0 46 32.5 78t77.5 32v80Zm135-40v-80h360v-560H200v160h-80v-160q0-33 23.5-56.5T200-840h560q33 0 56.5 23.5T840-760v560q0 33-23.5 56.5T760-120H400Z"/></svg>

After

Width:  |  Height:  |  Size: 443 B

-2
View File
@@ -1,2 +0,0 @@
[LocalizedFileNames]
updater.png=@updater.png,0
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="#1f1f1f"><path d="M160-160q-33 0-56.5-23.5T80-240v-480q0-33 23.5-56.5T160-800h240l80 80h320q33 0 56.5 23.5T880-640v242q-18-14-38-23t-42-19v-200H447l-80-80H160v480h120v80H160ZM640-40q-91 0-168-48T360-220q35-84 112-132t168-48q91 0 168 48t112 132q-35 84-112 132T640-40Zm0-80q57 0 107.5-26t82.5-74q-32-48-82.5-74T640-320q-57 0-107.5 26T450-220q32 48 82.5 74T640-120Zm0-40q-25 0-42.5-17.5T580-220q0-25 17.5-42.5T640-280q25 0 42.5 17.5T700-220q0 25-17.5 42.5T640-160Zm-480-80v-480 277-37 240Z"/></svg>

After

Width:  |  Height:  |  Size: 593 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="#1f1f1f"><path d="M200-120q-33 0-56.5-23.5T120-200v-560q0-33 23.5-56.5T200-840h560q33 0 56.5 23.5T840-760v560q0 33-23.5 56.5T760-120H200Zm0-80h320v-560H200v560Zm560 0v-560H600v560h160Z"/></svg>

After

Width:  |  Height:  |  Size: 291 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="#1f1f1f"><path d="M480-120q-138 0-240.5-91.5T122-440h82q14 104 92.5 172T480-200q117 0 198.5-81.5T760-480q0-117-81.5-198.5T480-760q-69 0-129 32t-101 88h110v80H120v-240h80v94q51-64 124.5-99T480-840q75 0 140.5 28.5t114 77q48.5 48.5 77 114T840-480q0 75-28.5 140.5t-77 114q-48.5 48.5-114 77T480-120Zm112-192L440-464v-216h80v184l128 128-56 56Z"/></svg>

After

Width:  |  Height:  |  Size: 444 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="#1f1f1f"><path d="M480-80q-155 0-269-103T82-440h81q15 121 105.5 200.5T480-160q134 0 227-93t93-227q0-134-93-227t-227-93q-86 0-159.5 42.5T204-640h116v80H88q29-140 139-230t253-90q83 0 156 31.5T763-763q54 54 85.5 127T880-480q0 83-31.5 156T763-197q-54 54-127 85.5T480-80Zm112-232L440-464v-216h80v184l128 128-56 56Z"/></svg>

After

Width:  |  Height:  |  Size: 416 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="#1f1f1f"><path d="M200-440v-80h560v80H200Z"/></svg>

After

Width:  |  Height:  |  Size: 149 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="#1f1f1f"><path d="M520-330v-60h160v60H520Zm60 210v-50h-60v-60h60v-50h60v160h-60Zm100-50v-60h160v60H680Zm40-110v-160h60v50h60v60h-60v50h-60Zm111-280h-83q-26-88-99-144t-169-56q-117 0-198.5 81.5T200-480q0 72 32.5 132t87.5 98v-110h80v240H160v-80h94q-62-50-98-122.5T120-480q0-75 28.5-140.5t77-114q48.5-48.5 114-77T480-840q129 0 226.5 79.5T831-560Z"/></svg>

After

Width:  |  Height:  |  Size: 449 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="#1f1f1f"><path d="M160-360v-80h400v80H160Zm0-160v-80h640v80H160Z"/></svg>

After

Width:  |  Height:  |  Size: 171 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="#1f1f1f"><path d="M160-160q-33 0-56.5-23.5T80-240v-480q0-33 23.5-56.5T160-800h640q33 0 56.5 23.5T880-720v480q0 33-23.5 56.5T800-160H160Zm0-80h640v-400H160v400Zm140-40-56-56 103-104-104-104 57-56 160 160-160 160Zm180 0v-80h240v80H480Z"/></svg>

After

Width:  |  Height:  |  Size: 340 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="#1f1f1f"><path d="M280-160v-80h400v80H280Zm160-160v-327L336-544l-56-56 200-200 200 200-56 56-104-103v327h-80Z"/></svg>

After

Width:  |  Height:  |  Size: 216 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="#1f1f1f"><path d="M451.5-251.5Q440-263 440-280t11.5-28.5Q463-320 480-320t28.5 11.5Q520-297 520-280t-11.5 28.5Q497-240 480-240t-28.5-11.5ZM440-360v-161l80 80v81h-80Zm433 158L655-419 480-720l-47 80-58-58 105-182 393 678Zm-695 2h469L350-497 178-200ZM819-28l-92-92H40l252-435L27-820l57-57L876-85l-57 57ZM499-348Zm45-181Z"/></svg>

After

Width:  |  Height:  |  Size: 423 B

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

+2041 -1241
View File
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -10,7 +10,7 @@ from pathlib import Path
import numpy as np import numpy as np
from scipy import linalg from scipy import linalg
from scipy.special import lpmv, sph_harm from scipy.special import lpmv, sph_harm_y
from .. import __version__ from .. import __version__
from .._fiff.compensator import make_compensator from .._fiff.compensator import make_compensator
@@ -1487,7 +1487,7 @@ def _sss_basis_basic(exp, coils, mag_scale=100.0, method="standard"):
S_in_out = list() S_in_out = list()
grads_in_out = list() grads_in_out = list()
# Same spherical harmonic is used for both internal and external # Same spherical harmonic is used for both internal and external
sph = sph_harm(order, degree, az, pol) sph = sph_harm_y(order, degree, az, pol)
sph_norm = _sph_harm_norm(order, degree) sph_norm = _sph_harm_norm(order, degree)
# Compute complex gradient for all integration points # Compute complex gradient for all integration points
# in spherical coordinates (Eq. 6). The gradient for rad, az, pol # in spherical coordinates (Eq. 6). The gradient for rad, az, pol
+1 -1
View File
@@ -12,7 +12,7 @@ from pathlib import Path
import numpy as np import numpy as np
from scipy import linalg from scipy import linalg
from scipy.spatial.distance import cdist from scipy.spatial.distance import cdist
from scipy.special import sph_harm from scipy.special import sph_harm_y
from ._fiff.constants import FIFF from ._fiff.constants import FIFF
from ._fiff.open import fiff_open from ._fiff.open import fiff_open
+1 -1
View File
@@ -1025,7 +1025,7 @@ def _handle_sensor_types(meg, eeg, fnirs):
fnirs=dict(channels="fnirs", pairs="fnirs_pairs"), fnirs=dict(channels="fnirs", pairs="fnirs_pairs"),
) )
sensor_alpha = { sensor_alpha = {
key: dict(meg_helmet=0.25, meg=0.25).get(key, 0.8) key: dict(meg_helmet=0.25, meg=0.25).get(key, 1.0)
for ch_dict in alpha_map.values() for ch_dict in alpha_map.values()
for key in ch_dict.values() for key in ch_dict.values()
} }
+1 -1
View File
@@ -586,7 +586,7 @@ class _PyVistaRenderer(_AbstractRenderer):
color = None color = None
else: else:
scalars = None scalars = None
tube = line.tube(radius, n_sides=self.tube_n_sides) tube = line.tube(radius=radius, n_sides=self.tube_n_sides)
actor = _add_mesh( actor = _add_mesh(
plotter=self.plotter, plotter=self.plotter,
mesh=tube, mesh=tube,
+826
View File
@@ -0,0 +1,826 @@
"""
Filename: project_manager.py
Description: Manager file for anything project related
Author: Tyler de Zeeuw
License: GPL-3.0
"""
# Built-in imports
import os
import sys
import copy
import pickle
import concurrent
from pathlib import Path, PurePosixPath
# External library imports
import pandas as pd
from PySide6.QtWidgets import QMessageBox, QVBoxLayout, QFileDialog, QLabel, QDialog
from PySide6.QtCore import QThread, Signal, Qt, QTimer
from PySide6.QtGui import QAction
from mne.io import read_raw_snirf
from mne.preprocessing.nirs import source_detector_distances
from mne_nirs.channels import get_short_channels # type: ignore
from src.shared.flaresbasewidget import ProgressBubble
from src.shared.shareddata import APP_NAME, CURRENT_VERSION, PLATFORM_NAME, DATA_SCHEMA
class SaveProjectThread(QThread):
finished_signal = Signal(str)
error_signal = Signal(str)
def __init__(self, filename, project_data):
super().__init__()
self.filename = filename
self.project_data = project_data
def run(self):
try:
with open(self.filename, "wb") as f:
pickle.dump(self.project_data, f)
self.finished_signal.emit(self.filename)
except Exception as e:
self.error_signal.emit(str(e))
class SavingOverlay(QDialog):
def __init__(self, parent=None):
super().__init__(parent)
self.setWindowFlags(Qt.WindowType.Dialog | Qt.WindowType.FramelessWindowHint)
self.setModal(True)
self.setWindowModality(Qt.WindowModality.ApplicationModal)
self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground)
layout = QVBoxLayout()
layout.setAlignment(Qt.AlignmentFlag.AlignCenter)
label = QLabel("Saving Project…")
label.setStyleSheet("font-size: 18px; color: white; background-color: rgba(0,0,0,150); padding: 20px; border-radius: 10px;")
layout.addWidget(label)
self.setLayout(layout)
class ProjectManager:
"""
Central manager for all I/O operations:
- File loading (individual files & folders)
- Project loading & saving (Save vs Save As)
- Relative / absolute path utilities
- State baseline synchronization (dirty tracking)
"""
def __init__(self, app, file_cfg, cfg_path):
self.app = app
self.file_cfg = file_cfg
self.cfg_path = cfg_path
# =========================================================================
# Path Utilities
# =========================================================================
def get_safe_path(self, target_path, project_dir):
"""Converts an absolute file path to a relative path relative to project_dir."""
try:
target = Path(target_path).resolve()
proj = Path(project_dir).resolve()
return str(PurePosixPath(target.relative_to(proj)))
except ValueError:
# Fall back to absolute path string if on a different drive/volume
return str(PurePosixPath(Path(target_path).resolve()))
# =========================================================================
# File & Folder Opening Dialogs
# =========================================================================
def open_file_dialog(self):
"""Opens dialog to pick a single .snirf file."""
file_path, _ = QFileDialog.getOpenFileName(
self.app, "Open File", "", "SNIRF Files (*.snirf);;All Files (*)"
)
if file_path:
self._load_files_into_pipeline([os.path.normpath(file_path)])
def open_folder_dialog(self):
"""Recursively finds all .snirf files in a selected directory."""
folder_path = QFileDialog.getExistingDirectory(self.app, "Select Folder", "")
if folder_path:
snirf_files = [os.path.normpath(str(f)) for f in Path(folder_path).rglob("*.snirf")]
self._load_files_into_pipeline(snirf_files)
def _load_files_into_pipeline(self, file_paths):
"""Loads .snirf files into UI using chunked batches and background workers."""
app = self.app
if not file_paths:
return
# 1. Warm up the executor if needed
if not hasattr(app, "file_executor") or app.file_executor is None:
app.file_executor = concurrent.futures.ProcessPoolExecutor(max_workers=1)
# 2. Track this session to prevent ghost updates
if not hasattr(app, "loading_session_id"):
app.loading_session_id = 0
app.loading_session_id += 1
current_session = app.loading_session_id
# 3. Setup internal tracking if not exists
if not hasattr(app, "bubble_widgets"):
app.bubble_widgets = {}
if not hasattr(app, "selected_paths"):
app.selected_paths = []
if not hasattr(app, "metadata_cache"):
app.metadata_cache = {}
# Filter out files already in the UI to avoid duplicates
new_files = [p for p in file_paths if p not in app.selected_paths]
if not new_files:
return
# Update the pending count for the current load batch
if not hasattr(app, "pending_files_count"):
app.pending_files_count = 0
app.pending_files_count += len(new_files)
app.button1.setVisible(True)
app.statusBar().showMessage(f"Loading {len(new_files)} new file(s)...")
# Queue chunked widget creation
CHUNK_SIZE = 10
def process_chunk(file_queue):
chunk = file_queue[:CHUNK_SIZE]
remaining = file_queue[CHUNK_SIZE:]
for path in chunk:
app.selected_paths.append(path)
self.add_to_recent_files(path)
display_name = os.path.basename(path)
bubble = ProgressBubble(display_name, path)
bubble.setCursor(Qt.CursorShape.WaitCursor)
bubble.set_loading_state(True)
app.bubble_widgets[path] = bubble
app.bubble_layout.addWidget(bubble)
# Submit background task as each bubble is constructed
future = app.file_executor.submit(extract_metadata_worker, path)
future.add_done_callback(
lambda f, p=path, s=current_session: app._on_metadata_ready(f, p, s)
)
app.files_are_dirty = True
app.is_saved = False
if hasattr(app, "check_if_app_is_dirty"):
app.check_if_app_is_dirty()
elif hasattr(app, "update_window_title"):
app.update_window_title()
# Schedule remaining files
if remaining:
QTimer.singleShot(0, lambda: process_chunk(remaining))
process_chunk(new_files)
def add_files_to_project(self, file_paths):
"""Adds file paths to the application state, creating bubble UI items."""
app = self.app
normalized_paths = [os.path.normpath(p) for p in file_paths]
# Merge with existing selected paths avoiding duplicates
existing_paths = getattr(app, "selected_paths", [])
new_paths = [p for p in normalized_paths if p not in existing_paths]
if not new_paths:
return
app.selected_paths = existing_paths + new_paths
# Render file bubbles in UI if method exists
if hasattr(app, "show_files_as_bubbles_from_list"):
progress_states = getattr(app, "progress_states", {})
current_project = getattr(app, "current_project_path", "")
app.show_files_as_bubbles_from_list(
app.selected_paths, progress_states, current_project
)
# Record in recent files menu
for path in new_paths:
self.add_to_recent_files(path)
# Trigger dirty check
if hasattr(app, "check_if_app_is_dirty"):
app.check_if_app_is_dirty()
# =========================================================================
# Project Loading
# =========================================================================
def load_project_dialog(self):
"""Prompts for a project file and loads it."""
app = self.app
filename, _ = QFileDialog.getOpenFileName(
app, "Load Project", "", "FLARE Project (*.flare)"
)
if filename:
self.load_project(filename)
def load_project(self, filename):
"""Loads a .flare project file into the application."""
app = self.app
try:
with open(filename, "rb") as f:
data = pickle.load(f)
checks = [
("version", "<=1.1.7"),
("file_metadata", "<=1.2.2"),
("file_parameters", "<=1.3.0"),
("roi_channel_map_dict", "<=1.5.2"),
]
for key, ver_str in checks:
if key not in data:
msg = (
f"This project was saved in an earlier version of {APP_NAME.upper()} ({ver_str}) "
"and is potentially not compatible with this version. "
)
if getattr(app, "incompatible_save_bypass", False):
QMessageBox.warning(
app, f"Warning - {APP_NAME.upper()}", msg + "Attempting load."
)
break
else:
QMessageBox.critical(
app,
f"Error - {APP_NAME.upper()}",
msg + "Enable bypass in Preferences to load.",
)
return
# Clear existing UI bubbles
if hasattr(app, "bubble_widgets"):
for bubble in list(app.bubble_widgets.values()):
bubble.setParent(None)
bubble.deleteLater()
app.bubble_widgets.clear()
app.selected_paths = []
app.current_project_path = filename
# Restore Data Schema
for item in DATA_SCHEMA:
setattr(app, item["key"], data.get(item["key"], {}))
project_dir = Path(filename).parent
saved_cache = data.get("file_metadata", {})
raw_params = data.get("file_parameters", {})
app.metadata_cache = {}
app.file_metadata = {}
for rel_path, meta_content in saved_cache.items():
abs_path = str((project_dir / Path(rel_path)).resolve())
app.metadata_cache[abs_path] = meta_content
file_list = [
str((project_dir / Path(rel_path)).resolve()) for rel_path in data["file_list"]
]
raw_progress = data.get("progress_states", {})
progress_states = {
str((project_dir / Path(rel_path)).resolve()): step
for rel_path, step in raw_progress.items()
}
for rel_path in data["file_list"]:
abs_path = str((project_dir / Path(rel_path)).resolve())
if rel_path in raw_params:
app.file_metadata[abs_path] = raw_params[rel_path]
elif hasattr(app, "config_dict") and abs_path in app.config_dict:
old_cfg = app.config_dict[abs_path]
app.file_metadata[abs_path] = {
"AGE": str(old_cfg.get("AGE", "")),
"SEX": str(old_cfg.get("SEX", "")),
"HAND": str(old_cfg.get("HAND", "")),
"GROUP": str(old_cfg.get("GROUP", "")),
}
else:
app.file_metadata[abs_path] = {
"AGE": "",
"SEX": "",
"HAND": "",
"GROUP": "",
}
app.show_files_as_bubbles_from_list(file_list, progress_states, filename)
if "current_ui_params" in data:
app.restore_sections_from_config(data["current_ui_params"])
elif getattr(app, "config_dict", None):
first_file = next(iter(app.config_dict.keys()))
app.restore_sections_from_config(app.config_dict[first_file])
has_data = any(len(getattr(app, item["key"], {})) > 0 for item in DATA_SCHEMA)
if hasattr(app, "button1"):
app.button1.setVisible(not has_data)
if hasattr(app, "button3"):
app.button3.setVisible(has_data)
self.add_to_recent_projects(os.path.normpath(filename))
# Reset baselines cleanly
self.reset_all_dirty_states()
QMessageBox.information(app, "Loaded", f"Project loaded from:\n{filename}")
except Exception as e:
QMessageBox.critical(app, "Error", f"Failed to load project:\n{e}")
# =========================================================================
# Project Saving (Save / Save As)
# =========================================================================
def save_project(self, onCrash=False, ask=False):
"""
Saves the project to disk.
- ask=False: Quick Save to self.app.current_project_path (prompts if unsaved).
- ask=True: Save As (always prompts for location).
"""
app = self.app
# 1. Sync active text fields into active metadata dict
if hasattr(app, "current_file") and app.current_file and hasattr(app, "meta_fields"):
if not hasattr(app, "file_metadata"):
app.file_metadata = {}
app.file_metadata[app.current_file] = {
key: field.text().strip() for key, field in app.meta_fields.items()
}
# 2. Check if saveable state exists
has_files = len(getattr(app, "selected_paths", [])) > 0
has_metadata = any(
any(val for val in m.values()) for m in getattr(app, "file_metadata", {}).values()
)
has_param_changes = any(
s.has_any_changes() for s in getattr(app, "param_sections", [])
)
has_processed_data = any(
len(getattr(app, item["key"], {})) > 0 for item in DATA_SCHEMA
)
if not (has_files or has_processed_data or has_metadata or has_param_changes):
if not onCrash:
QMessageBox.warning(
app,
"Save Project",
"There is no data or configuration to save.",
)
return
# 3. Path Resolution
filename = None
if not onCrash:
existing_path = getattr(app, "current_project_path", None)
if ask or not existing_path:
start_dir = existing_path if existing_path else ""
filename, _ = QFileDialog.getSaveFileName(
app, "Save Project", start_dir, "FLARE Project (*.flare)"
)
if not filename:
return
else:
filename = existing_path
else:
if PLATFORM_NAME == "darwin":
filename = os.path.join(
os.path.dirname(sys.executable), "../../../flares_autosave.flare"
)
else:
filename = os.path.join(os.getcwd(), "flares_autosave.flare")
try:
if not filename.endswith(".flare"):
filename += ".flare"
project_path = Path(filename).resolve()
project_dir = project_path.parent
# 4. Convert Paths to Relative
bubble_widgets = getattr(app, "bubble_widgets", {})
file_list = [
self.get_safe_path(b.file_path, project_dir) for b in bubble_widgets.values()
]
progress_states = {
self.get_safe_path(b.file_path, project_dir): getattr(b, "current_step", 0)
for b in bubble_widgets.values()
}
rel_metadata = {}
for full_path, meta in getattr(app, "metadata_cache", {}).items():
try:
rel_metadata[self.get_safe_path(full_path, project_dir)] = meta
except Exception as e:
print(f"Metadata conversion failed for {full_path}: {e}")
rel_file_params = {
self.get_safe_path(f_path, project_dir): meta
for f_path, meta in getattr(app, "file_metadata", {}).items()
}
current_params = app.get_all_current_ui_params()
if not current_params and getattr(app, "config_dict", None):
first_file = next(iter(app.config_dict.keys()))
current_params = app.config_dict[first_file]
# 5. Build Serialized Payload
project_data = {
item["key"]: getattr(app, item["key"], {}) for item in DATA_SCHEMA
}
project_data.update({
"version": CURRENT_VERSION,
"file_list": file_list,
"progress_states": progress_states,
"file_metadata": rel_metadata,
"file_parameters": rel_file_params,
"current_ui_params": current_params,
})
def sanitize(obj):
if isinstance(obj, Path):
return str(PurePosixPath(obj))
elif isinstance(obj, dict):
return {sanitize(k): sanitize(v) for k, v in obj.items()}
elif isinstance(obj, list):
return [sanitize(i) for i in obj]
return obj
project_data = sanitize(project_data)
self.add_to_recent_projects(os.path.normpath(filename))
# 6. Background Saving Execution
if not onCrash:
app.saving_overlay = SavingOverlay(app)
app.saving_overlay.resize(app.size())
app.saving_overlay.show()
app.save_thread = SaveProjectThread(filename, project_data)
def _on_save_success(saved_file):
if hasattr(app, "saving_overlay"):
app.saving_overlay.close()
self.add_to_recent_projects(os.path.normpath(saved_file))
app.current_project_path = saved_file
self.reset_all_dirty_states()
if not onCrash:
QMessageBox.information(
app, "Success", f"Project saved to:\n{saved_file}"
)
def _on_save_error(error_msg):
if hasattr(app, "saving_overlay"):
app.saving_overlay.close()
if not onCrash:
QMessageBox.critical(
app, "Error", f"Failed to save project:\n{error_msg}"
)
app.save_thread.finished_signal.connect(_on_save_success)
app.save_thread.error_signal.connect(_on_save_error)
app.save_thread.start()
except Exception as e:
if not onCrash:
QMessageBox.critical(app, "Error", f"Failed to save project:\n{e}")
def update_recent_projects_menu(self):
"""Clears and rebuilds the Recent Projects submenu items."""
app = self.app
if not hasattr(app, "recent_projects_menu"):
return
app.recent_projects_menu.clear()
raw_projects = self.file_cfg.get("File", "recent_projects", fallback="")
projects = [p.strip() for p in raw_projects.split(",") if p.strip()]
if not projects:
no_recent = app.recent_projects_menu.addAction(
"No Recent Projects"
)
no_recent.setEnabled(False)
return
for i, project_path in enumerate(projects):
action = QAction(f"{i+1}: {project_path}", app)
action.setToolTip(project_path)
action.triggered.connect(
lambda checked, path=project_path: self.open_recent_project(path)
)
app.recent_projects_menu.addAction(action)
def add_to_recent_projects(self, project_path):
"""Adds a project path, moves it to the top, and hard caps at 10."""
raw_projects = self.file_cfg.get("File", "recent_projects", fallback="")
projects = [p.strip() for p in raw_projects.split(",") if p.strip()]
if project_path in projects:
projects.remove(project_path)
projects.insert(0, project_path)
projects = projects[:10] # Hard cap of 10 items
self.file_cfg.set("File", "recent_projects", ",".join(projects))
try:
with open(self.cfg_path, "w") as f:
self.file_cfg.write(f)
except Exception as e:
print(f"Warning: Could not save config history: {e}")
self.update_recent_projects_menu()
def open_recent_project(self, project_path):
"""The slot that executes when a recent project entry is clicked."""
if os.path.exists(project_path):
print(f"Opening recent project: {project_path}")
# Route project loading through ProjectManager or app's loader
if hasattr(self.app, "project_loader"):
self.app.project_loader(project_path)
else:
self.load_project(project_path)
self.add_to_recent_projects(project_path)
else:
QMessageBox.warning(
self.app,
"Project Not Found",
f"The project file could not be found:\n{project_path}",
)
# Clean out the broken path
raw_projects = self.file_cfg.get("File", "recent_projects", fallback="")
projects = [
p.strip()
for p in raw_projects.split(",")
if p.strip() and p.strip() != project_path
]
self.file_cfg.set("File", "recent_projects", ",".join(projects))
self.update_recent_projects_menu()
# =========================================================================
# Recent Files Operations
# =========================================================================
def update_recent_files_menu(self):
"""Clears and rebuilds the Recent Files submenu items."""
app = self.app
if not hasattr(app, "recent_files_menu"):
return
app.recent_files_menu.clear()
raw_files = self.file_cfg.get("File", "recent_files", fallback="")
files = [f.strip() for f in raw_files.split(",") if f.strip()]
if not files:
no_recent = app.recent_files_menu.addAction("No Recent Files")
no_recent.setEnabled(False)
return
for i, file_path in enumerate(files):
action = QAction(f"{i+1}: {file_path}", app)
action.triggered.connect(
lambda checked, path=file_path: self.open_recent_file(path)
)
app.recent_files_menu.addAction(action)
def add_to_recent_files(self, file_path):
"""Adds a path, moves it to the top, and hard caps the list at 10."""
raw_files = self.file_cfg.get("File", "recent_files", fallback="")
files = [f.strip() for f in raw_files.split(",") if f.strip()]
if file_path in files:
files.remove(file_path)
files.insert(0, file_path)
files = files[:10]
self.file_cfg.set("File", "recent_files", ",".join(files))
try:
with open(self.cfg_path, "w") as f:
self.file_cfg.write(f)
except Exception as e:
print(f"Warning: Could not save config history: {e}")
self.update_recent_files_menu()
def open_recent_file(self, file_path):
"""The slot that executes when someone clicks a recent file entry."""
if os.path.exists(file_path):
print(f"Opening recent file: {file_path}")
self._load_files_into_pipeline([os.path.normpath(file_path)])
# Refresh position to top
self.add_to_recent_files(file_path)
else:
QMessageBox.warning(
self.app,
"File Not Found",
f"The file could not be found:\n{file_path}",
)
# Clean up the broken link from history
raw_files = self.file_cfg.get("File", "recent_files", fallback="")
files = [
f.strip()
for f in raw_files.split(",")
if f.strip() and f.strip() != file_path
]
self.file_cfg.set("File", "recent_files", ",".join(files))
self.update_recent_files_menu()
# =========================================================================
# Baseline Synchronization & Dirty Checks
# =========================================================================
def sync_metadata_baseline(self):
"""Captures current file_metadata state as baseline."""
self.app.saved_file_metadata = copy.deepcopy(getattr(self.app, "file_metadata", {}))
def is_metadata_dirty(self):
"""Returns True if file metadata has been modified relative to saved baseline."""
current_meta = getattr(self.app, "file_metadata", {})
saved_meta = getattr(self.app, "saved_file_metadata", {})
if current_meta != saved_meta:
return True
if (
hasattr(self.app, "current_file")
and self.app.current_file
and hasattr(self.app, "meta_fields")
):
active_saved = saved_meta.get(self.app.current_file, {})
for key, field in getattr(self.app, "meta_fields", {}).items():
if field.text().strip() != active_saved.get(key, "").strip():
return True
return False
def reset_all_dirty_states(self):
"""Resets parameter, file, and metadata baselines after load/save."""
# 1. Sync file list baseline
self.app.saved_selected_paths = copy.deepcopy(getattr(self.app, "selected_paths", []))
self.app.files_are_dirty = False
# 2. Sync metadata baseline
self.sync_metadata_baseline()
# 3. Sync parameter baselines
for section_widget in getattr(self.app, "param_sections", []):
if hasattr(section_widget, "save_current_as_baseline"):
section_widget.save_current_as_baseline()
# 4. Clear dirty status & update UI title
self.app.is_saved = True
if hasattr(self.app, "update_window_title"):
self.app.update_window_title()
def _get_bids_demographics(snirf_path: str) -> dict[str, str]:
"""Traverses the path of a SNIRF file to extract age/sex/hand from BIDS TSV files.
'hand' is only included if a value is present and isn't 'n/a' (case-insensitive) -
many datasets leave it unset/inapplicable, so surfacing 'n/a' explicitly just adds noise.
"""
path = Path(snirf_path)
fields = ["age", "sex", "hand"]
# Extract sub-XX and ses-YY labels from the path
sub_id = next((part for part in path.parts if part.startswith("sub-")), None)
ses_id = next((part for part in path.parts if part.startswith("ses-")), None)
if not sub_id:
return {}
def _row_to_dict(row) -> dict[str, str]:
result = {}
for field in fields:
if field not in row:
continue
val = row[field]
if pd.isna(val):
continue
val_str = str(val).strip()
if field == "hand" and val_str.lower() in ("n/a", "na", ""):
continue
result[field] = val_str
return result
# 1. Look for sub-<id>/sub-<id>_sessions.tsv
sub_dir = next((p for p in path.parents if p.name == sub_id), None)
if sub_dir and ses_id:
sessions_tsv = sub_dir / f"{sub_id}_sessions.tsv"
if sessions_tsv.exists():
try:
df = pd.read_csv(sessions_tsv, sep="\t")
matching = df[df["session_id"].astype(str).str.replace("ses-", "") == ses_id.replace("ses-", "")]
if not matching.empty:
result = _row_to_dict(matching.iloc[0])
if result:
return result
except Exception:
pass
# 2. Fallback: Check dataset root participants.tsv
bids_root = sub_dir.parent if sub_dir else None
if bids_root:
participants_tsv = bids_root / "participants.tsv"
if participants_tsv.exists():
try:
df = pd.read_csv(participants_tsv, sep="\t")
matching = df[df["participant_id"].astype(str).str.replace("sub-", "") == sub_id.replace("sub-", "")]
if not matching.empty:
result = _row_to_dict(matching.iloc[0])
if result:
return result
except Exception:
pass
return {}
def extract_metadata_worker(file_name):
"""Runs in the separate worker process. Returns a clean dict."""
# 1. Use preload=False! We only need metadata.
raw = None
try:
raw = read_raw_snirf(file_name, preload=False, verbose="ERROR")
snirf_info = {}
# 2. Measurement date
snirf_info['Measurement Date'] = str(raw.info.get('meas_date'))
# 3. Short Channels
try:
short_chans = get_short_channels(raw, max_dist=0.015)
names = list(short_chans.ch_names)
snirf_info['Short Channels'] = f"Likely - {names}"
total_chans = len(raw.ch_names)
pct_short = (len(names) / total_chans * 100) if total_chans else 0
if pct_short > 35:
snirf_info['Short Channels'] += "\n There are a lot of short channels. Perhaps the optode distances are incorrect?"
except:
snirf_info['Short Channels'] = "Unlikely"
# 4. Distances
dist_vals = source_detector_distances(raw.info)
snirf_info['Source-Detector Distances'] = [
f"{name}: {d:.4f} m" for name, d in zip(raw.info['ch_names'], dist_vals)
]
# 5. Digitization
dig = raw.info.get('dig', None)
if dig is not None:
snirf_info['Digitization Points'] = [
f"Kind: {p['kind']}, ID: {p['ident']}, Coord: {p['r']}" for p in dig
]
else:
snirf_info['Digitization Points'] = "Not found"
# 6. Annotations (using our copy-to-string trick)
if raw.annotations is not None and len(raw.annotations) > 0:
snirf_info['Annotations'] = [
f"Onset: {o:.2f}s, Duration: {d:.2f}s, Description: {str(desc)}"
for o, d, desc in zip(raw.annotations.onset, raw.annotations.duration, raw.annotations.description)
]
else:
snirf_info['Annotations'] = "No annotations found"
demographics = _get_bids_demographics(file_name)
if "age" in demographics:
snirf_info["BIDS - Age"] = demographics["age"]
if "sex" in demographics:
snirf_info["BIDS - Sex"] = demographics["sex"]
if "hand" in demographics:
snirf_info["BIDS - Handedness"] = demographics["hand"]
return snirf_info
except Exception as e:
print(f"Worker safely caught failure on {file_name}: {str(e)}")
return {'status': 'error', 'reason': str(e)}
finally:
if raw is not None:
try:
raw.close()
except:
pass
BIN
View File
Binary file not shown.
+157
View File
@@ -0,0 +1,157 @@
"""
Filename: crossgroupbrainimage.py
Description: Logic for the Cross-Group Brain & Image analysis window
Note: Compliant with pylance strict type checking
Author: Tyler de Zeeuw
License: GPL-3.0
"""
# Built-in Imports
from pathlib import Path
from typing import Any, cast
# External library imports
from mne.io.base import BaseRaw
import pandas as pd
from pandas import DataFrame
from flares import aggregate_fnirs_group_geometry, plot_2d_3d_contrasts_between_groups
from src.shared.flaresbasewidget import CrossGroupUIMixin, FlaresBaseWidget
from src.shared.shareddata import APP_NAME
PARAMETERIZED_INDEXES: dict[int, list[dict[str, Any]]] = {
0: [
{
"key": "show_optodes",
"label": "Determine what is rendered above the brain. Valid values are 'sensors', 'labels', 'none', 'all'.",
"default": "all",
"type": str,
},
{
"key": "t_or_theta",
"label": "Specify if t values or theta values should be plotted. Valid values are 't', 'theta'",
"default": "theta",
"type": str,
},
{
"key": "show_text",
"label": "Display informative text on the top left corner about the contrast.",
"default": "True",
"type": bool,
},
{
"key": "brain_bounds",
"label": "Graph Upper/Lower Limit",
"default": "1.0",
"type": float,
},
{
"key": "is_3d",
"label": "Should we display the results in a 3D interactive window?",
"default": "True",
"type": bool,
}
],
}
class CrossGroupBrainImageWidget(CrossGroupUIMixin, FlaresBaseWidget):
def __init__(
self,
haemo_dict: dict[str | Path, BaseRaw],
df_ind_dict: dict[str, DataFrame],
design_matrix_dict: dict[str, DataFrame],
contrast_results_dict: dict[str, dict[str, Any]],
group_dict: dict[str, str],
) -> None:
super().__init__("CrossGroupBrainImage")
self.setWindowTitle(f"Cross-Group Brain & Image Viewer - {APP_NAME.upper()}")
self.haemo_dict = haemo_dict
self.df_ind_dict = df_ind_dict
self.design_matrix_dict = design_matrix_dict
self.contrast_results_dict = contrast_results_dict
self.group_dict = group_dict
self.setup_cross_group_ui(["0 (Contrast Image)"])
def process_request(self):
request = self.get_common_request_data(PARAMETERIZED_INDEXES)
if request is None:
return
(selected_event, file_paths_a, file_paths_b, all_selected_paths, selected_indexes, raw_params) = request
param_values = cast(dict[int | str, dict[str, Any]], raw_params)
# Build group-level contrast DataFrames
def concat_group_contrasts(file_paths: list[str], event: str | None) -> pd.DataFrame:
group_df = pd.DataFrame()
for fp in file_paths:
print(f"Looking up contrast for: {fp}")
event_con_dict = self.contrast_results_dict.get(fp, {})
print("Available events for this file:", list(event_con_dict.keys()))
if event and event in event_con_dict:
df = event_con_dict[event]
print(f"Appending contrast df for event: {event}")
group_df = pd.concat([group_df, df], ignore_index=True)
else:
print(f"Event '{event}' not found for {fp}")
return group_df
print("Selected event:", selected_event)
print("File paths A:", file_paths_a)
print("File paths B:", file_paths_b)
contrast_df_a = concat_group_contrasts(file_paths_a, selected_event)
contrast_df_b = concat_group_contrasts(file_paths_b, selected_event)
print("contrast_df_a empty?", contrast_df_a.empty)
print("contrast_df_b empty?", contrast_df_b.empty)
all_raw_objs = [self.haemo_dict.get(fp) for fp in all_selected_paths if self.haemo_dict.get(fp)]
if len(all_raw_objs) > 1:
processed_raw = aggregate_fnirs_group_geometry(all_raw_objs)
elif len(all_raw_objs) == 1 and all_raw_objs[0] is not None:
processed_raw = all_raw_objs[0].copy()
processed_raw.pick(picks="hbo") # type: ignore
else:
processed_raw = None
# Visualizations
for idx in selected_indexes:
if idx == 0:
params = param_values.get(idx, {})
show_optodes = params.get("show_optodes", None)
t_or_theta = params.get("t_or_theta", None)
show_text = params.get("show_text", None)
brain_bounds = params.get("brain_bounds", None)
is_3d = params.get("is_3d", None)
if show_optodes is None or t_or_theta is None or show_text is None or brain_bounds is None or is_3d is None:
print(f"Missing parameters for index {idx}, skipping.")
continue
if not contrast_df_a.empty and not contrast_df_b.empty and processed_raw:
plot_2d_3d_contrasts_between_groups(
contrast_df_a,
contrast_df_b,
raw_haemo=processed_raw,
group_a_name=self.group_a_dropdown.currentText(),
group_b_name=self.group_b_dropdown.currentText(),
is_3d=is_3d,
t_or_theta=t_or_theta,
show_optodes=show_optodes,
show_text=show_text,
brain_bounds=brain_bounds
)
else:
print(f"No method defined for index {idx}")
+356
View File
@@ -0,0 +1,356 @@
"""
Filename: crossgroupstats.py
Description: Cross-Group stats analysis window
Note: Compliant with pylance strict type checking
Author: Tyler de Zeeuw
License: GPL-3.0
"""
# Built-in imports
from pathlib import Path
from typing import Any, cast
# External library imports
import pandas as pd
from pandas import DataFrame
from mne.io.base import BaseRaw
from flares import run_cross_group_contrast_analysis, run_cross_group_laterality_analysis, run_cross_group_second_level_analysis
from src.shared.flaresbasewidget import CrossGroupUIMixin, FlaresBaseWidget
from src.shared.shareddata import APP_NAME
PARAMETERIZED_INDEXES: dict[int, list[dict[str, Any]]] = {
0: [
{
"key": "p_threshold",
"label": "Significance threshold P-value (e.g. 0.05)",
"default": "0.05",
"type": float,
},
{
"key": "min_subjects",
"label": "Minimum number of participants to process",
"default": "3",
"type": int,
},
{
"key": "correction_method",
"label": "Correction method to utilize. Valid values are 'fdr_bh', 'None'",
"default": "fdr_bh",
"type": str,
},
{
"key": "target_chroma",
"label": "Which chroma to target. Valid values are 'hbo', 'hbr'",
"default": "hbo",
"type": str,
},
{
"key": "threshold_topo",
"label": "threshold_topo: TBD",
"default": False,
"type": bool,
}
],
1: [
{
"key": "p_threshold",
"label": "Significance threshold P-value (e.g. 0.05)",
"default": "0.05",
"type": float,
},
{
"key": "min_subjects",
"label": "Minimum number of participants to process",
"default": "3",
"type": int,
},
{
"key": "correction_method",
"label": "Correction method to utilize. Valid values are 'fdr_bh', 'None'",
"default": "None",
"type": str,
},
{
"key": "target_chroma",
"label": "Which chroma to target. Valid values are 'hbo', 'hbr'",
"default": "hbo",
"type": str,
},
{
"key": "roi_a",
"label": "ROI A (e.g. contralateral region name from regions.json)",
"default": [],
"type": list,
},
{
"key": "roi_b",
"label": "ROI B (e.g. ipsilateral region name from regions.json)",
"default": [],
"type": list,
}
],
2: [
{
"key": "p_value",
"label": "Significance threshold P-value (e.g. 0.05)",
"default": "0.05",
"type": float,
},
{
"key": "min_subjects",
"label": "Minimum number of participants to process",
"default": "3",
"type": int,
},
{
"key": "correction_method",
"label": "Correction method to utilize. Valid values are 'fdr_bh', 'None'",
"default": "fdr_bh",
"type": str,
},
{
"key": "target_chroma",
"label": "Which chroma to target. Valid values are 'hbo', 'hbr'",
"default": "hbo",
"type": str,
},
{
"key": "contrast_name",
"label": "Name of the contrast to use",
"default": [],
"type": list,
},
],
}
DESCRIPTION = """0. Raw ROI Comparison (run_cross_group_second_level_analysis)
\nCompares one ROI's raw response magnitude between two independent groups (e.g. control vs. target) for a given condition, using Welch's t-test. A significant result means the two populations differ in this ROI's response magnitude for this condition. It does not tell you whether that difference is a real, localized, task-specific effect or a generic between-population difference - different overall vascular reactivity, arousal, or skull/scalp optical properties can produce the exact same statistical signature, and two independently recruited groups (especially patients vs. healthy controls) are considerably more likely to differ this way than two subsets of one study population.
\nIf you expected a group difference and didn't find one, the most common cause is within-group heterogeneity swallowing a real between-group difference - a "target" population (e.g. a clinical group) is often more variable than a tightly-screened control group, and that added within-group variance directly weakens a between-group t-test even if the group means truly differ. Small per-group sample sizes compound this. It's also possible the true difference between your groups isn't in raw magnitude at all, but in spatial specificity or task-differentiation - which is exactly why the laterality and contrast-comparison methods exist alongside this one; a null result here doesn't rule those out.
\n\n1. Laterality Comparison (run_cross_group_laterality_analysis)
\nComputes each subject's own contralateral-minus-ipsilateral laterality index first, then compares those indices between the two groups with Welch's t-test. A significant result means the degree of spatial specificity/lateralization differs between the two populations - a claim about lateralization itself, harder to explain away as a generic population confound since person-level differences in overall reactivity largely cancel before the group comparison happens. It says nothing about overall response magnitude between groups (a group could have identical laterality but very different raw amplitude), and it only uses subjects who have both the contra and ipsi ROI valid, so it can lose subjects the raw-ROI comparison would have kept.
\nNon-significance here has two likely sources, and it's worth distinguishing them. First, the same covariance issue from the within-group paired test applies across a whole group: if contra/ipsi responses aren't well-correlated within subjects, the laterality index itself is noisier than either ROI alone, and that added noise now has to clear a between-group test on top of it - a double power cost at small N. Second, and more informative if true: the groups may genuinely have similar lateralization but differ in overall magnitude instead, in which case this test correctly returns null while method 4 (raw comparison) should be the one to look at.
\n\n2. Contrast Comparison (run_cross_group_contrast_analysis)
\nCompares a jointly-fit task contrast (e.g. Task A minus Task B, estimated together within each subject's GLM), aggregated to ROI level, between two independent groups. A significant result means one group differentiates between the two tasks more or less than the other does, at this specific ROI - with systemic noise cancelled at the model-fitting stage, the same benefit that makes the within-group version of this method the strongest of that trio. As with the within-group version, it does not by itself say where a difference is localized unless you compare sign/pattern across multiple ROIs - opposite-signed group differences across regions point to something spatially specific, same-signed differences everywhere point to a diffuse/non-specific group difference (e.g. one group simply has stronger contrast responses across the whole head).
\nIf this comes back non-significant despite an expected group difference, check first whether the underlying single-subject contrast estimates are noisy for either group - small per-group N means the joint contrast's precision depends on the same limited subject count as everything else, and a noisy input propagates all the way through the ROI aggregation. It's also possible for a real, localized sub-regional effect to get washed out by ROI averaging itself: if only part of an ROI's channels actually show the group difference while others don't, the inverse-variance-weighted average can dilute it toward null - in that case, a finer-grained ROI definition (splitting the region further) may recover the effect that a coarser ROI averaged away. Finally, FDR correction across every ROI tested reduces power exactly as it does everywhere else in this framework - a real but modest effect can fail to survive correction even when the raw p-value would have looked convincing on its own.
\n\n
\nWhy channels needed to be aggregated into ROIs: Testing every channel independently means paying a steep multiple-comparisons tax - with dozens of channels, FDR/Bonferroni correction demands very large effect sizes to call anything significant, and at small subject counts (n=5) essentially nothing survives even when a real, consistent effect exists. Collapsing channels into a handful of anatomically meaningful ROIs cuts the number of independent tests from a minimum of ~40 down to 2-8, which lets a genuinely present effect actually clear correction. It also matches the scientific question better: you have a hypothesis about regions (contralateral motor cortex, prefrontal cortex), not about individual source-detector pairs, so testing at the ROI level is testing the thing you actually believe in, using inverse-variance weighting so noisier channels contribute less to the region's combined estimate rather than diluting it equally.
\nWhy some analyses needed contrasts instead of raw values: A single condition's GLM beta is only ever measured relative to the model's implicit intercept, and that intercept absorbs whatever's happening for the rest of the recording - including systemic physiology (blood pressure, arousal, general vascular reactivity) that rises during almost any active task, not just the one you care about. Testing a raw "vs. zero" value can't tell a real, localized neural response apart from that shared full-head noise. A contrast - either a within-subject spatial subtraction (ROI A minus ROI B) or a jointly-fit task contrast (Condition A minus Condition B, estimated together in one GLM) cancels out whatever's common to both halves of the subtraction, leaving something closer to the actual differential signal.
\nWhy a minimum subject count is enforced: Every one of these tests is a t-test, and a t-test's ability to detect a real effect (its power) depends heavily on degrees of freedom - at n=5 (df=4), even a fairly large true effect can produce a middling p-value, and at n=2 (df=1) the test is barely meaningful at all regardless of the underlying data. The min_subjects floor exists to stop a channel or ROI from being silently tested (and potentially reported as significant or non-significant) on a sample too small for the resulting p-value to mean anything reliable - it's better to explicitly skip and flag an underpowered channel than to quietly produce a number that looks statistically legitimate but isn't backed by enough independent observations to trust."""
class CrossGroupStatsWidget(CrossGroupUIMixin, FlaresBaseWidget):
def __init__(
self,
haemo_dict: dict[str | Path, BaseRaw],
cha_dict: dict[str, DataFrame],
df_ind_dict: dict[str, DataFrame],
design_matrix_dict: dict[str, DataFrame],
contrast_results_dict: dict[str, dict[str, Any]],
roi_channel_map_dict: dict[str, dict[str, str]],
group_dict: dict[str, str],
) -> None:
super().__init__("CrossGroupStats")
self.setWindowTitle(f"Cross-Group Stats Viewer - {APP_NAME.upper()}")
self.haemo_dict = haemo_dict
self.cha_dict = cha_dict
self.df_ind_dict = df_ind_dict
self.design_matrix_dict = design_matrix_dict
self.contrast_results_dict = contrast_results_dict
self.roi_channel_map_dict = roi_channel_map_dict
self.group_dict = group_dict
self.setup_cross_group_ui(["0 (Raw ROI Comparison)", "1 (Laterality Comparison)", "2 (Contrast Comparison)",], placeholder_text=DESCRIPTION)
def process_request(self):
request = self.get_common_request_data(PARAMETERIZED_INDEXES, self.df_ind_dict, self.contrast_results_dict)
if request is None:
return
(selected_event, file_paths_a, file_paths_b, _, selected_indexes, raw_params) = request
param_values = cast(dict[int | str, dict[str, Any]], raw_params)
valid_dfs = [df for df in self.df_ind_dict.values() if not df.empty]
if valid_dfs:
df_ind_combined = pd.concat(valid_dfs, ignore_index=True)
else:
df_ind_combined = pd.DataFrame()
valid_chas = [df for df in self.cha_dict.values() if not df.empty]
cha_combined = pd.concat(valid_chas, ignore_index=True) if valid_chas else pd.DataFrame()
sample_path = file_paths_a[0]
p_haemo = self.haemo_dict.get(sample_path)
# Visualizations
for idx in selected_indexes:
if idx == 0:
params = param_values.get(idx, {})
p_threshold = params.get("p_threshold", 0.05)
min_subjects = params.get("min_subjects", 3)
correction_method = params.get("correction_method", "fdr_bh")
target_chroma = params.get("target_chroma", "hbo")
threshold_topo = params.get("threshold_topo", False)
selected_roi_maps = {
fp: self.roi_channel_map_dict[fp]
for fp in (file_paths_a + file_paths_b)
if fp in self.roi_channel_map_dict
}
run_cross_group_second_level_analysis(
df_roi_all=df_ind_combined, # Individual stats dataframe
file_paths_a=file_paths_a,
file_paths_b=file_paths_b,
group_a_name=self.group_a_dropdown.currentText(),
group_b_name=self.group_b_dropdown.currentText(),
df_cha_all=cha_combined,
raw_haemo=p_haemo,
p_threshold=p_threshold,
min_subjects=min_subjects,
correction_method=correction_method,
target_chroma=target_chroma,
selected_event=selected_event,
roi_channel_maps=selected_roi_maps,
threshold_topo=threshold_topo # Shows the raw difference map (Unthresholded)
)
elif idx == 1:
if not selected_event:
print("Laterality comparison requires a specific event/condition "
"to be selected first.")
continue
params = param_values.get(idx, {})
p_threshold = params.get("p_threshold", 0.05)
min_subjects = params.get("min_subjects", 3)
correction_method = params.get("correction_method", "None")
target_chroma = params.get("target_chroma", "hbo")
roi_a: str = params.get("roi_a", "").strip()
roi_b: str = params.get("roi_b", "").strip()
if not roi_a or not roi_b:
print("Both a contralateral and ipsilateral ROI name must be specified.")
continue
if correction_method == "None":
correction_method = None
# Build each group's dataframe directly from the dict using
# the file-path lists as keys - no ID cleaning/matching needed.
def _build_group_df(
file_paths: list[str],
dict_source: dict[str, DataFrame]
) -> DataFrame:
valid_dfs = [
dict_source[fp] for fp in file_paths
if fp in dict_source and not dict_source[fp].empty
]
return pd.concat(valid_dfs, ignore_index=True) if valid_dfs else pd.DataFrame()
df_roi_a = _build_group_df(file_paths_a, self.df_ind_dict)
df_roi_b = _build_group_df(file_paths_b, self.df_ind_dict)
if df_roi_a.empty or df_roi_b.empty:
print("No ROI data (df_ind) found for one or both groups.")
continue
run_cross_group_laterality_analysis(
df_roi_all_a=df_roi_a,
df_roi_all_b=df_roi_b,
roi_pairs=(roi_a, roi_b),
condition=selected_event,
group_a_name=self.group_a_dropdown.currentText(),
group_b_name=self.group_b_dropdown.currentText(),
target_chroma=target_chroma,
min_subjects=min_subjects,
p_threshold=p_threshold,
correction_method=correction_method,
roi_contra_label=roi_a,
roi_ipsi_label=roi_b,
)
elif idx == 2:
params = param_values.get(idx, {})
p_threshold = params.get("p_threshold", 0.05)
min_subjects = params.get("min_subjects", 3)
correction_method = params.get("correction_method", "fdr_bh")
target_chroma = params.get("target_chroma", "hbo")
contrast_name = params.get("contrast_name", "")
if not contrast_name:
print("A contrast name must be specified.")
continue
# Build each group's channel-level contrast dataframe
# directly from contrast_results_dict, keyed by file path -
# same dict-key approach as the laterality patch, avoids
# any ID-string matching.
def _build_group_contrast_df(
file_paths: list[str],
contrast_dict: dict[str, dict[str, pd.DataFrame]],
name: str,
) -> pd.DataFrame:
all_rows: list[DataFrame] = []
for fp in file_paths:
condition_dfs = contrast_dict.get(fp)
if condition_dfs is None:
print(f" [MISSING] '{fp}' not found in contrast_results.")
continue
if name in condition_dfs:
df = condition_dfs[name].copy()
df["ID"] = fp
df["contrast_name"] = name
all_rows.append(df)
else:
print(f" [MISSING CONTRAST] '{name}' not available for '{fp}'.")
return pd.concat(all_rows, ignore_index=True) if all_rows else pd.DataFrame()
df_contrasts_a = _build_group_contrast_df(file_paths_a, self.contrast_results_dict, contrast_name)
df_contrasts_b = _build_group_contrast_df(file_paths_b, self.contrast_results_dict, contrast_name)
if df_contrasts_a.empty or df_contrasts_b.empty:
print("No contrast data found for one or both groups.")
continue
roi_maps_a = {
fp: self.roi_channel_map_dict[fp]
for fp in file_paths_a
if fp in self.roi_channel_map_dict
}
roi_maps_b = {
fp: self.roi_channel_map_dict[fp]
for fp in file_paths_b
if fp in self.roi_channel_map_dict
}
if not roi_maps_a or not roi_maps_b:
print("No channel-to-ROI mapping available for one or both groups.")
continue
run_cross_group_contrast_analysis(
df_contrasts_a=df_contrasts_a,
df_contrasts_b=df_contrasts_b,
contrast_name=contrast_name,
roi_channel_maps_a=roi_maps_a,
roi_channel_maps_b=roi_maps_b,
group_a_name=self.group_a_dropdown.currentText(),
group_b_name=self.group_b_dropdown.currentText(),
target_chroma=target_chroma,
min_subjects=min_subjects,
p_threshold=p_threshold,
correction_method=correction_method,
)
else:
print("no")
+196
View File
@@ -0,0 +1,196 @@
"""
Filename: exporttocsv.py
Description: Logic for the Export To CSV analysis window
Note: Compliant with pylance strict type checking
Author: Tyler de Zeeuw
License: GPL-3.0
"""
# Built-in imports
import os
from pathlib import Path
from typing import Any
# External library imports
from pandas import DataFrame
from mne.io.base import BaseRaw
from PySide6.QtWidgets import QFileDialog, QMessageBox
from flares import sparks_csv_export
from src.shared.flaresbasewidget import CSVUIMixin, FlaresBaseWidget
from src.shared.shareddata import APP_NAME
class ExportToCSVWidget(CSVUIMixin, FlaresBaseWidget):
def __init__(
self,
haemo_dict: dict[str | Path, BaseRaw],
cha_dict: dict[str, DataFrame],
df_ind_dict: dict[str, DataFrame],
design_matrix_dict: dict[str, DataFrame],
contrast_results_dict: dict[str, dict[str, Any]],
group_dict: dict[str, str],
config_dict: dict[str, str],
) -> None:
super().__init__("ExportToCSV")
self.setWindowTitle(f"Export To CSV Viewer - {APP_NAME.upper()}")
self.haemo_dict = haemo_dict
self.cha_dict = cha_dict
# self.df_ind = df_ind_dict
# self.design_matrix = design_matrix_dict
# self.contrast_results_dict = contrast_results_dict
# self.group = group_dict
self.config_dict = config_dict
self.setup_csv_ui(["0 (Export Data to CSV)", "1 (CSV for SPARKS)", "2 (Export Configuration to CSV)", "3 (Paragraph of Configuration)"])
def process_request(self):
selected_display_names = self._get_checked_items(self.participant_dropdown)
selected_file_paths: list[str] = []
for display_name in selected_display_names:
for fp, short_label in self.participant_map.items():
expected_display = f"{short_label} ({os.path.basename(fp)})"
if display_name == expected_display:
selected_file_paths.append(fp)
break
selected_indexes = [
int(s.split(" ")[0]) for s in self._get_checked_items(self.image_index_dropdown)
]
if not selected_file_paths or not selected_indexes:
QMessageBox.warning(self, "Selection Missing", "Please select at least one participant and one export type.")
return
output_dir = QFileDialog.getExistingDirectory(self, "Select Output Folder for CSV Exports")
if not output_dir:
print("Export cancelled: No folder selected.")
return
success_count = 0
# Pass the necessary arguments to each method
for file_path in selected_file_paths:
base_filename = os.path.splitext(os.path.basename(file_path))[0]
haemo_obj = self.haemo_dict.get(file_path)
if haemo_obj is None:
continue
cha = self.cha_dict.get(file_path)
for idx in selected_indexes:
try:
if idx == 0:
save_path = os.path.join(output_dir, f"{base_filename}_exported.csv")
if cha is not None:
cha.to_csv(save_path)
success_count += 1
elif idx == 1:
# SPARKS Export
save_path = os.path.join(output_dir, f"{base_filename}_sparks.csv")
sparks_csv_export(haemo_obj, save_path)
success_count += 1
elif idx == 2:
formatted_data = {
os.path.basename(path.replace("\\", "/")): inner_dict
for path, inner_dict in self.config_dict.items()
}
df = DataFrame(formatted_data)
df.index.name = "Parameter"
save_path = os.path.join(output_dir, f"{APP_NAME}_configuration.csv")
df.to_csv(save_path)
success_count += 1
elif idx == 3:
first_params = next(iter(self.config_dict.values()))
magic_string = self.gen_magic_str(first_params)
self.placeholder_label.setText(magic_string)
else:
print(f"No method defined for index {idx}")
except Exception as e:
print(f"Failed to export {file_path} (Type {idx}): {e}")
# 4. Final Notification
if success_count > 0:
QMessageBox.information(self, "Export Complete", f"Successfully saved {success_count} CSV files to:\n{output_dir}")
# # If SPARKS export was included, show the Event Window once at the end
# if 1 in selected_indexes:
# win = UpdateEventsWindow(
# parent=self,
# mode=EventUpdateMode.WRITE_JSON,
# caller="Video Alignment Tool"
# )
# win.show()
def gen_magic_str(self, all_params):
magic_str = "To start, the data was loaded into the application. "
if all_params['DOWNSAMPLE']:
magic_str += f"The data was downsampled to {all_params['DOWNSAMPLE_FREQUENCY']}hz. "
else:
magic_str += "The data was not downsampled and was retained at its original sampling frequency. "
if all_params['TRIM']:
magic_str += f"The data was then trimmed to only keep {all_params['SECONDS_TO_KEEP']} seconds before the first event, as the earlier data with no events would not be needed. "
else:
magic_str += "The data was not trimmed and was retained at its original length. "
if all_params['OPTODE_PLACEMENT']:
magic_str += f"A visualization check of the optode locations in 3D space was performed to ensure that they were at the correct location. "
if all_params['SHORT_CHANNELS']:
magic_str += f"A short channel(s) was specified with the maximum length of a short channel being {all_params['SHORT_CHANNELS_THRESHOLD']} metres. Channels that were over this distance but under {all_params['LONG_CHANNELS_THRESHOLD']} metres were retained. "
else:
magic_str += f"No short channel was specified. Channels between the lengths of {all_params['SHORT_CHANNELS_THRESHOLD']} and {all_params['LONG_CHANNELS_THRESHOLD']} meters were used with all other channles being discarded. "
if all_params['HEART_RATE']:
magic_str += f"The heart rate of the participant was attempted to be calculated. {all_params['SECONDS_TO_STRIP_HR']} seconds were ignored from the start of the file to avoid motion artifacts. "
magic_str += f"The heart rate had hard limits of {all_params['MAX_LOW_HR']} to {all_params['MAX_HIGH_HR']} and was smoothed across {all_params['SMOOTHING_WINDOW_HR']} samples. "
else:
magic_str += "The heart rate of the participants was not attempted to be calculated. "
if all_params['SCI']:
magic_str += f"The Scalp Coupling Index was calculated over {all_params['SCI_TIME_WINDOW']} second windows. The threshold value was {all_params['SCI_THRESHOLD']}. "
else:
magic_str += "Scalp Coupling Index was not used. "
if all_params['SNR']:
magic_str += f"The Signal to Noise Ratio was calculated. The threshold value was set to {all_params['SNR_THRESHOLD']}db. "
else:
magic_str += "The Signal to Noise Ratio was not used. "
if all_params['PSP']:
magic_str += f"Peak Spectral Power was calculated over {all_params['PSP_TIME_WINDOW']} second windows. The threshold value was {all_params['PSP_THRESHOLD']}. "
else:
magic_str += "Peak Spectral Power was not used. "
if all_params['COEFF_VAR']:
magic_str += f"The Coefficient of Variation was calculated. The threshold value was set to {all_params['COEFF_VAR_THRESHOLD']}. "
else:
magic_str += "The Coefficient of Variation was not used. "
if all_params['MAD']:
magic_str += f"Median Absolute Deviation was calculated. The threshold value was set to {all_params['MAD_THRESHOLD']}. "
else:
magic_str += "Median Absolute Deviation was not used. "
if all_params['PSD_NOISE']:
magic_str += f"Power Spectral Density Noise was calculated with a target frequency divisor of {all_params['TARGET_FREQ_DIV']} and a decibal limit of {all_params['DB_LIMIT']}. "
else:
magic_str += f"Power Spectral Density Noise was not used. "
magic_str += "More coming soon."
return magic_str
+220
View File
@@ -0,0 +1,220 @@
"""
Filename: intergroupbrainimage.py
Description: Logic for the Inter-Group Brain & Image analysis window
Note: Compliant with pylance strict type checking
Author: Tyler de Zeeuw
License: GPL-3.0
"""
# Built-in Imports
from pathlib import Path
from typing import Any, cast
# External library imports
import pandas as pd
from pandas import DataFrame
from mne import Annotations
from mne.io.base import BaseRaw
from flares import aggregate_fnirs_group_geometry, plot_fir_model_results, brain_3d_visualization
from src.shared.flaresbasewidget import InterGroupUIMixin, FlaresBaseWidget
from src.shared.shareddata import APP_NAME
PARAMETERIZED_INDEXES: dict[int, list[dict[str, Any]]] = {
0: [
{
"key": "lower_bound",
"label": "Lower bound + <description>",
"default": "-0.3",
"type": float, # specify int here
},
{
"key": "upper_bound",
"label": "Upper bound + <description>",
"default": "0.8",
"type": float, # specify int here
}
],
1: [
{
"key": "p_value",
"label": "Significance threshold P-value (e.g. 0.05)",
"default": "0.05",
"type": float,
},
{
"key": "graph_bounds",
"label": "Graph Upper/Lower Limit",
"default": "3.0",
"type": float,
}
],
2: [
{
"key": "show_optodes",
"label": "Determine what is rendered above the brain. Valid values are 'sensors', 'labels', 'none', 'all'.",
"default": "all",
"type": str,
},
{
"key": "t_or_theta",
"label": "Specify if t values or theta values should be plotted. Valid values are 't', 'theta'",
"default": "theta",
"type": str,
},
{
"key": "show_text",
"label": "Display informative text on the top left corner. THIS DOES NOT WORK AND SHOULD BE LEFT AT FALSE",
"default": "False",
"type": bool,
},
{
"key": "brain_bounds",
"label": "Graph Upper/Lower Limit",
"default": "1.0",
"type": float,
}
],
}
class InterGroupBrainImageWidget(InterGroupUIMixin, FlaresBaseWidget):
def __init__(
self,
haemo_dict: dict[str | Path, BaseRaw],
cha_dict: dict[str, DataFrame],
df_ind_dict: dict[str, DataFrame],
design_matrix_dict: dict[str, DataFrame],
contrast_results_dict: dict[str, dict[str, Any]],
group_dict: dict[str, str]
) -> None:
super().__init__("InterGroupBrainImage")
self.setWindowTitle(f"Inter-Group Brain & Image Viewer - {APP_NAME.upper()}")
self.haemo_dict = haemo_dict
self.cha_dict = cha_dict
self.df_ind_dict = df_ind_dict
self.design_matrix_dict = design_matrix_dict
self.contrast_results_dict = contrast_results_dict
self.group_dict = group_dict
self.setup_inter_group_ui(["0 (GLM Results)", "1 (Significance)", "2 (Brain Activity Visualization)",])
def process_request(self):
request = self.get_common_request_data(PARAMETERIZED_INDEXES)
if request is None:
return
(selected_event, selected_file_paths, selected_indexes, raw_params) = request
param_values = cast(dict[int | str, dict[str, Any]], raw_params)
all_cha = pd.DataFrame()
for file_path in selected_file_paths:
haemo_obj = self.haemo_dict.get(file_path)
if haemo_obj is None:
continue
if selected_event:
raw_annotations = getattr(haemo_obj, "annotations", None)
if raw_annotations is not None:
annotations = cast(Annotations, raw_annotations)
descriptions = cast(list[str], list(annotations.description))
participant_events: set[str] = set(descriptions)
else:
participant_events: set[str] = set()
if selected_event not in participant_events:
print(f"Skipping {self.participant_map[file_path]}: Event '{selected_event}' not found.")
continue
cha_df = self.cha_dict.get(file_path)
if cha_df is not None:
all_cha = pd.concat([all_cha, cha_df], ignore_index=True)
# Pass the necessary arguments to each method
file_path = selected_file_paths[0]
p_haemo = self.haemo_dict.get(file_path)
p_design_matrix = self.design_matrix_dict.get(file_path)
df_group = pd.DataFrame()
if selected_file_paths:
for file_path in selected_file_paths:
df = self.df_ind_dict.get(file_path)
if df is not None:
df_group = pd.concat([df_group, df], ignore_index=True)
for idx in selected_indexes:
if idx == 0:
params = param_values.get(idx, {})
lower_bound = params.get("lower_bound", None)
upper_bound = params.get("upper_bound", None)
if lower_bound is None or upper_bound is None:
print(f"Missing parameters for index {idx}, skipping.")
continue
plot_fir_model_results(df_group, p_haemo, p_design_matrix, selected_event, lower_bound, upper_bound)
elif idx == 1:
params = param_values.get(idx, {})
p_val = params.get("p_value", None)
graph_bounds = params.get("graph_bounds", None)
if p_val is None or graph_bounds is None:
print(f"Missing parameters for index {idx}, skipping.")
continue
all_contrasts: list[DataFrame] = []
for fp in selected_file_paths:
condition_dfs = self.contrast_results_dict.get(fp, {})
if selected_event in condition_dfs:
df = condition_dfs[selected_event].copy()
df["ID"] = fp
all_contrasts.append(df)
if not all_contrasts:
print("No contrast data found for selected participants and event.")
return
# TODO: look at intergroupstats and figure out what to do
_ = pd.concat(all_contrasts, ignore_index=True)
#flares.run_second_level_analysis(df_contrasts, p_haemo, p_val, graph_bounds)
elif idx == 2:
params = param_values.get(idx, {})
show_optodes = params.get("show_optodes", None)
t_or_theta = params.get("t_or_theta", None)
show_text = params.get("show_text", None)
brain_bounds = params.get("brain_bounds", None)
if show_optodes is None or t_or_theta is None or show_text is None or brain_bounds is None:
print(f"Missing parameters for index {idx}, skipping.")
continue
all_raw_objs = [self.haemo_dict.get(fp) for fp in selected_file_paths if self.haemo_dict.get(fp)]
if len(all_raw_objs) > 1:
processed_raw = aggregate_fnirs_group_geometry(all_raw_objs)
elif len(all_raw_objs) == 1 and all_raw_objs[0] is not None:
processed_raw = all_raw_objs[0].copy()
processed_raw.pick(picks="hbo") # type: ignore
else:
processed_raw = None
brain_3d_visualization(processed_raw, all_cha, selected_event, t_or_theta=t_or_theta, show_optodes=show_optodes, show_text=show_text, brain_bounds=brain_bounds)
elif idx == 3:
pass
else:
print(f"No method defined for index {idx}")
@@ -0,0 +1,83 @@
"""
Filename: intergroupfunctionalconnectivity.py
Description: Logic for the Inter-Group Functional Connectivity analysis window
Note: Compliant with pylance strict type checking
Author: Tyler de Zeeuw
License: GPL-3.0
"""
# Built-in imports
from pathlib import Path
from typing import Any, cast
# External library imports
from PySide6.QtWidgets import QMessageBox
from mne.io.base import BaseRaw
from flares import run_group_functional_connectivity
from src.shared.flaresbasewidget import InterGroupUIMixin, FlaresBaseWidget
from src.shared.shareddata import APP_NAME
PARAMETERIZED_INDEXES: dict[int, list[dict[str, Any]]] = {
0: [
{
"key": "n_lines",
"label": "<Description>",
"default": "20",
"type": int,
},
{
"key": "vmin",
"label": "<Description>",
"default": "0.9",
"type": float,
},
],
}
class InterGroupFunctionalConnectivityWidget(InterGroupUIMixin, FlaresBaseWidget):
def __init__(
self,
haemo_dict: dict[str | Path, BaseRaw],
group_dict: dict[str, str],
config_dict: dict[str, str]
) -> None:
super().__init__("InterGroupFunctionalConnectivity")
self.setWindowTitle(f"Inter-Group Functional Connectivity Viewer [BETA] - {APP_NAME.upper()}")
self.haemo_dict = haemo_dict
#self.group_dict = group_dict
self.config_dict = config_dict
QMessageBox.warning(self, f"Warning - {APP_NAME.upper()}", f"Functional Connectivity is still in development and the results should currently be taken with a grain of salt. "
"By clicking OK, you accept that the images generated may not be factual.")
self.setup_inter_group_ui(["0 (Betas)",])
def process_request(self):
request = self.get_common_request_data(PARAMETERIZED_INDEXES)
if request is None:
return
(selected_event, selected_file_paths, selected_indexes, raw_params) = request
param_values = cast(dict[int | str, dict[str, Any]], raw_params)
for idx in selected_indexes:
if idx == 0:
params = param_values.get(idx, {})
n_lines = params.get("n_lines", None)
vmin = params.get("vmin", None)
if n_lines is None or vmin is None:
print(f"Missing parameters for index {idx}, skipping.")
continue
run_group_functional_connectivity(self.haemo_dict, self.config_dict, selected_file_paths, selected_event, 50, 0.5)
else:
print(f"No method defined for index {idx}")
+403
View File
@@ -0,0 +1,403 @@
"""
Filename: intergroupstats.py
Description: Logic for the Inter-Group Stats analysis window
Note: Compliant with pylance strict type checking
Author: Tyler de Zeeuw
License: GPL-3.0
"""
# Built-in imports
from pathlib import Path
from typing import Any, cast
# External library imports
import pandas as pd
from pandas import DataFrame
from mne import Annotations
from mne.io.base import BaseRaw
from flares import run_roi_paired_contrast_analysis, run_roi_second_level_analysis, aggregate_channel_contrasts_to_roi
from src.shared.flaresbasewidget import InterGroupUIMixin, FlaresBaseWidget
from src.shared.shareddata import APP_NAME
PARAMETERIZED_INDEXES: dict[int, list[dict[str, Any]]] = {
0: [
{
"key": "p_threshold",
"label": "Significance threshold P-value (e.g. 0.05)",
"default": "0.05",
"type": float,
},
{
"key": "min_subjects",
"label": "Minimum number of participants to process",
"default": "5",
"type": int,
},
{
"key": "correction_method",
"label": "Correction method to utilize. Valid values are 'fdr_bh', 'None'",
"default": "fdr_bh",
"type": str,
},
{
"key": "target_chroma",
"label": "Which chroma to target. Valid values are 'hbo', 'hbr'",
"default": "hbo",
"type": str,
},
{
"key": "graph_bounds",
"label": "Graph Upper/Lower Limit",
"default": "0.0",
"type": float,
}
],
1: [
{
"key": "p_threshold",
"label": "Significance threshold P-value (e.g. 0.05)",
"default": "0.05",
"type": float,
},
{
"key": "min_subjects",
"label": "Minimum number of participants to process",
"default": "5",
"type": int,
},
{
"key": "correction_method",
"label": "Correction method to utilize. Valid values are 'fdr_bh', 'None'",
"default": "None",
"type": str,
},
{
"key": "target_chroma",
"label": "Which chroma to target. Valid values are 'hbo', 'hbr'",
"default": "hbo",
"type": str,
},
{
"key": "roi_a",
"label": "ROI A (e.g. contralateral region name from regions.json)",
"default": [],
"type": list,
},
{
"key": "roi_b",
"label": "ROI B (e.g. ipsilateral region name from regions.json)",
"default": [],
"type": list,
}
],
2: [
{
"key": "p_value",
"label": "Significance threshold P-value (e.g. 0.05)",
"default": "0.05",
"type": float,
},
{
"key": "min_subjects",
"label": "Minimum number of participants to process",
"default": "5",
"type": int,
},
{
"key": "correction_method",
"label": "Correction method to utilize. Valid values are 'fdr_bh', 'None'",
"default": "fdr_bh",
"type": str,
},
{
"key": "target_chroma",
"label": "Which chroma to target. Valid values are 'hbo', 'hbr'",
"default": "hbo",
"type": str,
},
{
"key": "contrast_name",
"label": "Name of the contrast to use",
"default": [],
"type": list,
},
{
"key": "weighted",
"label": "Use inverse-variance weighting to minimize noisy channels",
"default": True,
"type": bool,
},
{
"key": "graph_bounds",
"label": "Graph Upper/Lower Limit",
"default": "0.0",
"type": float,
},
],
}
DESCRIPTION = """0. ROI vs. Zero (run_roi_second_level_analysis)
\nTests whether one ROI's response during one condition reliably differs from zero across subjects - a one-sample t-test on each subject's ROI-averaged theta. A significant result means the region's signal during this condition is consistently non-zero across your sample, not just noise. It does not tell you whether the response is localized/specific to this region, or whether it reflects real neural activity versus systemic physiology (blood pressure, arousal) shared across the whole head during any active task - a single-condition "vs. zero" test can't distinguish those two explanations on its own.
\nIf you expected significance here and didn't get it, likely causes include: the sample size is simply small relative to between-subject variability in true response magnitude or HRF shape (individual differences in timing/amplitude inflate the variance a t-test divides by); the ROI's channel composition differs slightly across subjects (missing channels get down-weighted or excluded from the inverse-variance average, diluting a real signal); FDR correction across many ROIs is suppressing a modest true effect that would clear an uncorrected threshold; or the condition itself may not reliably engage this region the way you assumed (worth checking the single-subject/individual-level results for this ROI before concluding the group effect isn't there).
\n\n1. Paired ROI Contrast (run_roi_paired_contrast_analysis)
\nFor one condition, subtracts each subject's ROI_A response from their ROI_B response, then tests whether that per-subject difference is reliably non-zero. A significant result is a genuine spatial contrast - the two regions respond differently from each other during this specific condition, with shared systemic noise partially cancelling in the subtraction. It says nothing about whether the condition produced meaningful activity at all (only a relative difference between two places), and its power depends entirely on ROI_A and ROI_B varying together across subjects - an assumption that isn't guaranteed.
\nIf this test underperforms a plain ROI-vs-zero result, which can occur, the most likely explanation is that ROI_A and ROI_B's noise isn't well-correlated across your subjects. The math is variance(A - B) = variance(A) + variance(B) - 2·covariance(A,B): subtraction only helps when the shared/systemic component is large relative to independent noise in each region. If the two regions are picking up largely independent noise sources (motion artifact affecting one side more, different channel quality, etc.), subtracting adds variance rather than removing it, and can turn a detectable single-ROI effect into an underpowered paired one. Small sample size makes this worse, since the covariance itself is poorly estimated with few subjects.
\n\n2. Joint Contrast, ROI-Aggregated (aggregate_channel_contrasts_to_roi + one-sample test)
\nUses a contrast fit jointly within each subject's GLM (Condition A minus Condition B, estimated together), then aggregates that per-channel contrast to ROI level using inverse-variance weighting, and tests it against zero across subjects. A significant result means the two conditions produce reliably different responses at this ROI, with systemic noise largely cancelled at the model-fitting stage itself - the most statistically efficient of the three within-group methods, since the correlation between conditions is handled natively rather than inferred afterward. It does not tell you where the difference is localized on its own - for that, compare the sign/pattern across multiple ROIs: opposite signs across regions indicates a real, spatially-specific effect, while the same sign everywhere suggests diffuse/systemic noise rather than localized activity (as seen when comparing a real task-vs-task contrast against a task-vs-inert-marker contrast).
\nIf this comes back non-significant despite expecting an effect, first check whether the two conditions are actually similar enough in their neural engagement of this ROI that a small or genuinely near-zero contrast is the correct answer - not every ROI should differentiate every pair of tasks, and a null result here can be the right result. Beyond that: FDR correction across every ROI in your regions file can suppress a real but modest contrast; the inverse-variance weighting can be destabilized if a few channels within the ROI have very noisy or near-zero t-statistics (their standard error estimate becomes huge or unstable); and - as always - small subject counts limit the achievable degrees of freedom regardless of how clean the underlying per-channel estimates are.
\n\n
\nWhy channels needed to be aggregated into ROIs: Testing every channel independently means paying a steep multiple-comparisons tax - with dozens of channels, FDR/Bonferroni correction demands very large effect sizes to call anything significant, and at small subject counts (n=5) essentially nothing survives even when a real, consistent effect exists. Collapsing channels into a handful of anatomically meaningful ROIs cuts the number of independent tests from a minimum of ~40 down to 2-8, which lets a genuinely present effect actually clear correction. It also matches the scientific question better: you have a hypothesis about regions (contralateral motor cortex, prefrontal cortex), not about individual source-detector pairs, so testing at the ROI level is testing the thing you actually believe in, using inverse-variance weighting so noisier channels contribute less to the region's combined estimate rather than diluting it equally.
\nWhy some analyses needed contrasts instead of raw values: A single condition's GLM beta is only ever measured relative to the model's implicit intercept, and that intercept absorbs whatever's happening for the rest of the recording - including systemic physiology (blood pressure, arousal, general vascular reactivity) that rises during almost any active task, not just the one you care about. Testing a raw "vs. zero" value can't tell a real, localized neural response apart from that shared full-head noise. A contrast - either a within-subject spatial subtraction (ROI A minus ROI B) or a jointly-fit task contrast (Condition A minus Condition B, estimated together in one GLM) cancels out whatever's common to both halves of the subtraction, leaving something closer to the actual differential signal.
\nWhy a minimum subject count is enforced: Every one of these tests is a t-test, and a t-test's ability to detect a real effect (its power) depends heavily on degrees of freedom - at n=5 (df=4), even a fairly large true effect can produce a middling p-value, and at n=2 (df=1) the test is barely meaningful at all regardless of the underlying data. The min_subjects floor exists to stop a channel or ROI from being silently tested (and potentially reported as significant or non-significant) on a sample too small for the resulting p-value to mean anything reliable - it's better to explicitly skip and flag an underpowered channel than to quietly produce a number that looks statistically legitimate but isn't backed by enough independent observations to trust."""
class InterGroupStatsWidget(InterGroupUIMixin, FlaresBaseWidget):
def __init__(
self,
haemo_dict: dict[str | Path, BaseRaw],
cha_dict: dict[str, DataFrame],
df_ind_dict: dict[str, DataFrame],
design_matrix_dict: dict[str, DataFrame],
contrast_results_dict: dict[str, dict[str, Any]],
roi_channel_map_dict: dict[str, dict[str, str]],
group_dict: dict[str, str],
) -> None:
super().__init__("InterGroupStats")
self.setWindowTitle(f"Inter-Group Stats Viewer - {APP_NAME.upper()}")
self.haemo_dict = haemo_dict
self.cha_dict = cha_dict
self.df_ind_dict = df_ind_dict
self.design_matrix_dict = design_matrix_dict
self.contrast_results_dict = contrast_results_dict
self.roi_channel_map_dict = roi_channel_map_dict
self.group_dict = group_dict
self.setup_inter_group_ui(["0 (ROI vs. Zero)", "1 (Paired ROI Contrast)", "2 (Joint Contrast, ROI-Aggregated)"], placeholder_text=DESCRIPTION)
def process_request(self):
request = self.get_common_request_data(PARAMETERIZED_INDEXES, self.df_ind_dict, self.contrast_results_dict)
if request is None:
return
(selected_event, selected_file_paths, selected_indexes, raw_params) = request
param_values = cast(dict[int | str, dict[str, Any]], raw_params)
all_cha = DataFrame()
for file_path in selected_file_paths:
haemo_obj = self.haemo_dict.get(file_path)
if haemo_obj is None:
continue
if selected_event:
raw_annotations = getattr(haemo_obj, "annotations", None)
if raw_annotations is not None:
annotations = cast(Annotations, raw_annotations)
descriptions = cast(list[str], list(annotations.description))
participant_events: set[str] = set(descriptions)
else:
participant_events: set[str] = set()
if selected_event not in participant_events:
print(f"Skipping {self.participant_map[file_path]}: Event '{selected_event}' not found.")
continue
cha_df = self.cha_dict.get(file_path)
if cha_df is not None:
all_cha = pd.concat([all_cha, cha_df], ignore_index=True)
file_path = selected_file_paths[0]
p_haemo = self.haemo_dict.get(file_path)
# Concatenate individual ROI stats (df_ind) for all chosen subjects
df_group = DataFrame()
if selected_file_paths:
for file_path in selected_file_paths:
df = self.df_ind_dict.get(file_path)
if df is not None:
df_group = pd.concat([df_group, df], ignore_index=True)
for idx in selected_indexes:
if idx == 0:
params = param_values.get(idx, {})
p_threshold = params.get("p_threshold", 0.05)
min_subjects = params.get("min_subjects", 5)
correction_method = params.get("correction_method", "fdr_bh")
target_chroma = params.get("target_chroma", "hbo")
graph_bounds = params.get("graph_bounds", 0.0)
if correction_method == "None":
correction_method = None
if df_group.empty:
print("No ROI data (df_ind) found for selected participants.")
continue
# Filter down to the selected experimental event/condition
if selected_event:
if 'Condition' in df_group.columns:
df_filtered = df_group[df_group['Condition'] == selected_event]
else:
print("Warning: 'Condition' column not found in ROI data.")
df_filtered = df_group
else:
df_filtered = df_group
if df_filtered.empty:
print(f"No ROI data matches the condition '{selected_event}'.")
continue
all_cha_filtered = DataFrame()
if not all_cha.empty:
if selected_event and 'Condition' in all_cha.columns:
all_cha_filtered = all_cha[all_cha['Condition'] == selected_event]
else:
all_cha_filtered = all_cha
run_roi_second_level_analysis(
df_roi_all=df_filtered,
df_cha_all=all_cha_filtered,
raw_haemo=p_haemo,
p_threshold=p_threshold,
min_subjects=min_subjects,
correction_method=correction_method,
target_chroma=target_chroma,
graph_bounds=graph_bounds if graph_bounds > 0.0 else None,
)
elif idx == 1:
params = param_values.get(idx, {})
p_threshold = params.get("p_threshold", 0.05)
min_subjects = params.get("min_subjects", 5)
correction_method = params.get("correction_method", "None")
target_chroma = params.get("target_chroma", "hbo")
roi_a = params.get("roi_a", "").strip()
roi_b = params.get("roi_b", "").strip()
if not selected_event:
print("Paired ROI contrast requires a specific event/condition "
"to be selected - pick one from the Event dropdown first.")
continue
if df_group.empty:
print("No ROI data (df_ind) found for selected participants.")
continue
if correction_method == "None":
correction_method = None
if not roi_a or not roi_b:
print("Both ROI A and ROI B must be specified.")
continue
print(min_subjects)
run_roi_paired_contrast_analysis(
df_roi_all=df_group,
roi_pairs=(roi_a, roi_b),
condition=selected_event,
target_chroma=target_chroma,
min_subjects=min_subjects,
p_threshold=p_threshold,
correction_method=correction_method,
roi_a_label=roi_a,
roi_b_label=roi_b,
)
elif idx == 2:
params = param_values.get(idx, {})
p_threshold = params.get("p_threshold", 0.05)
min_subjects = params.get("min_subjects", 5)
correction_method = params.get("correction_method", "fdr_bh")
target_chroma = params.get("target_chroma", "hbo")
contrast_name = params.get("contrast_name", "")
weighted = params.get("weighted", True)
graph_bounds = params.get("graph_bounds", 0.0)
if not selected_event:
print("Joint contrast ROI analysis requires a specific contrast "
"to be selected from the Event dropdown first.")
continue
if not contrast_name:
print("Contrast name must be specified.")
continue
all_contrasts: list[DataFrame] = []
for fp in selected_file_paths:
condition_dfs = self.contrast_results_dict.get(fp)
if condition_dfs is None:
print(f" [MISSING] '{fp}' not found in contrast_results.")
continue
if contrast_name in condition_dfs:
df = condition_dfs[contrast_name].copy()
df["ID"] = fp
df["contrast_name"] = contrast_name
all_contrasts.append(df)
else:
print(f" [MISSING CONTRAST] '{contrast_name}' not "
f"available for {self.participant_map.get(fp, fp)}.")
if not all_contrasts:
print(f"No contrast data found for '{contrast_name}' "
f"across selected participants.")
continue
df_contrasts = pd.concat(all_contrasts, ignore_index=True)
selected_roi_maps = {
fp: self.roi_channel_map_dict[fp]
for fp in selected_file_paths
if fp in self.roi_channel_map_dict
}
if not selected_roi_maps:
print("No channel-to-ROI mapping available for selected participants.")
continue
try:
roi_theta = aggregate_channel_contrasts_to_roi(
df_contrasts,
roi_channel_maps=selected_roi_maps,
weighted=weighted,
)
except Exception as e:
print(f"Failed to aggregate contrasts to ROI: {e}")
continue
if roi_theta.empty:
print("No ROI-level contrast values could be computed "
"(check regions.json channel names against this montage).")
continue
# TODO: Come back to this
# df_cha_all intentionally omitted (None): the topography
# section of run_roi_second_level_analysis expects
# single-condition Condition values in df_cha_all, which
# doesn't semantically match a contrast name - skip it here
# rather than pass mismatched data.
run_roi_second_level_analysis(
df_roi_all=roi_theta,
df_cha_all=None,
raw_haemo=p_haemo,
p_threshold=p_threshold,
min_subjects=min_subjects,
correction_method=correction_method,
target_chroma=target_chroma,
graph_bounds=graph_bounds if graph_bounds > 0.0 else None,
)
else:
print(f"No method defined for index {idx}")
+144
View File
@@ -0,0 +1,144 @@
"""
Filename: participantbrain.py
Description: Logic for the Participant Brain analysis window
Note: Compliant with pylance strict type checking
Author: Tyler de Zeeuw
License: GPL-3.0
"""
# Built-in imports
from pathlib import Path
from typing import Any, cast
# External library imports
from mne import Annotations
from pandas import DataFrame
from mne.io.base import BaseRaw
from flares import brain_3d_visualization, brain_landmarks_3d
from src.shared.flaresbasewidget import ParticipantUIMixin, FlaresBaseWidget
from src.shared.shareddata import APP_NAME
PARAMETERIZED_INDEXES: dict[int, list[dict[str, Any]]] = {
0: [
{
"key": "show_optodes",
"label": "Determine what is rendered above the brain. Valid values are 'sensors', 'labels', 'none', 'all'.",
"default": "all",
"type": str,
},
{
"key": "show_brodmann",
"label": "Show common brodmann areas on the brain.",
"default": "True",
"type": bool,
}
],
1: [
{
"key": "show_optodes",
"label": "Determine what is rendered above the brain. Valid values are 'sensors', 'labels', 'none', 'all'.",
"default": "all",
"type": str,
},
{
"key": "t_or_theta",
"label": "Specify if t values or theta values should be plotted. Valid values are 't', 'theta'",
"default": "theta",
"type": str,
},
{
"key": "show_text",
"label": "Display informative text on the top left corner. THIS DOES NOT WORK AND SHOULD BE LEFT AT FALSE",
"default": "False",
"type": bool,
},
{
"key": "brain_bounds",
"label": "Graph Upper/Lower Limit",
"default": "1.0",
"type": float,
}
],
}
class ParticipantBrainViewerWidget(ParticipantUIMixin, FlaresBaseWidget):
def __init__(
self,
haemo_dict: dict[str | Path, BaseRaw],
cha_dict: dict[str, DataFrame],
) -> None:
super().__init__("ParticipantBrain")
self.setWindowTitle(f"Participant Brain Viewer - {APP_NAME.upper()}")
self.haemo_dict = haemo_dict
self.cha_dict = cha_dict
self.setup_participant_ui(["0 (Brain Landmarks)", "1 (Brain Activity Visualization)",])
def process_request(self):
request = self.get_common_request_data(PARAMETERIZED_INDEXES)
if request is None:
return
(selected_event, selected_file_paths, selected_indexes, raw_params) = request
param_values = cast(dict[int | str, dict[str, Any]], raw_params)
# Pass the necessary arguments to each method
for file_path in selected_file_paths:
haemo_obj = self.haemo_dict.get(file_path)
if haemo_obj is None:
continue
if selected_event:
raw_annotations = getattr(haemo_obj, "annotations", None)
if raw_annotations is not None:
annotations = cast(Annotations, raw_annotations)
descriptions = cast(list[str], list(annotations.description))
participant_events: set[str] = set(descriptions)
else:
participant_events: set[str] = set()
if selected_event not in participant_events:
print(f"Skipping {self.participant_map[file_path]}: Event '{selected_event}' not found.")
continue
cha = self.cha_dict.get(file_path)
for idx in selected_indexes:
if idx == 0:
params = param_values.get(idx, {})
show_optodes = params.get("show_optodes", None)
show_brodmann = params.get("show_brodmann", None)
if show_optodes is None or show_brodmann is None:
print(f"Missing parameters for index {idx}, skipping.")
continue
brain_landmarks_3d(haemo_obj, show_optodes, show_brodmann)
elif idx == 1:
params = param_values.get(idx, {})
show_optodes = params.get("show_optodes", None)
t_or_theta = params.get("t_or_theta", None)
show_text = params.get("show_text", None)
brain_bounds = params.get("brain_bounds", None)
if show_optodes is None or t_or_theta is None or show_text is None or brain_bounds is None:
print(f"Missing parameters for index {idx}, skipping.")
continue
brain_3d_visualization(haemo_obj, cha, selected_event, t_or_theta=t_or_theta, show_optodes=show_optodes, show_text=show_text, brain_bounds=brain_bounds)
else:
print(f"No method defined for index {idx}")
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,182 @@
"""
Filename: participantfunctionalconnectivity.py
Description: Logic for the Participant Functional Connectivity analysis window
Note: Compliant with pylance strict type checking
Author: Tyler de Zeeuw
License: GPL-3.0
"""
# Built-in Imports
from pathlib import Path
from typing import Any, cast
# External library imports
from PySide6.QtWidgets import QMessageBox
from pandas import DataFrame
from mne import Annotations
from mne.io.base import BaseRaw
from flares import functional_connectivity_betas, functional_connectivity_envelope, functional_connectivity_spectral_epochs, functional_connectivity_spectral_time
from src.shared.flaresbasewidget import ParticipantUIMixin, FlaresBaseWidget
from src.shared.shareddata import APP_NAME
PARAMETERIZED_INDEXES: dict[int, list[dict[str, Any]]] = {
0: [
{
"key": "n_lines",
"label": "<Description>",
"default": "20",
"type": int,
},
{
"key": "vmin",
"label": "<Description>",
"default": "0.9",
"type": float,
},
],
1: [
{
"key": "n_lines",
"label": "<Description>",
"default": "20",
"type": int,
},
{
"key": "vmin",
"label": "<Description>",
"default": "0.9",
"type": float,
},
],
2: [
{
"key": "n_lines",
"label": "<Description>",
"default": "20",
"type": int,
},
{
"key": "vmin",
"label": "<Description>",
"default": "0.9",
"type": float,
},
],
3: [
{
"key": "n_lines",
"label": "<Description>",
"default": "20",
"type": int,
},
{
"key": "vmin",
"label": "<Description>",
"default": "0.9",
"type": float,
},
],
}
class ParticipantFunctionalConnectivityWidget(ParticipantUIMixin, FlaresBaseWidget):
def __init__(
self,
haemo_dict: dict[str | Path, BaseRaw],
epochs_dict: dict[str, DataFrame],
) -> None:
super().__init__("ParticipantFunctionalConnectivity")
self.setWindowTitle(f"Participant Functional Connectivity Viewer [BETA] - {APP_NAME.upper()}")
self.haemo_dict = haemo_dict
self.epochs_dict = epochs_dict
QMessageBox.warning(self, f"Warning - {APP_NAME.upper()}", f"Functional Connectivity is still in development and the results should currently be taken with a grain of salt. "
"By clicking OK, you accept that the images generated may not be factual.")
self.setup_participant_ui(["0 (Spectral Connectivity Epochs)", "1 (Envelope Correlation)", "2 (Betas)", "3 (Spectral Connectivity Epochs)",])
def process_request(self):
request = self.get_common_request_data(PARAMETERIZED_INDEXES)
if request is None:
return
(selected_event, selected_file_paths, selected_indexes, raw_params) = request
param_values = cast(dict[int | str, dict[str, Any]], raw_params)
# Pass the necessary arguments to each method
for file_path in selected_file_paths:
haemo_obj = self.haemo_dict.get(file_path)
epochs_obj = self.epochs_dict.get(file_path)
if haemo_obj is None:
continue
if selected_event:
raw_annotations = getattr(haemo_obj, "annotations", None)
if raw_annotations is not None:
annotations = cast(Annotations, raw_annotations)
descriptions = cast(list[str], list(annotations.description))
participant_events: set[str] = set(descriptions)
else:
participant_events: set[str] = set()
if selected_event not in participant_events:
print(f"Skipping {self.participant_map[file_path]}: Event '{selected_event}' not found.")
continue
for idx in selected_indexes:
if idx == 0:
params = param_values.get(idx, {})
n_lines = params.get("n_lines", None)
vmin = params.get("vmin", None)
if n_lines is None or vmin is None:
print(f"Missing parameters for index {idx}, skipping.")
continue
functional_connectivity_spectral_epochs(epochs_obj, n_lines, vmin)
elif idx == 1:
params = param_values.get(idx, {})
n_lines = params.get("n_lines", None)
vmin = params.get("vmin", None)
if n_lines is None or vmin is None:
print(f"Missing parameters for index {idx}, skipping.")
continue
functional_connectivity_envelope(epochs_obj, n_lines, vmin)
elif idx == 2:
params = param_values.get(idx, {})
n_lines = params.get("n_lines", None)
vmin = params.get("vmin", None)
if n_lines is None or vmin is None:
print(f"Missing parameters for index {idx}, skipping.")
continue
functional_connectivity_betas(haemo_obj, n_lines, vmin, selected_event)
elif idx == 3:
params = param_values.get(idx, {})
n_lines = params.get("n_lines", None)
vmin = params.get("vmin", None)
if n_lines is None or vmin is None:
print(f"Missing parameters for index {idx}, skipping.")
continue
functional_connectivity_spectral_time(epochs_obj, n_lines, vmin)
else:
print(f"No method defined for index {idx}")
+175
View File
@@ -0,0 +1,175 @@
"""
Filename: participantimage.py
Description: Logic for the Participant Image analysis window
Note: Compliant with pylance strict type checking
Author: Tyler de Zeeuw
License: GPL-3.0
"""
# Built-in Imports
import os.path as op
from pathlib import Path
from datetime import datetime
# External library imports
from mne.io.base import BaseRaw
from PySide6.QtWidgets import QGridLayout, QHBoxLayout, QMessageBox, QPushButton, QScrollArea, QWidget, QVBoxLayout, QLabel
from PySide6.QtCore import Qt, QSize
from PySide6.QtGui import QPixmap
from src.shared.flaresbasewidget import ClickableLabel, FlaresBaseWidget
from src.shared.shareddata import APP_NAME
class ParticipantImageViewerWidget(FlaresBaseWidget):
def __init__(
self,
haemo_dict: dict[str, BaseRaw],
fig_bytes_dict: dict[str, dict[str, bytes]]
) -> None:
super().__init__("ParticipantImage")
self.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose)
self.setWindowTitle(f"Participant Image Viewer - {APP_NAME.upper()}")
self.haemo_dict = haemo_dict
self.fig_bytes_dict = fig_bytes_dict
# Create mappings: file_path -> participant label and dropdown display text
self.participant_map: dict[str, str] = {}
self.participant_dropdown_items: list[str] = []
for i, file_path in enumerate(self.haemo_dict.keys(), start=1):
short_label = f"Participant {i}"
display_label = f"{short_label} ({op.basename(file_path)})"
self.participant_map[file_path] = short_label
self.participant_dropdown_items.append(display_label)
self.main_layout = QVBoxLayout(self)
self.top_bar = QHBoxLayout()
self.main_layout.addLayout(self.top_bar)
self.participant_dropdown = self._create_multiselect_dropdown(self.participant_dropdown_items)
self.participant_dropdown.currentIndexChanged.connect(self.update_participant_dropdown_label)
first_fig_dict = next(iter(self.fig_bytes_dict.values()))
image_label_items = list(first_fig_dict.keys())
self.image_index_dropdown = self._create_multiselect_dropdown(image_label_items)
self.image_index_dropdown.currentIndexChanged.connect(self.update_image_index_dropdown_label)
self.submit_button = QPushButton("Submit")
self.submit_button.clicked.connect(self.show_selected_images)
self.top_bar.addWidget(QLabel("Participants:"))
self.top_bar.addWidget(self.participant_dropdown)
self.top_bar.addWidget(QLabel("Image Indexes:"))
self.top_bar.addWidget(self.image_index_dropdown)
self.top_bar.addWidget(self.submit_button)
self.scroll_area = QScrollArea()
self.scroll_area.setWidgetResizable(True)
self.scroll_content = QWidget()
self.grid_layout = QGridLayout(self.scroll_content)
self.scroll_area.setWidget(self.scroll_content)
self.main_layout.addWidget(self.scroll_area)
self.thumb_size = QSize(280, 180)
self.save_button = QPushButton("Save Displayed Images")
self.save_button.clicked.connect(self.save_displayed_images)
self.top_bar.addWidget(self.save_button)
self.showMaximized()
def show_selected_images(self):
# Clear previous images
while self.grid_layout.count():
item = self.grid_layout.takeAt(0)
widget = item.widget()
if widget:
widget.deleteLater()
selected_display_names = self._get_checked_items(self.participant_dropdown)
# Map from display names back to file paths
selected_file_paths: list[str] = []
for display_name in selected_display_names:
# Find file_path by matching display name
for fp, short_label in self.participant_map.items():
expected_display = f"{short_label} ({Path(fp).name})"
if display_name == expected_display:
selected_file_paths.append(str(fp))
break
selected_labels = self._get_checked_items(self.image_index_dropdown)
row, col = 0, 0
for file_path in selected_file_paths:
fig_map: dict[str, bytes] = self.fig_bytes_dict.get(file_path, {})
participant_label: str = self.participant_map.get(file_path, "Unknown")
for label in selected_labels:
fig_bytes: bytes | None = fig_map.get(label)
if not fig_bytes:
continue
full_pixmap = QPixmap()
full_pixmap.loadFromData(fig_bytes)
thumbnail_pixmap = full_pixmap.scaled(
self.thumb_size,
Qt.AspectRatioMode.KeepAspectRatio,
Qt.TransformationMode.SmoothTransformation
)
container = QWidget()
hlayout = QHBoxLayout(container)
hlayout.setContentsMargins(0, 0, 0, 0)
hlayout.setSpacing(0)
hlayout.setAlignment(Qt.AlignmentFlag.AlignCenter)
image_label = ClickableLabel(full_pixmap, thumbnail_pixmap)
image_label.setToolTip(f"{participant_label}\n{label}")
hlayout.addWidget(image_label)
self.grid_layout.addWidget(container, row, col)
col += 1
if col >= 6:
col = 0
row += 1
# Update dropdown labels after display
self.update_participant_dropdown_label()
self.update_image_index_dropdown_label()
def save_displayed_images(self):
# Ensure the folder exists
save_dir = Path("individual_images")
save_dir.mkdir(exist_ok=True)
selected_display_names = self._get_checked_items(self.participant_dropdown)
selected_image_labels = self._get_checked_items(self.image_index_dropdown)
for display_name in selected_display_names:
# Match display name to file path
for file_path, short_label in self.participant_map.items():
expected_display = f"{short_label} ({op.basename(file_path)})"
if display_name == expected_display:
fig_dict = self.fig_bytes_dict.get(file_path, {})
for label in selected_image_labels:
if label not in fig_dict:
continue
fig_bytes = fig_dict[label]
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
filename = f"{op.basename(file_path)}_{label}_{timestamp}.png"
output_path = save_dir / filename
with open(output_path, "wb") as f:
f.write(fig_bytes)
break # file_path matched; stop loop
QMessageBox.information(self, "Save Complete", f"Images saved to {save_dir.resolve()}")
File diff suppressed because it is too large Load Diff
+78
View File
@@ -0,0 +1,78 @@
"""
Filename: shareddata.py
Description: Shared constants and methods other files depend on
Note: Compliant with pylance strict type checking
Author: Tyler de Zeeuw
License: GPL-3.0
"""
# Built-in imports
import os
import sys
import platform
CURRENT_VERSION = "1.6.0"
APP_NAME = "flares"
APP_NAME_EXPANDED = "fNIRS Lightweight Analysis, Research, & Evaluation Suite"
API_URL = f"https://git.research.dezeeuw.ca/api/v1/repos/tyler/{APP_NAME}/releases"
API_URL_SECONDARY = f"https://git.research2.dezeeuw.ca/api/v1/repos/tyler/{APP_NAME}/releases"
PLATFORM_NAME = platform.system().lower()
CHANGELOG_URL = f"https://git.research.dezeeuw.ca/tyler/{APP_NAME}/raw/branch/main/changelog_major.md"
WIKI_URL = f"https://git.research.dezeeuw.ca/tyler/{APP_NAME}/wiki"
PIPELINE_STAGES = [
"Preprocessing",
"Trimming",
"Verify Optode Placement",
"Short/Long Channels",
"Heart Rate",
"Scalp Coupling Index",
"Signal to Noise Ratio",
"Peak Spectral Power",
"Cross Validation",
"Median Absolute Deviation",
"Power Spectral Density Noise",
"Channel Variance",
"Bad Channels Handling",
"Optical Density",
"Temporal Derivative Distribution Repair Filtering",
"Wavelet Filtering",
"Haemoglobin Concentration",
"Enhance Negative Correlation",
"Filter",
"Extracting Events",
"Epoch Calculations",
"Design Matrix",
"General Linear Model",
"Generate GLM Results",
"Generate Channel Results",
"Generate Region of Interest Results",
"Generate Contrast Results",
"Finishing Up"
]
DATA_SCHEMA = [
{"key": "raw_haemo_dict", "help": "Dict[file_path, MNE RawArray]: Haemodynamic raw data"},
{"key": "epochs_dict", "help": "Dict[file_path, MNE Epochs]: Time-locked epoch data"},
{"key": "cha_dict", "help": "Dict[file_path, DataFrame]: Channel analysis results"},
{"key": "df_ind_dict", "help": "Dict[file_path, DataFrame]: Individual-level data/ROI results"},
{"key": "design_matrix_dict", "help": "Dict[file_path, DataFrame]: GLM design matrices"},
{"key": "config_dict", "help": "Dict[file_path, dict]: Processing configuration parameters"},
{"key": "fig_bytes_dict", "help": "Dict[file_path, dict]: Serialized figure data"},
{"key": "contrast_results_dict", "help": "Dict[file_path, dict]: Calculated contrast statistical results"},
{"key": "roi_channel_map_dict", "help": "Dict[file_path, dict]: Calculated contrast statistical results"},
{"key": "valid_dict", "help": "Dict[file_path, bool]: Boolean validity status per file"}
]
def resource_path(relative_path: str) -> str:
"""
Get absolute path to resource regardless of running directly or packaged using PyInstaller
"""
base_path = getattr(sys, "_MEIPASS", os.path.abspath("."))
return os.path.join(base_path, relative_path)
+41
View File
@@ -0,0 +1,41 @@
"""
Filename: about.py
Description: About window
Note: Compliant with pylance strict type checking
Author: Tyler de Zeeuw
License: GPL-3.0
"""
# External library imports
from PySide6.QtWidgets import QWidget, QVBoxLayout, QLabel
from PySide6.QtCore import Qt
from src.shared.shareddata import APP_NAME, APP_NAME_EXPANDED, CURRENT_VERSION
class AboutWindow(QWidget):
"""
Simple About window displaying basic application information.
Args:
parent (QWidget, optional): Parent widget of this window. Defaults to None.
"""
def __init__(self, parent: QWidget | None = None) -> None:
super().__init__(parent, Qt.WindowType.Window)
self.setWindowTitle(f"About {APP_NAME.upper()}")
self.resize(250, 100)
layout = QVBoxLayout()
label = QLabel(f"About {APP_NAME.upper()}", self)
label2 = QLabel(f"{APP_NAME_EXPANDED}", self)
label3 = QLabel(f"{APP_NAME.upper()} is licensed under the GPL-3.0 licence. For more information, visit https://www.gnu.org/licenses/gpl-3.0.en.html", self)
label4 = QLabel(f"Version v{CURRENT_VERSION}")
layout.addWidget(label)
layout.addWidget(label2)
layout.addWidget(label3)
layout.addWidget(label4)
self.setLayout(layout)
+166
View File
@@ -0,0 +1,166 @@
"""
Filename: terminal.py
Description: Terminal window
Note: Compliant with pylance strict type checking
Author: Tyler de Zeeuw
License: GPL-3.0
"""
# Built-in imports
from typing import Any, Callable
# External library imports
from PySide6.QtWidgets import QWidget, QVBoxLayout, QTextEdit, QLineEdit
from PySide6.QtCore import Qt, QThread, Signal
from file_ext_registration import register_file_association, is_windows_admin
from src.shared.shareddata import API_URL, API_URL_SECONDARY, APP_NAME, CURRENT_VERSION, PLATFORM_NAME
from src.window.about import AboutWindow
from updater import UpdateManager
class _AssocWorker(QThread):
"""
Runs register_file_association() off the UI thread. Only actually
needed for the force_admin=True path, since that blocks on
WaitForSingleObject while the UAC prompt is up and the elevated
child process runs which would otherwise freeze the terminal
window. Used for the non-elevated path too for consistency.
"""
result_ready = Signal(bool, str)
def __init__(self, force_admin: bool, parent: QWidget | None = None) -> None:
super().__init__(parent)
self._force_admin = force_admin
def run(self) -> None:
ok, msg = register_file_association(force_admin=self._force_admin)
self.result_ready.emit(ok, msg)
class TerminalWindow(QWidget):
def __init__(self, parent: QWidget | None = None) -> None:
super().__init__(parent, Qt.WindowType.Window)
self.setWindowTitle(f"Terminal - {APP_NAME.upper()}")
self.resize(320, 180)
self.output_area = QTextEdit()
self.output_area.setReadOnly(True)
self.input_line = QLineEdit()
self.input_line.returnPressed.connect(self.handle_command)
layout = QVBoxLayout()
layout.addWidget(self.output_area)
layout.addWidget(self.input_line)
self.setLayout(layout)
self.commands: dict[str, Callable[..., Any]] = {
"hello": self.cmd_hello,
"help": self.cmd_help,
"version": self.cmd_version,
"about": self.cmd_about,
"assoc": self.cmd_assoc,
"update": self.cmd_update,
}
self._pending_assoc_confirmation: bool = False
self._assoc_worker: _AssocWorker | None = None
self.output_area.append(f"Welcome to {APP_NAME.upper()}. You are running version {CURRENT_VERSION}.")
self.output_area.append("Type 'help' for a list of available commands.\n")
self.input_line.setFocus()
def handle_command(self):
command_text = self.input_line.text()
self.input_line.clear()
self.output_area.append(f"> {command_text}")
if self._pending_assoc_confirmation:
self._pending_assoc_confirmation = False
self._handle_assoc_confirmation(command_text.strip().lower())
return
parts = command_text.strip().split()
if not parts:
return
command_name = parts[0]
args = parts[1:]
func = self.commands.get(command_name)
if func:
try:
result = func(*args)
if result:
self.output_area.append(str(result))
except Exception as e:
self.output_area.append(f"[Error] {e}")
else:
self.output_area.append(f"[Unknown command] '{command_name}'")
def cmd_hello(self, *args: Any) -> str:
return "Hello from the terminal!"
def cmd_help(self, *args: Any) -> str:
return f"Available commands: {', '.join(self.commands.keys())}"
def cmd_version(self, *args: Any) -> str:
return f"{APP_NAME.upper()} is running version {CURRENT_VERSION}."
def cmd_about(self, *args: Any) -> None:
self.about = AboutWindow(self)
self.about.show()
def cmd_update(self, *args: Any) -> str:
main_win = self.parent()
if not isinstance(main_win, QWidget):
return "[Error] Main window context not found."
self.updater = UpdateManager(
main_window=main_win,
api_url=API_URL,
api_url_sec=API_URL_SECONDARY,
current_version=CURRENT_VERSION,
platform_name=PLATFORM_NAME,
platform_suffix="-" + PLATFORM_NAME,
app_name=APP_NAME
)
self.output_area.append("Checking for updates...")
self.updater.manual_check_for_updates()
return "See status bar for update information."
def cmd_assoc(self, *args: Any) -> str | None:
# Non-Windows platforms don't have the admin/non-admin split —
# just register directly.
if PLATFORM_NAME != "windows" or is_windows_admin():
self._run_assoc(force_admin=False)
return None
self._pending_assoc_confirmation = True
return "Not running as admin. Register system-wide via UAC elevation? (y/n)"
def _handle_assoc_confirmation(self, answer: str) -> None:
if answer in ("y", "yes"):
self._run_assoc(force_admin=True)
elif answer in ("n", "no"):
self._run_assoc(force_admin=False)
else:
self.output_area.append("Please answer 'y' or 'n'. Run 'assoc' again to retry.")
def _run_assoc(self, force_admin: bool) -> None:
if force_admin:
self.output_area.append("Requesting elevation. Check for a UAC prompt...")
self._assoc_worker = _AssocWorker(force_admin=force_admin, parent=self)
self._assoc_worker.result_ready.connect(self._on_assoc_result)
self._assoc_worker.start()
def _on_assoc_result(self, ok: bool, msg: str) -> None:
self.output_area.append(msg)
self._assoc_worker = None
+855
View File
@@ -0,0 +1,855 @@
"""
Filename: updateevents.py
Description: Methods to update snirf events for FLARES
Author: Tyler de Zeeuw
License: GPL-3.0
"""
import os
import json
from enum import Enum, auto
from datetime import datetime
import numpy as np
from PySide6.QtWidgets import QWidget, QVBoxLayout, QLabel, QLineEdit, QPushButton, QComboBox, QHBoxLayout, QMessageBox, QFileDialog
from PySide6.QtCore import Qt
from mne import Annotations
from mne.io import read_raw_snirf
from mne_nirs.io import write_raw_snirf
from src.shared.shareddata import APP_NAME
class EventUpdateMode(Enum):
WRITE_SNIRF = auto() # destructive
WRITE_JSON = auto() # non-destructive
class UpdateEventsWindow(QWidget):
def __init__(self, parent=None, mode=EventUpdateMode.WRITE_SNIRF, caller=None):
super().__init__(parent, Qt.WindowType.Window)
self.mode = mode
self.caller = caller or self.__class__.__name__
self.setWindowTitle(f"Update event markers - {APP_NAME.upper()}")
self.resize(760, 200)
print("INIT MODE:", mode)
self.label_file_a = QLabel("SNIRF file:")
self.line_edit_file_a = QLineEdit()
self.line_edit_file_a.setReadOnly(True)
self.btn_browse_a = QPushButton("Browse .snirf")
self.btn_browse_a.clicked.connect(self.browse_file_a)
self.label_file_b = QLabel("BORIS file:")
self.line_edit_file_b = QLineEdit()
self.line_edit_file_b.setReadOnly(True)
self.btn_browse_b = QPushButton("Browse .boris")
self.btn_browse_b.clicked.connect(self.browse_file_b)
self.label_suffix = QLabel("Filename in BORIS project file:")
self.combo_suffix = QComboBox()
self.combo_suffix.setEditable(False)
self.combo_suffix.currentIndexChanged.connect(self.on_observation_selected)
self.label_events = QLabel("Events in selected observation:")
self.combo_events = QComboBox()
self.combo_events.setEnabled(False)
self.label_snirf_events = QLabel("Events in SNIRF file:")
self.combo_snirf_events = QComboBox()
self.combo_snirf_events.setEnabled(False)
self.btn_clear = QPushButton("Clear")
self.btn_go = QPushButton("Go")
self.btn_clear.clicked.connect(self.clear_files)
self.btn_go.clicked.connect(self.go_action)
# ---
layout = QVBoxLayout()
self.description = QLabel()
self.description.setTextFormat(Qt.TextFormat.RichText)
self.description.setTextInteractionFlags(Qt.TextInteractionFlag.TextBrowserInteraction)
self.description.setOpenExternalLinks(True)
self.description.setText("The events that are present in a snirf file may not be the events that are to be studied and examined.<br>"
"Utilizing different software and video recordings, it is easy enough to see when an action actually occured in a file.<br>"
"The software <a href='https://www.boris.unito.it/'>BORIS</a> is used to add these events to video files, and these events can be applied to the snirf file <br>"
"selected below by selecting the correct BORIS observation and time syncing it to an event that it shares with the snirf file.")
layout.addWidget(self.description)
help_text_a = "Select the SNIRF (.snirf) file to update with new event markers."
file_a_layout = QHBoxLayout()
# Help button on the left
help_btn_a = QPushButton("?")
help_btn_a.setFixedWidth(25)
help_btn_a.setToolTip(help_text_a)
help_btn_a.clicked.connect(lambda _, text=help_text_a: self.show_help_popup(text))
file_a_layout.addWidget(help_btn_a)
# Container for label + line_edit + browse button with tooltip
file_a_container = QWidget()
file_a_container_layout = QHBoxLayout()
file_a_container_layout.setContentsMargins(0, 0, 0, 0)
file_a_container_layout.addWidget(self.label_file_a)
file_a_container_layout.addWidget(self.line_edit_file_a)
file_a_container_layout.addWidget(self.btn_browse_a)
file_a_container.setLayout(file_a_container_layout)
file_a_container.setToolTip(help_text_a)
file_a_layout.addWidget(file_a_container)
layout.addLayout(file_a_layout)
help_text_b = "Provide a .boris project file that contains events for this participant."
file_b_layout = QHBoxLayout()
help_btn_b = QPushButton("?")
help_btn_b.setFixedWidth(25)
help_btn_b.setToolTip(help_text_b)
help_btn_b.clicked.connect(lambda _, text=help_text_b: self.show_help_popup(text))
file_b_layout.addWidget(help_btn_b)
file_b_container = QWidget()
file_b_container_layout = QHBoxLayout()
file_b_container_layout.setContentsMargins(0, 0, 0, 0)
file_b_container_layout.addWidget(self.label_file_b)
file_b_container_layout.addWidget(self.line_edit_file_b)
file_b_container_layout.addWidget(self.btn_browse_b)
file_b_container.setLayout(file_b_container_layout)
file_b_container.setToolTip(help_text_b)
file_b_layout.addWidget(file_b_container)
layout.addLayout(file_b_layout)
help_text_suffix = "This participant from the .boris project file matches the .snirf file."
suffix_layout = QHBoxLayout()
help_btn_suffix = QPushButton("?")
help_btn_suffix.setFixedWidth(25)
help_btn_suffix.setToolTip(help_text_suffix)
help_btn_suffix.clicked.connect(lambda _, text=help_text_suffix: self.show_help_popup(text))
suffix_layout.addWidget(help_btn_suffix)
suffix_container = QWidget()
suffix_container_layout = QHBoxLayout()
suffix_container_layout.setContentsMargins(0, 0, 0, 0)
suffix_container_layout.addWidget(self.label_suffix)
suffix_container_layout.addWidget(self.combo_suffix)
suffix_container.setLayout(suffix_container_layout)
suffix_container.setToolTip(help_text_suffix)
suffix_layout.addWidget(suffix_container)
layout.addLayout(suffix_layout)
help_text_suffix = "The events extracted from the BORIS project file for the selected observation."
suffix2_layout = QHBoxLayout()
help_btn_suffix = QPushButton("?")
help_btn_suffix.setFixedWidth(25)
help_btn_suffix.setToolTip(help_text_suffix)
help_btn_suffix.clicked.connect(lambda _, text=help_text_suffix: self.show_help_popup(text))
suffix2_layout.addWidget(help_btn_suffix)
suffix2_container = QWidget()
suffix2_container_layout = QHBoxLayout()
suffix2_container_layout.setContentsMargins(0, 0, 0, 0)
suffix2_container_layout.addWidget(self.label_events)
suffix2_container_layout.addWidget(self.combo_events)
suffix2_container.setLayout(suffix2_container_layout)
suffix2_container.setToolTip(help_text_suffix)
suffix2_layout.addWidget(suffix2_container)
layout.addLayout(suffix2_layout)
snirf_events_layout = QHBoxLayout()
help_text_snirf_events = "The event markers extracted from the SNIRF file."
help_btn_snirf_events = QPushButton("?")
help_btn_snirf_events.setFixedWidth(25)
help_btn_snirf_events.setToolTip(help_text_snirf_events)
help_btn_snirf_events.clicked.connect(lambda _, text=help_text_snirf_events: self.show_help_popup(text))
snirf_events_layout.addWidget(help_btn_snirf_events)
snirf_events_container = QWidget()
snirf_events_container_layout = QHBoxLayout()
snirf_events_container_layout.setContentsMargins(0, 0, 0, 0)
snirf_events_container_layout.addWidget(self.label_snirf_events)
snirf_events_container_layout.addWidget(self.combo_snirf_events)
snirf_events_container.setLayout(snirf_events_container_layout)
snirf_events_container.setToolTip(help_text_snirf_events)
snirf_events_layout.addWidget(snirf_events_container)
layout.addLayout(snirf_events_layout)
buttons_layout = QHBoxLayout()
buttons_layout.addStretch()
buttons_layout.addWidget(self.btn_clear)
buttons_layout.addWidget(self.btn_go)
layout.addLayout(buttons_layout)
self.setLayout(layout)
def show_help_popup(self, text):
msg = QMessageBox(self)
msg.setWindowTitle(f"Parameter Info - {APP_NAME.upper()}")
msg.setText(text)
msg.exec()
def browse_file_a(self):
file_path, _ = QFileDialog.getOpenFileName(self, "Select SNIRF File", "", "SNIRF Files (*.snirf)")
if file_path:
self.line_edit_file_a.setText(file_path)
try:
# TODO: Bad! read_raw_snirf doesnt release memory properly! Should be spawned in a seperate process and killed once completed
raw = read_raw_snirf(file_path, preload=False)
annotations = raw.annotations
# Build individual event entries
event_entries = []
for onset, description in zip(annotations.onset, annotations.description):
event_str = f"{description} @ {onset:.3f}s"
event_entries.append(event_str)
if not event_entries:
QMessageBox.information(self, "No Events", "No events found in SNIRF file.")
self.combo_snirf_events.clear()
self.combo_snirf_events.setEnabled(False)
return
self.combo_snirf_events.clear()
self.combo_snirf_events.addItems(event_entries)
self.combo_snirf_events.setEnabled(True)
except Exception as e:
QMessageBox.warning(self, "Error", f"Could not read SNIRF file with MNE:\n{str(e)}")
self.combo_snirf_events.clear()
self.combo_snirf_events.setEnabled(False)
def browse_file_b(self):
file_path, _ = QFileDialog.getOpenFileName(self, "Select BORIS File", "", "BORIS project Files (*.boris)")
if file_path:
self.line_edit_file_b.setText(file_path)
try:
with open(file_path, 'r', encoding='utf-8') as f:
data = json.load(f)
self.boris_data = data
observation_keys = self.extract_boris_observation_keys(data)
self.combo_suffix.clear()
self.combo_suffix.addItems(observation_keys)
except (json.JSONDecodeError, FileNotFoundError, KeyError) as e:
QMessageBox.warning(self, "Error", f"Failed to parse BORIS file:\n{e}")
def extract_boris_observation_keys(self, data):
if "observations" not in data:
raise KeyError("Missing 'observations' key in BORIS file.")
observations = data["observations"]
if not isinstance(observations, dict):
raise TypeError("'observations' must be a dictionary.")
return list(observations.keys())
def on_observation_selected(self):
selected_obs = self.combo_suffix.currentText()
if not selected_obs or not hasattr(self, 'boris_data'):
self.combo_events.clear()
self.combo_events.setEnabled(False)
return
try:
events = self.boris_data["observations"][selected_obs]["events"]
except (KeyError, TypeError):
self.combo_events.clear()
self.combo_events.setEnabled(False)
return
event_entries = []
for event in events:
if isinstance(event, list) and len(event) >= 3:
timestamp = event[0]
label = event[2]
display = f"{label} @ {timestamp:.3f}"
event_entries.append(display)
self.combo_events.clear()
self.combo_events.addItems(event_entries)
self.combo_events.setEnabled(bool(event_entries))
def clear_files(self):
self.line_edit_file_a.clear()
self.line_edit_file_b.clear()
def go_action(self):
file_a = self.line_edit_file_a.text()
suffix = "flare"
if not hasattr(self, "boris_data") or self.combo_events.count() == 0 or self.combo_snirf_events.count() == 0:
QMessageBox.warning(self, "Missing data", "Please make sure a BORIS and SNIRF event are selected.")
return
# Extract BORIS anchor
try:
boris_label, boris_time_str = self.combo_events.currentText().split(" @ ")
boris_anchor_time = float(boris_time_str.replace("s", "").strip())
except Exception as e:
QMessageBox.critical(self, "BORIS Event Error", f"Could not parse BORIS anchor event:\n{e}")
return
# Extract SNIRF anchor
try:
snirf_label, snirf_time_str = self.combo_snirf_events.currentText().split(" @ ")
snirf_anchor_time = float(snirf_time_str.replace("s", "").strip())
except Exception as e:
QMessageBox.critical(self, "SNIRF Event Error", f"Could not parse SNIRF anchor event:\n{e}")
return
time_shift = snirf_anchor_time - boris_anchor_time
selected_obs = self.combo_suffix.currentText()
if not selected_obs or selected_obs not in self.boris_data["observations"]:
QMessageBox.warning(self, "Invalid selection", "Selected observation not found in BORIS file.")
return
boris_events = self.boris_data["observations"][selected_obs].get("events", [])
if not boris_events:
QMessageBox.warning(self, "No BORIS events", "No events found in selected BORIS observation.")
return
snirf_path = self.line_edit_file_a.text()
if not snirf_path:
QMessageBox.warning(self, "No SNIRF file", "Please select a SNIRF file.")
return
boris_obs = self.boris_data["observations"][selected_obs]
# --- Extract videos + delays ---
files = boris_obs.get("file", {})
offsets = boris_obs.get("media_info", {}).get("offset", {})
videos = {}
for key, path in files.items():
if path: # only include videos that exist
delay = offsets.get(key, 0.0) # default 0 if missing
videos[key] = {"file": path, "delay": delay}
base_name = os.path.splitext(os.path.basename(file_a))[0]
if self.mode == EventUpdateMode.WRITE_SNIRF:
# Open save dialog for SNIRF
base_name = os.path.splitext(os.path.basename(file_a))[0]
suggested_name = f"{base_name}_{suffix}.snirf"
save_path, _ = QFileDialog.getSaveFileName(
self,
"Save SNIRF File As",
suggested_name,
"SNIRF Files (*.snirf)"
)
if not save_path:
print("SNIRF save cancelled.")
return
if not save_path.lower().endswith(".snirf"):
save_path += ".snirf"
try:
raw = read_raw_snirf(file_a, preload=True)
# --- Align BORIS events to SNIRF ---
boris_events = boris_obs.get("events", [])
onsets, durations, descriptions = [], [], []
open_events = {} # label -> list of start times
label_counts = {}
used_times = set()
sfreq = raw.info['sfreq']
min_shift = 1.0 / sfreq
max_attempts = 10
for event in boris_events:
if not isinstance(event, list) or len(event) < 3:
continue
event_time = event[0]
label = event[2]
count = label_counts.get(label, 0) + 1
label_counts[label] = count
if label not in open_events:
open_events[label] = []
if count % 2 == 1:
open_events[label].append(event_time)
else:
if open_events[label]:
start_time = open_events[label].pop(0)
duration = event_time - start_time
if duration <= 0:
continue
adjusted_time = start_time + time_shift
attempts = 0
while round(adjusted_time, 6) in used_times and attempts < max_attempts:
adjusted_time += min_shift
attempts += 1
if attempts == max_attempts:
continue
adjusted_time = round(adjusted_time, 6)
used_times.add(adjusted_time)
onsets.append(adjusted_time)
durations.append(duration)
descriptions.append(label)
# Handle unmatched starts
for label, starts in open_events.items():
for start_time in starts:
adjusted_time = start_time + time_shift
attempts = 0
while round(adjusted_time, 6) in used_times and attempts < max_attempts:
adjusted_time += min_shift
attempts += 1
if attempts == max_attempts:
continue
adjusted_time = round(adjusted_time, 6)
used_times.add(adjusted_time)
onsets.append(adjusted_time)
durations.append(0.0)
descriptions.append(label)
new_annotations = Annotations(onset=onsets, duration=durations, description=descriptions)
raw.set_annotations(new_annotations)
write_raw_snirf(raw, save_path)
QMessageBox.information(self, "Success", "SNIRF file updated with aligned BORIS events.")
except Exception as e:
QMessageBox.critical(self, "Error", f"Failed to update SNIRF file:\n{e}")
elif self.mode == EventUpdateMode.WRITE_JSON:
# Open save dialog for JSON
base_name = os.path.splitext(os.path.basename(file_a))[0]
suggested_name = f"{base_name}_{suffix}_alignment.json"
save_path, _ = QFileDialog.getSaveFileName(
self,
"Save Event Alignment JSON As",
suggested_name,
"JSON Files (*.json)"
)
if not save_path:
print("JSON save cancelled.")
return
if not save_path.lower().endswith(".json"):
save_path += ".json"
# Build JSON dict
json_data = {
"observation": selected_obs,
"snirf_anchor": {"label": snirf_label, "time": snirf_anchor_time},
"boris_anchor": {"label": boris_label, "time": boris_anchor_time},
"time_shift": time_shift,
"videos": videos
}
# Write JSON
try:
with open(save_path, "w", encoding="utf-8") as f:
json.dump(json_data, f, indent=4)
QMessageBox.information(self, "Success", f"Event alignment saved to:\n{save_path}")
except Exception as e:
QMessageBox.critical(self, "Error", f"Failed to write JSON:\n{e}")
def update_optode_positions(self, file_a, file_b, save_path):
fiducials = {}
ch_positions = {}
# Read the lines from the optode file
with open(file_b, 'r') as f:
for line in f:
if line.strip():
# Split by the semicolon and convert to meters
ch_name, coords_str = line.split(":")
coords = np.array(list(map(float, coords_str.strip().split()))) * 0.001
# The key we have is a fiducial
if ch_name.lower() in ['lpa', 'nz', 'rpa']:
fiducials[ch_name.lower()] = coords
# The key we have is a source or detector
else:
ch_positions[ch_name.upper()] = coords
# Create montage with updated coords in head space
initial_montage = make_dig_montage(ch_pos=ch_positions, nasion=fiducials.get('nz'), lpa=fiducials.get('lpa'), rpa=fiducials.get('rpa'), coord_frame='head') # type: ignore
# Read the SNIRF file, set the montage, and write it back
# TODO: Bad! read_raw_snirf doesnt release memory properly! Should be spawned in a seperate process and killed once completed
raw = read_raw_snirf(file_a, preload=True)
raw.set_montage(initial_montage)
write_raw_snirf(raw, save_path)
def _apply_events_to_snirf(self, raw, new_annotations, save_path):
raw.set_annotations(new_annotations)
write_raw_snirf(raw, save_path)
def _write_event_mapping_json(
self,
file_a,
file_b,
selected_obs,
snirf_anchor,
boris_anchor,
time_shift,
mapped_events,
save_path
):
payload = {
"source": {
"called_from": self.caller,
"snirf_file": os.path.basename(file_a),
"boris_file": os.path.basename(file_b),
"observation": selected_obs
},
"alignment": {
"snirf_anchor": snirf_anchor,
"boris_anchor": boris_anchor,
"time_shift_seconds": time_shift
},
"events": mapped_events,
"created_at": datetime.utcnow().isoformat() + "Z"
}
with open(save_path, "w", encoding="utf-8") as f:
json.dump(payload, f, indent=2)
return save_path
class UpdateEventsBlazesWindow(QWidget):
def __init__(self, parent=None, mode=EventUpdateMode.WRITE_SNIRF, caller=None):
super().__init__(parent, Qt.WindowType.Window)
self.mode = mode
self.caller = caller or self.__class__.__name__
self.setWindowTitle("Update event markers (BLAZES)")
self.resize(760, 200)
self.label_file_a = QLabel("SNIRF file:")
self.line_edit_file_a = QLineEdit()
self.line_edit_file_a.setReadOnly(True)
self.btn_browse_a = QPushButton("Browse .snirf")
self.btn_browse_a.clicked.connect(self.browse_file_a)
self.label_file_b = QLabel("BLAZES file:")
self.line_edit_file_b = QLineEdit()
self.line_edit_file_b.setReadOnly(True)
self.btn_browse_b = QPushButton("Browse .blaze")
self.btn_browse_b.clicked.connect(self.browse_file_b)
self.label_events = QLabel("Events in selected blazes file:")
self.combo_events = QComboBox()
self.combo_events.setEnabled(False)
self.label_snirf_events = QLabel("Events in SNIRF file:")
self.combo_snirf_events = QComboBox()
self.combo_snirf_events.setEnabled(False)
self.btn_clear = QPushButton("Clear")
self.btn_go = QPushButton("Go")
self.btn_clear.clicked.connect(self.clear_files)
self.btn_go.clicked.connect(self.go_action)
# ---
layout = QVBoxLayout()
self.description = QLabel()
self.description.setTextFormat(Qt.TextFormat.RichText)
self.description.setTextInteractionFlags(Qt.TextInteractionFlag.TextBrowserInteraction)
self.description.setOpenExternalLinks(True)
self.description.setText("The events that are present in a snirf file may not be the events that are to be studied and examined.<br>"
"Utilizing different software and video recordings, it is easy enough to see when an action actually occured in a file.<br>"
"The software <a href='https://git.research.dezeeuw.ca/tyler/blazes/'>BLAZES</a> is used to create these events in video files, and these events can be applied to the snirf file <br>"
"selected below by time syncing it to an event that it shares with the snirf file.")
layout.addWidget(self.description)
help_text_a = "Select the SNIRF (.snirf) file to update with new event markers."
file_a_layout = QHBoxLayout()
# Help button on the left
help_btn_a = QPushButton("?")
help_btn_a.setFixedWidth(25)
help_btn_a.setToolTip(help_text_a)
help_btn_a.clicked.connect(lambda _, text=help_text_a: self.show_help_popup(text))
file_a_layout.addWidget(help_btn_a)
# Container for label + line_edit + browse button with tooltip
file_a_container = QWidget()
file_a_container_layout = QHBoxLayout()
file_a_container_layout.setContentsMargins(0, 0, 0, 0)
file_a_container_layout.addWidget(self.label_file_a)
file_a_container_layout.addWidget(self.line_edit_file_a)
file_a_container_layout.addWidget(self.btn_browse_a)
file_a_container.setLayout(file_a_container_layout)
file_a_container.setToolTip(help_text_a)
file_a_layout.addWidget(file_a_container)
layout.addLayout(file_a_layout)
help_text_b = "Provide a .blaze output file that contains events for this participant."
file_b_layout = QHBoxLayout()
help_btn_b = QPushButton("?")
help_btn_b.setFixedWidth(25)
help_btn_b.setToolTip(help_text_b)
help_btn_b.clicked.connect(lambda _, text=help_text_b: self.show_help_popup(text))
file_b_layout.addWidget(help_btn_b)
file_b_container = QWidget()
file_b_container_layout = QHBoxLayout()
file_b_container_layout.setContentsMargins(0, 0, 0, 0)
file_b_container_layout.addWidget(self.label_file_b)
file_b_container_layout.addWidget(self.line_edit_file_b)
file_b_container_layout.addWidget(self.btn_browse_b)
file_b_container.setLayout(file_b_container_layout)
file_b_container.setToolTip(help_text_b)
file_b_layout.addWidget(file_b_container)
layout.addLayout(file_b_layout)
help_text_suffix = "The events extracted from the blaze file."
suffix2_layout = QHBoxLayout()
help_btn_suffix = QPushButton("?")
help_btn_suffix.setFixedWidth(25)
help_btn_suffix.setToolTip(help_text_suffix)
help_btn_suffix.clicked.connect(lambda _, text=help_text_suffix: self.show_help_popup(text))
suffix2_layout.addWidget(help_btn_suffix)
suffix2_container = QWidget()
suffix2_container_layout = QHBoxLayout()
suffix2_container_layout.setContentsMargins(0, 0, 0, 0)
suffix2_container_layout.addWidget(self.label_events)
suffix2_container_layout.addWidget(self.combo_events)
suffix2_container.setLayout(suffix2_container_layout)
suffix2_container.setToolTip(help_text_suffix)
suffix2_layout.addWidget(suffix2_container)
layout.addLayout(suffix2_layout)
snirf_events_layout = QHBoxLayout()
help_text_snirf_events = "The event markers extracted from the SNIRF file."
help_btn_snirf_events = QPushButton("?")
help_btn_snirf_events.setFixedWidth(25)
help_btn_snirf_events.setToolTip(help_text_snirf_events)
help_btn_snirf_events.clicked.connect(lambda _, text=help_text_snirf_events: self.show_help_popup(text))
snirf_events_layout.addWidget(help_btn_snirf_events)
snirf_events_container = QWidget()
snirf_events_container_layout = QHBoxLayout()
snirf_events_container_layout.setContentsMargins(0, 0, 0, 0)
snirf_events_container_layout.addWidget(self.label_snirf_events)
snirf_events_container_layout.addWidget(self.combo_snirf_events)
snirf_events_container.setLayout(snirf_events_container_layout)
snirf_events_container.setToolTip(help_text_snirf_events)
snirf_events_layout.addWidget(snirf_events_container)
layout.addLayout(snirf_events_layout)
buttons_layout = QHBoxLayout()
buttons_layout.addStretch()
buttons_layout.addWidget(self.btn_clear)
buttons_layout.addWidget(self.btn_go)
layout.addLayout(buttons_layout)
self.setLayout(layout)
def show_help_popup(self, text):
msg = QMessageBox(self)
msg.setWindowTitle(f"Parameter Info - {APP_NAME.upper()}")
msg.setText(text)
msg.exec()
def browse_file_a(self):
file_path, _ = QFileDialog.getOpenFileName(self, "Select SNIRF File", "", "SNIRF Files (*.snirf)")
if file_path:
self.line_edit_file_a.setText(file_path)
try:
# TODO: Bad! read_raw_snirf doesnt release memory properly! Should be spawned in a seperate process and killed once completed
raw = read_raw_snirf(file_path, preload=False)
annotations = raw.annotations
# Build individual event entries
event_entries = []
for onset, description in zip(annotations.onset, annotations.description):
event_str = f"{description} @ {onset:.3f}s"
event_entries.append(event_str)
if not event_entries:
QMessageBox.information(self, "No Events", "No events found in SNIRF file.")
self.combo_snirf_events.clear()
self.combo_snirf_events.setEnabled(False)
return
self.combo_snirf_events.clear()
self.combo_snirf_events.addItems(event_entries)
self.combo_snirf_events.setEnabled(True)
except Exception as e:
QMessageBox.warning(self, "Error", f"Could not read SNIRF file with MNE:\n{str(e)}")
self.combo_snirf_events.clear()
self.combo_snirf_events.setEnabled(False)
def browse_file_b(self):
file_path, _ = QFileDialog.getOpenFileName(self, "Select JSON Timeline File", "", "JSON Files (*.json)")
if file_path:
self.line_edit_file_b.setText(file_path)
try:
with open(file_path, 'r', encoding='utf-8') as f:
data = json.load(f)
self.json_data = data
obs_keys = self.extract_json_observation_strings(data)
self.combo_events.clear()
if obs_keys:
self.combo_events.addItems(obs_keys)
self.combo_events.setEnabled(True)
else:
QMessageBox.information(self, "No Events", "No events found in JSON file.")
self.combo_events.setEnabled(False)
except (json.JSONDecodeError, FileNotFoundError, KeyError, TypeError) as e:
QMessageBox.warning(self, "Error", f"Failed to parse JSON file:\n{e}")
self.combo_events.clear()
self.combo_events.setEnabled(False)
def extract_json_observation_strings(self, data):
if "events" not in data:
raise KeyError("Missing 'events' key in JSON file.")
event_strings = []
# The new format is a flat list chronologically ordered
for event in data["events"]:
track_name = event.get("track_name", "Unknown")
onset = event.get("start_sec", 0.0)
# Formatting to match your SNIRF style: "Event Name @ 0.000s"
display_str = f"{track_name} @ {onset:.3f}s"
event_strings.append(display_str)
return event_strings
def clear_files(self):
self.line_edit_file_a.clear()
self.line_edit_file_b.clear()
def go_action(self):
file_a = self.line_edit_file_a.text()
file_b = self.line_edit_file_b.text()
suffix = APP_NAME
if not hasattr(self, "json_data") or self.combo_events.count() == 0 or self.combo_snirf_events.count() == 0:
QMessageBox.warning(self, "Missing data", "Please make sure a JSON and SNIRF event are selected.")
return
try:
json_text = self.combo_events.currentText()
_, json_time_str = json_text.split(" @ ")
json_anchor_time = float(json_time_str.replace("s", "").strip())
except Exception as e:
QMessageBox.critical(self, "JSON Event Error", f"Could not parse JSON anchor:\n{e}")
return
try:
snirf_text = self.combo_snirf_events.currentText()
_, snirf_time_str = snirf_text.split(" @ ")
snirf_anchor_time = float(snirf_time_str.replace("s", "").strip())
except Exception as e:
QMessageBox.critical(self, "SNIRF Event Error", f"Could not parse SNIRF anchor:\n{e}")
return
time_shift = snirf_anchor_time - json_anchor_time
onsets, durations, descriptions = [], [], []
skipped_count = 0
try:
events_list = self.json_data.get("events", [])
for event in events_list:
track_name = event.get("track_name", "Unknown")
clean_name = track_name.replace("AI: ", "").strip()
original_start = event.get("start_sec", 0.0)
original_end = event.get("end_sec", original_start)
duration = original_end - original_start
# FILTER: Minimum 0.1s duration
if duration < 0.1:
skipped_count += 1
continue
# Apply shift
adjusted_onset = original_start + time_shift
onsets.append(round(adjusted_onset, 6))
durations.append(round(duration, 6))
descriptions.append(clean_name)
except Exception as e:
QMessageBox.critical(self, "Track Error", f"Failed to process tracks: {e}")
return
if not onsets:
QMessageBox.warning(self, "No Data", f"No events met the 0.1s threshold. (Skipped {skipped_count})")
return
if self.mode == EventUpdateMode.WRITE_SNIRF:
suggested_name = f"{os.path.splitext(os.path.basename(file_a))[0]}_{suffix}.snirf"
save_path, _ = QFileDialog.getSaveFileName(self, "Save SNIRF", suggested_name, "SNIRF Files (*.snirf)")
if not save_path: return
if not save_path.lower().endswith(".snirf"): save_path += ".snirf"
try:
raw = read_raw_snirf(file_a, preload=True)
# Create annotations
new_annotations = Annotations(
onset=onsets,
duration=durations,
description=descriptions
)
# Replace existing annotations with the new aligned JSON tracks
raw.set_annotations(new_annotations)
write_raw_snirf(raw, save_path)
QMessageBox.information(self, "Success",
f"Aligned {len(onsets)} events.\n(Filtered out {skipped_count} short events)")
except Exception as e:
QMessageBox.critical(self, "Error", f"Failed to update SNIRF file:\n{e}")
+296
View File
@@ -0,0 +1,296 @@
"""
Filename: updateoptodes.py
Description: Methods to update optode locations for FLARES
Author: Tyler de Zeeuw
License: GPL-3.0
"""
import os
from pathlib import Path
import pandas as pd
import numpy as np
from PySide6.QtWidgets import QWidget, QVBoxLayout, QLabel, QHBoxLayout, QMessageBox, QLineEdit, QPushButton, QFileDialog
from PySide6.QtCore import Qt
from mne.io import read_raw_snirf #type: ignore
from mne_nirs.io import write_raw_snirf #type: ignore
from mne.channels import make_dig_montage #type: ignore
from src.shared.shareddata import APP_NAME
class UpdateOptodesWindow(QWidget):
def __init__(self, parent=None):
super().__init__(parent, Qt.WindowType.Window)
self.setWindowTitle(f"Update optode positions - {APP_NAME.upper()}")
self.resize(760, 200)
self.label_file_a = QLabel("SNIRF file:")
self.line_edit_file_a = QLineEdit()
self.line_edit_file_a.setReadOnly(True)
self.btn_browse_a = QPushButton("Browse .snirf")
self.btn_browse_a.clicked.connect(self.browse_file_a)
self.label_file_b = QLabel("Text file:")
self.line_edit_file_b = QLineEdit()
self.line_edit_file_b.setReadOnly(True)
self.btn_browse_b = QPushButton("Browse .txt/.xlsx")
self.btn_browse_b.clicked.connect(self.browse_file_b)
self.label_suffix = QLabel("Suffix to append to filename:")
self.line_edit_suffix = QLineEdit()
self.line_edit_suffix.setText("flare")
self.btn_clear = QPushButton("Clear")
self.btn_go = QPushButton("Go")
self.btn_clear.clicked.connect(self.clear_files)
self.btn_go.clicked.connect(self.go_action)
# ---
layout = QVBoxLayout()
self.description = QLabel()
self.description.setTextFormat(Qt.TextFormat.RichText)
self.description.setTextInteractionFlags(Qt.TextInteractionFlag.TextBrowserInteraction)
self.description.setOpenExternalLinks(False) # Handle the click internally
self.description.setText("Some software when creating snirf files will insert a template of optode positions as the correct position of the optodes for the participant.<br>"
"This is rarely correct as each head differs slightly in shape or size, and a lot of calculations require the optodes to be in the correct location.<br>"
"Using a .txt or .xlsx file, we can update the positions in the snirf file to match those of a digitization system such as one from Polhemus or elsewhere.<br>"
"The .txt file should have the fiducials, detectors, and sources clearly labeled, followed by the x, y, and z coordinates seperated by a space.<br>"
"An example format of what a digitization text file should look like can be found <a href='custom_link'>by clicking here</a>. Currently only .xlsx files directly exported from a<br>"
"Polhemus system are supported.")
self.description.linkActivated.connect(self.handle_link_click)
layout.addWidget(self.description)
help_text_a = "Select the SNIRF (.snirf) file to update with new optode positions."
file_a_layout = QHBoxLayout()
# Help button on the left
help_btn_a = QPushButton("?")
help_btn_a.setFixedWidth(25)
help_btn_a.setToolTip(help_text_a)
help_btn_a.clicked.connect(lambda _, text=help_text_a: self.show_help_popup(text))
file_a_layout.addWidget(help_btn_a)
# Container for label + line_edit + browse button with tooltip
file_a_container = QWidget()
file_a_container_layout = QHBoxLayout()
file_a_container_layout.setContentsMargins(0, 0, 0, 0)
file_a_container_layout.addWidget(self.label_file_a)
file_a_container_layout.addWidget(self.line_edit_file_a)
file_a_container_layout.addWidget(self.btn_browse_a)
file_a_container.setLayout(file_a_container_layout)
file_a_container.setToolTip(help_text_a)
file_a_layout.addWidget(file_a_container)
layout.addLayout(file_a_layout)
help_text_b = "Provide a .txt file with labeled optodes (e.g., nz, rpa, lpa, d1, s1) and their x, y, z coordinates, or a .xlsx file from a Polhemius system."
file_b_layout = QHBoxLayout()
help_btn_b = QPushButton("?")
help_btn_b.setFixedWidth(25)
help_btn_b.setToolTip(help_text_b)
help_btn_b.clicked.connect(lambda _, text=help_text_b: self.show_help_popup(text))
file_b_layout.addWidget(help_btn_b)
file_b_container = QWidget()
file_b_container_layout = QHBoxLayout()
file_b_container_layout.setContentsMargins(0, 0, 0, 0)
file_b_container_layout.addWidget(self.label_file_b)
file_b_container_layout.addWidget(self.line_edit_file_b)
file_b_container_layout.addWidget(self.btn_browse_b)
file_b_container.setLayout(file_b_container_layout)
file_b_container.setToolTip(help_text_b)
file_b_layout.addWidget(file_b_container)
layout.addLayout(file_b_layout)
help_text_suffix = "This text will be appended to the original filename when saving. Default is 'flare'."
suffix_layout = QHBoxLayout()
help_btn_suffix = QPushButton("?")
help_btn_suffix.setFixedWidth(25)
help_btn_suffix.setToolTip(help_text_suffix)
help_btn_suffix.clicked.connect(lambda _, text=help_text_suffix: self.show_help_popup(text))
suffix_layout.addWidget(help_btn_suffix)
suffix_container = QWidget()
suffix_container_layout = QHBoxLayout()
suffix_container_layout.setContentsMargins(0, 0, 0, 0)
suffix_container_layout.addWidget(self.label_suffix)
suffix_container_layout.addWidget(self.line_edit_suffix)
suffix_container.setLayout(suffix_container_layout)
suffix_container.setToolTip(help_text_suffix)
suffix_layout.addWidget(suffix_container)
layout.addLayout(suffix_layout)
buttons_layout = QHBoxLayout()
buttons_layout.addStretch()
buttons_layout.addWidget(self.btn_clear)
buttons_layout.addWidget(self.btn_go)
layout.addLayout(buttons_layout)
self.setLayout(layout)
def show_help_popup(self, text):
msg = QMessageBox(self)
msg.setWindowTitle(f"Parameter Info - {APP_NAME.upper()}")
msg.setText(text)
msg.exec()
def handle_link_click(self, link):
if link == "custom_link":
msg = QMessageBox(self)
msg.setWindowTitle("Example Digitization File")
text = "nz: -1.91 85.175 -31.1525\n" \
"rpa: 80.3825 -17.1925 -57.2775\n" \
"lpa: -81.815 -17.1925 -57.965\n" \
"d1: 0.01 -97.5175 62.5875\n" \
"d2: 25.125 -103.415 45.045\n" \
"d3: 49.095 -97.9025 30.2075\n" \
"s1: 0.01 -112.43 32.595\n" \
"s2: 30.325 -84.3125 71.8975\n" \
"s3: 0.01 -70.6875 89.0925\n"
msg.setText(text)
msg.exec()
def browse_file_a(self):
file_path, _ = QFileDialog.getOpenFileName(self, "Select SNIRF File", "", "SNIRF Files (*.snirf)")
if file_path:
self.line_edit_file_a.setText(file_path)
def browse_file_b(self):
file_path, _ = QFileDialog.getOpenFileName(self, "Select File", "", "Supported Files (*.txt *.xlsx)")
if file_path:
self.line_edit_file_b.setText(file_path)
def clear_files(self):
self.line_edit_file_a.clear()
self.line_edit_file_b.clear()
def go_action(self):
file_a = self.line_edit_file_a.text()
file_b = self.line_edit_file_b.text()
suffix = self.line_edit_suffix.text().strip() or "flare"
if not file_a:
QMessageBox.critical(self, "Missing File", "Please select a SNIRF file.")
return
if not file_b:
QMessageBox.critical(self, "Missing File", "Please select a TXT file.")
return
# Get original filename without extension
base_name = os.path.splitext(os.path.basename(file_a))[0]
suggested_name = f"{base_name}_{suffix}.snirf"
# Open save dialog with default name
save_path, _ = QFileDialog.getSaveFileName(
self,
"Save SNIRF File As",
suggested_name,
"SNIRF Files (*.snirf)"
)
if not save_path:
print("Save cancelled.")
return
# Ensure .snirf extension
if not save_path.lower().endswith(".snirf"):
save_path += ".snirf"
try:
self.update_optode_positions(file_a=file_a, file_b=file_b, save_path=save_path)
except Exception as e:
QMessageBox.critical(self, "Error", f"Failed to write file:\n{e}")
return
QMessageBox.information(self, "File Saved", f"File was saved to:\n{save_path}")
def update_optode_positions(self, file_a, file_b, save_path):
fiducials = {}
ch_positions = {}
extension = Path(file_b).suffix
# Read the lines from the optode file
if extension == '.txt':
with open(file_b, 'r') as f:
for line in f:
if line.strip():
# Split by the semicolon and convert to meters
ch_name, coords_str = line.split(":")
coords = np.array(list(map(float, coords_str.strip().split()))) * 0.001
# The key we have is a fiducial
if ch_name.lower() in ['lpa', 'nz', 'rpa']:
fiducials[ch_name.lower()] = coords
# The key we have is a source or detector
else:
ch_positions[ch_name.upper()] = coords
elif extension == '.xlsx':
# TODO: Bad! Why assume sheet1 has the contents?
df = pd.read_excel(file_b, sheet_name='Sheet1')
def _get_block_data(df, block_id, row_mapping, scale=0.001):
"""Isolates a block, cleans numeric data, and returns a scaled dictionary."""
# 1. Isolate and clean
block = df[df['block_id'] == block_id].iloc[:, [1, 2, 3]].copy()
block = block.apply(pd.to_numeric, errors='coerce')
# 2. Extract into dictionary based on mapping
result = {}
# If row_mapping is a dict (like {0: 'nz'}), use it directly
if isinstance(row_mapping, dict):
for row_idx, key in row_mapping.items():
if row_idx < len(block):
result[key] = block.iloc[row_idx].to_numpy(dtype=float) * scale
# If row_mapping is a string prefix (like 'D' or 'S'), auto-generate keys
elif isinstance(row_mapping, str):
for i in range(len(block)):
result[f"{row_mapping}{i+1}"] = block.iloc[i].to_numpy(dtype=float) * scale
return result
# Identify blocks
is_empty = df.isnull().all(axis=1)
df['block_id'] = is_empty.cumsum()
clean_df = df[~is_empty].copy()
# Process Block 2: Landmarks
fiducials = _get_block_data(clean_df, 2, {0: 'nz', 2: 'rpa', 3: 'lpa'})
# Process Block 3: D-Points
d_points = _get_block_data(clean_df, 3, 'D')
# Process Block 4: S-Points
s_points = _get_block_data(clean_df, 4, 'S')
ch_positions = {**d_points, **s_points}
# Create montage with updated coords in head space
initial_montage = make_dig_montage(ch_pos=ch_positions, nasion=fiducials.get('nz'), lpa=fiducials.get('lpa'), rpa=fiducials.get('rpa'), coord_frame='head') # type: ignore
# Read the SNIRF file, set the montage, and write it back
raw = read_raw_snirf(file_a, preload=True)
raw.set_montage(initial_montage)
write_raw_snirf(raw, save_path)
+43
View File
@@ -0,0 +1,43 @@
"""
Filename: userguide.py
Description: User guide window
Note: Compliant with pylance strict type checking
Author: Tyler de Zeeuw
License: GPL-3.0
"""
# External library imports
from PySide6.QtWidgets import QWidget, QVBoxLayout, QLabel
from PySide6.QtCore import Qt
from src.shared.shareddata import APP_NAME, PIPELINE_STAGES, WIKI_URL
class UserGuideWindow(QWidget):
"""
Simple User Guide window displaying basic information on how to use the software.
Args:
parent (QWidget, optional): Parent widget of this window. Defaults to None.
"""
def __init__(self, parent: QWidget | None = None) -> None:
super().__init__(parent, Qt.WindowType.Window)
self.setWindowTitle(f"User Guide - {APP_NAME.upper()}")
self.resize(250, 100)
layout = QVBoxLayout()
label = QLabel("Progress Bar Stages:", self)
label2_text = "\n".join(f"Stage {idx + 1}: {name}" for idx, name in enumerate(PIPELINE_STAGES)) + "\n"
label2 = QLabel(label2_text, self)
label3 = QLabel(f"For more information, visit the Git wiki page <a href='{WIKI_URL}'>here</a>.", self)
label3.setTextFormat(Qt.TextFormat.RichText)
label3.setTextInteractionFlags(Qt.TextInteractionFlag.TextBrowserInteraction)
label3.setOpenExternalLinks(True)
layout.addWidget(label)
layout.addWidget(label2)
layout.addWidget(label3)
self.setLayout(layout)
+75
View File
@@ -0,0 +1,75 @@
"""
Filename: viewerlauncher.py
Description: Viewer launcher window
Author: Tyler de Zeeuw
License: GPL-3.0
"""
# External library imports
from PySide6.QtWidgets import QPushButton, QWidget, QVBoxLayout
from PySide6.QtCore import QTimer
from src.analysis.exporttocsv import ExportToCSVWidget
from src.analysis.intergroupbrainimage import InterGroupBrainImageWidget
from src.analysis.crossgroupbrainimage import CrossGroupBrainImageWidget
from src.analysis.intergroupfunctionalconnectivity import InterGroupFunctionalConnectivityWidget
from src.analysis.intergroupstats import InterGroupStatsWidget
from src.analysis.crossgroupstats import CrossGroupStatsWidget
from src.analysis.participantimage import ParticipantImageViewerWidget
from src.analysis.participantbrain import ParticipantBrainViewerWidget
from src.analysis.participantfoldchannels import ParticipantFoldChannelsWidget
from src.analysis.participantfunctionalconnectivity import ParticipantFunctionalConnectivityWidget
from src.shared.shareddata import APP_NAME
class ViewerLauncherWidget(QWidget):
def __init__(self, haemo_dict, epochs_dict, cha_dict, df_ind_dict, design_matrix_dict, config_dict, fig_bytes_dict, contrast_results_dict, roi_channel_map_dict, folding_bypass):
super().__init__()
self.setWindowTitle(f"Viewer Launcher - {APP_NAME.upper()}")
group_dict = {f: c.get("GROUP", "Unknown") for f, c in config_dict.items()}
btn_data = [
("Participant Image Viewer", ParticipantImageViewerWidget, [haemo_dict, fig_bytes_dict], True),
("Participant Brain Viewer", ParticipantBrainViewerWidget, [haemo_dict, cha_dict], True),
("Participant Fold Channels Viewer", ParticipantFoldChannelsWidget, [haemo_dict, cha_dict], False),
("Participant Functional Connectivity Viewer [BETA]", ParticipantFunctionalConnectivityWidget, [haemo_dict, epochs_dict], True),
("Inter-Group Functional Connectivity Viewer [BETA]", InterGroupFunctionalConnectivityWidget, [haemo_dict, group_dict, config_dict], True),
("Inter-Group Stats Viewer", InterGroupStatsWidget, [haemo_dict, cha_dict, df_ind_dict, design_matrix_dict, contrast_results_dict, roi_channel_map_dict, group_dict], True),
("Cross-Group Stats Viewer", CrossGroupStatsWidget, [haemo_dict, cha_dict, df_ind_dict, design_matrix_dict, contrast_results_dict, roi_channel_map_dict, group_dict], True),
("Inter-Group Brain and Image Viewer", InterGroupBrainImageWidget, [haemo_dict, cha_dict, df_ind_dict, design_matrix_dict, contrast_results_dict, group_dict], True),
("Cross-Group Brain and Image Viewer", CrossGroupBrainImageWidget, [haemo_dict, df_ind_dict, design_matrix_dict, contrast_results_dict, group_dict], True),
("Export To CSV Viewer", ExportToCSVWidget, [haemo_dict, cha_dict, df_ind_dict, design_matrix_dict, contrast_results_dict, group_dict, config_dict], True)
]
layout = QVBoxLayout(self)
for label, widget_class, args, requires_bypass in btn_data:
btn = QPushButton(f"Open {label}")
# Connect directly to the generic opener
btn.clicked.connect(lambda _, c=widget_class, b=btn, a=args: self._open_viewer(c, b, *a))
btn.setEnabled(not (requires_bypass and folding_bypass))
layout.addWidget(btn)
def _open_viewer(self, widget_class, btn, *args):
# Instantiate and show dynamically
self.active_viewer = widget_class(*args)
self.active_viewer.show()
self._trigger_success(btn)
def _launch(self, func, btn, *args):
func(*args)
self._trigger_success(btn)
def _trigger_success(self, button):
"""Temporarily adds a green checkmark to the button text."""
original_text = button.text()
button.setText(f"{original_text}")
button.setStyleSheet("color: green; font-weight: bold;")
# Revert after 1 second
QTimer.singleShot(1000, lambda: self._revert_button(button, original_text))
def _revert_button(self, button, original_text):
button.setText(original_text)
button.setStyleSheet("")
+81
View File
@@ -0,0 +1,81 @@
"""
Filename: welcome.py
Description: Welcome dialog window
Note: Compliant with pylance strict type checking
Author: Tyler de Zeeuw
License: GPL-3.0
"""
# External library imports
from PySide6.QtWidgets import QTextBrowser, QVBoxLayout, QLabel, QDialog, QHBoxLayout, QPushButton
from PySide6.QtGui import QDesktopServices, QIcon
from PySide6.QtCore import QUrl
from PySide6.QtNetwork import QNetworkAccessManager, QNetworkRequest, QNetworkReply
from src.shared.shareddata import APP_NAME, PLATFORM_NAME, CURRENT_VERSION, CHANGELOG_URL, resource_path
class WelcomeDialog(QDialog):
def __init__(self, parent: QDialog | None = None, direct: bool = True, first: bool = False):
super().__init__(parent)
self.setWindowTitle(f"What's New - {APP_NAME.upper()}")
self.setMinimumSize(550, 450)
self.resize(800, 500)
layout = QVBoxLayout(self)
header_layout = QHBoxLayout()
logo_label = QLabel(self)
icon_ext = "icns" if PLATFORM_NAME == "darwin" else "ico"
logo_label.setPixmap(QIcon(resource_path(f"icons/main.{icon_ext}")).pixmap(48, 48))
if first:
title_label = QLabel(f"<h2>Welcome to {APP_NAME.upper()}!</h2>", self)
elif direct:
title_label = QLabel(f"<h2>{APP_NAME.upper()} has been sucessfully updated to version {CURRENT_VERSION}!</h2>", self)
else:
title_label = QLabel(f"<h2>{APP_NAME.upper()} is currently running version {CURRENT_VERSION}.</h2>", self)
header_layout.addWidget(logo_label)
header_layout.addWidget(title_label)
header_layout.addStretch()
layout.addLayout(header_layout)
self.text_browser = QTextBrowser(self)
self.text_browser.setHtml("<p style='color: gray;'>Loading latest updates from server...</p>")
# Ensure links open in the default web browser and not in this window
self.text_browser.setOpenLinks(False)
self.text_browser.anchorClicked.connect(QDesktopServices.openUrl)
layout.addWidget(self.text_browser)
footer_layout = QHBoxLayout()
ok_button = QPushButton("OK", self)
ok_button.setDefault(True)
ok_button.clicked.connect(self.accept)
footer_layout.addStretch()
footer_layout.addWidget(ok_button)
layout.addLayout(footer_layout)
# Fetch markdown from the web asynchronously
self.network_manager = QNetworkAccessManager(self)
self.network_manager.finished.connect(self._on_download_complete)
self.network_manager.get(QNetworkRequest(QUrl(CHANGELOG_URL)))
def _on_download_complete(self, reply: QNetworkReply) -> None:
"""Processes the downloaded markdown and drops it into the view frame."""
if reply.error() == reply.NetworkError.NoError:
raw_bytes = reply.readAll().data()
# Convert raw bytes to standard text string
markdown_text = str(raw_bytes, encoding='utf-8')
self.text_browser.setMarkdown(markdown_text)
else:
self.text_browser.setHtml(
f"<p style='color: red;'><b>Failed to load content.</b><br>Error: {reply.errorString()}</p>"
)
reply.deleteLater()
+41
View File
@@ -0,0 +1,41 @@
"""
Filename: startup_args.py
Description: Parses the startup arguments for the application
Note: Compliant with pylance strict type checking
Author: Tyler de Zeeuw
License: GPL-3.0
"""
import os
from dataclasses import dataclass
@dataclass
class StartupArgs:
finish_update: bool
initial_file: str | None
def parse_startup_args(argv: list[str]) -> StartupArgs:
"""
Parses the command-line flags.
Recognizes:
--finish-update set by the updater when relaunching post-update
<path> a project file to open on startup the first
argument that doesn't start with '-', found
anywhere in argv rather than assumed to be at
a fixed position, so it survives regardless of
whether --finish-update precedes it.
"""
finish_update = "--finish-update" in argv[1:]
initial_file = None
for arg in argv[1:]:
if not arg.startswith("-"):
initial_file = os.path.abspath(arg)
break
return StartupArgs(finish_update=finish_update, initial_file=initial_file)
+1
View File
@@ -0,0 +1 @@
update
+550
View File
@@ -0,0 +1,550 @@
"""
Filename: updater.py
Description: Generic updater file
Author: Tyler de Zeeuw
License: GPL-3.0
"""
# Built-in imports
import os
import re
import sys
import time
import shlex
import shutil
import zipfile
import traceback
import subprocess
import configparser
# External library imports
import psutil
import requests
from PySide6.QtWidgets import QMessageBox
from PySide6.QtCore import QThread, Signal, QObject
class UpdateDownloadThread(QThread):
"""
Thread that downloads and extracts an update package and emits a signal on completion or error.
Args:
download_url (str): URL of the update zip file to download.
latest_version (str): Version string of the latest update.
"""
update_ready = Signal(str, str)
error_occurred = Signal(str)
def __init__(self, download_url, latest_version, platform_name, app_name):
super().__init__()
self.download_url = download_url
self.latest_version = latest_version
self.platform_name = platform_name
self.app_name = app_name
def run(self):
try:
local_filename = os.path.basename(self.download_url)
if self.platform_name == 'darwin':
tmp_dir = f'/tmp/{self.app_name}tempupdate'
os.makedirs(tmp_dir, exist_ok=True)
local_path = os.path.join(tmp_dir, local_filename)
else:
local_path = os.path.join(os.getcwd(), local_filename)
# Download the file
with requests.get(self.download_url, stream=True, timeout=15) as r:
r.raise_for_status()
with open(local_path, 'wb') as f:
for chunk in r.iter_content(chunk_size=8192):
if chunk:
f.write(chunk)
# Extract folder name (remove .zip)
if self.platform_name == 'darwin':
extract_folder = os.path.splitext(local_filename)[0]
extract_path = os.path.join(tmp_dir, extract_folder)
else:
extract_folder = os.path.splitext(local_filename)[0]
extract_path = os.path.join(os.getcwd(), extract_folder)
# Create the folder if not exists
os.makedirs(extract_path, exist_ok=True)
# Extract the zip file contents
if self.platform_name == 'darwin':
subprocess.run(['ditto', '-xk', local_path, extract_path], check=True)
else:
with zipfile.ZipFile(local_path, 'r') as zip_ref:
zip_ref.extractall(extract_path)
# Remove the zip once extracted and emit a signal
os.remove(local_path)
self.update_ready.emit(self.latest_version, extract_path)
except Exception as e:
# Emit a signal signifying failure
self.error_occurred.emit(str(e))
class UpdateCheckThread(QThread):
"""
Thread that checks for updates by querying the API and emits a signal based on the result.
Signals:
download_requested(str, str): Emitted with (download_url, latest_version) when an update is available.
no_update_available(): Emitted when no update is found or current version is up to date.
error_occurred(str): Emitted with an error message if the update check fails.
"""
download_requested = Signal(str, str)
no_update_available = Signal()
error_occurred = Signal(str)
def __init__(self, api_url, api_url_sec, current_version, platform_name, app_name):
super().__init__()
self.api_url = api_url
self.api_url_sec = api_url_sec
self.current_version = current_version
self.platform_name = platform_name
self.app_name = app_name
def run(self):
# if not getattr(sys, 'frozen', False):
# self.error_occurred.emit("Application is not frozen (Development mode).")
# return
try:
latest_version, download_url = self.get_latest_release_for_platform()
if not latest_version:
self.no_update_available.emit()
return
if not download_url:
self.error_occurred.emit(f"No download available for platform '{self.platform_name}'")
return
if self.version_compare(latest_version, self.current_version) > 0:
self.download_requested.emit(download_url, latest_version)
else:
self.no_update_available.emit()
except Exception as e:
self.error_occurred.emit(f"Update check failed: {e}")
def version_compare(self, v1, v2):
def normalize(v): return [int(x) for x in v.split(".")]
return (normalize(v1) > normalize(v2)) - (normalize(v1) < normalize(v2))
def get_latest_release_for_platform(self):
urls = [self.api_url, self.api_url_sec]
for url in urls:
try:
response = requests.get(url, timeout=5)
response.raise_for_status()
releases = response.json()
if not releases:
continue
latest = next((r for r in releases if not r.get("prerelease") and not r.get("draft")), None)
if not latest:
continue
tag = latest["tag_name"].lstrip("v")
for asset in latest.get("assets", []):
if self.platform_name in asset["name"].lower():
return tag, asset["browser_download_url"]
return tag, None
except (requests.RequestException, ValueError) as e:
continue
return None, None
class LocalPendingUpdateCheckThread(QThread):
"""
Thread that checks for locally pending updates by scanning the download directory and emits a signal accordingly.
Args:
current_version (str): Current application version.
platform_suffix (str): Platform-specific suffix to identify update folders.
"""
pending_update_found = Signal(str, str)
no_pending_update = Signal()
def __init__(self, current_version, platform_suffix, platform_name, app_name):
super().__init__()
self.current_version = current_version
self.platform_suffix = platform_suffix
self.platform_name = platform_name
self.app_name = app_name
def version_compare(self, v1, v2):
def normalize(v): return [int(x) for x in v.split(".")]
return (normalize(v1) > normalize(v2)) - (normalize(v1) < normalize(v2))
def run(self):
if self.platform_name == 'darwin':
cwd = f'/tmp/{self.app_name}tempupdate'
else:
cwd = os.getcwd()
pattern = re.compile(r".*-(\d+\.\d+\.\d+)" + re.escape(self.platform_suffix) + r"$")
found = False
try:
for item in os.listdir(cwd):
folder_path = os.path.join(cwd, item)
if os.path.isdir(folder_path) and item.endswith(self.platform_suffix):
match = pattern.match(item)
if match:
folder_version = match.group(1)
if self.version_compare(folder_version, self.current_version) > 0:
self.pending_update_found.emit(folder_version, folder_path)
found = True
break
except:
pass
if not found:
self.no_pending_update.emit()
class UpdateManager(QObject):
"""
Orchestrates the update process.
Main apps should instantiate this and call check_for_updates().
"""
def __init__(self, main_window, api_url, api_url_sec, current_version, platform_name, platform_suffix, app_name):
super().__init__()
self.parent = main_window
self.api_url = api_url
self.api_url_sec = api_url_sec
self.current_version = current_version
self.platform_name = platform_name
self.platform_suffix = platform_suffix
self.app_name = app_name
self.pending_update_version = None
self.pending_update_path = None
def manual_check_for_updates(self):
self.local_check_thread = LocalPendingUpdateCheckThread(self.current_version, self.platform_suffix, self.platform_name, self.app_name)
self.local_check_thread.pending_update_found.connect(self.on_pending_update_found)
self.local_check_thread.no_pending_update.connect(self.on_no_pending_update)
self.local_check_thread.start()
def on_pending_update_found(self, version, folder_path):
self.parent.statusBar().showMessage(f"Pending update found: version {version}")
self.pending_update_version = version
self.pending_update_path = folder_path
self.show_pending_update_popup()
def on_no_pending_update(self):
# No pending update found locally, start server check directly
self.parent.statusBar().showMessage("No pending local update found. Checking server...")
self.start_update_check_thread()
def show_pending_update_popup(self):
msg_box = QMessageBox(self.parent)
msg_box.setWindowTitle("Pending Update Found")
msg_box.setText(f"A previously downloaded update for {self.app_name.upper()} (version {self.pending_update_version}) is available at:\n{self.pending_update_path}\nWould you like to install it now?")
install_now_button = msg_box.addButton("Install Now", QMessageBox.ButtonRole.AcceptRole)
install_later_button = msg_box.addButton("Install Later", QMessageBox.ButtonRole.RejectRole)
msg_box.exec()
if msg_box.clickedButton() == install_now_button:
self.install_update(self.pending_update_path)
else:
self.parent.statusBar().showMessage("Pending update available. Install later.")
# After user dismisses, still check the server for new updates
self.start_update_check_thread()
def start_update_check_thread(self):
self.check_thread = UpdateCheckThread(self.api_url, self.api_url_sec, self.current_version, self.platform_name, self.app_name)
self.check_thread.download_requested.connect(self.on_server_update_requested)
self.check_thread.no_update_available.connect(self.on_server_no_update)
self.check_thread.error_occurred.connect(self.on_error)
self.check_thread.start()
def on_server_no_update(self):
self.parent.statusBar().showMessage("No new updates found on server.", 5000)
def on_server_update_requested(self, download_url, latest_version):
if self.pending_update_version:
cmp = self.version_compare(latest_version, self.pending_update_version)
if cmp > 0:
# Server version is newer than pending update
self.parent.statusBar().showMessage(f"Newer version {latest_version} available on server. Removing old pending update...")
try:
shutil.rmtree(self.pending_update_path)
self.parent.statusBar().showMessage(f"Deleted old update folder: {self.pending_update_path}")
except Exception as e:
self.parent.statusBar().showMessage(f"Failed to delete old update folder: {e}")
# Clear pending update info so new download proceeds
self.pending_update_version = None
self.pending_update_path = None
# Download the new update
self.download_update(download_url, latest_version)
elif cmp == 0:
# Versions equal, no download needed
self.parent.statusBar().showMessage(f"Pending update version {self.pending_update_version} is already latest. No download needed.")
else:
# Server version older than pending? Unlikely but just keep pending update
self.parent.statusBar().showMessage(f"Pending update version {self.pending_update_version} is newer than server version. No action.")
else:
# No pending update, just download
self.download_update(download_url, latest_version)
def download_update(self, download_url, latest_version):
self.parent.statusBar().showMessage("Downloading update...")
self.download_thread = UpdateDownloadThread(download_url, latest_version, self.platform_name, self.app_name)
self.download_thread.update_ready.connect(self.on_update_ready)
self.download_thread.error_occurred.connect(self.on_error)
self.download_thread.start()
def on_update_ready(self, latest_version, extract_folder):
self.parent.statusBar().showMessage("Update downloaded and extracted.")
msg_box = QMessageBox(self.parent)
msg_box.setWindowTitle("Update Ready")
msg_box.setText(f"Version {latest_version} has been downloaded and extracted to:\n{extract_folder}\nWould you like to install it now?")
install_now_button = msg_box.addButton("Install Now", QMessageBox.ButtonRole.AcceptRole)
install_later_button = msg_box.addButton("Install Later", QMessageBox.ButtonRole.RejectRole)
msg_box.exec()
if msg_box.clickedButton() == install_now_button:
self.install_update(extract_folder)
else:
self.parent.statusBar().showMessage("Update ready. Install later.")
def install_update(self, extract_folder):
# Path to updater executable
if self.platform_name == 'windows':
updater_path = os.path.join(os.getcwd(), f"{self.app_name}_updater.exe")
elif self.platform_name == 'darwin':
if getattr(sys, 'frozen', False):
updater_path = os.path.join(os.path.dirname(sys.executable), f"../../../{self.app_name}_updater.app")
else:
updater_path = os.path.join(os.getcwd(), f"../{self.app_name}_updater.app")
elif self.platform_name == 'linux':
updater_path = os.path.join(os.getcwd(), f"{self.app_name}_updater")
else:
updater_path = os.getcwd()
if not os.path.exists(updater_path):
QMessageBox.critical(self.parent, "Error", f"Updater not found at:\n{updater_path}. The absolute path was {os.path.abspath(updater_path)}")
return
# Launch updater with extracted folder path as argument
try:
# Pass current app's executable path for updater to relaunch
main_app_executable = os.path.abspath(sys.argv[0])
print(f'Launching updater with: "{updater_path}" "{extract_folder}" "{main_app_executable}"')
if self.platform_name == 'darwin':
subprocess.Popen(['open', updater_path, '--args', extract_folder, main_app_executable])
else:
subprocess.Popen([updater_path, f'{extract_folder}', f'{main_app_executable}'], cwd=os.path.dirname(updater_path))
# Close the current app so updater can replace files
sys.exit(0)
except Exception as e:
QMessageBox.critical(self.parent, "Error", f"[Updater Launch Failed]\n{str(e)}\n{traceback.format_exc()}")
def on_error(self, message):
# print(f"Error: {message}")
self.parent.statusBar().showMessage(f"Error occurred during update process. {message}")
def version_compare(self, v1, v2):
def normalize(v): return [int(x) for x in v.split(".")]
return (normalize(v1) > normalize(v2)) - (normalize(v1) < normalize(v2))
def wait_for_process_to_exit(process_name, timeout=10):
"""
Waits for a process with the specified name to exit within a timeout period.
Args:
process_name (str): Name (or part of the name) of the process to wait for.
timeout (int, optional): Maximum time to wait in seconds. Defaults to 10.
Returns:
bool: True if the process exited before the timeout, False otherwise.
"""
print(f"Waiting for {process_name} to exit...")
deadline = time.time() + timeout
while time.time() < deadline:
still_running = False
for proc in psutil.process_iter(['name']):
try:
if proc.info['name'] and process_name.lower() in proc.info['name'].lower():
still_running = True
print(f"Still running: {proc.info['name']} (PID: {proc.pid})")
break
except (psutil.NoSuchProcess, psutil.AccessDenied):
continue
if not still_running:
print(f"{process_name} has exited.")
return True
time.sleep(0.5)
print(f"{process_name} did not exit in time.")
return False
def finish_update_if_needed(platform_name, app_name, cfg_path, finish_update):
"""
Completes a pending application update if '--finish-update' is present in the command-line arguments.
"""
if finish_update:
print("Finishing update...")
update_cfg = configparser.ConfigParser()
try:
update_cfg.read(cfg_path)
update_cfg.set("Options", "show_welcome_dialog", "true")
with open(cfg_path, "w") as f:
update_cfg.write(f)
print("Welcome dialog flag successfully reset to 'true' for next run.")
except Exception as e:
print(f"Warning: Could not update welcome dialog preference flag: {e}")
if platform_name == 'darwin':
app_dir = f'/tmp/{app_name}tempupdate'
else:
app_dir = os.getcwd()
# 1. Find update folder
update_folder = None
for entry in os.listdir(app_dir):
entry_path = os.path.join(app_dir, entry)
if os.path.isdir(entry_path) and entry.startswith(f"{app_name}-") and entry.endswith("-" + platform_name):
update_folder = os.path.join(app_dir, entry)
break
if update_folder is None:
print("No update folder found. Skipping update steps.")
return
if platform_name == 'darwin':
update_folder = os.path.join(update_folder, f"{app_name}-darwin")
# 2. Wait for updater to exit
print(f"Waiting for {app_name}_updater to exit...")
for proc in psutil.process_iter(['pid', 'name']):
if proc.info['name'] and f"{app_name}_updater" in proc.info['name'].lower():
try:
proc.wait(timeout=5)
except psutil.TimeoutExpired:
print(f"Force killing lingering {app_name}_updater")
proc.kill()
# 3. Replace the updater
if platform_name == 'windows':
new_updater = os.path.join(update_folder, f"{app_name}_updater.exe")
dest_updater = os.path.join(app_dir, f"{app_name}_updater.exe")
elif platform_name == 'darwin':
new_updater = os.path.join(update_folder, f"{app_name}_updater.app")
dest_updater = os.path.abspath(os.path.join(sys.executable, f"../../../../{app_name}_updater.app"))
elif platform_name == 'linux':
new_updater = os.path.join(update_folder, f"{app_name}_updater")
dest_updater = os.path.join(app_dir, f"{app_name}_updater")
else:
print("Unknown Platform")
new_updater = os.getcwd()
dest_updater = os.getcwd()
print(f"New updater is {new_updater}")
print(f"Dest updater is {dest_updater}")
print("Writable?", os.access(dest_updater, os.W_OK))
print("Executable path:", sys.executable)
print("Trying to copy:", new_updater, "->", dest_updater)
if os.path.exists(new_updater):
try:
if os.path.exists(dest_updater):
if platform_name == 'darwin':
try:
if os.path.isdir(dest_updater):
shutil.rmtree(dest_updater)
print(f"Deleted directory: {dest_updater}")
else:
os.remove(dest_updater)
print(f"Deleted file: {dest_updater}")
except Exception as e:
print(f"Error deleting {dest_updater}: {e}")
else:
os.remove(dest_updater)
if platform_name == 'darwin':
wait_for_process_to_exit(f"{app_name}_updater", timeout=10)
subprocess.check_call(["ditto", new_updater, dest_updater])
else:
shutil.copy2(new_updater, dest_updater)
if platform_name in ('linux', 'darwin'):
os.chmod(dest_updater, 0o755)
if platform_name == 'darwin':
remove_quarantine(dest_updater, app_name)
print(f"{app_name}_updater replaced.")
except Exception as e:
print(f"Failed to replace {app_name}_updater: {e}")
# 4. Delete the update folder
try:
if platform_name == 'darwin':
shutil.rmtree(app_dir)
else:
shutil.rmtree(update_folder)
except Exception as e:
print(f"Failed to delete update folder: {e}")
sys.argv.remove("--finish-update")
def remove_quarantine(app_path, app_name):
"""
Removes the macOS quarantine attribute from the specified application path.
"""
script = f'''
do shell script "xattr -d -r com.apple.quarantine {shlex.quote(app_path)}" with administrator privileges with prompt "{app_name.upper()} needs privileges to finish the update. (2/2)"
'''
try:
subprocess.run(['osascript', '-e', script], check=True)
print("✅ Quarantine attribute removed.")
except subprocess.CalledProcessError as e:
print("❌ Failed to remove quarantine attribute.")
print(e)
+1 -1
View File
@@ -18,7 +18,7 @@ VSVersionInfo(
StringStruct('FileDescription', 'FLARES main application'), StringStruct('FileDescription', 'FLARES main application'),
StringStruct('FileVersion', '1.0.0.0'), StringStruct('FileVersion', '1.0.0.0'),
StringStruct('InternalName', 'flares.exe'), StringStruct('InternalName', 'flares.exe'),
StringStruct('LegalCopyright', '© 2025 Tyler de Zeeuw'), StringStruct('LegalCopyright', '© 2025-2026 Tyler de Zeeuw'),
StringStruct('OriginalFilename', 'flares.exe'), StringStruct('OriginalFilename', 'flares.exe'),
StringStruct('ProductName', 'FLARES'), StringStruct('ProductName', 'FLARES'),
StringStruct('ProductVersion', '1.0.0.0')]) StringStruct('ProductVersion', '1.0.0.0')])
+1 -1
View File
@@ -18,7 +18,7 @@ VSVersionInfo(
StringStruct('FileDescription', 'FLARES updater application'), StringStruct('FileDescription', 'FLARES updater application'),
StringStruct('FileVersion', '1.0.0.0'), StringStruct('FileVersion', '1.0.0.0'),
StringStruct('InternalName', 'main.exe'), StringStruct('InternalName', 'main.exe'),
StringStruct('LegalCopyright', '© 2025 Tyler de Zeeuw'), StringStruct('LegalCopyright', '© 2025-2026 Tyler de Zeeuw'),
StringStruct('OriginalFilename', 'flares_updater.exe'), StringStruct('OriginalFilename', 'flares_updater.exe'),
StringStruct('ProductName', 'FLARES Updater'), StringStruct('ProductName', 'FLARES Updater'),
StringStruct('ProductVersion', '1.0.0.0')]) StringStruct('ProductVersion', '1.0.0.0')])