Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8b017005c5 | ||
|
|
2b019c1bc0 | ||
|
|
0680718398 | ||
|
|
2ff7cda93a | ||
|
|
d300dfb7f7 | ||
|
|
12afc5d3bc | ||
|
|
ffa14693b3 | ||
|
|
afde775b17 | ||
|
|
2708c78f8e | ||
|
|
b5de8709de | ||
|
|
ddb30d98f2 | ||
|
|
f10eb64332 | ||
|
|
617daa0e9b | ||
|
|
766bf75dd7 | ||
|
|
57b7082564 | ||
|
|
e1c7e1a0e6 | ||
|
|
69f3df8921 | ||
|
|
d74be5cdec | ||
|
|
0f6434121f | ||
|
|
8d922ecae9 | ||
|
|
8655115cca | ||
|
|
74ce2eeb2e | ||
|
|
70c4c1e779 | ||
|
|
94be2fb921 | ||
|
|
7ef07678ae | ||
|
|
f2c236bec5 | ||
|
|
46255e6aa3 | ||
|
|
3a0eaef30d | ||
|
|
868a67b44b | ||
|
|
9a32c8e301 | ||
|
|
3afcb54b77 | ||
|
|
9fe4a4dc30 | ||
|
|
8d893f846e |
+3
-1
@@ -179,4 +179,6 @@ cython_debug/
|
||||
*.csv
|
||||
*.snirf
|
||||
*.json
|
||||
flares-*
|
||||
flares-*
|
||||
*.flare
|
||||
*.cfg
|
||||
+109
-6
@@ -1,14 +1,117 @@
|
||||
# Version 1.5.1
|
||||
|
||||
- Open 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 remaing in the background when the participant was removed. Fixes [Issue 82](https://git.research.dezeeuw.ca/tyler/flares/issues/82)
|
||||
- Fixed processing remaining active hidden in the background when the "Clear" button was pushed. Fixes [Issue 81](https://git.research.dezeeuw.ca/tyler/flares/issues/81)
|
||||
- Now when "Clear" is pushed while data is processing, a popup will appear ensuring that the user understands that pushing "Clear" will stop processing
|
||||
- Reset to Default Configuration will now properly reset all of the parameters to their default values. [Issue 90](https://git.research.dezeeuw.ca/tyler/flares/issues/90)
|
||||
- A confirmation popup will now display once the application has been reset to default in addition to the status bar message
|
||||
- Changed the improper display text of "Cross Validation" to now properly read as "Coefficient of Variation"
|
||||
- Changed the parameters "CV" and "CV_THRESHOLD" to now be "COEFF_VAR and COEFF_VAR_THRESHOLD"
|
||||
- Changed the improper display text of "Mean Absolute Deviation" to now properly read as "Median Absolute Deviation"
|
||||
- Changed the parameters "SHORT_CHANNEL", "SHORT_CHANNEL_THRESH", and "LONG_CHANNEL_THRESH" to now be "SHORT_CHANNELS", "SHORT_CHANNELS_THRESHOLD", and "LONG_CHANNELS_THRESHOLD"
|
||||
- Changed the parameter section "Channel Variance" to now be "Sensor Dropout" to better reflect the action it performs, not the method employed to calculate it
|
||||
- Changed the parameters "CHANNEL_VAR" and "CHANNEL_THRESH" to now be "SENSOR_DROPOUT" and "SENSOR_DROPOUT_VARIANCE_THRESHOLD"
|
||||
- Changed the messaged displayed when the application crashes to better reflect what occured
|
||||
- Changed the processing stages 25-27 and updates their messages to better reflect their actions that they now perforn
|
||||
- Changed stage 25 from "Generate Channel Significance" to "Generate Channel Results"
|
||||
- Changed stage 26 from "Generate Channel, Region of Interest, and Contrast Results" to "Generate Region of Interest Results"
|
||||
- Changed stage 27 from "Compute Contrast Results" to "Generate Contrast Results"
|
||||
- Changed backend code to only load required methods and not load all methods every time
|
||||
- Fixed string parameters not going blue and bold when their value was different than default
|
||||
- Fixed all parameters not going blue and bold when a save file is loaded containing values that differ from default
|
||||
- Fixed a crucial bug where short channels were not being processed and filtered the same way as long channels before being used as regressors
|
||||
- Fixed a crucial bug where short channels were being presented to the design matrix as normal long channels
|
||||
- Fixed a crucial bug where long channels could be interpolated from short channels. Short channels are still potentially interpolated from long channels. See [this link](https://git.research.dezeeuw.ca/tyler/flares/issues/80) for more information regarding this issue.
|
||||
- Decreased unnecessary processing time when fOLDing channels by an order of magnitude
|
||||
- Added a welcome message when the terminal is opened, resized the terminal, and added more commands
|
||||
|
||||
|
||||
# Version 1.5.0
|
||||
|
||||
- This release introduces a new configuration file that may break existing installs. If your application does not update correctly, please download fresh from [this link.](https://git.research.dezeeuw.ca/tyler/flares/releases/)
|
||||
- This release features an almost complete rewrite of backend files. If you encounter any problems, please raise an issue at [this link.](https://git.research.dezeeuw.ca/tyler/flares/issues/new)
|
||||
- New configuration file has been added! Now your choices of preferences will be saved when the application is closed and re-opened. If the configuration file is missing, a new one will be generated
|
||||
- Recent files and recent projects are now saved and appear under the File menu for quick resuming
|
||||
- The new option "Reset to Default Configuration" under the Options menu will reset the configuration file back to it's default values and remove any recent files
|
||||
- A welcome dialog will now display the changelog on first startup and after every update. This popup will only appear once but can be reopened under the Options menu through the button "Show Update Changelog"
|
||||
- Changed the hotkey for "Update optodes in snirf file..." to be F9 instead of F6
|
||||
- Revamped the fOLD channels window. Images containing the pie charts are now interactable! Click whitespace to expand the whole image and click a chart to expand it.
|
||||
- fOLD progress bar when processing now updates the percentages live. Fixes [Issue 76](https://git.research.dezeeuw.ca/tyler/flares/issues/76)
|
||||
- Overall pie charts on an individal and global basis are now genereted. Fixes [Issue 78](https://git.research.dezeeuw.ca/tyler/flares/issues/78)
|
||||
- Brodmann images are now available when examining a pie chart to understand which area is being reported. Fixes [Issue 77](https://git.research.dezeeuw.ca/tyler/flares/issues/77)
|
||||
- Added a new option 'Folding Bypass' to the Preferences Menu. This skips most processing steps and the only analysis option available will be to fold. Parameters on the right will be ignored. Fixes [Issue 75](https://git.research.dezeeuw.ca/tyler/flares/issues/75)
|
||||
- Fixed an issue where the fOLD analysis progress window would go unresponsive before processing participants. Fixes [Issue 45](https://git.research.dezeeuw.ca/tyler/flares/issues/45), Fixes [Issue 34](https://git.research.dezeeuw.ca/tyler/flares/issues/34)
|
||||
- Added a feature to hover over the 28 stage progress bar and see which state the progress bar is at. Fixes [Issue 74](https://git.research.dezeeuw.ca/tyler/flares/issues/74)
|
||||
- Loading a broken snirf file no longer hangs its processing and can now be removed from the list. Fixes [Issue 73](https://git.research.dezeeuw.ca/tyler/flares/issues/73)
|
||||
|
||||
|
||||
# Version 1.4.3
|
||||
|
||||
- Fixed an issue where the fOLD files could not be located
|
||||
- Added better support for updating events from external software
|
||||
|
||||
|
||||
# Version 1.4.2
|
||||
|
||||
- Fixed AGE, GENDER, GROUP not visually appearing on a bubble after the metadata has been set. Fixes [Issue 42](https://git.research.dezeeuw.ca/tyler/flares/issues/42)
|
||||
- Fixed first stage of progress bar going yellow after loading from an unprocessed save
|
||||
- Fixed AGE, GENDER, GROUP not visually appearing on a bubble when loading from a save
|
||||
- Group images involving an optode template will now be the average of all participants selected in the group and not the last processed participant. Fixes [Issue 62](https://git.research.dezeeuw.ca/tyler/flares/issues/62)
|
||||
- Group images will no longer crash if being made with participants that have a different number of channels
|
||||
- Changed CSV files to now save to the same folder rather than asking each time for each file. Fixes [Issue 39](https://git.research.dezeeuw.ca/tyler/flares/issues/39)
|
||||
|
||||
|
||||
# Version 1.4.1
|
||||
|
||||
- Hotfix to fix a recursive child loop that would cause the MacOS version to not open
|
||||
|
||||
|
||||
# Version 1.4.0
|
||||
|
||||
- This is potentially a save-changing release due to changes in how file paths and parameters are stored. Please update your project files to ensure compatibility
|
||||
- It is still possible to load older saves by enabling 'Incompatible Save Bypass' from the Preferences menu, but your mileage may vary
|
||||
- AGE, GENDER, GROUP, loaded files, and all the parameters on the right side of the screen can now be saved before any data has been processed
|
||||
- If processing has not been completed, the process button will be visible. If processing has completed, the analysis button will be visible
|
||||
- If the values fail to load, they will fallback to the previous logic of retreiving these values after processing has occured. Fixes [Issue 66](https://git.research.dezeeuw.ca/tyler/flares/issues/66)
|
||||
- Added new parameters to the right side of the screen: MAX_SHIFT, T_MIN, T_MAX, MAX_BAD_CHANNELS. Fixes [Issue 69](https://git.research.dezeeuw.ca/tyler/flares/issues/69)
|
||||
- Participants that are bad on channels will no longer continue to the GLM stage. Fixes [Issue 18](https://git.research.dezeeuw.ca/tyler/flares/issues/18)
|
||||
- Added new sections to the right side of the screen to mark bad channels: Cross Validation, Median Absolute Deviation, PSD Noise, & Channel Variance
|
||||
- These sections come with the new parameters CV, CV_THRESHOLD, MAD, MAD_THRESHOLD, PSD_NOISE, TARGET_FREQ_DIV, DB_LIMIT, CHANNEL_VAR, & CHANNEL_THRESH
|
||||
- Changed number of rectangles in the progress bar to 28 to account for the new options and updated the User Guide with the new stages
|
||||
- Added feedback when clicking an analysis option that opens up a new window. Fixes [Issue 20](https://git.research.dezeeuw.ca/tyler/flares/issues/20)
|
||||
- Fixed an issue where projects can not be saved to a different drive letter on windows. Fixes [Issue 71](https://git.research.dezeeuw.ca/tyler/flares/issues/71)
|
||||
- Fixed an issue where the fOLD files were not included in the Windows version. Fixes [Issue 60](https://git.research.dezeeuw.ca/tyler/flares/issues/60)
|
||||
- Fixed an issue where the MacOS version would fail to perform some analysis options. Fixes [Issue 63](https://git.research.dezeeuw.ca/tyler/flares/issues/63)
|
||||
- Fixed an issue where processing too many participants would cause the analysis button to not appear. Fixes [Issue 61](https://git.research.dezeeuw.ca/tyler/flares/issues/61)
|
||||
- Fixed an issue where the error message when a participant fails would not appear. Fixes [Issue 68](https://git.research.dezeeuw.ca/tyler/flares/issues/68)
|
||||
- Fixed an issue where changes would not be saved if a project was originally loaded from a save. Fixes [Issue 44](https://git.research.dezeeuw.ca/tyler/flares/issues/44)
|
||||
- Fixed an issue where the significance image would be empty in the Inter-Group Viewer if only one participant was selected. Fixes [Issue 32](https://git.research.dezeeuw.ca/tyler/flares/issues/32)
|
||||
- Fixed an issue where pressing the 'Clear' button after loading a save would cause the application to crash. Fixes [Issue 67](https://git.research.dezeeuw.ca/tyler/flares/issues/67)
|
||||
- Fixed an issue where group dropdowns in the Cross-Group viewer would not be updated correctly based on the other groups selected value. Fixes [Issue 49](https://git.research.dezeeuw.ca/tyler/flares/issues/49)
|
||||
- Fixed an issue where scrollbars were still present after clearing all data. Fixes [Issue 70](https://git.research.dezeeuw.ca/tyler/flares/issues/70)
|
||||
- Fixed an issue where 'Missing Events Bypass' did not work on the Cross-Group viewer. Fixes [Issue 64](https://git.research.dezeeuw.ca/tyler/flares/issues/64)
|
||||
- Fixed an issue where bubbles loaded from a save would not resize correctly. Fixes [Issue 14](https://git.research.dezeeuw.ca/tyler/flares/issues/14)
|
||||
|
||||
|
||||
# Version 1.3.0
|
||||
|
||||
- This is a save-changing release due to a new save file format. Please update your project files to ensure compatibility
|
||||
- It is still potentially possible to load older saves by enabling 'Incompatible Save Bypass' from the Preferences menu
|
||||
- This is potentially a save-changing release due to a new parameter being saved. Please update your project files to ensure compatibility
|
||||
- It is still possible to load older saves by enabling 'Incompatible Save Bypass' from the Preferences menu, but your mileage may vary
|
||||
- Fixed workers not releasing memory when processing multiple participants. Fixes [Issue 55](https://git.research.dezeeuw.ca/tyler/flares/issues/55)
|
||||
- Fixed part of an issue where memory could increase over time despite clicking the clear button. There is still some edge cases where this can occur
|
||||
- Fixed an issue when clearing a bubble, reloading the same file, and clicking it would cause the app to crash. Fixes [Issue 57](https://git.research.dezeeuw.ca/tyler/flares/issues/57)
|
||||
- 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
|
||||
- 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)
|
||||
- Added a new parameter to the right side of the screen: EPOCH_EVENTS_HANDLING. Fixes [Issue 58](https://git.research.dezeeuw.ca/tyler/flares/issues/58)
|
||||
- EPOCH_EVENTS_HANDLING defaults to 'shift' compared to previous versions where the default would have been equivalent to 'strict'
|
||||
- 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
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
# Version 1.5.1
|
||||
|
||||
- Open 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 remaing in the background when the participant was removed. Fixes [Issue 82](https://git.research.dezeeuw.ca/tyler/flares/issues/82)
|
||||
- Fixed processing remaining active hidden in the background when the "Clear" button was pushed. Fixes [Issue 81](https://git.research.dezeeuw.ca/tyler/flares/issues/81)
|
||||
- Now when "Clear" is pushed while data is processing, a popup will appear ensuring that the user understands that pushing "Clear" will stop processing
|
||||
- Reset to Default Configuration will now properly reset all of the parameters to their default values. [Issue 90](https://git.research.dezeeuw.ca/tyler/flares/issues/90)
|
||||
- A confirmation popup will now display once the application has been reset to default in addition to the status bar message
|
||||
- Changed the improper display text of "Cross Validation" to now properly read as "Coefficient of Variation"
|
||||
- Changed the parameters "CV" and "CV_THRESHOLD" to now be "COEFF_VAR and COEFF_VAR_THRESHOLD"
|
||||
- Changed the improper display text of "Mean Absolute Deviation" to now properly read as "Median Absolute Deviation"
|
||||
- Changed the parameters "SHORT_CHANNEL", "SHORT_CHANNEL_THRESH", and "LONG_CHANNEL_THRESH" to now be "SHORT_CHANNELS", "SHORT_CHANNELS_THRESHOLD", and "LONG_CHANNELS_THRESHOLD"
|
||||
- Changed the parameter section "Channel Variance" to now be "Sensor Dropout" to better reflect the action it performs, not the method employed to calculate it
|
||||
- Changed the parameters "CHANNEL_VAR" and "CHANNEL_THRESH" to now be "SENSOR_DROPOUT" and "SENSOR_DROPOUT_VARIANCE_THRESHOLD"
|
||||
- Changed the messaged displayed when the application crashes to better reflect what occured
|
||||
- Changed the processing stages 25-27 and updates their messages to better reflect their actions that they now perforn
|
||||
- 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.
|
||||
- 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 it's default values and remove any recent files
|
||||
- A welcome dialog will now display the changelog on first startup and after every update. This popup will only appear once but can be reopened under the Options menu through the button "Show Update Changelog"
|
||||
- Changed the hotkey for "Update optodes in snirf file..." to be F9 instead of F6
|
||||
- Revamped the fOLD channels window. Images containing the pie charts are now interactable! Click whitespace to expand the whole image and click a chart to expand it.
|
||||
- fOLD progress bar when processing now updates the percentages live. Fixes [Issue 76](https://git.research.dezeeuw.ca/tyler/flares/issues/76)
|
||||
- Overall pie charts on an individal and global basis are now genereted. Fixes [Issue 78](https://git.research.dezeeuw.ca/tyler/flares/issues/78)
|
||||
- Brodmann images are now available when examining a pie chart to understand which area is being reported. Fixes [Issue 77](https://git.research.dezeeuw.ca/tyler/flares/issues/77)
|
||||
- Added a new option 'Folding Bypass' to the Preferences Menu. This skips most processing steps and the only analysis option available will be to fold. Parameters on the right will be ignored. Fixes [Issue 75](https://git.research.dezeeuw.ca/tyler/flares/issues/75)
|
||||
- Fixed an issue where the fOLD analysis progress window would go unresponsive before processing participants. Fixes [Issue 45](https://git.research.dezeeuw.ca/tyler/flares/issues/45), Fixes [Issue 34](https://git.research.dezeeuw.ca/tyler/flares/issues/34)
|
||||
- Added a feature to hover over the 28 stage progress bar and see which state the progress bar is at. Fixes [Issue 74](https://git.research.dezeeuw.ca/tyler/flares/issues/74)
|
||||
- Loading a broken snirf file no longer hangs its processing and can now be removed from the list. Fixes [Issue 73](https://git.research.dezeeuw.ca/tyler/flares/issues/73)
|
||||
@@ -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 |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="#1f1f1f"><path d="M480-120q-138 0-240.5-91.5T122-440h82q14 104 92.5 172T480-200q117 0 198.5-81.5T760-480q0-117-81.5-198.5T480-760q-69 0-129 32t-101 88h110v80H120v-240h80v94q51-64 124.5-99T480-840q75 0 140.5 28.5t114 77q48.5 48.5 77 114T840-480q0 75-28.5 140.5t-77 114q-48.5 48.5-114 77T480-120Zm112-192L440-464v-216h80v184l128 128-56 56Z"/></svg>
|
||||
|
After Width: | Height: | Size: 444 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="#1f1f1f"><path d="M480-80q-155 0-269-103T82-440h81q15 121 105.5 200.5T480-160q134 0 227-93t93-227q0-134-93-227t-227-93q-86 0-159.5 42.5T204-640h116v80H88q29-140 139-230t253-90q83 0 156 31.5T763-763q54 54 85.5 127T880-480q0 83-31.5 156T763-197q-54 54-127 85.5T480-80Zm112-232L440-464v-216h80v184l128 128-56 56Z"/></svg>
|
||||
|
After Width: | Height: | Size: 416 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="#1f1f1f"><path d="M520-330v-60h160v60H520Zm60 210v-50h-60v-60h60v-50h60v160h-60Zm100-50v-60h160v60H680Zm40-110v-160h60v50h60v60h-60v50h-60Zm111-280h-83q-26-88-99-144t-169-56q-117 0-198.5 81.5T200-480q0 72 32.5 132t87.5 98v-110h80v240H160v-80h94q-62-50-98-122.5T120-480q0-75 28.5-140.5t77-114q48.5-48.5 114-77T480-840q129 0 226.5 79.5T831-560Z"/></svg>
|
||||
|
After Width: | Height: | Size: 449 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="#1f1f1f"><path d="M451.5-251.5Q440-263 440-280t11.5-28.5Q463-320 480-320t28.5 11.5Q520-297 520-280t-11.5 28.5Q497-240 480-240t-28.5-11.5ZM440-360v-161l80 80v81h-80Zm433 158L655-419 480-720l-47 80-58-58 105-182 393 678Zm-695 2h469L350-497 178-200ZM819-28l-92-92H40l252-435L27-820l57-57L876-85l-57 57ZM499-348Zm45-181Z"/></svg>
|
||||
|
After Width: | Height: | Size: 423 B |
Binary file not shown.
|
After Width: | Height: | Size: 55 KiB |
@@ -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}")
|
||||
@@ -0,0 +1,335 @@
|
||||
"""
|
||||
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]],
|
||||
group_dict: dict[str, str],
|
||||
json_location: str | Path
|
||||
) -> 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.group_dict = group_dict
|
||||
self.json_location = json_location
|
||||
|
||||
self.setup_cross_group_ui(["0 (Raw ROI Comparison)", "1 (Laterality Comparison)", "2 (Contrast Comparison)",], placeholder_text=DESCRIPTION)
|
||||
|
||||
|
||||
def process_request(self):
|
||||
request = self.get_common_request_data(PARAMETERIZED_INDEXES, self.json_location, self.contrast_results_dict)
|
||||
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)
|
||||
|
||||
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_config=self.json_location,
|
||||
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
|
||||
|
||||
run_cross_group_contrast_analysis(
|
||||
df_contrasts_a=df_contrasts_a,
|
||||
df_contrasts_b=df_contrasts_b,
|
||||
contrast_name=contrast_name,
|
||||
roi_json_path=self.json_location,
|
||||
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")
|
||||
@@ -0,0 +1,116 @@
|
||||
"""
|
||||
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],
|
||||
) -> 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.setup_csv_ui(["0 (Export Data to CSV)", "1 (CSV for SPARKS)",])
|
||||
|
||||
|
||||
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
|
||||
|
||||
else:
|
||||
print(f"No method defined for index {idx}")
|
||||
|
||||
except Exception as e:
|
||||
print(f"Failed to export {file_path} (Type {idx}): {e}")
|
||||
|
||||
# 4. Final Notification
|
||||
if success_count > 0:
|
||||
QMessageBox.information(self, "Export Complete", f"Successfully saved {success_count} CSV files to:\n{output_dir}")
|
||||
|
||||
# # If SPARKS export was included, show the Event Window once at the end
|
||||
# if 1 in selected_indexes:
|
||||
# win = UpdateEventsWindow(
|
||||
# parent=self,
|
||||
# mode=EventUpdateMode.WRITE_JSON,
|
||||
# caller="Video Alignment Tool"
|
||||
# )
|
||||
# win.show()
|
||||
@@ -0,0 +1,221 @@
|
||||
"""
|
||||
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
|
||||
from mne.io import BaseRaw
|
||||
|
||||
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,84 @@
|
||||
"""
|
||||
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}")
|
||||
@@ -0,0 +1,396 @@
|
||||
"""
|
||||
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]],
|
||||
group_dict: dict[str, str],
|
||||
json_location: str | Path
|
||||
) -> 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.group_dict = group_dict
|
||||
self.json_location = json_location
|
||||
|
||||
self.setup_inter_group_ui(["0 (ROI vs. Zero)", "1 (Paired ROI Contrast)", "2 (Joint Contrast, ROI-Aggregated)"], placeholder_text=DESCRIPTION)
|
||||
|
||||
|
||||
def process_request(self):
|
||||
request = self.get_common_request_data(PARAMETERIZED_INDEXES, self.json_location, self.contrast_results_dict)
|
||||
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,
|
||||
roi_config=self.json_location
|
||||
)
|
||||
|
||||
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)
|
||||
|
||||
try:
|
||||
roi_theta = aggregate_channel_contrasts_to_roi(
|
||||
df_contrasts,
|
||||
roi_json_path=self.json_location,
|
||||
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}")
|
||||
@@ -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,185 @@
|
||||
"""
|
||||
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}")
|
||||
@@ -0,0 +1,176 @@
|
||||
"""
|
||||
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
@@ -0,0 +1,64 @@
|
||||
"""
|
||||
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.5.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"
|
||||
]
|
||||
|
||||
|
||||
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)
|
||||
@@ -0,0 +1,39 @@
|
||||
"""
|
||||
Filename: about.py
|
||||
Description: About window
|
||||
Note: Compliant with pylance strict type checking
|
||||
|
||||
Author: Tyler de Zeeuw
|
||||
License: GPL-3.0
|
||||
"""
|
||||
|
||||
from PySide6.QtWidgets import QWidget, QVBoxLayout, QLabel
|
||||
from PySide6.QtCore import Qt
|
||||
|
||||
from src.shared.shareddata import APP_NAME, 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)
|
||||
@@ -0,0 +1,104 @@
|
||||
"""
|
||||
Filename: terminal.py
|
||||
Description: Terminal window
|
||||
Note: Compliant with pylance strict type checking
|
||||
|
||||
Author: Tyler de Zeeuw
|
||||
License: GPL-3.0
|
||||
"""
|
||||
|
||||
from typing import Any, Callable
|
||||
|
||||
from PySide6.QtWidgets import QWidget, QVBoxLayout, QTextEdit, QLineEdit
|
||||
from PySide6.QtCore import Qt
|
||||
|
||||
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 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,
|
||||
"update": self.cmd_update,
|
||||
}
|
||||
|
||||
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}")
|
||||
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."
|
||||
@@ -0,0 +1,855 @@
|
||||
"""
|
||||
Filename: updateevents.py
|
||||
Description: Methods to update snirf events for FLARES
|
||||
|
||||
Author: Tyler de Zeeuw
|
||||
License: GPL-3.0
|
||||
"""
|
||||
|
||||
import os
|
||||
import json
|
||||
from enum import Enum, auto
|
||||
from datetime import datetime
|
||||
|
||||
import numpy as np
|
||||
|
||||
from PySide6.QtWidgets import QWidget, QVBoxLayout, QLabel, QLineEdit, QPushButton, QComboBox, QHBoxLayout, QMessageBox, QFileDialog
|
||||
from PySide6.QtCore import Qt
|
||||
|
||||
from mne import Annotations
|
||||
from mne.io import read_raw_snirf
|
||||
from mne_nirs.io import write_raw_snirf
|
||||
|
||||
from src.shared.shareddata import APP_NAME
|
||||
|
||||
|
||||
class EventUpdateMode(Enum):
|
||||
WRITE_SNIRF = auto() # destructive
|
||||
WRITE_JSON = auto() # non-destructive
|
||||
|
||||
|
||||
class UpdateEventsWindow(QWidget):
|
||||
def __init__(self, parent=None, mode=EventUpdateMode.WRITE_SNIRF, caller=None):
|
||||
super().__init__(parent, Qt.WindowType.Window)
|
||||
|
||||
self.mode = mode
|
||||
self.caller = caller or self.__class__.__name__
|
||||
self.setWindowTitle(f"Update event markers - {APP_NAME.upper()}")
|
||||
self.resize(760, 200)
|
||||
|
||||
print("INIT MODE:", mode)
|
||||
|
||||
self.label_file_a = QLabel("SNIRF file:")
|
||||
self.line_edit_file_a = QLineEdit()
|
||||
self.line_edit_file_a.setReadOnly(True)
|
||||
self.btn_browse_a = QPushButton("Browse .snirf")
|
||||
self.btn_browse_a.clicked.connect(self.browse_file_a)
|
||||
|
||||
self.label_file_b = QLabel("BORIS file:")
|
||||
self.line_edit_file_b = QLineEdit()
|
||||
self.line_edit_file_b.setReadOnly(True)
|
||||
self.btn_browse_b = QPushButton("Browse .boris")
|
||||
self.btn_browse_b.clicked.connect(self.browse_file_b)
|
||||
|
||||
self.label_suffix = QLabel("Filename in BORIS project file:")
|
||||
self.combo_suffix = QComboBox()
|
||||
self.combo_suffix.setEditable(False)
|
||||
self.combo_suffix.currentIndexChanged.connect(self.on_observation_selected)
|
||||
|
||||
self.label_events = QLabel("Events in selected observation:")
|
||||
self.combo_events = QComboBox()
|
||||
self.combo_events.setEnabled(False)
|
||||
|
||||
self.label_snirf_events = QLabel("Events in SNIRF file:")
|
||||
self.combo_snirf_events = QComboBox()
|
||||
self.combo_snirf_events.setEnabled(False)
|
||||
|
||||
self.btn_clear = QPushButton("Clear")
|
||||
self.btn_go = QPushButton("Go")
|
||||
self.btn_clear.clicked.connect(self.clear_files)
|
||||
self.btn_go.clicked.connect(self.go_action)
|
||||
|
||||
# ---
|
||||
layout = QVBoxLayout()
|
||||
self.description = QLabel()
|
||||
self.description.setTextFormat(Qt.TextFormat.RichText)
|
||||
self.description.setTextInteractionFlags(Qt.TextInteractionFlag.TextBrowserInteraction)
|
||||
self.description.setOpenExternalLinks(True)
|
||||
|
||||
self.description.setText("The events that are present in a snirf file may not be the events that are to be studied and examined.<br>"
|
||||
"Utilizing different software and video recordings, it is easy enough to see when an action actually occured in a file.<br>"
|
||||
"The software <a href='https://www.boris.unito.it/'>BORIS</a> is used to add these events to video files, and these events can be applied to the snirf file <br>"
|
||||
"selected below by selecting the correct BORIS observation and time syncing it to an event that it shares with the snirf file.")
|
||||
|
||||
layout.addWidget(self.description)
|
||||
|
||||
help_text_a = "Select the SNIRF (.snirf) file to update with new event markers."
|
||||
|
||||
file_a_layout = QHBoxLayout()
|
||||
|
||||
# Help button on the left
|
||||
help_btn_a = QPushButton("?")
|
||||
help_btn_a.setFixedWidth(25)
|
||||
help_btn_a.setToolTip(help_text_a)
|
||||
help_btn_a.clicked.connect(lambda _, text=help_text_a: self.show_help_popup(text))
|
||||
file_a_layout.addWidget(help_btn_a)
|
||||
|
||||
# Container for label + line_edit + browse button with tooltip
|
||||
file_a_container = QWidget()
|
||||
file_a_container_layout = QHBoxLayout()
|
||||
file_a_container_layout.setContentsMargins(0, 0, 0, 0)
|
||||
file_a_container_layout.addWidget(self.label_file_a)
|
||||
file_a_container_layout.addWidget(self.line_edit_file_a)
|
||||
file_a_container_layout.addWidget(self.btn_browse_a)
|
||||
file_a_container.setLayout(file_a_container_layout)
|
||||
file_a_container.setToolTip(help_text_a)
|
||||
|
||||
file_a_layout.addWidget(file_a_container)
|
||||
layout.addLayout(file_a_layout)
|
||||
|
||||
help_text_b = "Provide a .boris project file that contains events for this participant."
|
||||
|
||||
file_b_layout = QHBoxLayout()
|
||||
|
||||
help_btn_b = QPushButton("?")
|
||||
help_btn_b.setFixedWidth(25)
|
||||
help_btn_b.setToolTip(help_text_b)
|
||||
help_btn_b.clicked.connect(lambda _, text=help_text_b: self.show_help_popup(text))
|
||||
file_b_layout.addWidget(help_btn_b)
|
||||
|
||||
file_b_container = QWidget()
|
||||
file_b_container_layout = QHBoxLayout()
|
||||
file_b_container_layout.setContentsMargins(0, 0, 0, 0)
|
||||
file_b_container_layout.addWidget(self.label_file_b)
|
||||
file_b_container_layout.addWidget(self.line_edit_file_b)
|
||||
file_b_container_layout.addWidget(self.btn_browse_b)
|
||||
file_b_container.setLayout(file_b_container_layout)
|
||||
file_b_container.setToolTip(help_text_b)
|
||||
|
||||
file_b_layout.addWidget(file_b_container)
|
||||
layout.addLayout(file_b_layout)
|
||||
|
||||
help_text_suffix = "This participant from the .boris project file matches the .snirf file."
|
||||
|
||||
suffix_layout = QHBoxLayout()
|
||||
|
||||
help_btn_suffix = QPushButton("?")
|
||||
help_btn_suffix.setFixedWidth(25)
|
||||
help_btn_suffix.setToolTip(help_text_suffix)
|
||||
help_btn_suffix.clicked.connect(lambda _, text=help_text_suffix: self.show_help_popup(text))
|
||||
suffix_layout.addWidget(help_btn_suffix)
|
||||
|
||||
suffix_container = QWidget()
|
||||
suffix_container_layout = QHBoxLayout()
|
||||
suffix_container_layout.setContentsMargins(0, 0, 0, 0)
|
||||
suffix_container_layout.addWidget(self.label_suffix)
|
||||
suffix_container_layout.addWidget(self.combo_suffix)
|
||||
suffix_container.setLayout(suffix_container_layout)
|
||||
suffix_container.setToolTip(help_text_suffix)
|
||||
|
||||
suffix_layout.addWidget(suffix_container)
|
||||
layout.addLayout(suffix_layout)
|
||||
|
||||
help_text_suffix = "The events extracted from the BORIS project file for the selected observation."
|
||||
|
||||
suffix2_layout = QHBoxLayout()
|
||||
|
||||
help_btn_suffix = QPushButton("?")
|
||||
help_btn_suffix.setFixedWidth(25)
|
||||
help_btn_suffix.setToolTip(help_text_suffix)
|
||||
help_btn_suffix.clicked.connect(lambda _, text=help_text_suffix: self.show_help_popup(text))
|
||||
suffix2_layout.addWidget(help_btn_suffix)
|
||||
|
||||
suffix2_container = QWidget()
|
||||
suffix2_container_layout = QHBoxLayout()
|
||||
suffix2_container_layout.setContentsMargins(0, 0, 0, 0)
|
||||
suffix2_container_layout.addWidget(self.label_events)
|
||||
suffix2_container_layout.addWidget(self.combo_events)
|
||||
suffix2_container.setLayout(suffix2_container_layout)
|
||||
suffix2_container.setToolTip(help_text_suffix)
|
||||
|
||||
suffix2_layout.addWidget(suffix2_container)
|
||||
layout.addLayout(suffix2_layout)
|
||||
|
||||
snirf_events_layout = QHBoxLayout()
|
||||
|
||||
help_text_snirf_events = "The event markers extracted from the SNIRF file."
|
||||
help_btn_snirf_events = QPushButton("?")
|
||||
help_btn_snirf_events.setFixedWidth(25)
|
||||
help_btn_snirf_events.setToolTip(help_text_snirf_events)
|
||||
help_btn_snirf_events.clicked.connect(lambda _, text=help_text_snirf_events: self.show_help_popup(text))
|
||||
snirf_events_layout.addWidget(help_btn_snirf_events)
|
||||
|
||||
snirf_events_container = QWidget()
|
||||
snirf_events_container_layout = QHBoxLayout()
|
||||
snirf_events_container_layout.setContentsMargins(0, 0, 0, 0)
|
||||
snirf_events_container_layout.addWidget(self.label_snirf_events)
|
||||
snirf_events_container_layout.addWidget(self.combo_snirf_events)
|
||||
snirf_events_container.setLayout(snirf_events_container_layout)
|
||||
snirf_events_container.setToolTip(help_text_snirf_events)
|
||||
|
||||
snirf_events_layout.addWidget(snirf_events_container)
|
||||
layout.addLayout(snirf_events_layout)
|
||||
|
||||
buttons_layout = QHBoxLayout()
|
||||
buttons_layout.addStretch()
|
||||
buttons_layout.addWidget(self.btn_clear)
|
||||
buttons_layout.addWidget(self.btn_go)
|
||||
layout.addLayout(buttons_layout)
|
||||
|
||||
self.setLayout(layout)
|
||||
|
||||
def show_help_popup(self, text):
|
||||
msg = QMessageBox(self)
|
||||
msg.setWindowTitle(f"Parameter Info - {APP_NAME.upper()}")
|
||||
msg.setText(text)
|
||||
msg.exec()
|
||||
|
||||
def browse_file_a(self):
|
||||
file_path, _ = QFileDialog.getOpenFileName(self, "Select SNIRF File", "", "SNIRF Files (*.snirf)")
|
||||
if file_path:
|
||||
self.line_edit_file_a.setText(file_path)
|
||||
try:
|
||||
# TODO: Bad! read_raw_snirf doesnt release memory properly! Should be spawned in a seperate process and killed once completed
|
||||
raw = read_raw_snirf(file_path, preload=False)
|
||||
annotations = raw.annotations
|
||||
|
||||
# Build individual event entries
|
||||
event_entries = []
|
||||
for onset, description in zip(annotations.onset, annotations.description):
|
||||
event_str = f"{description} @ {onset:.3f}s"
|
||||
event_entries.append(event_str)
|
||||
|
||||
if not event_entries:
|
||||
QMessageBox.information(self, "No Events", "No events found in SNIRF file.")
|
||||
self.combo_snirf_events.clear()
|
||||
self.combo_snirf_events.setEnabled(False)
|
||||
return
|
||||
|
||||
self.combo_snirf_events.clear()
|
||||
self.combo_snirf_events.addItems(event_entries)
|
||||
self.combo_snirf_events.setEnabled(True)
|
||||
|
||||
except Exception as e:
|
||||
QMessageBox.warning(self, "Error", f"Could not read SNIRF file with MNE:\n{str(e)}")
|
||||
self.combo_snirf_events.clear()
|
||||
self.combo_snirf_events.setEnabled(False)
|
||||
|
||||
def browse_file_b(self):
|
||||
file_path, _ = QFileDialog.getOpenFileName(self, "Select BORIS File", "", "BORIS project Files (*.boris)")
|
||||
if file_path:
|
||||
self.line_edit_file_b.setText(file_path)
|
||||
|
||||
try:
|
||||
with open(file_path, 'r', encoding='utf-8') as f:
|
||||
data = json.load(f)
|
||||
self.boris_data = data
|
||||
|
||||
observation_keys = self.extract_boris_observation_keys(data)
|
||||
self.combo_suffix.clear()
|
||||
self.combo_suffix.addItems(observation_keys)
|
||||
|
||||
except (json.JSONDecodeError, FileNotFoundError, KeyError) as e:
|
||||
QMessageBox.warning(self, "Error", f"Failed to parse BORIS file:\n{e}")
|
||||
|
||||
def extract_boris_observation_keys(self, data):
|
||||
if "observations" not in data:
|
||||
raise KeyError("Missing 'observations' key in BORIS file.")
|
||||
|
||||
observations = data["observations"]
|
||||
if not isinstance(observations, dict):
|
||||
raise TypeError("'observations' must be a dictionary.")
|
||||
|
||||
return list(observations.keys())
|
||||
|
||||
def on_observation_selected(self):
|
||||
selected_obs = self.combo_suffix.currentText()
|
||||
if not selected_obs or not hasattr(self, 'boris_data'):
|
||||
self.combo_events.clear()
|
||||
self.combo_events.setEnabled(False)
|
||||
return
|
||||
|
||||
try:
|
||||
events = self.boris_data["observations"][selected_obs]["events"]
|
||||
except (KeyError, TypeError):
|
||||
self.combo_events.clear()
|
||||
self.combo_events.setEnabled(False)
|
||||
return
|
||||
|
||||
event_entries = []
|
||||
for event in events:
|
||||
if isinstance(event, list) and len(event) >= 3:
|
||||
timestamp = event[0]
|
||||
label = event[2]
|
||||
display = f"{label} @ {timestamp:.3f}"
|
||||
event_entries.append(display)
|
||||
|
||||
self.combo_events.clear()
|
||||
self.combo_events.addItems(event_entries)
|
||||
self.combo_events.setEnabled(bool(event_entries))
|
||||
|
||||
def clear_files(self):
|
||||
self.line_edit_file_a.clear()
|
||||
self.line_edit_file_b.clear()
|
||||
|
||||
def go_action(self):
|
||||
|
||||
file_a = self.line_edit_file_a.text()
|
||||
suffix = "flare"
|
||||
|
||||
if not hasattr(self, "boris_data") or self.combo_events.count() == 0 or self.combo_snirf_events.count() == 0:
|
||||
QMessageBox.warning(self, "Missing data", "Please make sure a BORIS and SNIRF event are selected.")
|
||||
return
|
||||
|
||||
# Extract BORIS anchor
|
||||
try:
|
||||
boris_label, boris_time_str = self.combo_events.currentText().split(" @ ")
|
||||
boris_anchor_time = float(boris_time_str.replace("s", "").strip())
|
||||
except Exception as e:
|
||||
QMessageBox.critical(self, "BORIS Event Error", f"Could not parse BORIS anchor event:\n{e}")
|
||||
return
|
||||
|
||||
# Extract SNIRF anchor
|
||||
try:
|
||||
snirf_label, snirf_time_str = self.combo_snirf_events.currentText().split(" @ ")
|
||||
snirf_anchor_time = float(snirf_time_str.replace("s", "").strip())
|
||||
except Exception as e:
|
||||
QMessageBox.critical(self, "SNIRF Event Error", f"Could not parse SNIRF anchor event:\n{e}")
|
||||
return
|
||||
|
||||
time_shift = snirf_anchor_time - boris_anchor_time
|
||||
|
||||
selected_obs = self.combo_suffix.currentText()
|
||||
if not selected_obs or selected_obs not in self.boris_data["observations"]:
|
||||
QMessageBox.warning(self, "Invalid selection", "Selected observation not found in BORIS file.")
|
||||
return
|
||||
|
||||
boris_events = self.boris_data["observations"][selected_obs].get("events", [])
|
||||
if not boris_events:
|
||||
QMessageBox.warning(self, "No BORIS events", "No events found in selected BORIS observation.")
|
||||
return
|
||||
|
||||
snirf_path = self.line_edit_file_a.text()
|
||||
if not snirf_path:
|
||||
QMessageBox.warning(self, "No SNIRF file", "Please select a SNIRF file.")
|
||||
return
|
||||
|
||||
boris_obs = self.boris_data["observations"][selected_obs]
|
||||
|
||||
# --- Extract videos + delays ---
|
||||
files = boris_obs.get("file", {})
|
||||
offsets = boris_obs.get("media_info", {}).get("offset", {})
|
||||
|
||||
videos = {}
|
||||
for key, path in files.items():
|
||||
if path: # only include videos that exist
|
||||
delay = offsets.get(key, 0.0) # default 0 if missing
|
||||
videos[key] = {"file": path, "delay": delay}
|
||||
|
||||
base_name = os.path.splitext(os.path.basename(file_a))[0]
|
||||
|
||||
if self.mode == EventUpdateMode.WRITE_SNIRF:
|
||||
# Open save dialog for SNIRF
|
||||
base_name = os.path.splitext(os.path.basename(file_a))[0]
|
||||
suggested_name = f"{base_name}_{suffix}.snirf"
|
||||
save_path, _ = QFileDialog.getSaveFileName(
|
||||
self,
|
||||
"Save SNIRF File As",
|
||||
suggested_name,
|
||||
"SNIRF Files (*.snirf)"
|
||||
)
|
||||
if not save_path:
|
||||
print("SNIRF save cancelled.")
|
||||
return
|
||||
if not save_path.lower().endswith(".snirf"):
|
||||
save_path += ".snirf"
|
||||
|
||||
try:
|
||||
raw = read_raw_snirf(file_a, preload=True)
|
||||
|
||||
# --- Align BORIS events to SNIRF ---
|
||||
boris_events = boris_obs.get("events", [])
|
||||
onsets, durations, descriptions = [], [], []
|
||||
open_events = {} # label -> list of start times
|
||||
label_counts = {}
|
||||
used_times = set()
|
||||
sfreq = raw.info['sfreq']
|
||||
min_shift = 1.0 / sfreq
|
||||
max_attempts = 10
|
||||
|
||||
for event in boris_events:
|
||||
if not isinstance(event, list) or len(event) < 3:
|
||||
continue
|
||||
event_time = event[0]
|
||||
label = event[2]
|
||||
count = label_counts.get(label, 0) + 1
|
||||
label_counts[label] = count
|
||||
|
||||
if label not in open_events:
|
||||
open_events[label] = []
|
||||
|
||||
if count % 2 == 1:
|
||||
open_events[label].append(event_time)
|
||||
else:
|
||||
if open_events[label]:
|
||||
start_time = open_events[label].pop(0)
|
||||
duration = event_time - start_time
|
||||
if duration <= 0:
|
||||
continue
|
||||
|
||||
adjusted_time = start_time + time_shift
|
||||
attempts = 0
|
||||
while round(adjusted_time, 6) in used_times and attempts < max_attempts:
|
||||
adjusted_time += min_shift
|
||||
attempts += 1
|
||||
if attempts == max_attempts:
|
||||
continue
|
||||
|
||||
adjusted_time = round(adjusted_time, 6)
|
||||
used_times.add(adjusted_time)
|
||||
onsets.append(adjusted_time)
|
||||
durations.append(duration)
|
||||
descriptions.append(label)
|
||||
# Handle unmatched starts
|
||||
for label, starts in open_events.items():
|
||||
for start_time in starts:
|
||||
adjusted_time = start_time + time_shift
|
||||
attempts = 0
|
||||
while round(adjusted_time, 6) in used_times and attempts < max_attempts:
|
||||
adjusted_time += min_shift
|
||||
attempts += 1
|
||||
if attempts == max_attempts:
|
||||
continue
|
||||
adjusted_time = round(adjusted_time, 6)
|
||||
used_times.add(adjusted_time)
|
||||
onsets.append(adjusted_time)
|
||||
durations.append(0.0)
|
||||
descriptions.append(label)
|
||||
|
||||
new_annotations = Annotations(onset=onsets, duration=durations, description=descriptions)
|
||||
raw.set_annotations(new_annotations)
|
||||
write_raw_snirf(raw, save_path)
|
||||
QMessageBox.information(self, "Success", "SNIRF file updated with aligned BORIS events.")
|
||||
|
||||
except Exception as e:
|
||||
QMessageBox.critical(self, "Error", f"Failed to update SNIRF file:\n{e}")
|
||||
|
||||
elif self.mode == EventUpdateMode.WRITE_JSON:
|
||||
# Open save dialog for JSON
|
||||
base_name = os.path.splitext(os.path.basename(file_a))[0]
|
||||
suggested_name = f"{base_name}_{suffix}_alignment.json"
|
||||
save_path, _ = QFileDialog.getSaveFileName(
|
||||
self,
|
||||
"Save Event Alignment JSON As",
|
||||
suggested_name,
|
||||
"JSON Files (*.json)"
|
||||
)
|
||||
if not save_path:
|
||||
print("JSON save cancelled.")
|
||||
return
|
||||
if not save_path.lower().endswith(".json"):
|
||||
save_path += ".json"
|
||||
|
||||
# Build JSON dict
|
||||
json_data = {
|
||||
"observation": selected_obs,
|
||||
"snirf_anchor": {"label": snirf_label, "time": snirf_anchor_time},
|
||||
"boris_anchor": {"label": boris_label, "time": boris_anchor_time},
|
||||
"time_shift": time_shift,
|
||||
"videos": videos
|
||||
}
|
||||
|
||||
# Write JSON
|
||||
try:
|
||||
with open(save_path, "w", encoding="utf-8") as f:
|
||||
json.dump(json_data, f, indent=4)
|
||||
QMessageBox.information(self, "Success", f"Event alignment saved to:\n{save_path}")
|
||||
except Exception as e:
|
||||
QMessageBox.critical(self, "Error", f"Failed to write JSON:\n{e}")
|
||||
|
||||
|
||||
def update_optode_positions(self, file_a, file_b, save_path):
|
||||
|
||||
fiducials = {}
|
||||
ch_positions = {}
|
||||
|
||||
# Read the lines from the optode file
|
||||
with open(file_b, 'r') as f:
|
||||
for line in f:
|
||||
if line.strip():
|
||||
# Split by the semicolon and convert to meters
|
||||
ch_name, coords_str = line.split(":")
|
||||
coords = np.array(list(map(float, coords_str.strip().split()))) * 0.001
|
||||
|
||||
# The key we have is a fiducial
|
||||
if ch_name.lower() in ['lpa', 'nz', 'rpa']:
|
||||
fiducials[ch_name.lower()] = coords
|
||||
|
||||
# The key we have is a source or detector
|
||||
else:
|
||||
ch_positions[ch_name.upper()] = coords
|
||||
|
||||
# Create montage with updated coords in head space
|
||||
initial_montage = make_dig_montage(ch_pos=ch_positions, nasion=fiducials.get('nz'), lpa=fiducials.get('lpa'), rpa=fiducials.get('rpa'), coord_frame='head') # type: ignore
|
||||
|
||||
# Read the SNIRF file, set the montage, and write it back
|
||||
# TODO: Bad! read_raw_snirf doesnt release memory properly! Should be spawned in a seperate process and killed once completed
|
||||
raw = read_raw_snirf(file_a, preload=True)
|
||||
raw.set_montage(initial_montage)
|
||||
write_raw_snirf(raw, save_path)
|
||||
|
||||
|
||||
def _apply_events_to_snirf(self, raw, new_annotations, save_path):
|
||||
raw.set_annotations(new_annotations)
|
||||
write_raw_snirf(raw, save_path)
|
||||
|
||||
def _write_event_mapping_json(
|
||||
self,
|
||||
file_a,
|
||||
file_b,
|
||||
selected_obs,
|
||||
snirf_anchor,
|
||||
boris_anchor,
|
||||
time_shift,
|
||||
mapped_events,
|
||||
save_path
|
||||
):
|
||||
|
||||
payload = {
|
||||
"source": {
|
||||
"called_from": self.caller,
|
||||
"snirf_file": os.path.basename(file_a),
|
||||
"boris_file": os.path.basename(file_b),
|
||||
"observation": selected_obs
|
||||
},
|
||||
"alignment": {
|
||||
"snirf_anchor": snirf_anchor,
|
||||
"boris_anchor": boris_anchor,
|
||||
"time_shift_seconds": time_shift
|
||||
},
|
||||
"events": mapped_events,
|
||||
"created_at": datetime.utcnow().isoformat() + "Z"
|
||||
}
|
||||
|
||||
with open(save_path, "w", encoding="utf-8") as f:
|
||||
json.dump(payload, f, indent=2)
|
||||
|
||||
return save_path
|
||||
|
||||
|
||||
|
||||
class UpdateEventsBlazesWindow(QWidget):
|
||||
|
||||
def __init__(self, parent=None, mode=EventUpdateMode.WRITE_SNIRF, caller=None):
|
||||
super().__init__(parent, Qt.WindowType.Window)
|
||||
|
||||
self.mode = mode
|
||||
self.caller = caller or self.__class__.__name__
|
||||
self.setWindowTitle("Update event markers (BLAZES)")
|
||||
self.resize(760, 200)
|
||||
|
||||
self.label_file_a = QLabel("SNIRF file:")
|
||||
self.line_edit_file_a = QLineEdit()
|
||||
self.line_edit_file_a.setReadOnly(True)
|
||||
self.btn_browse_a = QPushButton("Browse .snirf")
|
||||
self.btn_browse_a.clicked.connect(self.browse_file_a)
|
||||
|
||||
self.label_file_b = QLabel("BLAZES file:")
|
||||
self.line_edit_file_b = QLineEdit()
|
||||
self.line_edit_file_b.setReadOnly(True)
|
||||
self.btn_browse_b = QPushButton("Browse .blaze")
|
||||
self.btn_browse_b.clicked.connect(self.browse_file_b)
|
||||
|
||||
self.label_events = QLabel("Events in selected blazes file:")
|
||||
self.combo_events = QComboBox()
|
||||
self.combo_events.setEnabled(False)
|
||||
|
||||
self.label_snirf_events = QLabel("Events in SNIRF file:")
|
||||
self.combo_snirf_events = QComboBox()
|
||||
self.combo_snirf_events.setEnabled(False)
|
||||
|
||||
self.btn_clear = QPushButton("Clear")
|
||||
self.btn_go = QPushButton("Go")
|
||||
self.btn_clear.clicked.connect(self.clear_files)
|
||||
self.btn_go.clicked.connect(self.go_action)
|
||||
|
||||
# ---
|
||||
layout = QVBoxLayout()
|
||||
self.description = QLabel()
|
||||
self.description.setTextFormat(Qt.TextFormat.RichText)
|
||||
self.description.setTextInteractionFlags(Qt.TextInteractionFlag.TextBrowserInteraction)
|
||||
self.description.setOpenExternalLinks(True)
|
||||
|
||||
self.description.setText("The events that are present in a snirf file may not be the events that are to be studied and examined.<br>"
|
||||
"Utilizing different software and video recordings, it is easy enough to see when an action actually occured in a file.<br>"
|
||||
"The software <a href='https://git.research.dezeeuw.ca/tyler/blazes/'>BLAZES</a> is used to create these events in video files, and these events can be applied to the snirf file <br>"
|
||||
"selected below by time syncing it to an event that it shares with the snirf file.")
|
||||
|
||||
layout.addWidget(self.description)
|
||||
|
||||
help_text_a = "Select the SNIRF (.snirf) file to update with new event markers."
|
||||
|
||||
file_a_layout = QHBoxLayout()
|
||||
|
||||
# Help button on the left
|
||||
help_btn_a = QPushButton("?")
|
||||
help_btn_a.setFixedWidth(25)
|
||||
help_btn_a.setToolTip(help_text_a)
|
||||
help_btn_a.clicked.connect(lambda _, text=help_text_a: self.show_help_popup(text))
|
||||
file_a_layout.addWidget(help_btn_a)
|
||||
|
||||
# Container for label + line_edit + browse button with tooltip
|
||||
file_a_container = QWidget()
|
||||
file_a_container_layout = QHBoxLayout()
|
||||
file_a_container_layout.setContentsMargins(0, 0, 0, 0)
|
||||
file_a_container_layout.addWidget(self.label_file_a)
|
||||
file_a_container_layout.addWidget(self.line_edit_file_a)
|
||||
file_a_container_layout.addWidget(self.btn_browse_a)
|
||||
file_a_container.setLayout(file_a_container_layout)
|
||||
file_a_container.setToolTip(help_text_a)
|
||||
|
||||
file_a_layout.addWidget(file_a_container)
|
||||
layout.addLayout(file_a_layout)
|
||||
|
||||
help_text_b = "Provide a .blaze output file that contains events for this participant."
|
||||
|
||||
file_b_layout = QHBoxLayout()
|
||||
|
||||
help_btn_b = QPushButton("?")
|
||||
help_btn_b.setFixedWidth(25)
|
||||
help_btn_b.setToolTip(help_text_b)
|
||||
help_btn_b.clicked.connect(lambda _, text=help_text_b: self.show_help_popup(text))
|
||||
file_b_layout.addWidget(help_btn_b)
|
||||
|
||||
file_b_container = QWidget()
|
||||
file_b_container_layout = QHBoxLayout()
|
||||
file_b_container_layout.setContentsMargins(0, 0, 0, 0)
|
||||
file_b_container_layout.addWidget(self.label_file_b)
|
||||
file_b_container_layout.addWidget(self.line_edit_file_b)
|
||||
file_b_container_layout.addWidget(self.btn_browse_b)
|
||||
file_b_container.setLayout(file_b_container_layout)
|
||||
file_b_container.setToolTip(help_text_b)
|
||||
|
||||
file_b_layout.addWidget(file_b_container)
|
||||
layout.addLayout(file_b_layout)
|
||||
|
||||
help_text_suffix = "The events extracted from the blaze file."
|
||||
|
||||
suffix2_layout = QHBoxLayout()
|
||||
|
||||
help_btn_suffix = QPushButton("?")
|
||||
help_btn_suffix.setFixedWidth(25)
|
||||
help_btn_suffix.setToolTip(help_text_suffix)
|
||||
help_btn_suffix.clicked.connect(lambda _, text=help_text_suffix: self.show_help_popup(text))
|
||||
suffix2_layout.addWidget(help_btn_suffix)
|
||||
|
||||
suffix2_container = QWidget()
|
||||
suffix2_container_layout = QHBoxLayout()
|
||||
suffix2_container_layout.setContentsMargins(0, 0, 0, 0)
|
||||
suffix2_container_layout.addWidget(self.label_events)
|
||||
suffix2_container_layout.addWidget(self.combo_events)
|
||||
suffix2_container.setLayout(suffix2_container_layout)
|
||||
suffix2_container.setToolTip(help_text_suffix)
|
||||
|
||||
suffix2_layout.addWidget(suffix2_container)
|
||||
layout.addLayout(suffix2_layout)
|
||||
|
||||
snirf_events_layout = QHBoxLayout()
|
||||
|
||||
help_text_snirf_events = "The event markers extracted from the SNIRF file."
|
||||
help_btn_snirf_events = QPushButton("?")
|
||||
help_btn_snirf_events.setFixedWidth(25)
|
||||
help_btn_snirf_events.setToolTip(help_text_snirf_events)
|
||||
help_btn_snirf_events.clicked.connect(lambda _, text=help_text_snirf_events: self.show_help_popup(text))
|
||||
snirf_events_layout.addWidget(help_btn_snirf_events)
|
||||
|
||||
snirf_events_container = QWidget()
|
||||
snirf_events_container_layout = QHBoxLayout()
|
||||
snirf_events_container_layout.setContentsMargins(0, 0, 0, 0)
|
||||
snirf_events_container_layout.addWidget(self.label_snirf_events)
|
||||
snirf_events_container_layout.addWidget(self.combo_snirf_events)
|
||||
snirf_events_container.setLayout(snirf_events_container_layout)
|
||||
snirf_events_container.setToolTip(help_text_snirf_events)
|
||||
|
||||
snirf_events_layout.addWidget(snirf_events_container)
|
||||
layout.addLayout(snirf_events_layout)
|
||||
|
||||
buttons_layout = QHBoxLayout()
|
||||
buttons_layout.addStretch()
|
||||
buttons_layout.addWidget(self.btn_clear)
|
||||
buttons_layout.addWidget(self.btn_go)
|
||||
layout.addLayout(buttons_layout)
|
||||
|
||||
self.setLayout(layout)
|
||||
|
||||
|
||||
def show_help_popup(self, text):
|
||||
msg = QMessageBox(self)
|
||||
msg.setWindowTitle(f"Parameter Info - {APP_NAME.upper()}")
|
||||
msg.setText(text)
|
||||
msg.exec()
|
||||
|
||||
def browse_file_a(self):
|
||||
file_path, _ = QFileDialog.getOpenFileName(self, "Select SNIRF File", "", "SNIRF Files (*.snirf)")
|
||||
if file_path:
|
||||
self.line_edit_file_a.setText(file_path)
|
||||
try:
|
||||
# TODO: Bad! read_raw_snirf doesnt release memory properly! Should be spawned in a seperate process and killed once completed
|
||||
raw = read_raw_snirf(file_path, preload=False)
|
||||
annotations = raw.annotations
|
||||
|
||||
# Build individual event entries
|
||||
event_entries = []
|
||||
for onset, description in zip(annotations.onset, annotations.description):
|
||||
event_str = f"{description} @ {onset:.3f}s"
|
||||
event_entries.append(event_str)
|
||||
|
||||
if not event_entries:
|
||||
QMessageBox.information(self, "No Events", "No events found in SNIRF file.")
|
||||
self.combo_snirf_events.clear()
|
||||
self.combo_snirf_events.setEnabled(False)
|
||||
return
|
||||
|
||||
self.combo_snirf_events.clear()
|
||||
self.combo_snirf_events.addItems(event_entries)
|
||||
self.combo_snirf_events.setEnabled(True)
|
||||
|
||||
except Exception as e:
|
||||
QMessageBox.warning(self, "Error", f"Could not read SNIRF file with MNE:\n{str(e)}")
|
||||
self.combo_snirf_events.clear()
|
||||
self.combo_snirf_events.setEnabled(False)
|
||||
|
||||
def browse_file_b(self):
|
||||
file_path, _ = QFileDialog.getOpenFileName(self, "Select JSON Timeline File", "", "JSON Files (*.json)")
|
||||
if file_path:
|
||||
self.line_edit_file_b.setText(file_path)
|
||||
|
||||
try:
|
||||
with open(file_path, 'r', encoding='utf-8') as f:
|
||||
data = json.load(f)
|
||||
self.json_data = data
|
||||
|
||||
obs_keys = self.extract_json_observation_strings(data)
|
||||
self.combo_events.clear()
|
||||
if obs_keys:
|
||||
self.combo_events.addItems(obs_keys)
|
||||
self.combo_events.setEnabled(True)
|
||||
else:
|
||||
QMessageBox.information(self, "No Events", "No events found in JSON file.")
|
||||
self.combo_events.setEnabled(False)
|
||||
|
||||
except (json.JSONDecodeError, FileNotFoundError, KeyError, TypeError) as e:
|
||||
QMessageBox.warning(self, "Error", f"Failed to parse JSON file:\n{e}")
|
||||
self.combo_events.clear()
|
||||
self.combo_events.setEnabled(False)
|
||||
|
||||
|
||||
def extract_json_observation_strings(self, data):
|
||||
if "events" not in data:
|
||||
raise KeyError("Missing 'events' key in JSON file.")
|
||||
|
||||
event_strings = []
|
||||
|
||||
# The new format is a flat list chronologically ordered
|
||||
for event in data["events"]:
|
||||
track_name = event.get("track_name", "Unknown")
|
||||
onset = event.get("start_sec", 0.0)
|
||||
|
||||
# Formatting to match your SNIRF style: "Event Name @ 0.000s"
|
||||
display_str = f"{track_name} @ {onset:.3f}s"
|
||||
event_strings.append(display_str)
|
||||
|
||||
return event_strings
|
||||
|
||||
|
||||
def clear_files(self):
|
||||
self.line_edit_file_a.clear()
|
||||
self.line_edit_file_b.clear()
|
||||
|
||||
|
||||
def go_action(self):
|
||||
file_a = self.line_edit_file_a.text()
|
||||
file_b = self.line_edit_file_b.text()
|
||||
suffix = APP_NAME
|
||||
|
||||
if not hasattr(self, "json_data") or self.combo_events.count() == 0 or self.combo_snirf_events.count() == 0:
|
||||
QMessageBox.warning(self, "Missing data", "Please make sure a JSON and SNIRF event are selected.")
|
||||
return
|
||||
|
||||
try:
|
||||
json_text = self.combo_events.currentText()
|
||||
_, json_time_str = json_text.split(" @ ")
|
||||
json_anchor_time = float(json_time_str.replace("s", "").strip())
|
||||
except Exception as e:
|
||||
QMessageBox.critical(self, "JSON Event Error", f"Could not parse JSON anchor:\n{e}")
|
||||
return
|
||||
|
||||
try:
|
||||
snirf_text = self.combo_snirf_events.currentText()
|
||||
_, snirf_time_str = snirf_text.split(" @ ")
|
||||
snirf_anchor_time = float(snirf_time_str.replace("s", "").strip())
|
||||
except Exception as e:
|
||||
QMessageBox.critical(self, "SNIRF Event Error", f"Could not parse SNIRF anchor:\n{e}")
|
||||
return
|
||||
|
||||
time_shift = snirf_anchor_time - json_anchor_time
|
||||
|
||||
onsets, durations, descriptions = [], [], []
|
||||
skipped_count = 0
|
||||
|
||||
try:
|
||||
events_list = self.json_data.get("events", [])
|
||||
|
||||
for event in events_list:
|
||||
track_name = event.get("track_name", "Unknown")
|
||||
clean_name = track_name.replace("AI: ", "").strip()
|
||||
|
||||
original_start = event.get("start_sec", 0.0)
|
||||
original_end = event.get("end_sec", original_start)
|
||||
duration = original_end - original_start
|
||||
|
||||
# FILTER: Minimum 0.1s duration
|
||||
if duration < 0.1:
|
||||
skipped_count += 1
|
||||
continue
|
||||
|
||||
# Apply shift
|
||||
adjusted_onset = original_start + time_shift
|
||||
|
||||
onsets.append(round(adjusted_onset, 6))
|
||||
durations.append(round(duration, 6))
|
||||
descriptions.append(clean_name)
|
||||
|
||||
except Exception as e:
|
||||
QMessageBox.critical(self, "Track Error", f"Failed to process tracks: {e}")
|
||||
return
|
||||
|
||||
if not onsets:
|
||||
QMessageBox.warning(self, "No Data", f"No events met the 0.1s threshold. (Skipped {skipped_count})")
|
||||
return
|
||||
|
||||
if self.mode == EventUpdateMode.WRITE_SNIRF:
|
||||
suggested_name = f"{os.path.splitext(os.path.basename(file_a))[0]}_{suffix}.snirf"
|
||||
save_path, _ = QFileDialog.getSaveFileName(self, "Save SNIRF", suggested_name, "SNIRF Files (*.snirf)")
|
||||
|
||||
if not save_path: return
|
||||
if not save_path.lower().endswith(".snirf"): save_path += ".snirf"
|
||||
|
||||
try:
|
||||
raw = read_raw_snirf(file_a, preload=True)
|
||||
|
||||
# Create annotations
|
||||
new_annotations = Annotations(
|
||||
onset=onsets,
|
||||
duration=durations,
|
||||
description=descriptions
|
||||
)
|
||||
|
||||
# Replace existing annotations with the new aligned JSON tracks
|
||||
raw.set_annotations(new_annotations)
|
||||
|
||||
write_raw_snirf(raw, save_path)
|
||||
QMessageBox.information(self, "Success",
|
||||
f"Aligned {len(onsets)} events.\n(Filtered out {skipped_count} short events)")
|
||||
except Exception as e:
|
||||
QMessageBox.critical(self, "Error", f"Failed to update SNIRF file:\n{e}")
|
||||
@@ -0,0 +1,296 @@
|
||||
"""
|
||||
Filename: updateoptodes.py
|
||||
Description: Methods to update optode locations for FLARES
|
||||
|
||||
Author: Tyler de Zeeuw
|
||||
License: GPL-3.0
|
||||
"""
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
import pandas as pd
|
||||
import numpy as np
|
||||
|
||||
from PySide6.QtWidgets import QWidget, QVBoxLayout, QLabel, QHBoxLayout, QMessageBox, QLineEdit, QPushButton, QFileDialog
|
||||
from PySide6.QtCore import Qt
|
||||
|
||||
from mne.io import read_raw_snirf #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)
|
||||
@@ -0,0 +1,41 @@
|
||||
"""
|
||||
Filename: userguide.py
|
||||
Description: User guide for FLARES
|
||||
|
||||
Author: Tyler de Zeeuw
|
||||
License: GPL-3.0
|
||||
"""
|
||||
|
||||
from PySide6.QtWidgets import QWidget, QVBoxLayout, QLabel
|
||||
from PySide6.QtCore import Qt
|
||||
|
||||
from src.shared.shareddata import APP_NAME, PIPELINE_STAGES, WIKI_URL
|
||||
|
||||
|
||||
class UserGuideWindow(QWidget):
|
||||
"""
|
||||
Simple User Guide window displaying basic information on how to use the software.
|
||||
|
||||
Args:
|
||||
parent (QWidget, optional): Parent widget of this window. Defaults to None.
|
||||
"""
|
||||
|
||||
def __init__(self, parent: 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)
|
||||
@@ -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, folding_bypass, json_location):
|
||||
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, group_dict, json_location], True),
|
||||
("Cross-Group Stats Viewer", CrossGroupStatsWidget, [haemo_dict, cha_dict, df_ind_dict, design_matrix_dict, contrast_results_dict, group_dict, json_location], True),
|
||||
("Inter-Group 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], 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("")
|
||||
@@ -0,0 +1,80 @@
|
||||
"""
|
||||
Filename: welcome.py
|
||||
Description: Welcome dialog for FLARES
|
||||
Note: Compliant with pylance strict type checking
|
||||
|
||||
Author: Tyler de Zeeuw
|
||||
License: GPL-3.0
|
||||
"""
|
||||
|
||||
from PySide6.QtWidgets import QTextBrowser, QVBoxLayout, QLabel, QDialog, QHBoxLayout, QPushButton
|
||||
from PySide6.QtGui import QDesktopServices, QIcon
|
||||
from PySide6.QtCore import QUrl
|
||||
from PySide6.QtNetwork import QNetworkAccessManager, QNetworkRequest, QNetworkReply
|
||||
|
||||
from src.shared.shareddata import APP_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)
|
||||
|
||||
# NOTE: might not work on mac and need the icns file
|
||||
logo_label.setPixmap(QIcon(resource_path("icons/main.ico")).pixmap(48, 48))
|
||||
if first:
|
||||
title_label = QLabel(f"<h2>Welcome to {APP_NAME.upper()}!</h2>", self)
|
||||
elif direct:
|
||||
title_label = QLabel(f"<h2>{APP_NAME.upper()} has been sucessfully updated to version {CURRENT_VERSION}!</h2>", self)
|
||||
else:
|
||||
title_label = QLabel(f"<h2>{APP_NAME.upper()} is currently running version {CURRENT_VERSION}.</h2>", self)
|
||||
|
||||
header_layout.addWidget(logo_label)
|
||||
header_layout.addWidget(title_label)
|
||||
header_layout.addStretch()
|
||||
layout.addLayout(header_layout)
|
||||
|
||||
self.text_browser = QTextBrowser(self)
|
||||
self.text_browser.setHtml("<p style='color: gray;'>Loading latest updates from server...</p>")
|
||||
|
||||
# Ensure links open in the default web browser and not in this window
|
||||
self.text_browser.setOpenLinks(False)
|
||||
self.text_browser.anchorClicked.connect(QDesktopServices.openUrl)
|
||||
layout.addWidget(self.text_browser)
|
||||
|
||||
footer_layout = QHBoxLayout()
|
||||
|
||||
ok_button = QPushButton("OK", self)
|
||||
ok_button.setDefault(True)
|
||||
ok_button.clicked.connect(self.accept)
|
||||
|
||||
footer_layout.addStretch()
|
||||
footer_layout.addWidget(ok_button)
|
||||
layout.addLayout(footer_layout)
|
||||
|
||||
# Fetch markdown from the web asynchronously
|
||||
self.network_manager = QNetworkAccessManager(self)
|
||||
self.network_manager.finished.connect(self._on_download_complete)
|
||||
self.network_manager.get(QNetworkRequest(QUrl(CHANGELOG_URL)))
|
||||
|
||||
|
||||
def _on_download_complete(self, reply: 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()
|
||||
@@ -0,0 +1 @@
|
||||
update
|
||||
+13
-2
@@ -16,6 +16,7 @@ import shutil
|
||||
import zipfile
|
||||
import traceback
|
||||
import subprocess
|
||||
import configparser
|
||||
|
||||
# External library imports
|
||||
import psutil
|
||||
@@ -415,13 +416,24 @@ def wait_for_process_to_exit(process_name, timeout=10):
|
||||
return False
|
||||
|
||||
|
||||
def finish_update_if_needed(platform_name, app_name):
|
||||
def finish_update_if_needed(platform_name, app_name, cfg_path):
|
||||
"""
|
||||
Completes a pending application update if '--finish-update' is present in the command-line arguments.
|
||||
"""
|
||||
|
||||
if "--finish-update" in sys.argv:
|
||||
print("Finishing update...")
|
||||
|
||||
update_cfg = configparser.ConfigParser()
|
||||
try:
|
||||
update_cfg.read(cfg_path)
|
||||
update_cfg.set("Options", "show_welcome_dialog", "true")
|
||||
|
||||
with open(cfg_path, "w") as f:
|
||||
update_cfg.write(f)
|
||||
print("Welcome dialog flag successfully reset to 'true' for next run.")
|
||||
except Exception as e:
|
||||
print(f"Warning: Could not update welcome dialog preference flag: {e}")
|
||||
|
||||
if platform_name == 'darwin':
|
||||
app_dir = f'/tmp/{app_name}tempupdate'
|
||||
@@ -519,7 +531,6 @@ def finish_update_if_needed(platform_name, app_name):
|
||||
except Exception as e:
|
||||
print(f"Failed to delete update folder: {e}")
|
||||
|
||||
QMessageBox.information(None, "Update Complete", "The application has been successfully updated.")
|
||||
sys.argv.remove("--finish-update")
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user