diff --git a/.github/workflows/run_tutorial.yaml b/.github/workflows/run_tutorial.yaml index 58b990cc5..88d2ad8ed 100644 --- a/.github/workflows/run_tutorial.yaml +++ b/.github/workflows/run_tutorial.yaml @@ -1,7 +1,7 @@ # Workflow to test Brainstorm source on GitHub-Hosted Linux, Windows and macOS runners # Workflow name -name: Run tutorial (on Brainstorm source) +name: Run tutorial (source) # Parameters env: @@ -17,9 +17,9 @@ on: workflow_dispatch: # Inputs that appear on GitHub inputs: - testname: + tutorialname: type: choice - description: Test to run + description: Tutorial to run options: - tutorial_introduction - tutorial_connectivity @@ -27,7 +27,6 @@ on: - tutorial_ephys - tutorial_epilepsy - tutorial_epileptogenicity - - tutorial_fem_tensors - tutorial_neuromag - tutorial_phantom_ctf - tutorial_phantom_elekta @@ -37,13 +36,11 @@ on: - tutorial_simulations - tutorial_yokogawa required: true - bstusername: - description: Brainstorm username to send email - required: true - default: '' + # In addition to the tutorialname, there are two variables: TEST_TUTORIAL_BSTUSER and TEST_TUTORIAL_BSTPWD + # These variables are created as "secrets" in this repo, and are used to download data and send report by email # Name for each run -run-name: "Run: ${{ github.event.inputs.testname }}" +run-name: "Run: ${{ github.event.inputs.tutorialname }}" jobs: # Ubuntu job @@ -58,27 +55,19 @@ jobs: path: brainstorm3 # Setting Matlab, if done after 2nd checkout, Matlab cannot find brainstorm.m - name: Set up Matlab - uses: matlab-actions/setup-matlab@v1 + uses: matlab-actions/setup-matlab@v2 with: release: ${{ env.MATLAB_VER }} - # Get code to do the testing - - name: Checkout 'bst-tests' in 'bst-tests' - uses: actions/checkout@v3 - with: - repository: brainstorm-tools/bst-tests - ref: 'main' - # TOKEN_BST_TEST is a PAT in secrets in brainstorm3 - # TOKEN was create on rcassani account - token: ${{ secrets.TOKEN_BST_TEST }} - path: bst-tests - # Keep script at same level as brainstorm.m - - name: Create symbolic link for test_brainstorm.m - run: ln -s $GITHUB_WORKSPACE/bst-tests/test_brainstorm.m $GITHUB_WORKSPACE/brainstorm3/test_brainstorm.m + products: > + Optimization_Toolbox + Signal_Processing_Toolbox + Statistics_and_Machine_Learning_Toolbox + Image_Processing_Toolbox # Run testing - name: Run script - uses: matlab-actions/run-command@v1 + uses: matlab-actions/run-command@v2 with: - command: cd("brainstorm3"), brainstorm test_brainstorm.m ${{ github.event.inputs.testname }} ${{ github.event.inputs.bstusername }} local + command: cd("brainstorm3"), brainstorm ./toolbox/script/test_tutorial.m ${{ github.event.inputs.tutorialname }} '' '' ${{ secrets.TEST_TUTORIAL_BSTUSER }} ${{ secrets.TEST_TUTORIAL_BSTPWD }} local startup-options: -nodisplay # macOS job @@ -93,27 +82,19 @@ jobs: path: brainstorm3 # Setting Matlab, if done after 2nd checkout, Matlab cannot find brainstorm.m - name: Set up Matlab - uses: matlab-actions/setup-matlab@v1 + uses: matlab-actions/setup-matlab@v2 with: release: ${{ env.MATLAB_VER }} - # Get code to do the testing - - name: Checkout 'bst-tests' in 'bst-tests' - uses: actions/checkout@v3 - with: - repository: brainstorm-tools/bst-tests - ref: 'main' - # TOKEN_BST_TEST is a PAT in secrets in brainstorm3 - # TOKEN was create on rcassani account - token: ${{ secrets.TOKEN_BST_TEST }} - path: bst-tests - # Keep script at same level as brainstorm.m - - name: Create symbolic link for test_brainstorm.m - run: ln -s $GITHUB_WORKSPACE/bst-tests/test_brainstorm.m $GITHUB_WORKSPACE/brainstorm3/test_brainstorm.m + products: > + Optimization_Toolbox + Signal_Processing_Toolbox + Statistics_and_Machine_Learning_Toolbox + Image_Processing_Toolbox # Run testing - name: Run script - uses: matlab-actions/run-command@v1 + uses: matlab-actions/run-command@v2 with: - command: cd("brainstorm3"), brainstorm test_brainstorm.m ${{ github.event.inputs.testname }} ${{ github.event.inputs.bstusername }} local + command: cd("brainstorm3"), brainstorm ./toolbox/script/test_tutorial.m ${{ github.event.inputs.tutorialname }} '' '' ${{ secrets.TEST_TUTORIAL_BSTUSER }} ${{ secrets.TEST_TUTORIAL_BSTPWD }} local startup-options: -nodisplay # Windows job @@ -128,29 +109,17 @@ jobs: path: brainstorm3 # Setting Matlab, if done after 2nd checkout, Matlab cannot find brainstorm.m - name: Set up Matlab - uses: matlab-actions/setup-matlab@v1 + uses: matlab-actions/setup-matlab@v2 with: release: ${{ env.MATLAB_VER }} - # Get code to do the testing - - name: Checkout 'bst-tests' in 'bst-tests' - uses: actions/checkout@v3 - with: - repository: brainstorm-tools/bst-tests - ref: 'main' - # TOKEN_BST_TEST is a PAT in secrets in brainstorm3 - # TOKEN was create on rcassani account - token: ${{ secrets.TOKEN_BST_TEST }} - path: bst-tests - # Keep script at same level as brainstorm.m - - name: Create symbolic link for test_brainstorm.m - shell: cmd - run: | - mklink %GITHUB_WORKSPACE%\brainstorm3\test_brainstorm.m %GITHUB_WORKSPACE%\bst-tests\test_brainstorm.m - pwd - dir + products: > + Optimization_Toolbox + Signal_Processing_Toolbox + Statistics_and_Machine_Learning_Toolbox + Image_Processing_Toolbox # Run testing - name: Run script - uses: matlab-actions/run-command@v1 + uses: matlab-actions/run-command@v2 with: - command: cd("brainstorm3"), brainstorm test_brainstorm.m ${{ github.event.inputs.testname }} ${{ github.event.inputs.bstusername }} local + command: cd("brainstorm3"), brainstorm .\toolbox\script\test_tutorial.m ${{ github.event.inputs.tutorialname }} '' '' ${{ secrets.TEST_TUTORIAL_BSTUSER }} ${{ secrets.TEST_TUTORIAL_BSTPWD }} local startup-options: -nodisplay diff --git a/.github/workflows/startup_test.yml b/.github/workflows/startup_test.yml new file mode 100644 index 000000000..f768e0396 --- /dev/null +++ b/.github/workflows/startup_test.yml @@ -0,0 +1,65 @@ +# Workflow to perform a minimal startup test of Brainstorm source + +# Workflow name +name: Startup test + +# Environment variables +env: + MATLAB_VER: R2021b # Oldest "b" available (Feb2024) + TMP_ERROR_FILE: tmp_error.txt # Flag file to indicate error + MATLAB_SCRIPT_FILE: scripto.m # Matlab script to handle errors + +# Run manually from GitHub Actions tab, it must be in the default branch +on: + workflow_dispatch: + +# Name for each run +run-name: "Startup test: ${{ github.ref_name }}" +jobs: + Run-Ubuntu: + name: Run on Linux (Ubuntu 20.04) + runs-on: ubuntu-20.04 + steps: + # Get the Brainstorm code to test + - name: Checkout 'brainstorm3' + uses: actions/checkout@v3 + # Setting Matlab + - name: Set up Matlab + uses: matlab-actions/setup-matlab@v1 + with: + release: ${{ env.MATLAB_VER }} + # Create error file and Matlab test script + - name: Create required files + run: | + touch $TMP_ERROR_FILE + echo "function scripto()" > $MATLAB_SCRIPT_FILE + MATLAB_SCRIPT_TEXT="try brainstorm server local; catch ME; disp(getReport(ME)); exit; end; delete('$TMP_ERROR_FILE'); brainstorm stop; exit;" + echo $MATLAB_SCRIPT_TEXT >> $MATLAB_SCRIPT_FILE + cat $MATLAB_SCRIPT_FILE + ls -al + pwd + # Run test script + - name: Run test script + uses: matlab-actions/run-command@v1 + with: + command: scripto() + startup-options: -nodisplay + # Check error file was deleted + - id: startuptest + name: Check error file + continue-on-error: true + run: | + if [ -f "$TMP_ERROR_FILE" ]; then + echo "ERROR: Brainstorm could not start on GitHub runner" + exit 1 + fi + # Actions depending of outcome + - id: succeeded + if: steps.startuptest.outcome == 'success' + run: | + echo "Success action" + - id: failed + if: steps.startuptest.outcome == 'failure' + run: | + echo "Failure action" + exit 1 diff --git a/.gitignore b/.gitignore index c166fc2a6..d40b7279f 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,5 @@ defaults/anatomy/* bin/*/brainstorm3.jar # macOS desktop service store files .DS_Store + +*.log diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 67871fc7c..b8996b33a 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -3,7 +3,7 @@ ## Thank you for contributing to **Brainstorm**! This repository (repo) holds the source code for the [Brainstorm application](https://neuroimage.usc.edu/brainstorm/Introduction). - When contributing, please ***first discuss the change*** you wish to make in one of the three following ways: +When contributing, please ***first discuss the change*** you wish to make in one of the three following ways: - A post in the [Brainstorm forum](https://neuroimage.usc.edu/forums/) (preferred communication method) - A [GitHub issue](https://github.com/brainstorm-tools/brainstorm3/issues) @@ -16,6 +16,17 @@ Contributions to ***this*** repository include: To know other ways in which you can collaborate with Brainstorm, visit the [Contribute](https://neuroimage.usc.edu/brainstorm/Contribute) page. +## MATLAB resources +Brainstorm is developed with [MATLAB](https://www.mathworks.com/products/matlab.html) (and bit of [Java](https://www.java.com/en/) for the GUI). +This is a brief list of resources to get started with MATLAB if you are new or come from a different programming language: +- [Get Started with MATLAB](https://www.mathworks.com/help/matlab/getting-started-with-matlab.html) +- [MATLAB Fundamentals](https://matlabacademy.mathworks.com/details/matlab-fundamentals/mlbe) +- [Introduction to MATLAB for Python Users](https://blogs.mathworks.com/student-lounge/2021/02/19/introduction-to-matlab-for-python-users/) +- [MATLAB for Brain and Cognitive Scientists](https://mitpress.mit.edu/9780262035828/) +- [Brainstorm scripting](https://neuroimage.usc.edu/brainstorm/Tutorials/Scripting) +- [Debug MATLAB Code Files](https://www.mathworks.com/help/matlab/matlab_prog/debugging-process-and-features.html) +- [MATLAB Debugging Tutorial (video)](https://www.youtube.com/watch?v=PdNY9n8lV1Y) + ## Git and GitHub resources Before starting a new contribution, you need to be familiar with [Git](https://git-scm.com/) and [GitHub](https://github.com/) concepts like: ***commit, branch, push, pull, remote, fork, repository***, etc. There are plenty resources online to learn Git and GitHub, for example: - [Git Guide](https://github.com/git-guides/) @@ -92,7 +103,7 @@ See: [Git tools rewriting history](https://git-scm.com/book/en/v2/Git-Tools-Rewr 6. ### **Create a new Pull Request** Once you're **happy** with all the changes that you have done, and you have pushed them to your remote repo, using the GitHub website, create a [Pull Request (PR)](https://docs.github.com/en/github/collaborating-with-pull-requests/proposing-changes-to-your-work-with-pull-requests/creating-a-pull-request) from your **remote branch** to the **master** branch in the official Brainstorm repo. - + > :warning: For greater collaboration, select the option [Allow edits by maintainers](https://docs.github.com/en/github/collaborating-with-pull-requests/working-with-forks/allowing-changes-to-a-pull-request-branch-created-from-a-fork) before creating your PR. This will allow Brainstorm maintainers to add commits to your PR branch before merging it. You can always change this setting later. ![image](https://user-images.githubusercontent.com/8238803/135626746-aaaac892-8c44-494e-a79d-b7195e3b2b5e.png) diff --git a/bin/R2022b/brainstorm3.bat b/bin/R2023a/brainstorm3.bat similarity index 98% rename from bin/R2022b/brainstorm3.bat rename to bin/R2023a/brainstorm3.bat index 2c78ec430..a6ccf9667 100644 --- a/bin/R2022b/brainstorm3.bat +++ b/bin/R2023a/brainstorm3.bat @@ -1,8 +1,8 @@ @ECHO. @SET MATLABROOT= -@SET VER_NAME=R2022b -@SET VER_NUMBER=9.13 -@SET MCR_FOLDER=v913 +@SET VER_NAME=R2023a +@SET VER_NUMBER=9.14 +@SET MCR_FOLDER=R2023a @REM ===== SKIP DETECTION ===== diff --git a/bin/R2022b/brainstorm3.command b/bin/R2023a/brainstorm3.command old mode 100644 new mode 100755 similarity index 71% rename from bin/R2022b/brainstorm3.command rename to bin/R2023a/brainstorm3.command index 61c947984..cbc34761b --- a/bin/R2022b/brainstorm3.command +++ b/bin/R2023a/brainstorm3.command @@ -4,17 +4,17 @@ # brainstorm3.command # # If MATLABROOT argument is specified, the Matlab root path is saved -# in the file ~/.brainstorm/MATLABROOTXX.txt. +# in the file ~/.brainstorm/MATLABROOT_R20YYx.txt # Else, MATLABROOT is read from this file # # AUTHOR: Francois Tadel, 2011-2022 +# Raymundo Cassani, 2024 # Configuration -VER_NAME="2022b" -VER_NUMBER="9.13" -VER_DIR="913" +VER_YEAR_VERSION="2023a" +VER_NAME="R$VER_YEAR_VERSION" MDIR="$HOME/.brainstorm" -MFILE="$MDIR/MATLABROOT$VER_DIR.txt" +MFILE="$MDIR/MATLABROOT_$VER_NAME.txt" ######################################################################### # Detect system type @@ -38,7 +38,7 @@ SH_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" # JAR is in the same folder (Linux) if [ -f "$SH_DIR/brainstorm3.jar" ]; then JAR_FILE=$SH_DIR/brainstorm3.jar -# JAR is 3 levels up (on MacOSX: brainstorm3.app/Contents/MacOS/brainstorm3.command) +# JAR is 3 levels up (on macOS: brainstorm3.app/Contents/MacOS/brainstorm3.command) elif [ -f "$SH_DIR/../../../brainstorm3.jar" ]; then JAR_FILE=$SH_DIR/../../../brainstorm3.jar else @@ -52,14 +52,21 @@ if [ "$1" ]; then # Read the folder from the file elif [ -f $MFILE ]; then MATLABROOT=$(<$MFILE) -# MacOS: Try the default installation folders for Matlab or the MCR -elif [ $SYST == "maci64" ] && [ -d "/Applications/MATLAB/MATLAB_Runtime/v$VER_DIR" ]; then - MATLABROOT="/Applications/MATLAB/MATLAB_Runtime/v$VER_DIR" - echo "MATLAB Runtime library was found in folder:" - echo "$MATLABROOT" +# macOS: Try the default installation folder for Matlab +elif [ $SYST == "maci64" ] && [ -d "/Applications/MATLAB_$VER_NAME.app" ]; then + MATLABROOT="/Applications/MATLAB_$VER_NAME.app" +# macOS: Try the default installation folder for Matlab Runtime +elif [ $SYST == "maci64" ] && [ -d "/Applications/MATLAB/MATLAB_Runtime/$VER_NAME" ]; then + MATLABROOT="/Applications/MATLAB/MATLAB_Runtime/$VER_NAME" +# Linux: Try the default installation folder for Matlab +elif ([ $SYST == "glnx86" ] || [ $SYST == "glnxa64" ]) && [ -d "/usr/local/MATLAB/$VER_NAME" ]; then + MATLABROOT="/usr/local/MATLAB/$VER_NAME" +# Linux: Try the default installation folder for Matlab Runtime +elif ([ $SYST == "glnx86" ] || [ $SYST == "glnxa64" ]) && [ -d "/usr/local/MATLAB/MATLAB_Runtime/$VER_NAME" ]; then + MATLABROOT="/usr/local/MATLAB/MATLAB_Runtime/$VER_NAME" # Run the java file selector else - java -classpath "$JAR_FILE" org.brainstorm.file.SelectMcr$VER_NAME + java -classpath "$JAR_FILE" org.brainstorm.file.SelectMcr$VER_YEAR_VERSION # Read again the folder from the file if [ -f $MFILE ]; then MATLABROOT=$(<$MFILE) @@ -73,17 +80,16 @@ if [ -z "$MATLABROOT" ]; then echo "USAGE: brainstorm3.command " echo " brainstorm3.command " echo " " - echo "MATLABROOT is the installation folder of the Runtime $VER_NUMBER (R$VER_NAME)" - echo "The Matlab Runtime $VER_NUMBER is the library needed to" + echo "MATLABROOT is the installation folder of the Runtime ($VER_NAME)" + echo "The Matlab Runtime $VER_NAME is the library needed to" echo "run executables compiled with Matlab $VER_NAME." echo " " - echo "Examples:" - echo " Linux: /usr/local/MATLAB_Runtime/v$VER_DIR" - echo " Linux: $HOME/MATLAB_Runtime_$VER_NAME" - echo " MacOSX: /Applications/MATLAB/MATLAB_Runtime/v$VER_DIR" + echo "Default Matlab Runtime installation folders:" + echo " Linux: /usr/local/MATLAB_Runtime/$VER_NAME" + echo " macOS: /Applications/MATLAB/MATLAB_Runtime/v$VER_NAME" echo " " echo "MATLABROOT has to be specified only at the first call," - echo "then it is saved in the file ~/.brainstorm/MATLABROOT$VER_DIR.txt" + echo "then it is saved in the file ~/.brainstorm/MATLABROOT_$VER_NAME.txt" echo " " exit 1 # If folder not a valid Matlab root path @@ -110,6 +116,9 @@ fi if [ ! -d "$MDIR" ]; then mkdir $MDIR fi +# Matlab path found +echo "Matlab $VER_NAME found:" +echo "$MATLABROOT" # Save Matlab path in user folder echo "$MATLABROOT" > $MFILE @@ -119,10 +128,10 @@ export JVM_DIR=$MATLABROOT/sys/java/jre/$SYST/jre export JAVA_EXE=$JVM_DIR/bin/java ########################################################################## -# Setting library path for MACOSX +# Setting library path for macOS if [ $SYST == "maci64" ]; then export DYLD_LIBRARY_PATH=$DYLD_LIBRARY_PATH:$MATLABROOT/runtime/maci64:$MATLABROOT/sys/os/maci64:$MATLABROOT/bin/maci64 -# Setting library path for LINUX +# Setting library path for Linux else export PATH=$PATH:$MATLABROOT/runtime/$SYST JAVA_SUBDIR=$(find $MATLABROOT/sys/java/jre -type d | tr '\n' ':') @@ -144,7 +153,7 @@ echo " " # Run Brainstorm "$JAVA_EXE" -jar "$JAR_FILE" "${@:2}" -# Force shell death on MacOSX +# Force shell death on macOS if [ $SYST == "maci64" ]; then exit 0 fi diff --git a/brainstorm.m b/brainstorm.m index 22300d8b1..8b601d2b5 100644 --- a/brainstorm.m +++ b/brainstorm.m @@ -102,6 +102,9 @@ javaaddpath(BstJar); end +% Default anatomy template +TemplateName = 'ICBM152_2023b'; + % Default action : start if (nargin == 0) action = 'start'; @@ -120,17 +123,17 @@ switch action case 'start' bst_set_path(BrainstormHomeDir); - bst_startup(BrainstormHomeDir, 1, BrainstormDbDir); + bst_startup(BrainstormHomeDir, 1, BrainstormDbDir, TemplateName); case 'nogui' bst_set_path(BrainstormHomeDir); - bst_startup(BrainstormHomeDir, 0, BrainstormDbDir); + bst_startup(BrainstormHomeDir, 0, BrainstormDbDir, TemplateName); case 'server' bst_set_path(BrainstormHomeDir); - bst_startup(BrainstormHomeDir, -1, BrainstormDbDir); + bst_startup(BrainstormHomeDir, -1, BrainstormDbDir, TemplateName); case 'autopilot' if ~isappdata(0, 'BrainstormRunning') bst_set_path(BrainstormHomeDir); - bst_startup(BrainstormHomeDir, 2, BrainstormDbDir); + bst_startup(BrainstormHomeDir, 2, BrainstormDbDir, TemplateName); end res = bst_autopilot(varargin{2:end}); case 'digitize' @@ -211,7 +214,7 @@ % Runs Brainstorm normally (asks for brainstorm_db) if ~isappdata(0, 'BrainstormRunning') bst_set_path(BrainstormHomeDir); - bst_startup(BrainstormHomeDir, 1, BrainstormDbDir); + bst_startup(BrainstormHomeDir, 1, BrainstormDbDir, TemplateName); end % Message java_dialog('msgbox', 'Brainstorm will now download additional files needed for the workshop.', 'Workshop'); diff --git a/defaults/eeg/Colin27/channel_ANT_Waveguard_128.mat b/defaults/eeg/Colin27/channel_ANT_Waveguard_128.mat index a288c9dfc..3b39cc12b 100644 Binary files a/defaults/eeg/Colin27/channel_ANT_Waveguard_128.mat and b/defaults/eeg/Colin27/channel_ANT_Waveguard_128.mat differ diff --git a/defaults/eeg/Colin27/channel_ANT_Waveguard_256.mat b/defaults/eeg/Colin27/channel_ANT_Waveguard_256.mat index 21619a302..36b5e807f 100644 Binary files a/defaults/eeg/Colin27/channel_ANT_Waveguard_256.mat and b/defaults/eeg/Colin27/channel_ANT_Waveguard_256.mat differ diff --git a/defaults/eeg/Colin27/channel_ANT_Waveguard_65.mat b/defaults/eeg/Colin27/channel_ANT_Waveguard_65.mat new file mode 100644 index 000000000..3071f5b6b Binary files /dev/null and b/defaults/eeg/Colin27/channel_ANT_Waveguard_65.mat differ diff --git a/defaults/eeg/Colin27/channel_ASA_10-05_343.mat b/defaults/eeg/Colin27/channel_ASA_10-05_343.mat index 84709e53d..84d0afa91 100644 Binary files a/defaults/eeg/Colin27/channel_ASA_10-05_343.mat and b/defaults/eeg/Colin27/channel_ASA_10-05_343.mat differ diff --git a/defaults/eeg/Colin27/channel_BioSemi_160_A01.mat b/defaults/eeg/Colin27/channel_BioSemi_160_A01.mat index 0fc8a4993..cf6e9d83e 100644 Binary files a/defaults/eeg/Colin27/channel_BioSemi_160_A01.mat and b/defaults/eeg/Colin27/channel_BioSemi_160_A01.mat differ diff --git a/defaults/eeg/Colin27/channel_BioSemi_160_A1.mat b/defaults/eeg/Colin27/channel_BioSemi_160_A1.mat index 99a03af27..3dbb6f07d 100644 Binary files a/defaults/eeg/Colin27/channel_BioSemi_160_A1.mat and b/defaults/eeg/Colin27/channel_BioSemi_160_A1.mat differ diff --git a/defaults/eeg/Colin27/channel_BrainProducts_ActiCap_128.mat b/defaults/eeg/Colin27/channel_BrainProducts_ActiCap_128.mat index a1d8916cb..ebdf00fc7 100644 Binary files a/defaults/eeg/Colin27/channel_BrainProducts_ActiCap_128.mat and b/defaults/eeg/Colin27/channel_BrainProducts_ActiCap_128.mat differ diff --git a/defaults/eeg/Colin27/channel_BrainProducts_ActiCap_68.mat b/defaults/eeg/Colin27/channel_BrainProducts_ActiCap_68.mat new file mode 100644 index 000000000..c4ab50847 Binary files /dev/null and b/defaults/eeg/Colin27/channel_BrainProducts_ActiCap_68.mat differ diff --git a/defaults/eeg/Colin27/channel_BrainProducts_ActiCap_97.mat b/defaults/eeg/Colin27/channel_BrainProducts_ActiCap_97.mat index b7bc02080..51a6431e9 100644 Binary files a/defaults/eeg/Colin27/channel_BrainProducts_ActiCap_97.mat and b/defaults/eeg/Colin27/channel_BrainProducts_ActiCap_97.mat differ diff --git a/defaults/eeg/Colin27/channel_BrainProducts_EasyCap_128.mat b/defaults/eeg/Colin27/channel_BrainProducts_EasyCap_128.mat index 670387eec..ce50f6913 100644 Binary files a/defaults/eeg/Colin27/channel_BrainProducts_EasyCap_128.mat and b/defaults/eeg/Colin27/channel_BrainProducts_EasyCap_128.mat differ diff --git a/defaults/eeg/Colin27/channel_BrainProducts_EasyCap_M10.mat b/defaults/eeg/Colin27/channel_BrainProducts_EasyCap_M10.mat index e0e2b22f4..95ebce4c1 100644 Binary files a/defaults/eeg/Colin27/channel_BrainProducts_EasyCap_M10.mat and b/defaults/eeg/Colin27/channel_BrainProducts_EasyCap_M10.mat differ diff --git a/defaults/eeg/Colin27/channel_GSN_HydroCel_256_E001.mat b/defaults/eeg/Colin27/channel_GSN_HydroCel_256_E001.mat index 77b4449e2..d74530bab 100644 Binary files a/defaults/eeg/Colin27/channel_GSN_HydroCel_256_E001.mat and b/defaults/eeg/Colin27/channel_GSN_HydroCel_256_E001.mat differ diff --git a/defaults/eeg/Colin27/channel_GSN_HydroCel_256_E1.mat b/defaults/eeg/Colin27/channel_GSN_HydroCel_256_E1.mat index 80d887171..079044fbf 100644 Binary files a/defaults/eeg/Colin27/channel_GSN_HydroCel_256_E1.mat and b/defaults/eeg/Colin27/channel_GSN_HydroCel_256_E1.mat differ diff --git a/defaults/eeg/Colin27/channel_WearableSensing_DSI_24.mat b/defaults/eeg/Colin27/channel_WearableSensing_DSI_24.mat new file mode 100644 index 000000000..5b54295d1 Binary files /dev/null and b/defaults/eeg/Colin27/channel_WearableSensing_DSI_24.mat differ diff --git a/defaults/eeg/ICBM152/channel_ANT_Waveguard_65.mat b/defaults/eeg/ICBM152/channel_ANT_Waveguard_65.mat new file mode 100644 index 000000000..48603e510 Binary files /dev/null and b/defaults/eeg/ICBM152/channel_ANT_Waveguard_65.mat differ diff --git a/defaults/eeg/ICBM152/channel_BrainProducts_ActiCap_68.mat b/defaults/eeg/ICBM152/channel_BrainProducts_ActiCap_68.mat new file mode 100644 index 000000000..2288cd075 Binary files /dev/null and b/defaults/eeg/ICBM152/channel_BrainProducts_ActiCap_68.mat differ diff --git a/defaults/eeg/ICBM152/channel_WearableSensing_DSI_24.mat b/defaults/eeg/ICBM152/channel_WearableSensing_DSI_24.mat new file mode 100644 index 000000000..04240e1e3 Binary files /dev/null and b/defaults/eeg/ICBM152/channel_WearableSensing_DSI_24.mat differ diff --git a/deploy/RunCompiled_2023a.class b/deploy/RunCompiled_2023a.class new file mode 100644 index 000000000..5ce5775cf Binary files /dev/null and b/deploy/RunCompiled_2023a.class differ diff --git a/deploy/RunCompiled_2023b.class b/deploy/RunCompiled_2023b.class new file mode 100644 index 000000000..e13a55760 Binary files /dev/null and b/deploy/RunCompiled_2023b.class differ diff --git a/deploy/RunCompiled_2024a.class b/deploy/RunCompiled_2024a.class new file mode 100644 index 000000000..94d069f8c Binary files /dev/null and b/deploy/RunCompiled_2024a.class differ diff --git a/deploy/RunCompiled_2024b.class b/deploy/RunCompiled_2024b.class new file mode 100644 index 000000000..32f0e28af Binary files /dev/null and b/deploy/RunCompiled_2024b.class differ diff --git a/deploy/bst_compile.m b/deploy/bst_compile.m index bdf3895eb..af302bcd9 100644 --- a/deploy/bst_compile.m +++ b/deploy/bst_compile.m @@ -52,10 +52,26 @@ function bst_compile(isPlugs) error('You must install the toolboxes "Matlab Compiler" and "Matlab Compiler SDK" to run this function.'); end % Start brainstorm without the GUI -isNogui = ~brainstorm('status'); -if isNogui +wasBstRunning = brainstorm('status'); +if ~wasBstRunning brainstorm nogui end +isGUI = bst_get('isGUI'); +% Delete current default anatomy and download it +templateDir = bst_fullfile(bst_get('BrainstormHomeDir'), 'defaults', 'anatomy'); +if exist(templateDir, 'dir') + brainstorm stop + try + rmdir(templateDir, 's'); + catch + disp(['COMPILE> Error: Could not delete folder: ' templateDir]); + end + if isGUI + brainstorm start + else + brainstorm nogui + end +end % Remove .brainstorm from the path rmpath(bst_get('UserMexDir')); rmpath(bst_get('UserProcessDir')); @@ -154,6 +170,9 @@ function bst_compile(isPlugs) if ~isempty(bst_plugin('GetInstalled', 'cat12')) bst_plugin('LinkCatSpm', 0); end + % Unload FieldTrip and SPM plugins + bst_plugin('Unload', 'fieldtrip'); + bst_plugin('Unload', 'spm12'); % Extract functions to compile from SPM and Fieldtrip bst_spmtrip(SpmDir, FieldTripDir, spmtripDir); % Add to Matlab path @@ -258,13 +277,15 @@ function bst_compile(isPlugs) delete(bstJar); end if ispc - cmdSeparator = '&'; + cdCall = 'cd /d'; + cmdSeparator = '&&'; jarExePath = '\bin\jar.exe'; -else +else + cdCall = 'cd'; cmdSeparator = ';'; jarExePath = '/bin/jar'; end -system(['cd "' jarDir '" ' cmdSeparator ' "' JdkDir, jarExePath '" cmf manifest.txt "' bstJar '" bst_javabuilder_' ReleaseName(2:end) ' org com']); +system([cdCall ' "' jarDir '" ' cmdSeparator ' "' JdkDir, jarExePath '" cmf manifest.txt "' bstJar '" bst_javabuilder_' ReleaseName(2:end) ' org com']); diff --git a/deploy/bst_deploy.m b/deploy/bst_deploy.m index 813edb0a9..1c0b60e07 100644 --- a/deploy/bst_deploy.m +++ b/deploy/bst_deploy.m @@ -31,20 +31,22 @@ function bst_deploy(GitDir, GitExe) % =============================================================================@ % % Authors: Francois Tadel, 2011-2021 +% Raymundo Cassani, 2024 %% ===== CONFIGURATION ===== -% Default GIT directory (windows only) -if ~ispc - GitDir = []; - GitExe = []; -else +% Default GIT directory +if ispc if (nargin < 1) GitDir = 'C:\Work\Dev\brainstorm_git\brainstorm3'; end if (nargin < 2) GitExe = 'C:\Program Files\Git\cmd\git-gui.exe'; end +else + if (nargin < 1) + GitDir = '/home/work/GitHub/brainstorm3'; + end end @@ -109,8 +111,8 @@ function bst_deploy(GitDir, GitExe) % Get all the Brainstorm subdirectories splitPath = cat(2, ... {bstDir}, ... - str_split(genpath(fullfile(bstDir, 'toolbox')), ';'), ... - str_split(genpath(fullfile(bstDir, 'deploy')), ';')); + str_split(genpath(fullfile(bstDir, 'toolbox')), pathsep), ... + str_split(genpath(fullfile(bstDir, 'deploy')), pathsep)); % Initialize line counts nFiles = 0; nCode = 0; @@ -131,30 +133,43 @@ function bst_deploy(GitDir, GitExe) % Count files and lines [tmpComment, tmpCode] = CountLines(strFile, autoComment); nFiles = nFiles + 1; - nCode = nCode + tmpComment; - nComment = nComment + tmpCode; + nCode = nCode + tmpCode; + nComment = nComment + tmpComment; end end disp('DEPLOY> Statistics:'); -disp(['DEPLOY> - Number of files : ' num2str(nFiles)]); -disp(['DEPLOY> - Lines of code : ' num2str(nCode)]); -disp(['DEPLOY> - Lines of comment : ' num2str(nComment)]); +disp(['DEPLOY> - Number of files : ' regexprep(num2str(nFiles, '%07d'), '(?<=^0*)0', ' ')]); +disp(['DEPLOY> - Lines of code : ' regexprep(num2str(nCode, '%07d'), '(?<=^0*)0', ' ')]); +disp(['DEPLOY> - Lines of comment : ' regexprep(num2str(nComment, '%07d'), '(?<=^0*)0', ' ')]); %% ===== COPY TO GIT FOLDER ===== % Copy all the subfolders if ~isempty(GitDir) disp('DEPLOY> Copying to GIT folder...'); - system(['xcopy ' fullfile(bstDir, 'brainstorm.m') ' ' fullfile(GitDir, 'brainstorm.m') '/y /q']); - system(['xcopy ' fullfile(bstDir, 'defaults', 'eeg') ' ' fullfile(GitDir, 'defaults', 'eeg') ' /s /e /y /q']); - system(['xcopy ' fullfile(bstDir, 'defaults', 'meg') ' ' fullfile(GitDir, 'defaults', 'meg') ' /s /e /y /q']); - system(['xcopy ' fullfile(bstDir, 'deploy') ' ' fullfile(GitDir, 'deploy') ' /s /e /y /q']); - system(['xcopy ' fullfile(bstDir, 'doc') ' ' fullfile(GitDir, 'doc') ' /s /e /y /q']); - system(['xcopy ' fullfile(bstDir, 'external') ' ' fullfile(GitDir, 'external') ' /s /e /y /q']); - % system(['xcopy ' fullfile(bstDir, 'java') ' ' fullfile(GitDir, 'java') ' /s /e /y /q']); - system(['xcopy ' fullfile(bstDir, 'toolbox') ' ' fullfile(GitDir, 'toolbox') ' /s /e /y /q']); - % Start GIT GUI in the deployment folder - system(['start /b cmd /c ""' GitExe '" --working-dir "' GitDir '""']); + if ispc + system(['xcopy ' fullfile(bstDir, 'brainstorm.m') ' ' fullfile(GitDir, 'brainstorm.m') ' /y /q']); + system(['xcopy ' fullfile(bstDir, 'defaults', 'eeg') ' ' fullfile(GitDir, 'defaults', 'eeg') ' /s /e /y /q']); + system(['xcopy ' fullfile(bstDir, 'defaults', 'meg') ' ' fullfile(GitDir, 'defaults', 'meg') ' /s /e /y /q']); + system(['xcopy ' fullfile(bstDir, 'deploy') ' ' fullfile(GitDir, 'deploy') ' /s /e /y /q']); + system(['xcopy ' fullfile(bstDir, 'doc') ' ' fullfile(GitDir, 'doc') ' /s /e /y /q']); + system(['xcopy ' fullfile(bstDir, 'external') ' ' fullfile(GitDir, 'external') ' /s /e /y /q']); + % system(['xcopy ' fullfile(bstDir, 'java') ' ' fullfile(GitDir, 'java') ' /s /e /y /q']); + system(['xcopy ' fullfile(bstDir, 'toolbox') ' ' fullfile(GitDir, 'toolbox') ' /s /e /y /q']); + % Start GIT GUI in the deployment folder + system(['start /b cmd /c ""' GitExe '" --working-dir "' GitDir '""']); + else + system(['rsync ' fullfile(bstDir, 'brainstorm.m') ' ' fullfile(GitDir) ' -q']); + system(['rsync ' fullfile(bstDir, 'defaults', 'eeg') ' ' fullfile(GitDir, 'defaults') ' -rq']); + system(['rsync ' fullfile(bstDir, 'defaults', 'meg') ' ' fullfile(GitDir, 'defaults') ' -rq']); + system(['rsync ' fullfile(bstDir, 'deploy') ' ' fullfile(GitDir) ' -rq']); + system(['rsync ' fullfile(bstDir, 'doc') ' ' fullfile(GitDir) ' -rq']); + system(['rsync ' fullfile(bstDir, 'external') ' ' fullfile(GitDir) ' -rq']); + % system(['rsync ' fullfile(bstDir, 'java') ' ' fullfile(GitDir) ' -rq']); + system(['rsync ' fullfile(bstDir, 'toolbox') ' ' fullfile(GitDir) ' -rq']); + % Start GIT GUI in the deployment folder + system(['bash -c ''cd ' GitDir '; git gui &''']); + end end % Close Brainstorm (if it was started in this script) @@ -219,8 +234,8 @@ function writeAsciiFile(filename, fContents) return; end iStop = iStop(1); - % If no change: exit - if strcmp(strNew, fContents(iStart:iStop-1)) + % Exit if no change (ignore '\r' in line breaks) + if strcmp(strrep(strNew, char(13), ''), strrep(fContents(iStart:iStop-1), char(13), '')) return; end % Replace file block with new one diff --git a/deploy/bst_spmtrip.m b/deploy/bst_spmtrip.m index 5dd933b40..78415b669 100644 --- a/deploy/bst_spmtrip.m +++ b/deploy/bst_spmtrip.m @@ -53,10 +53,12 @@ function bst_spmtrip(SpmDir, FieldTripDir, OutputDir) % ===== SPM STANDALONE ===== if ~exist(fullfile(SpmDir, 'Contents.txt'), 'file') || ~exist(fullfile(fileparts(SpmDir), 'standalone'), 'file') + bst_plugin('Load', 'spm12'); disp('SPMTRIP> Compiling SPM...'); spm eeg; spm quit; spm_make_standalone(); + bst_plugin('Unload', 'spm12'); end % ===== REQUIRED FUNCTIONS ===== @@ -258,7 +260,7 @@ function bst_spmtrip(SpmDir, FieldTripDir, OutputDir) rmpath(genpath(OutputDir)); warning on % Initalize FieldTrip -addpath(FieldTripDir); +bst_plugin('Load', 'fieldtrip'); ft_defaults; if ~exist('contains', 'builtin') addpath(fullfile(FieldTripDir, 'compat', 'matlablt2016b')); diff --git a/doc/license.html b/doc/license.html index c60805796..5f16fb6d5 100644 --- a/doc/license.html +++ b/doc/license.html @@ -5,8 +5,8 @@

THERE IS NO UNDO BUTTON!
SET UP A BACKUP OF YOUR DATABASE


-Version: 3.230919 (19-Sep-2023)
-COPYRIGHT © 2000-2023 +Version: 3.250107 (07-Jan-2025)
+COPYRIGHT © 2000-2025 USC & McGill University.

Reference to cite

diff --git a/doc/logo_splash.gif b/doc/logo_splash.gif index 6ba1ae443..e6de3f5c3 100644 Binary files a/doc/logo_splash.gif and b/doc/logo_splash.gif differ diff --git a/doc/plugins/brainsuite_logo.png b/doc/plugins/brainsuite_logo.png new file mode 100644 index 000000000..16cb37001 Binary files /dev/null and b/doc/plugins/brainsuite_logo.png differ diff --git a/doc/plugins/mtrf_logo.gif b/doc/plugins/mtrf_logo.gif new file mode 100644 index 000000000..d2ab0c485 Binary files /dev/null and b/doc/plugins/mtrf_logo.gif differ diff --git a/doc/plugins/neuromaps_logo.png b/doc/plugins/neuromaps_logo.png new file mode 100644 index 000000000..872b17737 Binary files /dev/null and b/doc/plugins/neuromaps_logo.png differ diff --git a/doc/plugins/zeffiro_logo.png b/doc/plugins/zeffiro_logo.png new file mode 100644 index 000000000..45914a756 Binary files /dev/null and b/doc/plugins/zeffiro_logo.png differ diff --git a/doc/updates.txt b/doc/updates.txt index 307b2c79b..f5d6a924a 100644 --- a/doc/updates.txt +++ b/doc/updates.txt @@ -1,3 +1,132 @@ +December 2024 +- Anatomy: Copy/Paste scout operations +- Anatomy: Scalp scouts from sensor positions +- Distrib: Compilation with Matlab 2024a +- Distrib: Compilation with Matlab 2024b +-------------------------------------------------------------- +November 2024 +- SEEG/ECOG: Allow iEEG implantation using MRI and/or CT and/or IsoSurface +- Registration: Manual and auto localization of EEG sensors with 3D scanner mesh +- Registration: Project EEG sensors for default caps in Colin27 def anat +- Anatomy: Add skull stripping in Brainstorm (uses SPM or BrainSuite) +- Anatomy: Add template 'Colin27 4NIRS' (2024) +- IO: Process to merge (channel-wise) raw continue recordings +- Distrib: Update script for epilepsy tutorial, add source estimation with MEM +- Distrib: Script to run the BEst (Brain Entropy in space and time) tutorial +- Plugins: iso2mesh, brain2mesh and xdf now support Apple Silicon +-------------------------------------------------------------- +October 2024 +- Anatomy: Spatial correlations and brain annotations: 'bst-neuromaps' plugin +- Anatomy: Support exporting mixed sources as NIfTI +- Registration: Allow selecting and deleting head points +- TimeFreq: Add model selection to FOOOF and SPRiNT +- IO: Support of EDF files with multiple sampling rates (using FielTrip plugin) +- Bugfix: Importing MEG sensor locations from FieldTrip data +-------------------------------------------------------------- +September 2024 +- Artifacts: ICA, display explaned variance for ICs +- Distrib: Function 'test_tutorial.m' and GitHub workflow 'run_tutorial.yaml' +- Plugins: Bugfix, interaction between SPM and FieldTrip +- Plugins: Bugfix, always read FIFF with functions in 'brainstorm3/external' +- GUI: Show/hide hidden files (Linux, Windows and macOS) +-------------------------------------------------------------- +August 2024 +- Anatomy: Improve volume computation, use 'boundary' +- Coreg: New digitization panel +- SEEG/ECOG: Improvements and bugfix for IEEG panel +- IO: EDF+ support negative gains +- Distrib: Linux and macOS, find Matlab runtime in full Matlab installation +- Distrib: Add deep-brain-activity script +- Distrib: Add deviation-maps tutorial +-------------------------------------------------------------- +July 2024 +- Anatomy: Import output from FreeSurfer recon-all-clinical +- Bugfix: Importing non-Atlas MRI volumes given in MNI space +- Inverse: Process to apply exclusion zone around sensors for volume grids +- Artifacts: New process for automatic detection of artifacts on MEG +- Plugins: Add mTRF-Toolbox and Zeffiro +- Plugins: OpenMEEG and MCXLAB now support Apple Silicon +- Distrib: Menu option to retrieve system information +-------------------------------------------------------------- +June 2024 +- Anatomy: Bugfix, set proper subtype on importing Fibers and FEM +- Viz: Allow combination of resection options in Fig3D +- Viz: MRI viewer contactsheets support overlayed Atlases and Volumes +- Viz: Improved figure positioning for Windows11 +- IO: SNIRF, improve import of Landmarks and Events +- IO: Intan RHS, bugfix, incorrect block selection +- IO: Neuralyn, bugfix reading NSE files, add support for NTT files +- Distrib: Add brain fingerprinting tutorial script and page +- Distrib: Brainstorm compiled with Matlab 2023a +-------------------------------------------------------------- +May 2024 +- Anatomy: Bugfix at selecting proper int type when exporting MRI as NIfTI +- Viz: Improved contactsheets from MRIviewer and 3D slices +- IO: Updated support for Open Ephys +- IO: Support double, complex-float and complex-double .fif data +- Events: Added "Every sample" option for extended2simple conversion +- Plugins: Remove npy-matlab code from Brainstorm, add it as plugin +- Plugins: Allow user registering plugins as supported plugins in Brainstorm +-------------------------------------------------------------- +April 2024 +- Anatomy: Added AAL1 MNI parcellation +- PSD: Added 2D layout visualization +- Artifacts: Detect bad channels: peak-to-peak for continuous data +- Distrib: Update Matlab RunTime path binary in macOS and Linux +-------------------------------------------------------------- +March 2024 +- SEEG/ECOG: Improved contact localization using 3D figures +- SEEG/ECOG: Revamped iEEG panel +- Anatomy: New scout operations (intersect and duplicate) +- IO: Improved support for ITAB files +- IO: Bugfix on importing Curry .pom channel files +- Events: Allow digital mask for events from channel +-------------------------------------------------------------- +February 2024 +- Database: Integration of CT volumes and their options in database explorer +- Database: Links to raw files are updated if disk letter or mount point changes +- IO: Syncronize raw data with common event +-------------------------------------------------------------- +January 2024 +- IO: Import channel-wise events from BrainVision BrainAmp +-------------------------------------------------------------- +December 2023 +- Bugfix: Keep BadTrial flags when merging Studies +- Plugin: Improve handling of processes from installed plugins +- Distrib: Basic support Apple silicon (OsType `mac64arm`) +- IO: Add process Export to file +-------------------------------------------------------------- +November 2023 +- Plugin: CT2MRIREG to perform CT to MRI co-registration +- IO: Import Brainstorm sources +- Plugins: Remove EASYH5 and JSNIRF code from Brainstorm, add them as plugins +- Bugfix: Export EDF+ with UTF-8 encoding +- IO: Export to .xlsx files for Matlab >= R2019a +- IO: Export EEG as Brainsight format +-------------------------------------------------------------- +October 2023 +- Distrib: Compilation with Matlab 2023a/2023b +- Bugfix: Merging events with, and events without 'channels' and 'notes' +- Connectivity: Reorganize different correlation options +- iEEG: Save, Load, Export and Import electrode models +-------------------------------------------------------------- +September 2023 +- Anatomy: Import resection mask from BrainSuite SVReg +- IO: Export raw data as FieldTrip structure +-------------------------------------------------------------- +August 2023 +- Scouts: Add 'power' as scout function +- Reports: Send compact report by email if requested +- Anatomy: Display anatomical atlases on 3D orthogonal slices view +- Connectivity: Reorganize connectivity metrics +- Connectivity: Fix across-trials average of phase metrics +- Distrib: Add workflow to run tutorials GitHub runners +-------------------------------------------------------------- +July 2023 +- Anatomy : Export scouts as FreeSurfer annotations +- New process: Remove evoke response +- Bugfix: (e-phys) Error computing tuning curves +-------------------------------------------------------------- June 2023 - PCA: Major fixes and improvements for Scouts and Flattening unconstrained sources | New tutorial page. Fix 1st PC sign inconsistency across epochs and conditions diff --git a/doc/version.txt b/doc/version.txt index aea2e169c..df76bca69 100644 --- a/doc/version.txt +++ b/doc/version.txt @@ -1,2 +1,2 @@ % Brainstorm -% v. 3.230919 (19-Sep-2023) \ No newline at end of file +% v. 3.250107 (07-Jan-2025) \ No newline at end of file diff --git a/external/easyh5/ChangeLog.txt b/external/easyh5/ChangeLog.txt deleted file mode 100644 index 217fa4cb2..000000000 --- a/external/easyh5/ChangeLog.txt +++ /dev/null @@ -1,23 +0,0 @@ -= Change Log = - -Major updates are marked with a "*" - -== EasyH5 v0.8 (Go - Japanese 5), FangQ == - - 2019-09-30*[104a9ed] add regroup option for loadh5, change regexp, add demo - 2019-09-30*[bb762a8] support saving and restoring non-ascii group and dataset names, like JSONLab - 2019-09-29*[1486603] restore the original position for a grouped item - 2019-09-29*[d420b3d] support data compression, close #2 - 2019-09-29*[04f12ee] transpose data when saving not loading, fix sparse array loading bug - 2019-09-28*[77e0d47] support saving and restoring sparse array, both real and complex, close #3 - 2019-09-28 [f05e305] add helper functions copied from jsonlab - 2019-09-28 [2e385ae] collapse a single numbered group with in the form of ...1 - 2019-09-23*[5e694b8] apply both order tracked and indexed when writing datasets - 2019-09-22 [289d2b6] update readme - 2019-09-22*[b09c80f] now reading data in creation order, fix #1, also reads specified node using rootpath - -== EasyH5 v0.5 (Cinco - Spanish 5), FangQ == - - 2019-09-19 [dc62ed5] update code name and version number - 2019-09-19 [66de6e2] tracking creation order, need to use links to read in loadh5 - 2019-09-19*[69979e4] initial but fully working version diff --git a/external/easyh5/README.md b/external/easyh5/README.md deleted file mode 100644 index f1b7daf31..000000000 --- a/external/easyh5/README.md +++ /dev/null @@ -1,103 +0,0 @@ -# EasyH5 Toolbox - An easy-to-use HDF5 data interface (loadh5 and saveh5) - -* Copyright (C) 2019 Qianqian Fang -* License: GNU General Public License version 3 (GPL v3) or 3-clause BSD license, see LICENSE*.txt -* Version: 0.8 (code name: Go - Japanese 5) -* URL: http://github.com/fangq/easyh5 - -## Overview - -EasyH5 is a fully automated, fast, compact and portable MATLAB object to HDF5 -exporter/importer. It contains two easy-to-use functions - `loadh5.m` and -`saveh5.m`. The `saveh5.m` can handle almost all MATLAB data types, including -structs, struct arrays, cells, cell arrays, real and complex arrays, strings, -and `containers.Map` objects. All other data classes (such as a table, digraph, -etc) can also be stored/loaded seemlessly using an undocumented data serialization -interface (MATLAB only). - -EasyH5 stores complex numerical arrays using a special compound data type in an -HDF5 dataset. The real-part of the data are stored as `Real` and the imaginary -part is stored as the `Imag` component. The `loadh5.m` automatically converts -such data structure to a complex array. Starting from v0.8, EasyH5 also supports -saving and loading sparse arrays using a compound dataset with 2 or 3 -specialized subfields: `SparseArray`, `Real`, and, in the case of a sparse -complex array, `Imag`. The sparse array dimension is stored as an attribute -named `SparseArraySize`, attached with the dataset. Using the `deflate` filter -to save compressed arrays is supported in v0.8 and later. - -Because HDF5 does not directly support 1-D/N-D cell arrays or struct arrays, -EasyH5 converts these data structures into data groups with names in the -following format -``` - ['/hdf5/path/.../varname',num2str(idx1d)] -``` -where `varname` is the variable/field name to the cell/struct array object, -and `idx1d` is the 1-D integer index of the cell/struct array. We also provide -a function, `regrouph5.m` to automatically collapse these group/dataset names -into 1-D cell/struct arrays after loading the data using `loadh5.m`. See examples -below. - -## Installation - -The EasyH5 toolbox can be installed using a single command -``` - addpath('/path/to/easyh5'); -``` -where the `/path/to/easyh5` should be replaced by the unzipped folder -of the toolbox (i.e. the folder containing `loadh5.m/saveh5.m`). - -## Usage - -### `saveh5` - Save a MATLAB struct (array) or cell (array) into an HDF5 file -Save a MATLAB struct (array) or cell (array) into an HDF5 file. - -Example: -``` - a=struct('a',rand(5),'c','string','b',true,'d',2+3i,'e',{'test',[],1:5}); - saveh5(a,'test.h5'); - saveh5(a(1),'test2.h5','rootname',''); - saveh5(a(1),'test2.h5','compression','deflate','compressarraysize',1); -``` -### `loadh5` - Load data in an HDF5 file to a MATLAB structure. -Load data in an HDF5 file to a MATLAB structure. - -Example: -``` - a={rand(2), struct('va',1,'vb','string'), 1+2i}; - saveh5(a,'test.h5'); - a2=loadh5('test.h5') - a3=loadh5('test.h5','regroup',1) - isequaln(a,a3.a) - a4=loadh5('test.h5','/a1') -``` -### `regrouph5` - Processing an HDF5 based data and group indexed datasets into a cell array -Processing a loadh5 restored data and merge "indexed datasets", whose -names start with an ASCII string followed by a contiguous integer -sequence number starting from 1, into a cell array. For example, -datasets {data.a1, data.a2, data.a3} will be merged into a cell/struct -array data.a with 3 elements. - -Example: -``` - a=struct('a1',rand(5),'a2','string','a3',true,'d',2+3i,'e',{'test',[],1:5}); - a(1).a1=0; a(2).a2='test'; - data=regrouph5(a) - saveh5(a,'test.h5'); - rawdata=loadh5('test.h5') - data=regrouph5(rawdata) -``` - -## Known problems -- EasyH5 currently does not support 2D cell and struct arrays -- If a cell name ends with a number, such as `a10={...}`; `regrouph5` can not group the cell correctly -- If a database/group name is longer than 63 characters, it may have the risk of being truncated - -## Contribute to EasyH5 - -Please submit your bug reports, feature requests and questions to the Github Issues page at - -https://github.com/fangq/easyh5/issues - -Please feel free to fork our software, making changes, and submit your revision back -to us via "Pull Requests". EasyH5 is open-source and welcome to your contributions! - diff --git a/external/easyh5/decodevarname.m b/external/easyh5/decodevarname.m deleted file mode 100644 index 66298d2b5..000000000 --- a/external/easyh5/decodevarname.m +++ /dev/null @@ -1,72 +0,0 @@ -function newname = decodevarname(name,varargin) -% -% newname = decodevarname(name) -% -% Decode a hex-encoded variable name (from encodevarname) and restore -% its original form -% -% This function is sensitive to the default charset -% settings in MATLAB, please call feature('DefaultCharacterSet','utf8') -% to set the encoding to UTF-8 before calling this function. -% -% author: Qianqian Fang (q.fang neu.edu) -% -% input: -% name: a string output from encodevarname, which converts the leading non-ascii -% letter into "x0xHH_" and non-ascii letters into "_0xHH_" -% format, where hex key HH stores the ascii (or Unicode) value -% of the character. -% -% output: -% newname: the restored original string -% -% example: -% decodevarname('x0x5F_a') % returns _a -% decodevarname('a_') % returns a_ as it is a valid variable name -% decodevarname('x0xE58F98__0xE9878F_') % returns '变量' -% -% this file is part of EasyH5 Toolbox: https://github.com/fangq/easyh5 -% -% License: GPLv3 or 3-clause BSD license, see https://github.com/fangq/easyh5 for details -% - -newname=name; -isunpack=1; -if(nargin==2 && ~isstruct(varargin{1})) - isunpack=varargin{1}; -elseif(nargin>1) - isunpack=jsonopt('UnpackHex',1,varargin{:}); -end - -if(isunpack) - if(isempty(regexp(name,'0x([0-9a-fA-F]+)_','once'))) - return - end - if(exist('native2unicode','builtin')) - h2u=@hex2unicode; - newname=regexprep(name,'(^x|_){1}0x([0-9a-fA-F]+)_','${h2u($2)}'); - else - pos=regexp(name,'(^x|_){1}0x([0-9a-fA-F]+)_','start'); - pend=regexp(name,'(^x|_){1}0x([0-9a-fA-F]+)_','end'); - if(isempty(pos)) - return; - end - str0=name; - pos0=[0 pend(:)' length(name)]; - newname=''; - for i=1:length(pos) - newname=[newname str0(pos0(i)+1:pos(i)-1) char(hex2dec(str0(pos(i)+3:pend(i)-1)))]; - end - if(pos(end)~=length(name)) - newname=[newname str0(pos0(end-1)+1:pos0(end))]; - end - end -end - -%-------------------------------------------------------------------------- -function str=hex2unicode(hexstr) -val=hex2dec(hexstr); -id=histc(val,[0 2^8 2^16 2^32 2^64]); -type={'uint8','uint16','uint32','uint64'}; -bytes=typecast(cast(val,type{id~=0}),'uint8'); -str=native2unicode(fliplr(bytes(:,1:find(bytes,1,'last')))); diff --git a/external/easyh5/encodevarname.m b/external/easyh5/encodevarname.m deleted file mode 100644 index 5534f8a7b..000000000 --- a/external/easyh5/encodevarname.m +++ /dev/null @@ -1,67 +0,0 @@ -function str = encodevarname(str,varargin) -% -% newname = encodevarname(name) -% -% Encode an invalid variable name using a hex-format for bi-directional -% conversions. - -% This function is sensitive to the default charset -% settings in MATLAB, please call feature('DefaultCharacterSet','utf8') -% to set the encoding to UTF-8 before calling this function. -% -% author: Qianqian Fang (q.fang neu.edu) -% -% input: -% name: a string, can be either a valid or invalid variable name -% -% output: -% newname: a valid variable name by converting the leading non-ascii -% letter into "x0xHH_" and non-ascii letters into "_0xHH_" -% format, where HH is the ascii (or Unicode) value of the -% character. -% -% if the encoded variable name CAN NOT be longer than 63, i.e. -% the maximum variable name specified by namelengthmax, and -% one uses the output of this function as a struct or variable -% name, the name will be trucated at 63. Please consider using -% the name as a containers.Map key, which does not have such -% limit. -% -% example: -% encodevarname('_a') % returns x0x5F_a -% encodevarname('a_') % returns a_ as it is a valid variable name -% encodevarname('变量') % returns 'x0xE58F98__0xE9878F_' -% -% this file is part of EasyH5 Toolbox: https://github.com/fangq/easyh5 -% -% License: GPLv3 or 3-clause BSD license, see https://github.com/fangq/easyh5 for details -% - - if(~isvarname(str(1))) - if(exist('unicode2native','builtin')) - str=regexprep(str,'^([^A-Za-z])','x0x${sprintf(''%X'',unicode2native($1))}_','once'); - else - str=sprintf('x0x%X_%s',char(str(1))+0,str(2:end)); - end - end - if(isvarname(str)) - return; - end - if(exist('unicode2native','builtin')) - str=regexprep(str,'([^0-9A-Za-z_])','_0x${sprintf(''%X'',unicode2native($1))}_'); - else - cpos=regexp(str,'[^0-9A-Za-z_]'); - if(isempty(cpos)) - return; - end - str0=str; - pos0=[0 cpos(:)' length(str)]; - str=''; - for i=1:length(cpos) - str=[str str0(pos0(i)+1:cpos(i)-1) sprintf('_0x%X_',str0(cpos(i))+0)]; - end - if(cpos(end)~=length(str)) - str=[str str0(pos0(end-1)+1:pos0(end))]; - end - end -end \ No newline at end of file diff --git a/external/easyh5/jdatadecode.m b/external/easyh5/jdatadecode.m deleted file mode 100644 index ade9ff9a6..000000000 --- a/external/easyh5/jdatadecode.m +++ /dev/null @@ -1,306 +0,0 @@ -function newdata=jdatadecode(data,varargin) -% -% newdata=jdatadecode(data,opt,...) -% -% Convert all JData object (in the form of a struct array) into an array -% (accepts JData objects loaded from either loadjson/loadubjson or -% jsondecode for MATLAB R2018a or later) -% -% This function implements the JData Specification Draft 2 (Oct. 2019) -% see http://github.com/fangq/jdata for details -% -% authors:Qianqian Fang (q.fang neu.edu) -% -% input: -% data: a struct array. If data contains JData keywords in the first -% level children, these fields are parsed and regrouped into a -% data object (arrays, trees, graphs etc) based on JData -% specification. The JData keywords are -% "_ArrayType_", "_ArraySize_", "_ArrayData_" -% "_ArrayIsSparse_", "_ArrayIsComplex_", -% "_ArrayZipType_", "_ArrayZipSize", "_ArrayZipData_" -% opt: (optional) a list of 'Param',value pairs for additional options -% The supported options include -% Recursive: [1|0] if set to 1, will apply the conversion to -% every child; 0 to disable -% Base64: [0|1] if set to 1, _ArrayZipData_ is assumed to -% be encoded with base64 format and need to be -% decoded first. This is needed for JSON but not -% UBJSON data -% Prefix: ['x0x5F'|'x'] for JData files loaded via loadjson/loadubjson, the -% default JData keyword prefix is 'x0x5F'; if the -% json file is loaded using matlab2018's -% jsondecode(), the prefix is 'x'; this function -% attempts to automatically determine the prefix; -% for octave, the default value is an empty string ''. -% FormatVersion: [2|float]: set the JSONLab output version; -% since v2.0, JSONLab uses JData specification Draft 1 -% for output format, it is incompatible with all -% previous releases; if old output is desired, -% please set FormatVersion to 1 -% -% output: -% newdata: the covnerted data if the input data does contain a JData -% structure; otherwise, the same as the input. -% -% examples: -% obj={[],{'test'},true,struct('sparse',sparse(2,3),'magic',uint8(magic(5)))} -% jdata=jdatadecode(jdataencode(obj)) -% isequaln(obj,jdata) -% -% license: -% BSD or GPL version 3, see LICENSE_{BSD,GPLv3}.txt files for details -% -% -- this function is part of JSONLab toolbox (http://iso2mesh.sf.net/cgi-bin/index.cgi?jsonlab) -% - - newdata=data; - opt=struct; - if(nargin==2) - opt=varargin{1}; - elseif(nargin>2) - opt=varargin2struct(varargin{:}); - end - - %% process non-structure inputs - if(~isstruct(data)) - if(iscell(data)) - newdata=cellfun(@(x) jdatadecode(x,opt),data,'UniformOutput',false); - elseif(isa(data,'containers.Map')) - newdata=containers.Map('KeyType',data.KeyType,'ValueType','any'); - names=data.keys; - for i=1:length(names) - newdata(names{i})=jdatadecode(data(names{i}),opt); - end - end - return; - end - - %% assume the input is a struct below - fn=fieldnames(data); - len=length(data); - needbase64=jsonopt('Base64',0,opt); - format=jsonopt('FormatVersion',2,opt); - if(isoctavemesh) - prefix=jsonopt('Prefix','',opt); - else - prefix=jsonopt('Prefix','x0x5F',opt); - end - if(~isfield(data,N_('_ArrayType_')) && isfield(data,'x_ArrayType_')) - prefix='x'; - opt.prefix='x'; - end - - %% recursively process subfields - if(jsonopt('Recursive',1,opt)==1) - for i=1:length(fn) % depth-first - for j=1:len - if(isstruct(data(j).(fn{i})) || isa(data(j).(fn{i}),'containers.Map')) - newdata(j).(fn{i})=jdatadecode(data(j).(fn{i}),opt); - elseif(iscell(data(j).(fn{i}))) - newdata(j).(fn{i})=cellfun(@(x) jdatadecode(x,opt),newdata(j).(fn{i}),'UniformOutput',false); - end - end - end - end - - %% handle array data - if(isfield(data,N_('_ArrayType_')) && (isfield(data,N_('_ArrayData_')) || isfield(data,N_('_ArrayZipData_')))) - newdata=cell(len,1); - for j=1:len - if(isfield(data,N_('_ArrayZipSize_')) && isfield(data,N_('_ArrayZipData_'))) - zipmethod='zip'; - if(isfield(data,N_('_ArrayZipType_'))) - zipmethod=data(j).(N_('_ArrayZipType_')); - end - if(~isempty(strmatch(zipmethod,{'zlib','gzip','lzma','lzip','lz4','lz4hc'}))) - decompfun=str2func([zipmethod 'decode']); - if(needbase64) - ndata=reshape(typecast(decompfun(base64decode(data(j).(N_('_ArrayZipData_')))),data(j).(N_('_ArrayType_'))),data(j).(N_('_ArrayZipSize_'))(:)'); - else - ndata=reshape(typecast(decompfun(data(j).(N_('_ArrayZipData_'))),data(j).(N_('_ArrayType_'))),data(j).(N_('_ArrayZipSize_'))(:)'); - end - else - error('compression method is not supported'); - end - else - if(iscell(data(j).(N_('_ArrayData_')))) - data(j).(N_('_ArrayData_'))=cell2mat(cellfun(@(x) double(x(:)),data(j).(N_('_ArrayData_')),'uniformoutput',0)).'; - end - ndata=cast(data(j).(N_('_ArrayData_')),char(data(j).(N_('_ArrayType_')))); - end - if(isfield(data,N_('_ArrayZipSize_'))) - ndata=reshape(ndata(:),fliplr(data(j).(N_('_ArrayZipSize_'))(:)')); - ndata=permute(ndata,ndims(ndata):-1:1); - end - iscpx=0; - if(isfield(data,N_('_ArrayIsComplex_'))) - if(data(j).(N_('_ArrayIsComplex_'))) - iscpx=1; - end - end - if(isfield(data,N_('_ArrayIsSparse_')) && data(j).(N_('_ArrayIsSparse_'))) - if(isfield(data,N_('_ArraySize_'))) - dim=double(data(j).(N_('_ArraySize_'))(:)'); - if(iscpx) - ndata(end-1,:)=complex(ndata(end-1,:),ndata(end,:)); - end - if isempty(ndata) - % All-zeros sparse - ndata=sparse(dim(1),prod(dim(2:end))); - elseif dim(1)==1 - % Sparse row vector - ndata=sparse(1,ndata(1,:),ndata(2,:),dim(1),prod(dim(2:end))); - elseif dim(2)==1 - % Sparse column vector - ndata=sparse(ndata(1,:),1,ndata(2,:),dim(1),prod(dim(2:end))); - else - % Generic sparse array. - ndata=sparse(ndata(1,:),ndata(2,:),ndata(3,:),dim(1),prod(dim(2:end))); - end - else - if(iscpx && size(ndata,2)==4) - ndata(3,:)=complex(ndata(3,:),ndata(4,:)); - end - ndata=sparse(ndata(1,:),ndata(2,:),ndata(3,:)); - end - elseif(isfield(data,N_('_ArraySize_'))) - if(iscpx) - ndata=complex(ndata(1,:),ndata(2,:)); - end - if(format>1.9) - data(j).(N_('_ArraySize_'))=data(j).(N_('_ArraySize_'))(end:-1:1); - end - dims=data(j).(N_('_ArraySize_'))(:)'; - ndata=reshape(ndata(:),dims(:)'); - if(format>1.9) - ndata=permute(ndata,ndims(ndata):-1:1); - end - end - newdata{j}=ndata; - end - if(len==1) - newdata=newdata{1}; - end - end - - %% handle table data - if(isfield(data,N_('_TableRecords_'))) - newdata=cell(len,1); - for j=1:len - ndata=data(j).(N_('_TableRecords_')); - if(iscell(ndata)) - rownum=length(ndata); - colnum=length(ndata{1}); - nd=cell(rownum, colnum); - for i1=1:rownum; - for i2=1:colnum - nd{i1,i2}=ndata{i1}{i2}; - end - end - newdata{j}=cell2table(nd); - else - newdata{j}=array2table(ndata); - end - if(isfield(data(j),N_('_TableRows_'))&& ~isempty(data(j).(N_('_TableRows_')))) - newdata{j}.Properties.RowNames=data(j).(N_('_TableRows_'))(:); - end - if(isfield(data(j),N_('_TableCols_')) && ~isempty(data(j).(N_('_TableCols_')))) - newdata{j}.Properties.VariableNames=data(j).(N_('_TableCols_')); - end - end - if(len==1) - newdata=newdata{1}; - end - end - - %% handle map data - if(isfield(data,N_('_MapData_'))) - newdata=cell(len,1); - for j=1:len - key=cell(1,length(data(j).(N_('_MapData_')))); - val=cell(size(key)); - for k=1:length(data(j).(N_('_MapData_'))) - key{k}=data(j).(N_('_MapData_')){k}{1}; - val{k}=jdatadecode(data(j).(N_('_MapData_')){k}{2},opt); - end - ndata=containers.Map(key,val); - newdata{j}=ndata; - end - if(len==1) - newdata=newdata{1}; - end - end - - %% handle graph data - if(isfield(data,N_('_GraphNodes_')) && exist('graph','file') && exist('digraph','file')) - newdata=cell(len,1); - isdirected=1; - for j=1:len - nodedata=data(j).(N_('_GraphNodes_')); - if(isstruct(nodedata)) - nodetable=struct2table(nodedata); - elseif(isa(nodedata,'containers.Map')) - nodetable=[keys(nodedata);values(nodedata)]; - if(strcmp(nodedata.KeyType,'char')) - nodetable=table(nodetable(1,:)',nodetable(2,:)','VariableNames',{'Name','Data'}); - else - nodetable=table(nodetable(2,:)','VariableNames',{'Data'}); - end - else - nodetable=table; - end - - if(isfield(data,N_('_GraphEdges_'))) - edgedata=data(j).(N_('_GraphEdges_')); - elseif(isfield(data,N_('_GraphEdges0_'))) - edgedata=data(j).(N_('_GraphEdges0_')); - isdirected=0; - elseif(isfield(data,N_('_GraphMatrix_'))) - edgedata=jdatadecode(data(j).(N_('_GraphMatrix_')),varargin{:}); - end - - if(exist('edgedata','var')) - if(iscell(edgedata)) - endnodes=edgedata(:,1:2); - endnodes=reshape([endnodes{:}],size(edgedata,1),2); - weight=cell2mat(edgedata(:,3:end)); - edgetable=table(endnodes,[weight.Weight]','VariableNames',{'EndNodes','Weight'}); - - if(isdirected) - newdata{j}=digraph(edgetable,nodetable); - else - newdata{j}=graph(edgetable,nodetable); - end - elseif(ismatrix(edgedata) && isstruct(nodetable)) - newdata{j}=digraph(edgedata,fieldnames(nodetable)); - end - end - end - if(len==1) - newdata=newdata{1}; - end - end - - %% handle bytestream and arbitrary matlab objects - if(isfield(data,N_('_ByteStream_')) && isfield(data,N_('_DataInfo_'))==2) - newdata=cell(len,1); - for j=1:len - if(isfield(data(j).(N_('_DataInfo_')),'MATLABObjectClass')) - if(needbase64) - newdata{j}=getArrayFromByteStream(base64decode(data(j).(N_('_ByteStream_')))); - else - newdata{j}=getArrayFromByteStream(data(j).(N_('_ByteStream_'))); - end - end - end - if(len==1) - newdata=newdata{1}; - end - end - - %% subfunctions - function escaped=N_(str) - escaped=[prefix str]; - end -end diff --git a/external/easyh5/jdataencode.m b/external/easyh5/jdataencode.m deleted file mode 100644 index 348664f61..000000000 --- a/external/easyh5/jdataencode.m +++ /dev/null @@ -1,301 +0,0 @@ -function jdata=jdataencode(data, varargin) -% -% jdata=jdataencode(data) -% or -% jdata=jdataencode(data, options) -% jdata=jdataencode(data, 'Param1',value1, 'Param2',value2,...) -% -% Serialize a MATLAB struct or cell array into a JData-compliant -% structure as defined in the JData spec: http://github.com/fangq/jdata -% -% This function implements the JData Specification Draft 2 (Oct. 2019) -% see http://github.com/fangq/jdata for details -% -% author: Qianqian Fang (q.fang neu.edu) -% -% input: -% data: a structure (array) or cell (array) to be encoded. -% options: (optional) a struct or Param/value pairs for user -% specified options (first in [.|.] is the default) -% Base64: [0|1] if set to 1, _ArrayZipData_ is assumed to -% be encoded with base64 format and need to be -% decoded first. This is needed for JSON but not -% UBJSON data -% Prefix: ['x0x5F'|'x'] for JData files loaded via loadjson/loadubjson, the -% default JData keyword prefix is 'x0x5F'; if the -% json file is loaded using matlab2018's -% jsondecode(), the prefix is 'x'; this function -% attempts to automatically determine the prefix; -% for octave, the default value is an empty string ''. -% UseArrayZipSize: [1|0] if set to 1, _ArrayZipSize_ will be added to -% store the "pre-processed" data dimensions, i.e. -% the original data stored in _ArrayData_, and then flaten -% _ArrayData_ into a row vector using row-major -% order; if set to 0, a 2D _ArrayData_ will be used -% MapAsStruct: [0|1] if set to 1, convert containers.Map into -% struct; otherwise, keep it as map -% Compression: ['zlib'|'gzip','lzma','lz4','lz4hc'] - use zlib method -% to compress data array -% CompressArraySize: [100|int]: only to compress an array if the -% total element count is larger than this number. -% FormatVersion [2|float]: set the JSONLab output version; since -% v2.0, JSONLab uses JData specification Draft 1 -% for output format, it is incompatible with all -% previous releases; if old output is desired, -% please set FormatVersion to 1.9 or earlier. -% -% example: -% jd=jdataencode(struct('a',rand(5)+1i*rand(5),'b',[],'c',sparse(5,5))) -% -% license: -% BSD or GPL version 3, see LICENSE_{BSD,GPLv3}.txt files for details -% -% -- this function is part of JSONLab toolbox (http://iso2mesh.sf.net/cgi-bin/index.cgi?jsonlab) -% - - -if(nargin==0) - help jdataencode - return; -end - -opt=varargin2struct(varargin{:}); -if(isoctavemesh) - opt.prefix=jsonopt('Prefix','',opt); -else - opt.prefix=jsonopt('Prefix',sprintf('x0x%X','_'+0),opt); -end -opt.compression=jsonopt('Compression','',opt); -opt.nestarray=jsonopt('NestArray',0,opt); -opt.formatversion=jsonopt('FormatVersion',2,opt); -opt.compressarraysize=jsonopt('CompressArraySize',100,opt); -opt.base64=jsonopt('Base64',0,opt); -opt.mapasstruct=jsonopt('MapAsStruct',0,opt); -opt.usearrayzipsize=jsonopt('UseArrayZipSize',1,opt); -opt.messagepack=jsonopt('MessagePack',0,opt); - -jdata=obj2jd(data,opt); - -%%------------------------------------------------------------------------- -function newitem=obj2jd(item,varargin) - -if(iscell(item)) - newitem=cell2jd(item,varargin{:}); -elseif(isstruct(item)) - newitem=struct2jd(item,varargin{:}); -elseif(isnumeric(item) || islogical(item)) - newitem=mat2jd(item,varargin{:}); -elseif(ischar(item) || isa(item,'string')) - newitem=mat2jd(item,varargin{:}); -elseif(isa(item,'containers.Map')) - newitem=map2jd(item,varargin{:}); -elseif(isa(item,'categorical')) - newitem=cell2jd(cellstr(item),varargin{:}); -elseif(isa(item,'function_handle')) - newitem=struct2jd(functions(item),varargin{:}); -elseif(isa(item,'table')) - newitem=table2jd(item,varargin{:}); -elseif(isa(item,'digraph') || isa(item,'graph')) - newitem=graph2jd(item,varargin{:}); -elseif(isobject(item)) - newitem=matlabobject2jd(item,varargin{:}); -else - newitem=item; -end - -%%------------------------------------------------------------------------- -function newitem=cell2jd(item,varargin) - -newitem=cellfun(@(x) obj2jd(x, varargin{:}), item, 'UniformOutput',false); - -%%------------------------------------------------------------------------- -function newitem=struct2jd(item,varargin) - -num=numel(item); -if(num>1) % struct array - newitem=obj2jd(num2cell(item),varargin{:}); - try - newitem=cell2mat(newitem); - catch - end -else % a single struct - names=fieldnames(item); - newitem=struct; - for i=1:length(names) - newitem.(names{i})=obj2jd(item.(names{i}),varargin{:}); - end -end - -%%------------------------------------------------------------------------- -function newitem=map2jd(item,varargin) - -names=item.keys; -if(varargin{1}.mapasstruct) % convert a map to struct - newitem=struct; - if(~strcmp(item.KeyType,'char')) - data=num2cell(reshape([names, item.values],length(names),2),2); - for i=1:length(names) - data{i}{2}=obj2jd(data{i}{2},varargin{:}); - end - newitem.(N_('_MapData_',varargin{:}))=data; - else - for i=1:length(names) - newitem.(N_(names{i},varargin{:}))=obj2jd(item(names{i}),varargin{:}); - end - end -else % keep as a map and only encode its values - newitem=containers.Map('KeyType',item.KeyType,'ValueType','any'); - for i=1:length(names) - newitem(names{i})=obj2jd(item(names{i}),varargin{:}); - end -end -%%------------------------------------------------------------------------- -function newitem=mat2jd(item,varargin) - -if(isempty(item) || isa(item,'string') || ischar(item) || varargin{1}.nestarray || ... - ((isvector(item) || ismatrix(item)) && isreal(item) && ~issparse(item))) - newitem=item; - if(~(varargin{1}.messagepack && size(item,1)>1)) - return; - end -end - -zipmethod=varargin{1}.compression; -minsize=varargin{1}.compressarraysize; - -if(isa(item,'logical')) - item=uint8(item); -end - -N=@(x) N_(x,varargin{:}); - -newitem=struct(N('_ArrayType_'),class(item),N('_ArraySize_'),size(item)); - -if(isreal(item)) - if(issparse(item)) - fulldata=full(item(find(item))); - newitem.(N('_ArrayIsSparse_'))=true; - newitem.(N('_ArrayZipSize_'))=[2+(~isvector(item)),length(fulldata)]; - if(isvector(item)) - newitem.(N('_ArrayData_'))=[find(item(:))', fulldata(:)']; - else - [ix,iy]=find(item); - newitem.(N('_ArrayData_'))=[ix(:)' , iy(:)', fulldata(:)']; - end - else - if(varargin{1}.formatversion>1.9) - item=permute(item,ndims(item):-1:1); - end - newitem.(N('_ArrayData_'))=item(:)'; - end -else - newitem.(N('_ArrayIsComplex_'))=true; - if(issparse(item)) - fulldata=full(item(find(item))); - newitem.(N('_ArrayIsSparse_'))=true; - newitem.(N('_ArrayZipSize_'))=[3+(~isvector(item)),length(fulldata)]; - if(isvector(item)) - newitem.(N('_ArrayData_'))=[find(item(:))', real(fulldata(:))', imag(fulldata(:))']; - else - [ix,iy]=find(item); - newitem.(N('_ArrayData_'))=[ix(:)' , iy(:)' , real(fulldata(:))', imag(fulldata(:))']; - end - else - if(varargin{1}.formatversion>1.9) - item=permute(item,ndims(item):-1:1); - end - newitem.(N('_ArrayZipSize_'))=[2,numel(item)]; - newitem.(N('_ArrayData_'))=[real(item(:))', imag(item(:))']; - end -end - -if(varargin{1}.usearrayzipsize==0 && isfield(newitem,N('_ArrayZipSize_'))) - data=newitem.(N('_ArrayData_')); - data=reshape(data,fliplr(newitem.(N('_ArrayZipSize_')))); - newitem.(N('_ArrayData_'))=permute(data,ndims(data):-1:1); - newitem=rmfield(newitem,N('_ArrayZipSize_')); -end - -if(~isempty(zipmethod) && numel(item)>minsize) - compfun=str2func([zipmethod 'encode']); - newitem.(N('_ArrayZipType_'))=lower(zipmethod); - newitem.(N('_ArrayZipSize_'))=size(newitem.(N('_ArrayData_'))); - newitem.(N('_ArrayZipData_'))=compfun(typecast(newitem.(N('_ArrayData_')),'uint8')); - newitem=rmfield(newitem,N('_ArrayData_')); - if(varargin{1}.base64) - newitem.(N('_ArrayZipData_'))=char(base64encode(newitem.(N('_ArrayZipData_')))); - end -end - -if(isfield(newitem,N('_ArrayData_')) && isempty(newitem.(N('_ArrayData_')))) - newitem.(N('_ArrayData_'))=[]; -end - -%%------------------------------------------------------------------------- -function newitem=table2jd(item,varargin) - -newitem=struct; -newitem.(N_('_TableCols_',varargin{:}))=item.Properties.VariableNames; -newitem.(N_('_TableRows_',varargin{:}))=item.Properties.RowNames'; -newitem.(N_('_TableRecords_',varargin{:}))=table2cell(item); - -%%------------------------------------------------------------------------- -function newitem=graph2jd(item,varargin) - -newitem=struct; -nodedata=table2struct(item.Nodes); -if(isfield(nodedata,'Name')) - nodedata=rmfield(nodedata,'Name'); - newitem.(N_('_GraphNodes_',varargin{:}))=containers.Map(item.Nodes.Name,num2cell(nodedata),'UniformValues',false); -else - newitem.(N_('_GraphNodes_',varargin{:}))=containers.Map(1:max(item.Edges.EndNodes(:)),num2cell(nodedata),'UniformValues',false); -end -edgenodes=num2cell(item.Edges.EndNodes); -edgedata=table2struct(item.Edges); -if(isfield(edgedata,'EndNodes')) - edgedata=rmfield(edgedata,'EndNodes'); -end -edgenodes(:,3)=num2cell(edgedata); -if(isa(item,'graph')) - if(strcmp(varargin{1}.prefix,'x')) - newitem.(genvarname('_GraphEdges0_'))=edgenodes; - else - newitem.(encodevarname('_GraphEdges0_'))=edgenodes; - end -else - newitem.(N_('_GraphEdges_',varargin{:}))=edgenodes; -end - -%%------------------------------------------------------------------------- -function newitem=matlabobject2jd(item,varargin) -try - if numel(item) == 0 %empty object - newitem = struct(); - elseif numel(item) == 1 % - newitem = char(item); - else - propertynames = properties(item); - for p = 1:numel(propertynames) - for o = numel(item):-1:1 % aray of objects - newitem(o).(propertynames{p}) = item(o).(propertynames{p}); - end - end - end -catch - newitem=any2jd(item,varargin{:}); -end - -%%------------------------------------------------------------------------- -function newitem=any2jd(item,varargin) - -N=@(x) N_(x,varargin{:}); -newitem.(N('_DataInfo_'))=struct('MATLABObjectClass',class(item),'MATLABObjectSize',size(item)); -newitem.(N('_ByteStream_'))=getByteStreamFromArray(item); % use undocumented matlab function -if(varargin{1}.base64) - newitem.(N('_ByteStream_'))=char(base64encode(newitem.(N('_ByteStream_')))); -end - -%%------------------------------------------------------------------------- -function newname=N_(name,varargin) - -newname=[varargin{1}.prefix name]; diff --git a/external/easyh5/jsonopt.m b/external/easyh5/jsonopt.m deleted file mode 100644 index bd7ef6816..000000000 --- a/external/easyh5/jsonopt.m +++ /dev/null @@ -1,36 +0,0 @@ -function val=jsonopt(key,default,varargin) -% -% val=jsonopt(key,default,optstruct) -% -% setting options based on a struct. The struct can be produced -% by varargin2struct from a list of 'param','value' pairs -% -% authors:Qianqian Fang (q.fang neu.edu) -% -% input: -% key: a string with which one look up a value from a struct -% default: if the key does not exist, return default -% optstruct: a struct where each sub-field is a key -% -% output: -% val: if key exists, val=optstruct.key; otherwise val=default -% -% license: -% BSD or GPL version 3, see LICENSE_{BSD,GPLv3}.txt files for details -% -% -- this function is part of JSONLab toolbox (http://iso2mesh.sf.net/cgi-bin/index.cgi?jsonlab) -% - -val=default; -if(nargin<=2) - return; -end -key0=lower(key); -opt=varargin{1}; -if(isstruct(opt)) - if(isfield(opt,key0)) - val=opt.(key0); - elseif(isfield(opt,key)) - val=opt.(key); - end -end diff --git a/external/easyh5/loadh5.m b/external/easyh5/loadh5.m deleted file mode 100644 index 8ff263421..000000000 --- a/external/easyh5/loadh5.m +++ /dev/null @@ -1,281 +0,0 @@ -function varargout=loadh5(filename, varargin) -% -% [data, meta] = loadh5(filename) -% [data, meta] = loadh5(root_id) -% [data, meta] = loadh5(filename, rootpath) -% [data, meta] = loadh5(filename, rootpath, options) -% [data, meta] = loadh5(filename, options) -% [data, meta] = loadh5(filename, 'Param1',value1, 'Param2',value2,...) -% -% Load data in an HDF5 file to a MATLAB structure. -% -% author: Qianqian Fang (q.fang neu.edu) -% -% input -% filename -% Name of the file to load data from -% root_id: an HDF5 handle (of type 'H5ML.id' in MATLAB) -% rootpath : (optional) -% Root path to read part of the HDF5 file to load -% options: (optional) a struct or Param/value pairs for user specified options -% Order: 'creation' - creation order (default), or 'alphabet' - alphabetic -% Regroup: [0|1]: if 1, call regrouph5() to combine indexed -% groups into a cell array -% PackHex: [1|0]: convert invalid characters in the group/dataset -% names to 0x[hex code] by calling encodevarname.m; -% if set to 0, call getvarname -% ComplexFormat: {'realKey','imagKey'}: use 'realKey' and 'imagKey' -% as possible keywords for the real and the imaginary part -% of a complex array, respectively (sparse arrays not supported); -% a common list of keypairs is used even without this option -% -% output -% data: a structure (array) or cell (array) -% meta: optional output to store the attributes stored in the file -% -% example: -% a={rand(2), struct('va',1,'vb','string'), 1+2i}; -% saveh5(a,'test.h5'); -% a2=loadh5('test.h5') -% a3=loadh5('test.h5','regroup',1) -% isequaln(a,a3.a) -% a4=loadh5('test.h5','/a1') -% -% This function was adapted from h5load.m by Pauli Virtanen -% This file is part of EasyH5 Toolbox: https://github.com/fangq/easyh5 -% -% License: GPLv3 or 3-clause BSD license, see https://github.com/fangq/easyh5 for details -% - -path = ''; -if(bitand(length(varargin),1)==0) - opt=varargin2struct(varargin{:}); -elseif(length(varargin)>=3) - path=varargin{1}; - opt=varargin2struct(varargin{2:end}); -elseif(length(varargin)==1) - path=varargin{1}; -end - -if(isa(filename,'H5ML.id')) - loc=filename; -else - if(exist('h5read','file')) - loc = H5F.open(filename); - else - error('HDF5 is not supported'); - end -end - -opt.rootpath=path; - -if(~(isfield(opt,'complexformat') && iscellstr(opt.complexformat) && numel(opt.complexformat)==2)) - opt.complexformat={'Real','Imag'}; -end - -opt.releaseid=0; -vers=ver('MATLAB'); -if(~isempty(vers)) - opt.releaseid=datenum(vers(1).Date); -end - -if((isfield(opt,'order') && strcmpi(opt.order,'alphabet')) || opt.releaseid1 && ~isempty(path)) - try - rootgid=H5G.open(loc,path); - [varargout{1:nargout}]=load_one(rootgid, opt); - H5G.close(rootgid); - catch - [gname,dname]=fileparts(path); - rootgid=H5G.open(loc,gname); - [status, res]=group_iterate(rootgid,dname,struct('data',struct,'meta',struct,'opt',opt)); - if(nargout>0) - varargout{1}=res.data; - elseif(nargout>1) - varargout{2}=res.meta; - end - H5G.close(rootgid); - end - else - [varargout{1:nargout}]=load_one(loc, opt); - end - H5F.close(loc); -catch ME - H5F.close(loc); - rethrow(ME); -end - -if(jsonopt('Regroup',0,opt)) - if(nargout>=1) - varargout{1}=regrouph5(varargout{1}); - elseif(nargout>=2) - varargout{2}=regrouph5(varargout{2}); - end -end - -if(isfield(opt,'jdata') && opt.jdata && nargout>=1) - varargout{1}=jdatadecode(varargout{1},'Base64',0,opt); -end - -%-------------------------------------------------------------------------- -function [data, meta]=load_one(loc, opt) - -data = struct(); -meta = struct(); -inputdata=struct('data',data,'meta',meta,'opt',opt); - - -% Load groups and datasets -try - [status,count,inputdata] = H5L.iterate(loc,opt.order,'H5_ITER_INC',0,@group_iterate,inputdata); -catch ME - if(strcmp(opt.order,'H5_INDEX_CRT_ORDER')) - [status,count,inputdata] = H5L.iterate(loc,'H5_INDEX_NAME','H5_ITER_INC',0,@group_iterate,inputdata); - else - rethrow(ME); - end -end - -data=inputdata.data; -meta=inputdata.meta; - -%-------------------------------------------------------------------------- -function [status, res]=group_iterate(group_id,objname,inputdata) -status=0; -attr=struct(); - -encodename=jsonopt('PackHex',1,inputdata.opt); - -try - data=inputdata.data; - meta=inputdata.meta; - - % objtype index - info = H5G.get_objinfo(group_id,objname,0); - objtype = info.type; - objtype = objtype+1; - - if objtype == 1 - % Group - name = regexprep(objname, '.*/', ''); - - group_loc = H5G.open(group_id, name); - try - [sub_data, sub_meta] = load_one(group_loc, inputdata.opt); - H5G.close(group_loc); - catch ME - H5G.close(group_loc); - rethrow(ME); - end - if(encodename) - name=encodevarname(name); - else - name=genvarname(name); - end - data.(name) = sub_data; - meta.(name) = sub_meta; - - elseif objtype == 2 - % Dataset - name = regexprep(objname, '.*/', ''); - - dataset_loc = H5D.open(group_id, name); - try - sub_data = H5D.read(dataset_loc, ... - 'H5ML_DEFAULT', 'H5S_ALL','H5S_ALL','H5P_DEFAULT'); - [status, count, attr]=H5A.iterate(dataset_loc, 'H5_INDEX_NAME', 'H5_ITER_INC', 0, @getattribute, attr); - H5D.close(dataset_loc); - catch exc - H5D.close(dataset_loc); - rethrow(exc); - end - - sub_data = fix_data(sub_data, attr, inputdata.opt); - if(encodename) - name=encodevarname(name); - else - name=genvarname(name); - end - data.(name) = sub_data; - meta.(name) = attr; - end -catch ME - rethrow(ME); -end -res=struct('data',data,'meta',meta,'opt',inputdata.opt); - -%-------------------------------------------------------------------------- -function data=fix_data(data, attr, opt) -% Fix some common types of data to more friendly form. - -if isstruct(data) - fields = fieldnames(data); - - if(length(intersect(fields,{'SparseIndex',opt.complexformat{1}}))==2) - if isnumeric(data.SparseIndex) && isnumeric(data.(opt.complexformat{1})) - if(nargin>1 && isstruct(attr)) - if(isfield(attr,'SparseArraySize')) - spd=sparse(1,prod(attr.SparseArraySize)); - if(isfield(data,opt.complexformat{2})) - spd(data.SparseIndex)=complex(data.(opt.complexformat{1}),data.(opt.complexformat{2})); - else - spd(data.SparseIndex)=data.(opt.complexformat{1}); - end - data=reshape(spd,attr.SparseArraySize(:)'); - return; - end - end - end - else - if(numel(opt.complexformat)==2 && length(intersect(fields,opt.complexformat))==2) - if isnumeric(data.(opt.complexformat{1})) && isnumeric(data.(opt.complexformat{2})) - data = data.(opt.complexformat{1}) + 1j*data.(opt.complexformat{2}); - end - else - % if complexformat is not specified or not found, try some common complex number storage formats - if(length(intersect(fields,{'Real','Imag'}))==2) - if isnumeric(data.Real) && isnumeric(data.Imag) - data = data.Real + 1j*data.Imag; - end - elseif(length(intersect(fields,{'real','imag'}))==2) - if isnumeric(data.real) && isnumeric(data.imag) - data = data.real + 1j*data.imag; - end - elseif(length(intersect(fields,{'Re','Im'}))==2) - if isnumeric(data.Re) && isnumeric(data.Im) - data = data.Re + 1j*data.Im; - end - elseif(length(intersect(fields,{'re','im'}))==2) - if isnumeric(data.re) && isnumeric(data.im) - data = data.re + 1j*data.im; - end - elseif(length(intersect(fields,{'r','i'}))==2) - if isnumeric(data.r) && isnumeric(data.i) - data = data.r + 1j*data.i; - end - end - end - end -end - -if(isa(data,'uint8') || isa(data,'int8')) - if(nargin>1 && isstruct(attr)) - if(isfield(attr,'MATLABObjectClass')) - data=getArrayFromByteStream(data); % use undocumented function - end - end -end - -%-------------------------------------------------------------------------- -function [status, dataout]= getattribute(loc_id,attr_name,info,datain) -status=0; -attr_id = H5A.open(loc_id, attr_name, 'H5P_DEFAULT'); -datain.(attr_name) = H5A.read(attr_id, 'H5ML_DEFAULT'); -H5A.close(attr_id); -dataout=datain; diff --git a/external/easyh5/mergestruct.m b/external/easyh5/mergestruct.m deleted file mode 100644 index 3c5208390..000000000 --- a/external/easyh5/mergestruct.m +++ /dev/null @@ -1,33 +0,0 @@ -function s=mergestruct(s1,s2) -% -% s=mergestruct(s1,s2) -% -% merge two struct objects into one -% -% authors:Qianqian Fang (q.fang neu.edu) -% date: 2012/12/22 -% -% input: -% s1,s2: a struct object, s1 and s2 can not be arrays -% -% output: -% s: the merged struct object. fields in s1 and s2 will be combined in s. -% -% license: -% BSD or GPL version 3, see LICENSE_{BSD,GPLv3}.txt files for details -% -% -- this function is part of JSONLab toolbox (http://iso2mesh.sf.net/cgi-bin/index.cgi?jsonlab) -% - -if(~isstruct(s1) || ~isstruct(s2)) - error('input parameters contain non-struct'); -end -if(length(s1)>1 || length(s2)>1) - error('can not merge struct arrays'); -end -fn=fieldnames(s2); -s=s1; -for i=1:length(fn) - s.(fn{i})=s2.(fn{i}); -end - diff --git a/external/easyh5/regrouph5.m b/external/easyh5/regrouph5.m deleted file mode 100644 index 99776f565..000000000 --- a/external/easyh5/regrouph5.m +++ /dev/null @@ -1,134 +0,0 @@ -function data=regrouph5(root, varargin) -% -% data=regrouph5(root) -% or -% data=regrouph5(root,type) -% data=regrouph5(root,{'nameA','nameB',...}) -% -% Processing a loadh5 restored data and merge "indexed datasets", whose -% names start with an ASCII string followed by a contiguous integer -% sequence number starting from 1, into a cell array. For example, -% datasets {data.a1, data.a2, data.a3} will be merged into a cell/struct -% array data.a with 3 elements. -% -% A single subfield .name1 will be renamed as .name. Items with -% non-contigous numbering will not be grouped. If .name and -% .name1/.name2 co-exist in the input struct, no grouping will be done. -% -% The grouped subfield will appear at the position of the first -% pre-grouped item in the original input structure. -% -% author: Qianqian Fang (q.fang neu.edu) -% -% input: -% root: the raw input HDF5 data structure (loaded from loadh5.m) -% type: if type is set as a cell array of strings, it restrict the -% grouping only to the subset of field names in this list; -% if type is a string as 'snirf', it is the same as setting -% type as {'aux','data','nirs','stim','measurementList'}. -% -% output: -% data: a reorganized matlab structure. -% -% example: -% a=struct('a1',rand(5),'a2','string','a3',true,'d',2+3i,'e',{'test',[],1:5}); -% regrouph5(a) -% saveh5(a,'test.h5'); -% rawdata=loadh5('test.h5') -% data=regrouph5(rawdata) -% -% this file is part of EasyH5 Toolbox: https://github.com/fangq/easyh5 -% -% License: GPLv3 or 3-clause BSD license, see https://github.com/fangq/easyh5 for details -% - -if(nargin<1) - help regrouph5; - return; -end - -dict={}; -if(~isempty(varargin)) - if(ischar(varargin{1}) && strcmpi(varargin{1},'snirf')) - dict={'aux','data','nirs','stim','measurementList'}; - elseif(iscell(varargin{1})) - dict=varargin{1}; - end -end - -data=struct; -if(isstruct(root)) - data=repmat(struct,size(root)); - names=fieldnames(root); - newnames=struct(); - firstpos=struct(); - - for i=1:length(names) - item=regexp(names{i},'^(.*\D)(\d+)$','tokens'); - if(~isempty(item) && str2double(item{1}{2})~=0 && ~isfield(root,item{1}{1})) - if(~isfield(newnames,item{1}{1})) - newnames.(item{1}{1})=str2double(item{1}{2}); - else - newnames.(item{1}{1})=[newnames.(item{1}{1}), str2double(item{1}{2})]; - end - if(~isfield(firstpos,item{1}{1})) - firstpos.(item{1}{1})=length(fieldnames(data(1))); - end - else - for j=1:length(root) - if(isstruct(root(j).(names{i}))) - data(j).(names{i})=regrouph5(root(j).(names{i})); - else - data(j).(names{i})=root(j).(names{i}); - end - end - end - end - - names=fieldnames(newnames); - if(~isempty(dict)) - names=intersect(names,dict); - end - - for i=length(names):-1:1 - len=length(newnames.(names{i})); - idx=newnames.(names{i}); - if((min(idx)~=1 || max(idx)~=len) && len~=1) - for j=1:len - dataname=sprintf('%s%d',names{i},idx(j)); - for k=1:length(root) - if(isstruct(root(k).(dataname))) - data(k).(dataname)=regrouph5(root(k).(dataname)); - else - data(k).(dataname)=root(k).(dataname); - end - end - end - pos=firstpos.(names{i}); - len=length(fieldnames(data)); - data=orderfields(data,[1:pos,len,pos+1:len-1]); - continue; - end - for j=1:length(data) - data(j).(names{i})=cell(1,len); - end - idx=sort(idx); - for j=1:len - for k=1:length(root) - obj=root(k).(sprintf('%s%d',names{i},idx(j))); - if(isstruct(obj)) - data(k).(names{i}){j}=regrouph5(obj); - else - data(k).(names{i}){j}=obj; - end - end - end - pos=firstpos.(names{i}); - len=length(fieldnames(data)); - data=orderfields(data,[1:pos,len,pos+1:len-1]); - try - data.(names{i})=cell2mat(data.(names{i})); - catch - end - end -end diff --git a/external/easyh5/saveh5.m b/external/easyh5/saveh5.m deleted file mode 100644 index 31ac10cde..000000000 --- a/external/easyh5/saveh5.m +++ /dev/null @@ -1,377 +0,0 @@ -function saveh5(data, fname, varargin) -% -% saveh5(data, outputfile) -% or -% saveh5(data, outputfile, options) -% saveh5(data, outputfile, 'Param1',value1, 'Param2',value2,...) -% -% Save a MATLAB struct (array) or cell (array) into an HDF5 file -% -% author: Qianqian Fang (q.fang neu.edu) -% -% input: -% data: a structure (array) or cell (array) to be stored. -% fname: the output HDF5 (.h5) file name -% options: (optional) a struct or Param/value pairs for user specified options -% JData [0|1] use JData Specifiation to serialize complex data structures -% such as complex/sparse arrays, tables, maps, graphs etc by -% calling jdataencode before saving data to HDF5 -% RootName: the HDF5 path of the root object. If not given, the -% actual variable name for the data input will be used as -% the root object. The value shall not include '/'. -% UnpackHex [1|0]: convert the 0x[hex code] in variable names -% back to Unicode string using decodevarname.m -% Compression: ['deflate'|''] - use zlib-deflate method -% to compress data array -% CompressArraySize: [100|int]: only to compress an array if the -% total element count is larger than this number. -% CompressLevel: [5|int] - a number between 1-9 to set -% compression level -% Chunk: a size vector or empty - breaking a large array into -% small chunks of size specified by this parameter -% ComplexFormat: {'realKey','imagKey'}: use 'realKey' and 'imagKey' -% as keywords for the real and the imaginary part of a -% complex array, respectively (sparse arrays not supported); -% the default values are {'Real','Imag'} -% -% example: -% a=struct('a',rand(5),'b','string','c',true,'d',2+3i,'e',{'test',[],1:5}); -% saveh5(a,'test.h5'); -% saveh5(a(1),'test2.h5','rootname',''); -% saveh5(a(1),'test2.h5','compression','deflate','compressarraysize',1); -% saveh5(a,'test.h5j','jdata',1); -% -% this file is part of EasyH5 Toolbox: https://github.com/fangq/easyh5 -% -% License: GPLv3 or 3-clause BSD license, see https://github.com/fangq/easyh5 for details -% - - -if(nargin<2) - error('you must provide at least two inputs'); -end - -rootname=['/' inputname(1)]; -opt=struct; - -if(length(varargin)==1 && ischar(varargin{1})) - rootname=[varargin{1} '/' inputname(1)]; -else - opt=varargin2struct(varargin{:}); -end - -opt.compression=jsonopt('Compression','',opt); -opt.compresslevel=jsonopt('CompressLevel',5,opt); -opt.compressarraysize=jsonopt('CompressArraySize',100,opt); -opt.unpackhex=jsonopt('UnpackHex',1,opt); - -opt.releaseid=0; -vers=ver('MATLAB'); -if(~isempty(vers)) - opt.releaseid=datenum(vers(1).Date); -end -opt.skipempty=(opt.releaseid0) - H5F.close(fid); - end - rethrow(ME); -end - -if(~isa(fname,'H5ML.id')) - H5F.close(fid); -end - -%%------------------------------------------------------------------------- -function oid=obj2h5(name, item,handle,level,varargin) - -if(iscell(item)) - oid=cell2h5(name,item,handle,level,varargin{:}); -elseif(isstruct(item)) - oid=struct2h5(name,item,handle,level,varargin{:}); -elseif(ischar(item) || isa(item,'string')) - oid=mat2h5(name,item,handle,level,varargin{:}); -elseif(isa(item,'containers.Map')) - oid=map2h5(name,item,handle,level,varargin{:}); -elseif(isa(item,'categorical')) - oid=cell2h5(name,cellstr(item),handle,level,varargin{:}); -elseif(islogical(item) || isnumeric(item)) - oid=mat2h5(name,item,handle,level,varargin{:}); -else - oid=any2h5(name,item,handle,level,varargin{:}); -end - -%%------------------------------------------------------------------------- -function oid=idxobj2h5(name, idx, varargin) -oid=obj2h5(sprintf('%s%d',name,idx), varargin{:}); - -%%------------------------------------------------------------------------- -function oid=cell2h5(name, item,handle,level,varargin) - -num=numel(item); -if(num>1) - idx=reshape(1:num,size(item)); - idx=num2cell(idx); - oid=cellfun(@(x,id) idxobj2h5(name, id, x, handle,level,varargin{:}), item, idx, 'UniformOutput',false); -else - oid=cellfun(@(x) obj2h5(name, x, handle,level,varargin{:}), item, 'UniformOutput',false); -end - -%%------------------------------------------------------------------------- -function oid=struct2h5(name, item,handle,level,varargin) - -num=numel(item); -if(num>1) - oid=obj2h5(name, num2cell(item),handle,level,varargin{:}); -else - pd = 'H5P_DEFAULT'; - gcpl = H5P.create('H5P_GROUP_CREATE'); - tracked = H5ML.get_constant_value('H5P_CRT_ORDER_TRACKED'); - indexed = H5ML.get_constant_value('H5P_CRT_ORDER_INDEXED'); - order = bitor(tracked,indexed); - H5P.set_link_creation_order(gcpl,order); - if(varargin{1}.unpackhex) - name=decodevarname(name); - end - try - handle=H5G.create(handle, name, pd,gcpl,pd); - isnew=1; - catch - isnew=0; - end - - names=fieldnames(item); - oid=cell(1,length(names)); - for i=1:length(names) - oid{i}=obj2h5(names{i},item.(names{i}),handle,level+1,varargin{:}); - end - - if(isnew) - H5G.close(handle); - end -end - -%%------------------------------------------------------------------------- -function oid=map2h5(name, item,handle,level,varargin) - -pd = 'H5P_DEFAULT'; -gcpl = H5P.create('H5P_GROUP_CREATE'); -tracked = H5ML.get_constant_value('H5P_CRT_ORDER_TRACKED'); -indexed = H5ML.get_constant_value('H5P_CRT_ORDER_INDEXED'); -order = bitor(tracked,indexed); -H5P.set_link_creation_order(gcpl,order); -try - if(varargin{1}.unpackhex) - name=decodevarname(name); - end - handle=H5G.create(handle, name, pd,gcpl,pd); - isnew=1; -catch - isnew=0; -end - -names=item.keys; -oid=zeros(length(names)); -for i=1:length(names) - oid(i)=obj2h5(names{i},item(names{i}),handle,level+1,varargin{:}); -end - -if(isnew) - H5G.close(handle); -end - -%%------------------------------------------------------------------------- -function oid=mat2h5(name, item,handle,level,varargin) -if(isa(item,'string')) - item=char(item); -end -typemap=h5types; - -pd = 'H5P_DEFAULT'; -gcpl = H5P.create('H5P_GROUP_CREATE'); -tracked = H5ML.get_constant_value('H5P_CRT_ORDER_TRACKED'); -indexed = H5ML.get_constant_value('H5P_CRT_ORDER_INDEXED'); -order = bitor(tracked,indexed); -H5P.set_link_creation_order(gcpl,order); - -opt=varargin{1}; -if(~(isfield(opt,'complexformat') && iscellstr(opt.complexformat) && numel(opt.complexformat)==2) || strcmp(opt.complexformat{1},opt.complexformat{2})) - opt.complexformat={'Real','Imag'}; -end - -usefilter=opt.compression; -complevel=opt.compresslevel; -minsize=opt.compressarraysize; -chunksize=jsonopt('Chunk',size(item),opt); - -if(isa(item,'logical')) - item=uint8(item); -end - -if(~isempty(usefilter) && numel(item)>=minsize) - if(isnumeric(usefilter) && usefilter(1)==1) - usefilter='deflate'; - end - if(strcmpi(usefilter,'deflate')) - pd = H5P.create('H5P_DATASET_CREATE'); - h5_chunk_dims = fliplr(chunksize); - H5P.set_chunk(pd,h5_chunk_dims); - H5P.set_deflate(pd,complevel); - else - error('Filter %s is unsupported',usefilter); - end -end - -if(opt.unpackhex) - name=decodevarname(name); -end - -oid=[]; - -if(isempty(item) && opt.skipempty) - warning('The HDF5 library is older than v1.8.7, and can not save empty datasets. Skip saving "%s"',name); - return; -end - -if(isreal(item)) - if(issparse(item)) - idx=find(item); - oid=sparse2h5(name,struct('Size',size(item),'SparseIndex',idx,'Real',item(idx)),handle,level,varargin{:}); - else - oid=H5D.create(handle,name,H5T.copy(typemap.(class(item))),H5S.create_simple(ndims(item), fliplr(size(item)),fliplr(size(item))),pd); - H5D.write(oid,'H5ML_DEFAULT','H5S_ALL','H5S_ALL','H5P_DEFAULT',item); - end -else - if(issparse(item)) - idx=find(item); - oid=sparse2h5(name,struct('Size',size(item),'SparseIndex',idx,'Real',real(item(idx)),'Imag',imag(item(idx))),handle,level,varargin{:}); - else - typeid=H5T.copy(typemap.(class(item))); - elemsize=H5T.get_size(typeid); - memtype = H5T.create ('H5T_COMPOUND', elemsize*2); - H5T.insert (memtype,opt.complexformat{1}, 0, typeid); - H5T.insert (memtype,opt.complexformat{2}, elemsize, typeid); - oid=H5D.create(handle,name,memtype,H5S.create_simple(ndims(item), fliplr(size(item)),fliplr(size(item))),pd); - H5D.write(oid,'H5ML_DEFAULT','H5S_ALL','H5S_ALL','H5P_DEFAULT',struct(opt.complexformat{1},real(item),opt.complexformat{2},imag(item))); - end -end -if(~isempty(oid)) - H5D.close(oid); -end - -%%------------------------------------------------------------------------- -function oid=sparse2h5(name, item,handle,level,varargin) - -opt=varargin{1}; - -idx=item.SparseIndex; - -if(isempty(idx) && opt.skipempty) - warning('The HDF5 library is older than v1.8.7, and can not save empty datasets. Skip saving "%s"',name); - oid=[]; - return; -end - -adata=item.Size; -item=rmfield(item,'Size'); -hasimag=isfield(item,'Imag'); - -typemap=h5types; - -pd = 'H5P_DEFAULT'; - - -usefilter=opt.compression; -complevel=opt.compresslevel; -minsize=opt.compressarraysize; -chunksize=jsonopt('Chunk',size(item),opt); - -if(~isempty(usefilter) && numel(idx)>=minsize) - if(isnumeric(usefilter) && usefilter(1)==1) - usefilter='deflate'; - end - if(strcmpi(usefilter,'deflate')) - pd = H5P.create('H5P_DATASET_CREATE'); - h5_chunk_dims = fliplr(chunksize); - H5P.set_chunk(pd,h5_chunk_dims); - H5P.set_deflate(pd,complevel); - else - error('Filter %s is unsupported',usefilter); - end -end - -idxtypeid=H5T.copy(typemap.(class(idx))); -idxelemsize=H5T.get_size(idxtypeid); -datatypeid=H5T.copy(typemap.(class(item.Real))); -dataelemsize=H5T.get_size(datatypeid); -memtype = H5T.create ('H5T_COMPOUND', idxelemsize+dataelemsize*(1+hasimag)); -H5T.insert (memtype,'SparseIndex', 0, idxtypeid); -H5T.insert (memtype,'Real', idxelemsize, datatypeid); -if(hasimag) - H5T.insert (memtype,'Imag', idxelemsize+dataelemsize, datatypeid); -end -oid=H5D.create(handle,name,memtype,H5S.create_simple(ndims(idx), fliplr(size(idx)),fliplr(size(idx))),pd); -H5D.write(oid,'H5ML_DEFAULT','H5S_ALL','H5S_ALL','H5P_DEFAULT',item); - -space_id=H5S.create_simple(ndims(adata), fliplr(size(adata)),fliplr(size(adata))); -attr_size = H5A.create(oid,'SparseArraySize',H5T.copy('H5T_NATIVE_DOUBLE'),space_id,H5P.create('H5P_ATTRIBUTE_CREATE')); -H5A.write(attr_size,'H5ML_DEFAULT',adata); -H5A.close(attr_size); - -%%------------------------------------------------------------------------- -function oid=any2h5(name, item,handle,level,varargin) -pd = 'H5P_DEFAULT'; - -if(varargin{1}.unpackhex) - name=decodevarname(name); -end - -rawdata=getByteStreamFromArray(item); % use undocumented matlab function -oid=H5D.create(handle,name,H5T.copy('H5T_STD_U8LE'),H5S.create_simple(ndims(rawdata), size(rawdata),size(rawdata)),pd); -H5D.write(oid,'H5ML_DEFAULT','H5S_ALL','H5S_ALL',pd,rawdata); - -adata=class(item); -space_id=H5S.create_simple(ndims(adata), size(adata),size(adata)); -attr_type = H5A.create(oid,'MATLABObjectClass',H5T.copy('H5T_C_S1'),space_id,H5P.create('H5P_ATTRIBUTE_CREATE')); -H5A.write(attr_type,'H5ML_DEFAULT',adata); -H5A.close(attr_type); - -adata=size(item); -space_id=H5S.create_simple(ndims(adata), size(adata),size(adata)); -attr_size = H5A.create(oid,'MATLABObjectSize',H5T.copy('H5T_NATIVE_DOUBLE'),space_id,H5P.create('H5P_ATTRIBUTE_CREATE')); -H5A.write(attr_size,'H5ML_DEFAULT',adata); -H5A.close(attr_size); - -H5D.close(oid); - -%%------------------------------------------------------------------------- -function typemap=h5types -typemap.char='H5T_C_S1'; -typemap.string='H5T_C_S1'; -typemap.double='H5T_IEEE_F64LE'; -typemap.single='H5T_IEEE_F32LE'; -typemap.logical='H5T_STD_U8LE'; -typemap.uint8='H5T_STD_U8LE'; -typemap.int8='H5T_STD_I8LE'; -typemap.uint16='H5T_STD_U16LE'; -typemap.int16='H5T_STD_I16LE'; -typemap.uint32='H5T_STD_U32LE'; -typemap.int32='H5T_STD_I32LE'; -typemap.uint64='H5T_STD_U64LE'; -typemap.int64='H5T_STD_I64LE'; diff --git a/external/easyh5/varargin2struct.m b/external/easyh5/varargin2struct.m deleted file mode 100644 index 7cf618721..000000000 --- a/external/easyh5/varargin2struct.m +++ /dev/null @@ -1,40 +0,0 @@ -function opt=varargin2struct(varargin) -% -% opt=varargin2struct('param1',value1,'param2',value2,...) -% or -% opt=varargin2struct(...,optstruct,...) -% -% convert a series of input parameters into a structure -% -% authors:Qianqian Fang (q.fang neu.edu) -% date: 2012/12/22 -% -% input: -% 'param', value: the input parameters should be pairs of a string and a value -% optstruct: if a parameter is a struct, the fields will be merged to the output struct -% -% output: -% opt: a struct where opt.param1=value1, opt.param2=value2 ... -% -% license: -% BSD or GPL version 3, see LICENSE_{BSD,GPLv3}.txt files for details -% -% -- this function is part of JSONLab toolbox (http://iso2mesh.sf.net/cgi-bin/index.cgi?jsonlab) -% - -len=length(varargin); -opt=struct; -if(len==0) return; end -i=1; -while(i<=len) - if(isstruct(varargin{i})) - opt=mergestruct(opt,varargin{i}); - elseif(ischar(varargin{i}) && i 0) - if ismember(i, selectedBlocks) - board_dig_in_raw(board_dig_in_index:(board_dig_in_index + num_samples_per_data_block - 1)) = fread(fid, num_samples_per_data_block, 'uint16'); + if ismember(i, selectedBlocks) && loadEvents + board_dig_in_raw(:, board_dig_in_index:(board_dig_in_index + num_samples_per_data_block - 1)) = fread(fid, num_samples_per_data_block, 'uint16'); else fseek(fid, num_samples_per_data_block*2,'cof'); end end if (num_board_dig_out_channels > 0) - if ismember(i, selectedBlocks) + if ismember(i, selectedBlocks) && loadEvents board_dig_out_raw(board_dig_out_index:(board_dig_out_index + num_samples_per_data_block - 1)) = fread(fid, num_samples_per_data_block, 'uint16'); else fseek(fid, num_samples_per_data_block*2,'cof'); diff --git a/external/jsnirfy/README.md b/external/jsnirfy/README.md deleted file mode 100644 index 9101c8ffa..000000000 --- a/external/jsnirfy/README.md +++ /dev/null @@ -1,198 +0,0 @@ -# JSNIRF Toolbox - A portable MATLAB toolbox for parsing SNIRF (HDF5) and JSNIRF (JSON) files - -* Copyright (C) 2019 Qianqian Fang -* License: GNU General Public License version 3 (GPL v3) or Apache License 2.0, see License*.txt -* Version: 0.4 (code name: Amygdala - alpha) -* URL: https://github.com/NeuroJSON/jsnirf/tree/master/lib/matlab - -## Overview - -JSNIRF is a portable format for storage, interchange and processing data generated -from functional near-infrared spectroscopy, or fNIRS - an emerging functional neuroimaging -technique. Built upon the JData and SNIRF specifications, a JSNIRF file has both a -text-based interface using the JavaScript Object Notation (JSON) [RFC4627] format -and a binary interface using the Universal Binary JSON (UBJSON, http://ubjson.org) derived -Binary JData ([BJData](https://github.com/NeuroJSON/bjdata)) serialization format. -It contains a compatibility layer to provide a 1-to-1 mapping to the existing -HDF5 based SNIRF files. A JSNIRF file can be directly parsed by most existing -JSON and BJData parsers. Advanced features include optional hierarchical data -storage, grouping, compression, integration with heterogeneous scientific data -enabled by JData data serialization framework. - -This toolbox also provides a fast/complete reader/writer for the HDF5-based SNIRF -files (along with any HDF5 data) via the EazyH5 toolbox -(http://github.com/fangq/eazyh5). The toolbox can read/write SNIRF v1.0 data -files specified by the SNIRF specification http://github.com/fNIRS/snirf . - -This toolbox is selectively dependent on the below toolboxes -- To read/write SNIRF/HDF5 files, one must install the EazyH5 toolbox at - http://github.com/fangq/eazyh5 ; this is only supported on MATLAB, not Octave. -- To create/read/write JSNIRF files, one must install the JSONLab toolbox - http://github.com/NeuroJSON/jsonlab ; this is supported on both MATLAB and Octave. -- To read/write JSNIRF files with internal data compression, one must install - the JSONLab toolbox http://github.com/NeuroJSON/jsonlab as well as ZMat toolbox - http://github.com/fangq/zmat ; this is supported on both MATLAB and Octave. - -## Why JSNIRF? - -A SNIRF data file is basically an HDF5 file. HDF5 (Hierarchical Data Format version 5) -is a general purpose file format for storing flexible binary data. However, it has -the below limitations: - -- it is binary, not human readable, you must use a parser to load the file - and understand the content -- it requires a spacial library, although widely and freely available, to load - or save such file; dependeny to such library requires extra work for deployment -- HDF5 is a very sophisticated format; writing your own parser is quite difficult -- when storing a small dataset, an HDF5 file has an overhead in file size - -In comparison, the JSNIRF data format is defined based on the JData specification. -and supports both a text-based interface and a binary interface. The text form -JSNIRF file is a plain JSON file, and has various advantages - -- JSNIRF is human readable, you can read the data using an editor -- JSNIRF is very simple (because JSON format is very simple) -- JSNIRF is lightweight, little overhead for storing small datasets -- JSNIRF can be readily parsed by numerous free JSON parsers available -- Programming your own specialized JSNIRF parser is very easy to write - -The binary JSNIRF format uses a binary JSON format (BJData) which is also -- quasi-human readable despite it is binary -- free parsers available for [MATLAB](http://github.com/fangq/jsonlab), - [Python](https://pypi.org/project/bjdata/), [C++](https://github.com/NeuroJSON/json), - and [C](https://github.com/NeuroJSON/ubj) -- easy to write your own parser because of the simplicity - - - -## SNIRF and JSNIRF format compatibility - -The JSNIRF data structure is highly compatible with the SNIRF data structure. -This toolbox provides utilities convert from one form to the other losslessly. - -There are only two minor differences: -* A JSNIRF data container renames the SNIRF `/nirs` root object as `SNIRFData`. - If multiple measurement datasets are provided in the SNIRF data in the forms of - `/nirs1`, `/nirs2` ..., or `/nirs/data1`. `/nirs/data2` ..., JSNIRF merges these - data objects into struct/cell arrays, and removes the group indices from the - group names. These grouped objects are stored as an JSON/BJData array object - '[]' when saving to disk. -* The `/formatVersion` object in the SNIRF data are moved from the root level - to a subfield of `SNIRFData`, this allows the JSNIRF data files to be easily - mixed/integrated with other JSON-based data containers, such as `SNIRFData` - defined in other JData based data formats. - -To further illustrate the above data reorganization steps, please find below -an example - -An original SNIRF/HDF5 data outline -``` -/formatVersion -/nirs1/ - /metaDataTags - /data1 - /data2 - /aux1 - /aux2 - /probe - ... -/nirs2/ - /metaDataTags - /data - /aux1 - /aux2 - /aux3 - /probe - ... -``` -is converted to the below JSON/JSNIRF data structure -``` -{ - "SNIRFData": [ - { - "formatVersion": '1.0', - "metaDataTags":{ - "SubjectID": ... - }, - "data": [ - {..for data1 ...}, - {..for data2 ...} - ], - "aux": [ - {..for aux1 ...}, - {..for aux2 ...} - ], - "probe": ... - }, - { - "formatVersion": '1.0', - "metaDataTags":{ - "SubjectID": ... - }, - "data": {...}, - "aux": [ - {..for aux1 ...}, - {..for aux2 ...}, - {..for aux3 ...} - ], - "probe": ... - }, - ... - ] -} -``` - -## Installation - -The JSNIRF toolbox can be installed using a single command -``` - addpath('/path/to/jsnirf'); -``` -where the `/path/to/jsnirf` should be replaced by the unzipped folder -of the toolbox (i.e. the folder containing `savejsnirf.m/loadjsnirf.m`). - -In order for this toolbox to work, one must install the below dependencies -- the `saveh5/loadh5` functions are provided by the EazyH5 toolbox at - http://github.com/fangq/eazyh5 -- the `savejson` and `savebj` functions are provided by the JSONLab - toolbox at http://github.com/NeuroJSON/jsonlab -- if data compression is specified by `'compression','zlib'` param/value - pairs, ZMat toolbox will be needed, http://github.com/fangq/zmat - - -## Usage - -### `snirfcreate/jsnirfcreate` - Create an empty SNIRF or JSNIRF data container (structure) -Example: -``` - data=snirfcreate; % create an empty SNIRF data structure - data=snirfcreate('data',realdata,'aux',realauxdata); % setting the default values to user data - data=jsnirfcreate('format','snirf'); % specify 'snirf' or 'jsnirf' using 'format' option - jsn=snirfdecode(loadh5('mydata.snirf')); % load raw HDF5 data and convert to a JSNIRF struct -``` -### `loadsnirf/loadjsnirf` - Loading SNIRF/JSNIRF files as in-memory MATLAB data structures -Example: -``` - data=loadsnirf('mydata.snirf'); % load an HDF5 SNIRF data file, same as loadh5+regrouph5 - jdata=loadjsnirf('mydata.bnirs'); % load a binary JSON/JSNIRF data file -``` -### `savesnirf/savejsnirf` - Saving in-memory MATLAB data structure into SNIRF/HDF5 or JSNIRF/JSON files -Example: -``` - data=snirfcreate; - data.nirs.data.dataTimeSeries=rand(100,5); - data.nirs.metaDataTags.SubjectID='subj1'; - data.nirs.metaDataTags.MeasurementDate=date; - data.nirs.metaDataTags.MeasurementTime=datestr(now,'HH:MM:SS'); - savesnirf(data,'test.snirf'); - savejsnirf(data,'test.jnirs'); -``` - -## Contribute to JSNIRF - -Please submit your bug reports, feature requests and questions to the Github Issues page at - -https://github.com/NeuroJSON/jsnirf/issues - -Please feel free to fork our software, making changes, and submit your revision back -to us via "Pull Requests". JSNIRF toolbox is open-source and we welcome your contributions! diff --git a/external/jsnirfy/aos2soa.m b/external/jsnirfy/aos2soa.m deleted file mode 100644 index 48f963692..000000000 --- a/external/jsnirfy/aos2soa.m +++ /dev/null @@ -1,40 +0,0 @@ -function st=aos2soa(starray) -% -% st=aos2soa(starray) -% -% Convert an array-of-structs (AoS) to a struct-of-arrays (SoA) -% -% author: Qianqian Fang (q.fang neu.edu) -% -% input: -% starray: a struct array, with each subfield a simple scalar -% -% output: -% str: a struct, containing the same number of subfields as starray -% with each subfield a horizontal-concatenation of the struct -% array subfield values. -% -% example: -% a=struct('a',1,'b','0','c',[1,3]'); -% st=aos2soa(repmat(a,1,10)) -% -% this file is part of JSNIRF specification: https://github.com/NeuroJSON/jsnirf -% -% License: GPLv3 or Apache 2.0, see https://github.com/NeuroJSON/jsnirf for details -% - - -if(nargin<1 || ~isstruct(starray)) - error('you must give an array of struct'); -end - -if(numel(starray)==1) - st=starray; - return; -end - -st=struct; -fn=fieldnames(starray); -for i=1:length(fn) - st.(fn{i})=[starray(:).(fn{i})]; -end \ No newline at end of file diff --git a/external/jsnirfy/jsnirfcreate.m b/external/jsnirfy/jsnirfcreate.m deleted file mode 100644 index e62853eb5..000000000 --- a/external/jsnirfy/jsnirfcreate.m +++ /dev/null @@ -1,78 +0,0 @@ -function jsn=jsnirfcreate(varargin) -% -% jsn=jsnirfcreate -% or -% jsn=jsnirfcreate(option) -% jsn=jsnirfcreate('Format',format,'Param1',value1, 'Param2',value2,...) -% -% Create an empty JSNIRF data structure defined in the JSNIRF -% specification: https://github.com/NeuroJSON/jsnirf or a SNIRF data structure -% based on https://github.com/fNIRS/snirf -% -% author: Qianqian Fang (q.fang neu.edu) -% -% input: -% option (optional): option can be ignored. If it is a string with a -% value 'snirf', this creates a default SNIRF data structure; -% otherwise, a JSNIRF data structure is created. -% format: same as option. -% param/value: a list of name/value pairs specify -% additional subfields to be stored under the /nirs object. -% -% output: -% jsn: a default SNIRF or JSNIRF data structure. -% -% example: -% jsn=jsnirfcreate('data',mydata,'aux',myauxdata,'comment','test'); -% -% this file is part of JSNIRF specification: https://github.com/NeuroJSON/jsnirf -% -% License: GPLv3 or Apache 2.0, see https://github.com/NeuroJSON/jsnirf for details -% - -% define empty SNIRF data structure with all required fields - -defaultmeta=struct('SubjectID','default','MeasurementDate',datestr(now,29),... - 'MeasurementTime',datestr(now,'hh:mm:ss'),'LengthUnit','mm', ... - 'TimeUnit','s', 'FrequencyUnit','Hz'); -defaultsrcmap=struct('sourceIndex',[],'detectorIndex',[],... - 'wavelengthIndex',[],'dataType',1,'dataTypeIndex',1); -defaultdata=struct('dataTimeSeries',[],'time',[],'measurementList',defaultsrcmap); -defaultaux=struct('name','','dataTimeSeries',[],'time',[],'timeOffset',0); -defaultstim=struct('name','','data',[]); -defaultprobe=struct('wavelengths',[],'sourcePos2D',[],'detectorPos2D',[]); - -nirsdata=struct('metaDataTags',defaultmeta,... - 'data',defaultdata,... - 'aux',defaultaux,... - 'stim',defaultstim,... - 'probe',defaultprobe); - -% read user specified data fields - will validate format in future updates - -if(nargin>1 && bitand(nargin,1)==0) - for i=1:nargin*0.5 - key=varargin{2*i-1}; - if(strcmpi(key,'format')) - key='format'; - end - nirsdata.(key)=varargin{2*i}; - end -end - -jsn=struct(); - -% return either a SNIRF data structure, or JSNIRF data (enclosed in SNIRFData tag) - -if((nargin==1 && strcmpi(varargin{1},'snirf')) || ... - (isfield(nirsdata,'format') && strcmpi(nirsdata.format,'snirf'))) - if(isfield(nirsdata,'format')) - nirsdata=rmfield(nirsdata,'format'); - end - jsn=struct('formatVersion','1.0','nirs', nirsdata); -else - nirsdata.formatVersion='1.0'; - len=length(fieldnames(nirsdata)); - nirsdata=orderfields(nirsdata,[len,1:len-1]); - jsn=struct('SNIRFData', nirsdata); -end diff --git a/external/jsnirfy/loadsnirf.m b/external/jsnirfy/loadsnirf.m deleted file mode 100644 index 44704cf2c..000000000 --- a/external/jsnirfy/loadsnirf.m +++ /dev/null @@ -1,63 +0,0 @@ -function data=loadsnirf(fname,varargin) -% -% data=loadsnirf(fname) -% or -% jnirs=loadsnirf(fname, 'Param1',value1, 'Param2',value2,...) -% -% Load an HDF5 based SNIRF file, and optionally convert it to a JSON -% file based on the JSNIRF specification: -% https://github.com/NeuroJSON/jsnirf -% -% author: Qianqian Fang (q.fang neu.edu) -% -% input: -% fname: the input snirf data file name (HDF5 based) -% -% output: -% data: a MATLAB structure with the grouped data fields -% -% dependency: -% - the loadh5/regrouph5 functions are provided by the eazyh5 -% toolbox at http://github.com/fangq/eazyh5 -% - the varargin2struct and jsonopt functions are provided by the JSONLab -% toolbox at http://github.com/NeuroJSON/jsonlab -% - if data compression is specified by 'compression','zlib' param/value -% pairs, ZMat toolbox will be needed, http://github.com/fangq/zmat -% -% example: -% data=loadsnirf('test.snirf'); -% -% this file is part of JSNIRF specification: https://github.com/NeuroJSON/jsnirf -% -% License: GPLv3 or Apache 2.0, see https://github.com/NeuroJSON/jsnirf for details -% - -if(nargin==0 || ~ischar(fname)) - error('you must provide a file name'); -end - -data=loadh5(fname); - -opt=struct; - -if(length(varargin)==1) - data=snirfdecode(data,varargin{:}); -elseif(length(varargin)>=2) - opt=varargin2struct(varargin{:}); - data=snirfdecode(data,varargin{:}); -else - data=snirfdecode(data); -end - -outfile=jsonopt('FileName','',opt); -if(~isempty(outfile)) - if(regexp(outfile,'\.[Bb][Nn][Ii][Rr][Ss]$')) - savebj('SNIRFData',data,'FileName',outfile,opt); - elseif(~isempty(regexp(outfile,'\.[Jj][Nn][Ii][Rr][Ss]$', 'once'))|| ~isempty(regexp(outfile,'\.[Jj][Ss][Oo][Nn]$', 'once'))) - savejson('SNIRFData',data,'FileName',outfile,opt); - elseif(regexp(outfile,'\.[Mm][Aa][Tt]$')) - save(outfile,'data'); - else - error('only support .jnirs,.bnirs and .mat files'); - end -end diff --git a/external/jsnirfy/savesnirf.m b/external/jsnirfy/savesnirf.m deleted file mode 100644 index 32f225754..000000000 --- a/external/jsnirfy/savesnirf.m +++ /dev/null @@ -1,77 +0,0 @@ -function savesnirf(data, outfile,varargin) -% -% savesnirf(snirfdata, fname) -% or -% savesnirf(snirfdata, fname, 'Param1',value1, 'Param2',value2,...) -% -% Load an HDF5 based SNIRF file, and optionally convert it to a JSON -% file based on the JSNIRF specification: -% https://github.com/NeuroJSON/jsnirf -% -% author: Qianqian Fang (q.fang neu.edu) -% -% input: -% snirfdata: a raw SNIRF data, preprocessed SNIRF data or JSNIRF -% data (root object must be SNIRFData) -% fname: the output SNIRF (.snirf) or JSNIRF data file name (.jnirs, .bnirs) -% -% output: -% data: a MATLAB structure with the grouped data fields -% -% example: -% data=loadsnirf('test.snirf'); -% savesnirf(data,'newfile.snirf'); -% -% this file is part of JSNIRF specification: https://github.com/NeuroJSON/jsnirf -% -% License: GPLv3 or Apache 2.0, see https://github.com/NeuroJSON/jsnirf for details -% - -if(nargin<2 || ~ischar(outfile)) - error('you must provide data and a file name'); -end - -opt=varargin2struct(varargin{:}); -if(~isfield(opt,'root')) - opt.rootname=''; -end - -if(isfield(data,'SNIRFData')) - data.nirs=data.SNIRFData; - data.formatVersion=data.SNIRFData.formatVersion; - data.nirs=rmfield(data.nirs,'formatVersion'); - data=rmfield(data,'SNIRFData'); -end - -if(~isempty(outfile)) - if(~isempty(regexp(outfile,'\.[Hh]5$', 'once'))) - saveh5(data,outfile,opt); - elseif(~isempty(regexp(outfile,'\.[Ss][Nn][Ii][Rr][Ff]$', 'once'))) - data.nirs.data=forceindex(data.nirs.data,'measurementList'); - data.nirs=forceindex(data.nirs,'data'); - data.nirs=forceindex(data.nirs,'stim'); - data.nirs=forceindex(data.nirs,'aux'); - saveh5(data,outfile,opt); - elseif(~isempty(regexp(outfile,'\.[Jj][Nn][Ii][Rr][Ss]$', 'once'))|| ~isempty(regexp(outfile,'\.[Jj][Ss][Oo][Nn]$', 'once'))) - savejson('SNIRFData',data,'FileName',outfile,opt); - elseif(regexp(outfile,'\.[Mm][Aa][Tt]$')) - save(outfile,'data'); - elseif(regexp(outfile,'\.[Bb][Nn][Ii][Rr][Ss]$')) - savebj('SNIRFData',data,'FileName',outfile,opt); - else - error('only support .snirf, .h5, .jnirs, .bnirs and .mat files'); - end -end - -% force adding index 1 to the group name for singular struct and cell -function newroot=forceindex(root,name) -newroot=root; -fields=fieldnames(newroot); -idx=find(ismember(fields,name)); -if(~isempty(idx) && length(newroot.(name))==1) - newroot.(sprintf('%s1',name))=newroot.(name); - newroot=rmfield(newroot,name); - fields{idx(1)}=sprintf('%s1',name); - newroot=orderfields(newroot,fields); -end - diff --git a/external/jsnirfy/snirfcreate.m b/external/jsnirfy/snirfcreate.m deleted file mode 100644 index 169d0ab0a..000000000 --- a/external/jsnirfy/snirfcreate.m +++ /dev/null @@ -1,36 +0,0 @@ -function snf=snirfcreate(varargin) -% -% snf=snirfcreate -% or -% snf=snirfcreate(option) -% snf=snirfcreate('Format',format,'Param1',value1, 'Param2',value2,...) -% -% Create a empty SNIRF data structure defined in the SNIRF -% specification: https://github.com/fNIRS/snirf -% -% author: Qianqian Fang (q.fang neu.edu) -% -% input: -% option (optional): option can be ignored. If it is a string with a -% value 'snirf', this creates a default SNIRF data structure; -% otherwise, a JSNIRF data structure is created. -% format: save as option. -% param/value: a list of name/value pairs specify -% additional subfields to be stored under the /nirs object. -% -% output: -% snf: a default SNIRF or JSNIRF data structure. -% -% example: -% snf=snirfcreate('data',mydata,'aux',myauxdata,'comment','test'); -% -% this file is part of JSNIRF specification: https://github.com/fangq/snirf -% -% License: GPLv3 or Apache 2.0, see https://github.com/NeuroJSON/jsnirf for details -% - -if(nargin==1) - snf=jsnirfcreate(varargin{:}); -else - snf=jsnirfcreate('Format','snirf',varargin{:}); -end diff --git a/external/jsnirfy/snirfdecode.m b/external/jsnirfy/snirfdecode.m deleted file mode 100644 index e16469c26..000000000 --- a/external/jsnirfy/snirfdecode.m +++ /dev/null @@ -1,65 +0,0 @@ -function data=snirfdecode(root, varargin) -% -% data=snirfdecode(root) -% or -% data=snirfdecode(root,type) -% data=snirfdecode(root,{'nameA','nameB',...}) -% -% Processing an HDF5 based SNIRF data and group indexed datasets into a -% cell array -% -% author: Qianqian Fang (q.fang neu.edu) -% -% input: -% root: the raw input snirf data structure (loaded from loadh5.m) -% type: if type is set as a cell array of strings, it restrict the -% grouping only to the subset of field names in this list; -% if type is a string as 'snirf', it is the same as setting -% type as {'aux','data','nirs','stim','measurementList'}. -% -% output: -% data: a reorganized matlab structure. Each SNIRF data chunk is -% enclosed inside a 'SNIRFData' subfield or cell array. -% -% example: -% rawdata=loadh5('mydata.snirf'); -% data=snirfdecode(rawdata); -% -% this file is part of JSNIRF specification: https://github.com/NeuroJSON/jsnirf -% -% License: Apache 2.0, see https://github.com/NeuroJSON/jsnirf for details -% - -if(nargin<1) - help snirfdecode; - return; -end - -data=regrouph5(root, varargin{:}); - -issnirf=1; - -if(~isempty(varargin)) - if(ischar(varargin{1}) && strcmpi(varargin{1},'jsnirf')) - issnirf=0; - end -end - -if(issnirf==0 && isfield(data,'nirs') && isfield(data,'formatVersion') && ~isfield(data,'SNIRFData')) - data.SNIRFData=data.nirs; - if(isfield(data.SNIRFData,'data') && isfield(data.SNIRFData.data,'measurementList')) - data.SNIRFData.data.measurementList=aos2soa(data.nirs.data.measurementList); - end - if(iscell(data.nirs)) - for i=1:length(data.nirs) - data.SNIRFData{i}.formatVersion=data.formatVersion; - len=length(fieldnames(data.SNIRFData{i})); - data.SNIRFData{i}=orderfields(data.SNIRFData{i},[len,1:len-1]); - end - else - data.SNIRFData.formatVersion=data.formatVersion; - len=length(fieldnames(data.SNIRFData)); - data.SNIRFData=orderfields(data.SNIRFData,[len,1:len-1]); - end - data=rmfield(data,{'nirs','formatVersion'}); -end \ No newline at end of file diff --git a/external/npy-matlab/readNPY.m b/external/npy-matlab/readNPY.m deleted file mode 100644 index 9095d003a..000000000 --- a/external/npy-matlab/readNPY.m +++ /dev/null @@ -1,37 +0,0 @@ - - -function data = readNPY(filename) -% Function to read NPY files into matlab. -% *** Only reads a subset of all possible NPY files, specifically N-D arrays of certain data types. -% See https://github.com/kwikteam/npy-matlab/blob/master/tests/npy.ipynb for -% more. -% - -[shape, dataType, fortranOrder, littleEndian, totalHeaderLength, ~] = readNPYheader(filename); - -if littleEndian - fid = fopen(filename, 'r', 'l'); -else - fid = fopen(filename, 'r', 'b'); -end - -try - - [~] = fread(fid, totalHeaderLength, 'uint8'); - - % read the data - data = fread(fid, prod(shape), [dataType '=>' dataType]); - - if length(shape)>1 && ~fortranOrder - data = reshape(data, shape(end:-1:1)); - data = permute(data, [length(shape):-1:1]); - elseif length(shape)>1 - data = reshape(data, shape); - end - - fclose(fid); - -catch me - fclose(fid); - rethrow(me); -end diff --git a/external/npy-matlab/readNPYheader.m b/external/npy-matlab/readNPYheader.m deleted file mode 100644 index 165a58c89..000000000 --- a/external/npy-matlab/readNPYheader.m +++ /dev/null @@ -1,69 +0,0 @@ - - -function [arrayShape, dataType, fortranOrder, littleEndian, totalHeaderLength, npyVersion] = readNPYheader(filename) -% function [arrayShape, dataType, fortranOrder, littleEndian, ... -% totalHeaderLength, npyVersion] = readNPYheader(filename) -% -% parse the header of a .npy file and return all the info contained -% therein. -% -% Based on spec at http://docs.scipy.org/doc/numpy-dev/neps/npy-format.html - -fid = fopen(filename); - -% verify that the file exists -if (fid == -1) - if ~isempty(dir(filename)) - error('Permission denied: %s', filename); - else - error('File not found: %s', filename); - end -end - -try - - dtypesMatlab = {'uint8','uint16','uint32','uint64','int8','int16','int32','int64','single','double', 'logical'}; - dtypesNPY = {'u1', 'u2', 'u4', 'u8', 'i1', 'i2', 'i4', 'i8', 'f4', 'f8', 'b1'}; - - - magicString = fread(fid, [1 6], 'uint8=>uint8'); - - if ~all(magicString == [147,78,85,77,80,89]) - error('readNPY:NotNUMPYFile', 'Error: This file does not appear to be NUMPY format based on the header.'); - end - - majorVersion = fread(fid, [1 1], 'uint8=>uint8'); - minorVersion = fread(fid, [1 1], 'uint8=>uint8'); - - npyVersion = [majorVersion minorVersion]; - - headerLength = fread(fid, [1 1], 'uint16=>uint16'); - - totalHeaderLength = 10+headerLength; - - arrayFormat = fread(fid, [1 headerLength], 'char=>char'); - - % to interpret the array format info, we make some fairly strict - % assumptions about its format... - - r = regexp(arrayFormat, '''descr''\s*:\s*''(.*?)''', 'tokens'); - dtNPY = r{1}{1}; - - littleEndian = ~strcmp(dtNPY(1), '>'); - - dataType = dtypesMatlab{strcmp(dtNPY(2:3), dtypesNPY)}; - - r = regexp(arrayFormat, '''fortran_order''\s*:\s*(\w+)', 'tokens'); - fortranOrder = strcmp(r{1}{1}, 'True'); - - r = regexp(arrayFormat, '''shape''\s*:\s*\((.*?)\)', 'tokens'); - shapeStr = r{1}{1}; - arrayShape = str2num(shapeStr(shapeStr~='L')); - - - fclose(fid); - -catch me - fclose(fid); - rethrow(me); -end diff --git a/external/piotr_toolbox/LICENCE.txt b/external/piotr_toolbox/LICENCE.txt new file mode 100644 index 000000000..4b692e467 --- /dev/null +++ b/external/piotr_toolbox/LICENCE.txt @@ -0,0 +1,26 @@ +Copyright (c) 2012, Piotr Dollar +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. +2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR +ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND +ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +The views and conclusions contained in the software and documentation are those +of the authors and should not be interpreted as representing official policies, +either expressed or implied, of the FreeBSD Project. \ No newline at end of file diff --git a/external/piotr_toolbox/tpsGetWarp.m b/external/piotr_toolbox/tpsGetWarp.m new file mode 100644 index 000000000..a88563b82 --- /dev/null +++ b/external/piotr_toolbox/tpsGetWarp.m @@ -0,0 +1,77 @@ +function [warp,L,LnInv,bendE] = tpsGetWarp( lambda, xsS, ysS, xsD, ysD ) +% Given two sets of corresponding points, calculates warp between them. +% +% Uses booksteins PAMI89 method. Can then apply warp to a new set of +% points (tpsInterpolate), or even an image (tpsInterpolateIm). +% "Principal Warps: Thin-Plate Splines and the Decomposition of +% Deformations". Bookstein. PAMI 1989. +% +% USAGE +% [warp,L,LnInv,bendE] = tpsGetWarp( lambda, xsS, ysS, xsD, ysD ) +% +% INPUTS +% lambda - rigidity of warp (inf means warp becomes affine) +% xsS, ysS - [1xn] correspondence points from source image +% xsD, ysD - [1xn] correspondence points from destination image +% +% OUTPUTS +% warp - bookstein warping parameters +% L, LnInv - see bookstein +% bendE - bending energy +% +% EXAMPLE - 1 +% xsS=[0 -1 0 1]; ysS=[1 0 -1 0]; xsD=xsS; ysD=[3/4 1/4 -5/4 1/4]; +% warp = tpsGetWarp( 0, xsS, ysS, xsD, ysD ); +% [gxs, gys] = meshgrid(-1.25:.25:1.25,-1.25:.25:1.25); +% tpsInterpolate( warp, gxs, gys, 1 ); +% +% EXAMPLE - 2 +% xsS = [3.6929 6.5827 6.7756 4.8189 5.6969]; +% ysS = [10.3819 8.8386 12.0866 11.2047 10.0748]; +% xsD = [3.9724 6.6969 6.5394 5.4016 5.7756]; +% ysD = [6.5354 4.1181 7.2362 6.4528 5.1142]; +% warp = tpsGetWarp( 0, xsS, ysS, xsD, ysD ); +% [gxs, gys] = meshgrid(3.5:.25:7, 8.5:.25: 12.5); +% tpsInterpolate( warp, gxs, gys, 1 ); +% +% See also TPSINTERPOLATE, TPSINTERPOLATEIM, TPSRANDOM +% +% Piotr's Computer Vision Matlab Toolbox Version 2.0 +% Copyright 2014 Piotr Dollar. [pdollar-at-gmail.com] +% Licensed under the Simplified BSD License [see https://github.com/pdollar/toolbox/blob/master/external/bsd.txt] + +dim = size( xsS ); +if( all(size(xsS)~=dim) || all(size(ysS)~=dim) || all(size(xsD)~=dim)) + error( 'argument sizes do not match' ); +end + +% get L +n = size(xsS,2); +deltaXs = xsS'*ones(1,n) - ones(n,1) * xsS; +deltaYs = ysS'*ones(1,n) - ones(n,1) * ysS; +Rsq = (deltaXs .* deltaXs + deltaYs .* deltaYs); +Rsq = Rsq+eye(n); K = Rsq .* log( Rsq ); K( isnan(K) )=0; +K = K + lambda * eye( n ); +P = [ ones(n,1), xsS', ysS' ]; +L = [ K, P; P', zeros(3,3) ]; +LInv = L^(-1); +LnInv = LInv(1:n,1:n); + +% recover W's +wx = LInv * [xsD 0 0 0]'; +affinex = wx(n+1:n+3); +wx = wx(1:n); +wy = LInv * [ysD 0 0 0]'; +affiney = wy(n+1:n+3); +wy = wy(1:n); + +% record warp +warp.wx = wx; warp.affinex = affinex; +warp.wy = wy; warp.affiney = affiney; +warp.xsS = xsS; warp.ysS = ysS; +warp.xsD = xsD; warp.ysD = ysD; + +% get bending energy (without regularization) +w = [wx'; wy']; +K = K - lambda * eye( n ); +bendE = trace(w*K*w')/2; \ No newline at end of file diff --git a/external/piotr_toolbox/tpsInterpolate.m b/external/piotr_toolbox/tpsInterpolate.m new file mode 100644 index 000000000..2c75ab4fd --- /dev/null +++ b/external/piotr_toolbox/tpsInterpolate.m @@ -0,0 +1,52 @@ +function [xsR,ysR] = tpsInterpolate( warp, xs, ys, show ) +% Apply warp (obtained by tpsGetWarp) to a set of new points. +% +% USAGE +% [xsR,ysR] = tpsInterpolate( warp, xs, ys, [show] ) +% +% INPUTS +% warp - [see tpsGetWarp] bookstein warping parameters +% xs, ys - points to apply warp to +% show - [1] will display results in figure(show) +% +% OUTPUTS +% xsR, ysR - result of warp applied to xs, ys +% +% EXAMPLE +% +% See also TPSGETWARP +% +% Piotr's Computer Vision Matlab Toolbox Version 2.0 +% Copyright 2014 Piotr Dollar. [pdollar-at-gmail.com] +% Licensed under the Simplified BSD License [see https://github.com/pdollar/toolbox/blob/master/external/bsd.txt] + +if( nargin<4 || isempty(show)); show = 1; end + +wx = warp.wx; affinex = warp.affinex; +wy = warp.wy; affiney = warp.affiney; +xsS = warp.xsS; ysS = warp.ysS; +xsD = warp.xsD; ysD = warp.ysD; + +% interpolate points (xs,ys) +xsR = f( wx, affinex, xsS, ysS, xs(:)', ys(:)' ); +ysR = f( wy, affiney, xsS, ysS, xs(:)', ys(:)' ); + +% optionally show points (xsR, ysR) +if( show ) + figure(show); + subplot(2,1,1); plot( xs, ys, '.', 'color', [0 0 1] ); + hold('on'); plot( xsS, ysS, '+' ); hold('off'); + subplot(2,1,2); plot( xsR, ysR, '.' ); + hold('on'); plot( xsD, ysD, '+' ); hold('off'); +end + +function zs = f( w, aff, xsS, ysS, xs, ys ) +% find f(x,y) for xs and ys given W and original points +n = size(w,1); ns = size(xs,2); +delXs = xs'*ones(1,n) - ones(ns,1)*xsS; +delYs = ys'*ones(1,n) - ones(ns,1)*ysS; +distSq = (delXs .* delXs + delYs .* delYs); +distSq = distSq + eye(size(distSq)) + eps; +U = distSq .* log( distSq ); U( isnan(U) )=0; +zs = aff(1)*ones(ns,1)+aff(2)*xs'+aff(3)*ys'; +zs = zs + sum((U.*(ones(ns,1)*w')),2); diff --git a/external/spm/spm_bsplinc.mexmaca64 b/external/spm/spm_bsplinc.mexmaca64 new file mode 100644 index 000000000..14dcc98ea Binary files /dev/null and b/external/spm/spm_bsplinc.mexmaca64 differ diff --git a/external/spm/spm_bsplins.mexmaca64 b/external/spm/spm_bsplins.mexmaca64 new file mode 100644 index 000000000..c76a423ea Binary files /dev/null and b/external/spm/spm_bsplins.mexmaca64 differ diff --git a/toolbox/anatomy/CalcVertexNormals.m b/toolbox/anatomy/CalcVertexNormals.m new file mode 100644 index 000000000..f79530155 --- /dev/null +++ b/toolbox/anatomy/CalcVertexNormals.m @@ -0,0 +1,138 @@ +function [VertexNormals,VertexArea,FaceNormals,FaceArea]=CalcVertexNormals(FV,FaceNormals) % ,up,vp + % Very slightly modified for efficiency and convenience, and added normr. Marc L. +%% Summary +%Author: Itzik Ben Shabat +%Last Update: July 2014 + +%summary: CalcVertexNormals calculates the normals and voronoi areas at each vertex +%INPUT: +%FV - triangle mesh in face vertex structure +%N - face normals +%OUTPUT - +%VertexNormals - [Nv X 3] matrix of normals at each vertex +%Avertex - [NvX1] voronoi area at each vertex +%Acorner - [NfX3] slice of the voronoi area at each face corner + +%% Code + +if nargin < 2 || isempty(FaceNormals) + [FaceNormals, FaceArea] = CalcFaceNormals(FV); +end + +% disp('Calculating vertex normals... Please wait'); +% Get all edge vectors +e0=FV.Vertices(FV.Faces(:,3),:)-FV.Vertices(FV.Faces(:,2),:); +e1=FV.Vertices(FV.Faces(:,1),:)-FV.Vertices(FV.Faces(:,3),:); +e2=FV.Vertices(FV.Faces(:,2),:)-FV.Vertices(FV.Faces(:,1),:); +% Normalize edge vectors +% e0_norm=normr(e0); +% e1_norm=normr(e1); +% e2_norm=normr(e2); + +%normalization procedure +%calculate face Area +%edge lengths +de0=sqrt(e0(:,1).^2+e0(:,2).^2+e0(:,3).^2); +de1=sqrt(e1(:,1).^2+e1(:,2).^2+e1(:,3).^2); +de2=sqrt(e2(:,1).^2+e2(:,2).^2+e2(:,3).^2); +l2=[de0.^2 de1.^2 de2.^2]; + +%using ew to calulate the cot of the angles for the voronoi area +%calculation. ew is the triangle barycenter, I later check if its inside or +%outide the triangle +ew=[l2(:,1).*(l2(:,2)+l2(:,3)-l2(:,1)) l2(:,2).*(l2(:,3)+l2(:,1)-l2(:,2)) l2(:,3).*(l2(:,1)+l2(:,2)-l2(:,3))]; + +s=(de0+de1+de2)/2; +%Af - face area vector +FaceArea=sqrt(max(0, s.*(s-de0).*(s-de1).*(s-de2)));%herons formula for triangle area, could have also used 0.5*norm(cross(e0,e1)) +% if any(~Af) || any(~FaceArea) +% error('Degenerate faces.'); +% end + +%calculate weights +Acorner=zeros(size(FV.Faces,1),3); +VertexArea=zeros(size(FV.Vertices,1),1); + +% Calculate Vertice Normals +VertexNormals=zeros([size(FV.Vertices,1) 3]); + +% up=zeros([size(FV.Vertices,1) 3]); +% vp=zeros([size(FV.Vertices,1) 3]); +for i=1:size(FV.Faces,1) + %Calculate weights according to N.Max [1999] + + wfv1=FaceArea(i)/(de1(i)^2*de2(i)^2); + wfv2=FaceArea(i)/(de0(i)^2*de2(i)^2); + wfv3=FaceArea(i)/(de1(i)^2*de0(i)^2); + + VertexNormals(FV.Faces(i,1),:)=VertexNormals(FV.Faces(i,1),:)+wfv1*FaceNormals(i,:); + VertexNormals(FV.Faces(i,2),:)=VertexNormals(FV.Faces(i,2),:)+wfv2*FaceNormals(i,:); + VertexNormals(FV.Faces(i,3),:)=VertexNormals(FV.Faces(i,3),:)+wfv3*FaceNormals(i,:); + %Calculate areas for weights according to Meyer et al. [2002] + %check if the tringle is obtuse, right or acute + + if ew(i,1)<=0 + Acorner(i,2)=-0.25*l2(i,3)*FaceArea(i)/(e0(i,:)*e2(i,:)'); + Acorner(i,3)=-0.25*l2(i,2)*FaceArea(i)/(e0(i,:)*e1(i,:)'); + Acorner(i,1)=FaceArea(i)-Acorner(i,2)-Acorner(i,3); + elseif ew(i,2)<=0 + Acorner(i,3)=-0.25*l2(i,1)*FaceArea(i)/(e1(i,:)*e0(i,:)'); + Acorner(i,1)=-0.25*l2(i,3)*FaceArea(i)/(e1(i,:)*e2(i,:)'); + Acorner(i,2)=FaceArea(i)-Acorner(i,1)-Acorner(i,3); + elseif ew(i,3)<=0 + Acorner(i,1)=-0.25*l2(i,2)*FaceArea(i)/(e2(i,:)*e1(i,:)'); + Acorner(i,2)=-0.25*l2(i,1)*FaceArea(i)/(e2(i,:)*e0(i,:)'); + Acorner(i,3)=FaceArea(i)-Acorner(i,1)-Acorner(i,2); + else + ewscale=0.5*FaceArea(i)/(ew(i,1)+ew(i,2)+ew(i,3)); + Acorner(i,1)=ewscale*(ew(i,2)+ew(i,3)); + Acorner(i,2)=ewscale*(ew(i,1)+ew(i,3)); + Acorner(i,3)=ewscale*(ew(i,2)+ew(i,1)); + end + VertexArea(FV.Faces(i,1))=VertexArea(FV.Faces(i,1))+Acorner(i,1); + VertexArea(FV.Faces(i,2))=VertexArea(FV.Faces(i,2))+Acorner(i,2); + VertexArea(FV.Faces(i,3))=VertexArea(FV.Faces(i,3))+Acorner(i,3); + +% %Calculate initial coordinate system +% up(FV.Faces(i,1),:)=e2_norm(i,:); +% up(FV.Faces(i,2),:)=e0_norm(i,:); +% up(FV.Faces(i,3),:)=e1_norm(i,:); +end +VertexNormals=normr(VertexNormals); + +% %Calculate initial vertex coordinate system +% for i=1:size(FV.Vertices,1) +% up(i,:)=cross(up(i,:),VertexNormals(i,:)); +% up(i,:)=up(i,:)/norm(up(i,:)); +% vp(i,:)=cross(VertexNormals(i,:),up(i,:)); +% end + +% disp('Finished Calculating vertex normals'); +end + + +function [FaceNormals, FaceArea]=CalcFaceNormals(FV) +%% Summary +%Author: Itzik Ben Shabat +%Last Update: July 2014 + +%CalcFaceNormals recives a list of vrtexes and Faces in FV structure +% and calculates the normal at each face and returns it as FaceNormals +%INPUT: +%FV - face-vertex data structure containing a list of Vertices and a list of Faces +%OUTPUT: +%FaceNormals - an nX3 matrix (n = number of Faces) containng the norml at each face +%% Code +% Get all edge vectors +e0=FV.Vertices(FV.Faces(:,3),:)-FV.Vertices(FV.Faces(:,2),:); +e1=FV.Vertices(FV.Faces(:,1),:)-FV.Vertices(FV.Faces(:,3),:); +% Calculate normal of face +FaceNormalsA=cross(e0,e1); +FaceArea = sqrt(sum(FaceNormalsA.^2, 2)) / 2; +FaceNormals=normr(FaceNormalsA); +end + +function V = normr(V) + V = bsxfun(@rdivide, V, sqrt(sum(V.^2, 2))); +end + diff --git a/toolbox/anatomy/SurfaceSmooth.m b/toolbox/anatomy/SurfaceSmooth.m new file mode 100755 index 000000000..90be3eb74 --- /dev/null +++ b/toolbox/anatomy/SurfaceSmooth.m @@ -0,0 +1,544 @@ +function V = SurfaceSmooth(Surf, Faces, VoxSize, DisplTol, IterTol, Freedom, Verbose) + % Smooth closed triangulated surface to remove "voxel" artefacts. + % + % V = SurfaceSmooth(Surf, [], VoxSize, DisplTol, IterTol, Freedom, Verbose) + % V = SurfaceSmooth(Vertices, Faces, VoxSize, DisplTol, IterTol, Freedom, Verbose) + % + % Smooth a triangulated surface, trying to optimize getting rid of blocky + % voxel segmentation artefacts but still respect initial segmentation. + % This is achieved by restricting vertex displacement along the surface + % normal to half the voxel size, and by compensating normal displacement + % of a voxel by an opposite distributed shift in its neighbors. That + % way, the total normal displacement is approximately zero (before + % potential restrictions are applied). + % + % Tangential motion is currently left unrestricted, which means the mesh + % will readjust over many iterations, much more than necessary to obtain + % a smoothed surface. On the other hand, this produces a more uniform + % triangulation, which may be desirable in some cases, e.g. after a + % reducepatch operation. This tangential motion may also make the normal + % restriction a bit less accurate. This all depends on how irregular the + % mesh was to start with. To avoid long running times and some of the + % tangential deformation, DisplTol and IterTol can be used to limit the + % number of iterations. + % + % To separate these two effects (normal smoothing and tangential mesh + % uniformization), the function can be run to achieve each type of motion + % separately, by setting Freedom to the appropriate value (see below). + % Even if both effects are desired, but precision in the normal + % displacement restriction is preferred over running time, I would + % suggest running twice (norm., tang.) or three times (tang., norm., + % tang.), but only once in the normal direction. Note that tangential + % motion is not perfect and may cause a small amount of smoothing as + % well. + % + % Input variables: + % Surf: Instead of Vertices and Faces, a single structure can be given + % with fields 'Vertices' and 'Faces' (lower-case v, f also work). In this + % case, leave Faces empty []. + % Vertices [nV, 3]: Point 3d coordinates. + % Faces [nF, 3]: Triangles, i.e. 3 point indices. + % VoxSize (default inf): Length of voxels, this determines the amount of + % smoothing. For a voxel size of 1, vertices are allowed to move only + % 0.5 units in the surface normal direction. This is somewhat optimal + % for getting rid of artefacts: it allows steps to become flat even at + % shallow angles and a single voxel cube would be transformed to a + % sphere of identical voxel volume. + % DisplTol (default 0.01*VoxSize): Once the maximum displacement of + % vertices is less than this distance, the algorithm stops. If two + % values are given, e.g. [0.01, 0.01], the second value is compared to + % normal displacement only. The first limit encountered stops + % iterating. This allows stopping earlier if only smoothing is + % desired and not mesh uniformity. + % IterTol (default 100): If the algorithm did not converge, it will stop + % after this many iterations. + % Freedom (default 2): Indicate which motion is allowed by the + % algorithm with an integer value: 0 for (restricted) normal + % smoothing, 1 for (unrestricted) tangential motion to get a more + % uniform triangulation, or 2 for both at the same time. + % Verbose (default 0): If 1, writes initial and final volumes and + % areas on the command line. Also gives the number of iterations and + % final displacement when the algorithm converged. (A warning is + % always given if convergence was not obtained in IterTol iterations.) If + % > 1, details are given at each iteration. + % + % Output: Modified voxel coordinates [nV, 3]. + % + % Written by Marc Lalancette, Toronto, Canada, 2014-02-04 + % Volume calculation from divergence theorem idea: + % http://www.mathworks.com/matlabcentral/fileexchange/26982-volume-of-a-surface-triangulation + + % Note: Although this seems to work relatively well, it is still very new + % and not fully tested. Despite the description above which is what was + % intended, the algorithm had the tendency to drive growing oscillations + % (from iteration to iteration) on the surface. Thus a basic damping + % mechanism was added: I simply multiply each movement by a fraction that + % seems to avoid oscillations and still converge rapidly enough. + + % Attempt at damping oscillations. Reduce any movement by a certain + % fraction. (Multiply movements by this factor.) + DampingFactor = 0.91; + + % Add visualizations to debug. + isDebugFigures = true; + + if ~isstruct(Surf) + if nargin < 2 || isempty(Faces) + error('Faces required as second input or "faces" field of first input.'); + else + SurfV = Surf; + clear 'Surf'; + Surf.Vertices = SurfV; + Surf.Faces = Faces; + clear SurfV Faces; + end + else + if isfield(Surf, 'faces') + Surf.Faces = Surf.faces; + Surf = rmfield(Surf, 'faces'); + end + if isfield(Surf, 'vertices') + Surf.Vertices = Surf.vertices; + Surf = rmfield(Surf, 'vertices'); + elseif ~isfield(Surf, 'Vertices') + error('Surf.Vertices field required when second input is empty.'); + end + end + if nargin < 3 || isempty(VoxSize) + VoxSize = inf; + if nargin < 5 || isempty(IterTol) + error(['Unrestricted smoothing (no VoxSize) would lead to a sphere of similar volume, ', ... + 'unless limited by the number of iterations.']); + end + end + if nargin < 4 || isempty(DisplTol) + DisplTol = 0.01 * VoxSize; + end + if numel(DisplTol) == 1 + % Only stop when total displacement reaches the limit, normal displacement alone + % isn't checked for stopping. + DisplTol = [DisplTol, 0]; + end + if nargin < 5 || isempty(IterTol) + IterTol = 100; + end + if nargin < 6 || isempty(Freedom) + Freedom = 2; % 0=norm, 1=tang, 2=both. + end + if nargin < 7 || isempty(Verbose) + Verbose = false; + end + + % Verify surface is a triangulation. + if size(Surf.Faces, 2) > 3 + error('SurfaceSmooth only works with a triangulated surface.'); + end + + % Optimal allowed normal displacement, in units of voxel side length. + % Based on turning a single voxel into a sphere of same volume: max + % needed displacement is in corner: + % sqrt(3)/2 - 1/(4/3*pi)^(1/3) = 0.2457 + % In middle of face it is rather: + % 1/(4/3*pi)^(1/3) - 1/2 = 0.1204 + % Based on very gentle sloped staircase, it would be 0.5, but for 45 + % degree steps, we only need cos(pi/4)/2 = 0.3536. So something along + % those lines seems like a good compromize. For now try to make steps + % completely disappear. + MaxNormDispl = 0.5 * VoxSize; + % MaxDispl = 2 * VoxSize; % To avoid large scale slow flows tangentially, which could distort. + + nV = size(Surf.Vertices, 1); + %nF = size(Surf.Faces, 1); + + % Remove duplicate faces. Not necessary considering we have to use + % unique on the edges later anyway. + % Surf.Faces = unique(Surf.Faces, 'rows'); + + if Verbose + if isDebugFigures + [FdA, VdA, FN, VN] = CalcVertexNormals(Surf); + ViewSurfWithNormals(Surf.Vertices, Surf.Faces, VN, FN, VdA, FdA) + else + [FdA, VdA, FN] = CalcAreas(Surf); + end + FaceCentroidZ = ( Surf.Vertices(Surf.Faces(:, 1), 3) + ... + Surf.Vertices(Surf.Faces(:, 2), 3) + Surf.Vertices(Surf.Faces(:, 3), 3) ) /3; + Pre.Volume = FaceCentroidZ' * (FN(:, 3) .* FdA); + Pre.Area = sum(FdA); + fprintf('Total enclosed volume before smoothing: %g\n', Pre.Volume); + fprintf('Total area before smoothing: %g\n', Pre.Area); + end + + % Calculate connectivity matrix. + + % Euler characteristic (2-2handles) = V - E + F + % For simple closed surface, E = 3*F/2 + % disp(nV) + % disp(2 + nF/2) + + % This expression works when each edge is found once in each direction, + % i.e. as long as all normals are consistently pointing in (or out). + % C = sparse(Faces(:), [Faces(:, 2); Faces(:, 3); Faces(:, 1)], true); + % Seems users had patches that didn't satisfy this restriction, or had + % duplicate faces or had possibly intersecting surfaces with 3 faces + % sharing an edge. + [Edges, ~, iE] = unique(sort([Surf.Faces(:), ... + [Surf.Faces(:, 2); Surf.Faces(:, 3); Surf.Faces(:, 1)]], 2), 'rows'); % [Surf.Faces...] = Edges(iE,:) + % Look for boundaries of open surface. + isBoundE = false(size(Edges, 1), 1); + isBoundV = false(nV, 1); + iE = sort(iE); + n = 1; + for i = 2:numel(iE) + if iE(i) ~= iE(i-1) + if n == 1 + % Only one copy, boundary edge. + isBoundE(iE(i-1)) = true; + else + n = 1; + end + else + if n == 2 + % This makes a 3rd copy of the same edge. Strange surface. + isBoundE(iE(i)) = true; + end + n = n + 1; + end + end + % This was very slow for many edges. + % for i = 1:size(Edges, 1) + % isBoundE(i) = sum(iE == i) < 2; + % end + if any(isBoundE) + if Verbose + warning('Open surface detected. Results may be unexpected.'); + end + isBoundV(Edges(isBoundE, :)) = true; + end + iBoundV = find(isBoundV); + iBulkV = setdiff(1:nV, iBoundV); + % Vertex connectivity matrix, does not include diagonal (self) + C = sparse(Edges, Edges(:, [2,1]), true); % Fills symetrically 1>2, 2>1 + %C = C | C'; + % Logical matrix would be huge, so use sparse. However tests in R2011b + % indicate that using logical sparse indexing is sometimes slightly + % faster (possibly when using linear indexing) but sometimes noticeably + % slower. Seems here using a cell array is better. + CCell = cell(nV, 1); + CCellBulk = cell(nV, 1); + for v = 1:nV + CCell{v} = find(C(:, v)); + CCellBulk{v} = setdiff(CCell{v}, iBoundV); + end + clear C + % Number of connected neighbors at each vertex. + % nC = full(sum(C, 1)); + + V = Surf.Vertices; + LastMaxDispl = [inf, inf]; % total, normal only + Iter = 0; + NormDispl = zeros(nV, 1); + while LastMaxDispl(1) > DisplTol(1) && LastMaxDispl(2) > DisplTol(2) && ... + Iter < IterTol + Iter = Iter + 1; + [~, VdA, ~, N] = CalcVertexNormals(Surf); + % Double boundary vertex areas to balance their "pull". But not very precise, depends on boundary shape. + VdA(isBoundV) = 2 * VdA(isBoundV); + VWeighted = bsxfun(@times, VdA, Surf.Vertices); + + % Moving step. (This is slow.) + switch Freedom + case 2 % Both. + for v = iBulkV + % Neighborhood average. Improved to weigh by area element to avoid + % tangential deformation based on number of neighbors (e.g. shrinking + % towards vertices with fewer neighbors). + NeighdA = sum(VdA(CCell{v})); + NeighdABulk = sum(VdA(CCellBulk{v})); + NeighborAverage = sum(VWeighted(CCell{v}, :) / NeighdA, 1); % / nC(v); + % Neighborhood correction displacement along normal. Volume + % corresponding to this point's normal movement, distributed (divided) + % over neighborhood area + itself, which will be shifted inversely. + NormalDisplCorr = (NeighborAverage - Surf.Vertices(v, :)) * N(v, :)' / (NeighdABulk/VdA(v) + 1); % / (nC(v) + 1); + % Central point is moved to average of neighbors, but shifted back a + % bit as they all will be. + V(v, :) = V(v, :) + DampingFactor * ( NeighborAverage - Surf.Vertices(v, :) - NormalDisplCorr * N(v, :) ); + % Neighbors are shifted a bit too along their own normals, such that + % the total change in volume (normal displacement times surface area) + % is close to zero. + V(CCellBulk{v}, :) = V(CCellBulk{v}, :) - DampingFactor * NormalDisplCorr * N(CCellBulk{v}, :); + end + case 0 % Normal motion only. + for v = iBulkV + NeighdA = sum(VdA(CCell{v})); + NeighdABulk = sum(VdA(CCellBulk{v})); + NeighborAverage = sum(VWeighted(CCell{v}, :) / NeighdA, 1); % / nC(v); + % d * a / (b+a) = d / (b/a + 1) + NormalDisplCorr = (NeighborAverage - Surf.Vertices(v, :)) * N(v, :)' / (NeighdABulk/VdA(v) + 1); % / (nC(v) + 1); + % The vertex should move the projected distance minus the correction. + % 1 - a/(b+a) = b/(b+a) = 1/(1+a/b) = (b/a) * 1/(b/a+1) + V(v, :) = V(v, :) + DampingFactor * NeighdABulk/VdA(v) * NormalDisplCorr * N(v, :); + V(CCellBulk{v}, :) = V(CCellBulk{v}, :) - DampingFactor * NormalDisplCorr * N(CCellBulk{v}, :); + end + case 1 % Tangential motion only. Unrestricted. + for v = iBulkV + NeighdA = sum(VdA(CCell{v})); + NeighborAverage = sum(VWeighted(CCell{v}, :) / NeighdA, 1); % / nC(v); + NormalDisplacement = (NeighborAverage - Surf.Vertices(v, :)) * N(v, :)'; % / (nC(v) + 1); + V(v, :) = V(v, :) + DampingFactor * ( (NeighborAverage - Surf.Vertices(v, :)) - NormalDisplacement * N(v, :) ); + % No compensation among neighbors. + end + otherwise + error('Unrecognized Freedom parameter. Should be 0, 1 or 2.'); + end + % Restricting step. + % Displacements along normals (N at last positions, Surf.Vertices), added to + % previous normal displacement since we want to restrict total normal + % displacement. + D = NormDispl + dot((V - Surf.Vertices), N, 2); + % New restricted total normal displacement. + NormDispl = sign(D) .* min(abs(D), MaxNormDispl); + % Amounts to move back if greater than allowed. + D = D - NormDispl; + Where = abs(D) > DisplTol(1) * 1e-6; % > 0, but ignore precision errors. + % Fix. + if any(Where) + V(Where, :) = V(Where, :) - [D(Where), D(Where), D(Where)] .* N(Where, :); + end + % New restriction on tangential displacement. [Not implemented.] + % MaxDispl + + if Verbose > 1 + [LastMaxDispl(1), iMax(1)] = max(sqrt( sum((V - Surf.Vertices).^2, 2)) ); + [LastMaxDispl(2), iMax(2)] = max(abs(dot(V - Surf.Vertices, N, 2))); + TangDisplVec = CrossProduct(V - Surf.Vertices, N); + [LastMaxDispl(3), iMax(3)] = max(sqrt(TangDisplVec(:,1).^2 + TangDisplVec(:,2).^2 + TangDisplVec(:,3).^2)); + fprintf('Iter %d: max displ %1.4g at vox %d; norm %1.4g (vox %d); tang %1.4g (vox %d)\n', ... + Iter, LastMaxDispl(1), iMax(1), ... + sign((V(iMax(2),:) - Surf.Vertices(iMax(2),:)) * N(iMax(2),:)')*LastMaxDispl(2), iMax(2), ... + sign(TangDisplVec(iMax(3), 1))*LastMaxDispl(3), iMax(3)); + % Signs are to see if these are oscillations or translations. + else + LastMaxDispl(1) = sqrt( max(sum((V - Surf.Vertices).^2, 2)) ); + LastMaxDispl(2) = max(dot(V - Surf.Vertices, N, 2)); + end + Surf.Vertices = V; + end + + if Iter >= IterTol && Verbose + warning('SurfaceSmooth did not converge within %d iterations. \nLast max point displacement = %f', ... + IterTol, LastMaxDispl(1)); + elseif Verbose + fprintf('SurfaceSmooth converged in %d iterations. \nLast max point displacement = %f\n', ... + Iter, LastMaxDispl(1)); + end + if Verbose && IterTol > 0 + [FdA, ~, FN] = CalcVertexNormals(Surf); + FaceCentroidZ = ( Surf.Vertices(Surf.Faces(:, 1), 3) + ... + Surf.Vertices(Surf.Faces(:, 2), 3) + Surf.Vertices(Surf.Faces(:, 3), 3) ) /3; + Post.Volume = FaceCentroidZ' * (FN(:, 3) .* FdA); + Post.Area = sum(FdA); + fprintf('Total enclosed volume after smoothing: %g\n', Post.Volume); + fprintf('Relative volume change: %g %%\n', ... + 100 * (Post.Volume - Pre.Volume)/Pre.Volume); + fprintf('Total area after smoothing: %g\n', Post.Area); + fprintf('Relative area change: %g %%\n', ... + 100 * (Post.Area - Pre.Area)/Pre.Area); + end + + + +end + +% Much faster than using the Matlab version. +function c = CrossProduct(a, b) + c = [a(:,2).*b(:,3)-a(:,3).*b(:,2), ... + a(:,3).*b(:,1)-a(:,1).*b(:,3), ... + a(:,1).*b(:,2)-a(:,2).*b(:,1)]; +end + +% ---------------------------------------------------------------------- +function [FaceArea, VertexArea, FaceNormals, VertexNormals] = CalcVertexNormals(Surf) + % Get face and vertex normals and areas + + % First, see if Brainstorm function is present. + isBst = exist('tess_normals', 'file') == 2; + + % Get areas first. + if isBst + [FaceArea, VertexArea] = CalcAreas(Surf); + % Might need to use connectivity matrix if we get bad normals. + [VertexNormals, FaceNormals] = tess_normals(Surf.Vertices, Surf.Faces); % , VertConn + else + [FaceArea, VertexArea, FaceNormals, VertexNormals] = CalcAreas(Surf); + end +end + +% % Calculate dA normal vectors to each vertex. +% function [N, VdA, FN, FdA] = CalcVertexNormals(S) +% N = zeros(nV, 3); +% % Get face normal vectors with length the size of the face area. +% FNdA = CrossProduct( (S.Vertices(S.Faces(:, 2), :) - S.Vertices(S.Faces(:, 1), :)), ... +% (S.Vertices(S.Faces(:, 3), :) - S.Vertices(S.Faces(:, 2), :)) ) / 2; +% % For vertex normals, add adjacent face normals, then normalize. Also +% % add 1/3 of each adjacent area element for vertex area. +% FdA = sqrt(FNdA(:,1).^2 + FNdA(:,2).^2 + FNdA(:,3).^2); +% VdA = zeros(nV, 1); +% for ff = 1:size(S.Faces, 1) % (This is slow.) +% N(S.Faces(ff, :), :) = N(S.Faces(ff, :), :) + FNdA([ff, ff, ff], :); +% VdA(S.Faces(ff, :), :) = VdA(S.Faces(ff, :), :) + FdA(ff)/3; +% end +% N = bsxfun(@rdivide, N, sqrt(N(:,1).^2 + N(:,2).^2 + N(:,3).^2)); +% FN = bsxfun(@rdivide, FNdA, FdA); +% end + +% % Calculate areas of faces and vertices. +% function [FdA, VdA, FN] = CalcAreas(S) +% % Get face normal vectors with length the size of the face area. +% FN = CrossProduct( (S.Vertices(S.Faces(:, 2), :) - S.Vertices(S.Faces(:, 1), :)), ... +% (S.Vertices(S.Faces(:, 3), :) - S.Vertices(S.Faces(:, 2), :)) ) / 2; +% FdA = sqrt(FN(:,1).^2 + FN(:,2).^2 + FN(:,3).^2); % no sum for speed +% if nargout > 2 +% FN = bsxfun(@rdivide, FN, FdA); +% end +% % For vertex areas, add 1/3 of each adjacent area element. +% VdA = zeros(size(S.Vertices, 1), 1); +% for iV = 1:3 +% VdA(s.Faces(:, iV)) = VdA(s.Faces(:, iV)) + FdA / 3; +% end +% end + +% % Find boundary vertices. +% function isBound = FindBoundary(Faces) +% nF = size(Faces, 1); +% Found = logical(nF); +% Inside = logical(nF); +% for f = 1:nF +% for e = 1:3 +% if Found(Faces(f, e), Faces(f, mod(e, 3)+1)) +% Inside(Faces(f, e), Faces(f, mod(e, 3)+1)) = true; +% else +% Found(Faces(f, e), Faces(f, mod(e, 3)+1)) = true; +% end +% end +% end +% isBound = Found & ~Inside; +% end + + +function ViewSurfWithNormals(Vertices, Faces, VNorm, FNorm, VArea, FArea) + figure; + hold on; + axis equal; + + % Create surface patch + patch('Faces', Faces, 'Vertices', Vertices, ... + 'FaceColor', 'grey', 'EdgeColor', 'k', 'FaceAlpha', 0.6); + + % Compute face centers + face_centers = mean(reshape(Vertices(Faces', :), [], 3, size(Faces, 2)), 2); + face_centers = squeeze(face_centers); + + % Scale normals for visualization + normal_length = 0.05 * mean(range(Vertices)); % Adjust scaling as needed + FNorm = normal_length * bsxfun(@times, FNorm, FArea) / max(FArea); + VNorm = normal_length * bsxfun(@times, VNorm, VArea) / max(VArea); + + % Plot face normals (Red) + quiver3(face_centers(:,1), face_centers(:,2), face_centers(:,3), ... + FNorm(:,1), FNorm(:,2), FNorm(:,3), ... + 'r', 'LineWidth', 1.5, 'MaxHeadSize', 0.5); + + % Plot vertex normals (Blue) + quiver3(Vertices(:,1), Vertices(:,2), Vertices(:,3), ... + VNorm(:,1), VNorm(:,2), VNorm(:,3), ... + 'b', 'LineWidth', 1, 'MaxHeadSize', 0.5); + + % Lighting and view settings + camlight; + lighting gouraud; + view(3); +end + + +function [FaceArea, VertexArea, FaceNormals, VertexNormals] = CalcAreas(S) + % Compute face areas, and divides them to assign vertex areas. + % Face areas are split into three parts such that each point is assigned to the + % vertex it is closest to, similar to Voronoi diagrams, using triangle + % circumcenters. + + nV = size(S.Vertices, 1); + nF = size(S.Faces, 1); + + % Extract triangle vertex positions + Vertices = reshape(S.Vertices(S.Faces(:), :), [nF, 3, 3]); % nF, 3 (x,y,z), 3 (iV) + % Edge vectors, ordered such that each is opposite to the correspondingly indexed + % vertex (edge 1 is from v2 to v3, opposite vertex 1). + % circshift +1 moves elements to the next (larger) index, so the last element + % gets to position 1. So here we're doing, e.g. v3 - v2 in first position. + Edges = circshift(Vertices, 1, 3) - circshift(Vertices, -1, 3); % nF, 3 (x,y,z), 3 (iE) + + % Get face normal vectors with length the size of the face area. + FaceNormals = CrossProduct(Edges(:,:,3), Edges(:,:,1)) / 2; + FaceArea = sqrt(FaceNormals(:,1).^2 + FaceNormals(:,2).^2 + FaceNormals(:,3).^2); % no sum for speed + if nargout > 2 + FaceNormals = bsxfun(@rdivide, FaceNormals, FaceArea); + end + + % Edge lengths + EdgeSq = squeeze(Edges(:,1,:).^2 + Edges(:,2,:).^2 + Edges(:,3,:).^2); % nF, 3 (iE) + % Circumcenter barycentric weights + BaryWeights = EdgeSq .* (circshift(EdgeSq, 1, 2) + circshift(EdgeSq, -1, 2) - EdgeSq); % nF, 3 (iE) + + FaceVertAreas = zeros(nF, 3); + + % Process in 4 batches + % Logical index for faces that have not been processed + isRemain = true(nF, 1); + % First three cases: circumcenter outside triangular face, past one of 3 edges + % This divides the area into two triangles, and one pentagon (or rectangle if + % original face has a right angle). + for iLongest = 1:3 + isDo = BaryWeights(:,iLongest) <= 0; + isRemain = isRemain & ~isDo; + iSh = circshift(1:3, 1-iLongest); % place iLongest in first position + % Two vertices of long edge; areas are triangles with right angle + % -1/4 face area * ratio of adjoining short edge to projection of long edge on that short edge. + % (show with similar right triangle and edge length ratios) Minus sign because of + % "dot product" with vectors always making obtuse angle. + FaceVertAreas(isDo,iSh(2)) = 0.25 * EdgeSq(isDo,iSh(3)) .* FaceArea(isDo,:) ./ -sum(Edges(isDo,:,iSh(1)) .* Edges(isDo,:,iSh(3)), 2); + FaceVertAreas(isDo,iSh(3)) = 0.25 * EdgeSq(isDo,iSh(2)) .* FaceArea(isDo,:) ./ -sum(Edges(isDo,:,iSh(1)) .* Edges(isDo,:,iSh(2)), 2); + % Vertex opposite long edge; area has 4 or 5 sides, just assign remaining area. + FaceVertAreas(isDo,iSh(1)) = FaceArea(isDo,:) - FaceVertAreas(isDo,iSh(2)) - FaceVertAreas(isDo,iSh(3)); + end + % Last case: circumcenter is inside face, each region is a quadrilateral + % Normalize weights and scale with half face area + BaryWeights = BaryWeights ./ (BaryWeights(:,1) + BaryWeights(:,2) + BaryWeights(:,3)); + FaceVertAreas(isRemain,:) = 1/2 * FaceArea(isRemain,:) .* (circshift(BaryWeights(isRemain,:), 1, 2) + circshift(BaryWeights(isRemain,:), -1, 2)); + + % Now sum back to single vertex area list + VertexArea = accumarray(S.Faces(:), FaceVertAreas(:), [nV, 1]); + + % Use areas as weights to average face normals, if requested + if nargout > 3 + VertexNormals = zeros(nV, 3); + % Have to do each coordinate (x,y,z) sequentially with accumarray + for i = 1:3 + % Weight face normals by face vertex area, which we will normalize at the + % end by dividing by total vertex area. + WeightedFaceVertN = bsxfun(@times, FaceVertAreas, FaceNormals(:,i)); + VertexNormals(:,i) = accumarray(S.Faces(:), WeightedFaceVertN(:), [nV, 1]); + end + % Normalize for the area weights + VertexNormals = bsxfun(@rdivide, VertexNormals, VertexArea); + + % Final check no NaN + if any(isnan(VertexNormals)) + error('NaN values in VertexNormals.'); + end + end + if any(isnan(VertexArea)) + error('NaN values in VertexArea.'); + end + +end diff --git a/toolbox/anatomy/bst_normalize_mni.m b/toolbox/anatomy/bst_normalize_mni.m index db313340e..5a8645fed 100644 --- a/toolbox/anatomy/bst_normalize_mni.m +++ b/toolbox/anatomy/bst_normalize_mni.m @@ -82,16 +82,16 @@ TpmFile = bst_get('SpmTpmAtlas'); % If it is not found: download if isempty(TpmFile) - % Create folder - if ~file_exist(bst_fileparts(TpmFile)) - mkdir(bst_fileparts(TpmFile)); - end % URL to download - tmpUrl = 'http://neuroimage.usc.edu/bst/getupdate.php?t=SPM_TPM'; + tpmUrl = 'http://neuroimage.usc.edu/bst/getupdate.php?t=SPM_TPM'; % Path to downloaded file - tpmZip = bst_fullfile(bst_get('BrainstormUserDir'), 'defaults', 'spm', 'SPM_TPM'); + tpmZip = bst_fullfile(bst_get('BrainstormUserDir'), 'defaults', 'spm', 'SPM_TPM.zip'); + % Create 'spm' folder if needed + if ~isdir(bst_fileparts(tpmZip)) + mkdir(bst_fileparts(tpmZip)); + end % Download file - errMsg = gui_brainstorm('DownloadFile', tmpUrl, tpmZip, 'Download template'); + errMsg = gui_brainstorm('DownloadFile', tpmUrl, tpmZip, 'Download template'); % Error message if ~isempty(errMsg) errMsg = ['Impossible to download template:' 10 errMsg]; @@ -99,7 +99,7 @@ end % Progress bar bst_progress('text', 'Importing SPM template...'); - % URL: Download zip file + % Unzip file try unzip(tpmZip, bst_fileparts(tpmZip)); catch diff --git a/toolbox/anatomy/bst_warp_prepare.m b/toolbox/anatomy/bst_warp_prepare.m index 91d41ee56..254fac751 100644 --- a/toolbox/anatomy/bst_warp_prepare.m +++ b/toolbox/anatomy/bst_warp_prepare.m @@ -156,7 +156,7 @@ file_delete(MriFiles, 1); end end -% Force subject to us default anatomy +% Force subject to use default anatomy s.UseDefaultAnat = 0; s.Anatomy = []; s.Surface = []; @@ -268,10 +268,39 @@ file_copy(bst_fullfile(atlasDir, dirScout(i).name), bst_fullfile(OutputDir, dirScout(i).name)) end - %% ===== UPDATE DATABASE ===== % Reload subject db_reload_subjects(iSubject); + +%% ===== SET DEFAULT SURFACES ===== +isUpdate = 0; +sSubject = bst_get('Subject', iSubject); +for surfaceCatergory = {'Anatomy' 'Scalp', 'Cortex', 'InnerSkull', 'OuterSkull', 'Fibers', 'FEM'} + if strcmp('Anatomy', surfaceCatergory{1}) + surfaceGroup = surfaceCatergory{1}; + else + surfaceGroup = 'Surface'; + end + if ~isempty(sDefSubject.(['i', surfaceCatergory{1}])) + defDefFilename = sDefSubject.(surfaceGroup)(sDefSubject.(['i', surfaceCatergory{1}])).FileName; + [~, filename, ext] = bst_fileparts(defDefFilename); + iDef = find(file_compare({sSubject.(surfaceGroup).FileName}, bst_fullfile(fileparts(sSubject.FileName), [filename, OutputTag, ext] )), 1); + if ~isempty(iDef) + sSubject.(['i', surfaceCatergory{1}]) = iDef; + matUpdate.(surfaceCatergory{1}) = bst_fullfile(fileparts(sSubject.FileName), [filename, OutputTag, ext]); + isUpdate = 1; + end + end +end +if isUpdate + % Update Database + bst_set('Subject', iSubject, sSubject); + % Update SubjectFile + bst_save(file_fullpath(sSubject.FileName), matUpdate, 'v7', 1); +end + + +%% ===== DISPLAY WARPED HEAD AND CORTEX ===== % Unload all the surfaces, close all the figures bst_memory('UnloadAll', 'Forced'); % Get subject again diff --git a/toolbox/anatomy/cs_convert.m b/toolbox/anatomy/cs_convert.m index 250fa170b..706e4cb15 100644 --- a/toolbox/anatomy/cs_convert.m +++ b/toolbox/anatomy/cs_convert.m @@ -16,7 +16,7 @@ % DESCRIPTION: https://neuroimage.usc.edu/brainstorm/CoordinateSystems % - voxel : X=left>right, Y=posterior>anterior, Z=bottom>top % Coordinate of the center of the first voxel at the bottom-left-posterior of the MRI volume: (1,1,1) -% - mri : Same as 'voxel' but in millimeters instead of voxels: mriXYZ = voxelXYZ * Voxsize +% - mri : Same as 'voxel' but in meters (not mm here) instead of voxels: mriXYZ = voxelXYZ * Voxsize % - scs : Based on: Nasion, left pre-auricular point (LPA), and right pre-auricular point (RPA). % Origin: Midway on the line joining LPA and RPA % Axis X: From the origin towards the Nasion (exactly through) @@ -122,7 +122,7 @@ scs2captrak = [tCapTrak.R, tCapTrak.T; 0 0 0 1]; end -% ===== CONVERT SRC => MRI ===== +% ===== CONVERT SRC => MRI (m) ===== % Evaluate the transformation to apply switch lower(src) case 'voxel' @@ -174,7 +174,7 @@ error(['Invalid coordinate system: ' src]); end -% ===== CONVERT MRI => DEST ===== +% ===== CONVERT MRI (m) => DEST ===== % Evaluate the transformation to apply switch lower(dest) case 'voxel' diff --git a/toolbox/anatomy/mri_coregister.m b/toolbox/anatomy/mri_coregister.m index 291d1941b..87547a012 100644 --- a/toolbox/anatomy/mri_coregister.m +++ b/toolbox/anatomy/mri_coregister.m @@ -9,7 +9,7 @@ % - MriFileRef : Relative path to the Brainstorm MRI file used as a reference % - sMriSrc : Brainstorm MRI structure to register (fields Cube, Voxsize, SCS, NCS...) % - sMriRef : Brainstorm MRI structure used as a reference -% - Method : Method used for the coregistration of the volume: 'spm', 'mni', 'vox2ras' +% - Method : Method used for the coregistration of the volume: 'spm', 'mni', 'vox2ras', 'ct2mri' % - isReslice : If 1, reslice the output volume to match dimensions of the reference volume % - isAtlas : If 1, perform only integer/nearest neighbors interpolations (MNI and VOX2RAS registration only) % @@ -38,6 +38,7 @@ % =============================================================================@ % % Authors: Francois Tadel, 2016-2023 +% Chinmay Chinara, 2023 % ===== LOAD INPUTS ===== % Parse inputs @@ -133,7 +134,8 @@ % Create coregistration batch if isReslice - % Coreg: Estimate and reslice + % Coregister: Estimate and reslice + bst_progress('text', 'Calling SPM batch...(Coregister: Estimate & Reslice)'); matlabbatch{1}.spm.spatial.coreg.estwrite.ref = {[NiiRefFile, ',1']}; matlabbatch{1}.spm.spatial.coreg.estwrite.source = {[NiiSrcFile, ',1']}; matlabbatch{1}.spm.spatial.coreg.estwrite.other = {''}; @@ -143,7 +145,8 @@ % Output file NiiRegFile = bst_fullfile(TmpDir, 'rspm_src.nii'); else - % Coreg: Estimate + % Coregister: Estimate + bst_progress('text', 'Calling SPM batch...(Coregister: Estimate)'); matlabbatch{1}.spm.spatial.coreg.estimate.ref = {[NiiRefFile, ',1']}; matlabbatch{1}.spm.spatial.coreg.estimate.source = {[NiiSrcFile, ',1']}; matlabbatch{1}.spm.spatial.coreg.estimate.other = {''}; @@ -233,7 +236,68 @@ end % Output file tag fileTag = '_mni'; + + % ===== CT2MRIREG ===== + case 'ct2mri' + % Check if ct2mrireg plugin is installed + [isInstalled, errMsg] = bst_plugin('Install', 'ct2mrireg'); + if ~isInstalled + if ~isProgress + bst_progress('stop'); + end + return; + end + + % Save files in tmp directory + bst_progress('text', 'Saving temporary files...'); + % Get temporary folder + TmpDir = bst_get('BrainstormTmpDir', 0, 'ct2mrireg'); + % Save source CT in .nii format + NiiSrcFile = bst_fullfile(TmpDir, 'ct2mri_src.nii'); + out_mri_nii(sMriSrc, NiiSrcFile); + % Save reference MRI in .nii format + NiiRefFile = bst_fullfile(TmpDir, 'ct2mri_ref.nii'); + out_mri_nii(sMriRef, NiiRefFile); + + % Perform the coregistration of the CT to MRI + NiiRegFile = bst_fullfile(TmpDir, 'contrastmri2preMRI.nii.gz'); + bst_progress('text', 'Performing co-registration using ct2mrireg plugin...'); + NiiRegFile = ct2mrireg(NiiSrcFile, NiiRefFile, NiiRegFile); + + % Read output volume + sMriReg = in_mri(NiiRegFile, 'ALL', 0, 0); + + % Delete the temporary files + file_delete(TmpDir, 1, 1); + % Output file tag + fileTag = '_ct2mri'; + % === UPDATE FIDUCIALS === + if isReslice + % Use the reference SCS coordinates + if isfield(sMriRef, 'SCS') + sMriReg.SCS = sMriRef.SCS; + end + % Use the reference NCS coordinates + if isfield(sMriRef, 'NCS') + sMriReg.NCS = sMriRef.NCS; + end + + % Reslice the volume + bst_progress('text', 'Performing Reslicing...'); + [sMriReg, errMsg] = mri_reslice(sMriReg, sMriRef, 'scs', 'scs', isAtlas); + % Error handling + if isempty(sMriReg) || ~isempty(errMsg) + if ~isProgress + bst_progress('stop'); + end + return + end + else + isUpdateScs = 1; + isUpdateNcs = 1; + end + % ===== VOX2RAS ===== case 'vox2ras' % Nothing to do, just reslice if needed @@ -334,6 +398,10 @@ % Add history entry sMriReg.History = sMriSrc.History; sMriReg = bst_history('add', sMriReg, 'resample', ['MRI co-registered on default file (' Method '): ' MriFileRef]); + % Add history entry (reslice) + if isReslice + sMriReg = bst_history('add', sMriReg, 'resample', ['MRI resliced to default file: ' MriFileRef]); + end % Save new file MriFileRegFull = file_unique(strrep(file_fullpath(MriFileSrc), '.mat', [fileTag '.mat'])); MriFileReg = file_short(MriFileRegFull); diff --git a/toolbox/anatomy/mri_histogram.m b/toolbox/anatomy/mri_histogram.m index 6d985006f..579008bfa 100644 --- a/toolbox/anatomy/mri_histogram.m +++ b/toolbox/anatomy/mri_histogram.m @@ -23,11 +23,11 @@ % |- max[4] : array of the 4 most important maxima (structure) % | |- x : intensity of the given maximum % | |- y : amplitude of this maximum (number of MRI voxels with this value) -% | |- power : difference of this maximum and the adjacent minima +% | |- power : difference of this maximum and the adjacent minimum % |- min[4] : array of the 3 most important minima (structure) % | |- x : intensity of the given minimum % | |- y : amplitude of this minimum (number of MRI voxels with this value) -% | |- power : difference of this minimum and the adjacent maxima +% | |- power : difference of this minimum and the adjacent maximum % |- bgLevel : intensity value that separates the background and the objects (estimation) % |- whiteLevel : white matter threshold % |- intensityMax : maximum value in the volume @@ -102,11 +102,11 @@ end % Construct a regular Histogram function -% Suppress all indices that has zero-values (to avoid previous normalizations) -% NOTA : Do not consider the values at the intensity value 0, it may +% Suppress all indices that have zero values (to avoid previous normalizations) +% NOTA : Do not consider the first bin (intensity 0), it may % not correspond to the real image Histogram... index = find(Histogram.fncY > 10); % PREVIOUSLY: 100 instead of 10 -index = index(2:length(index)); +index = index(2:length(index)); % discard first bin histoX = [0 Histogram.fncX(index)]; histoY = [0 Histogram.fncY(index)]; @@ -136,7 +136,7 @@ minIndex(diff(minIndex) == 1) = []; end -% Detect and deleting all "wrong" extrema (that are too close to each other) +% Detect and delete all "wrong" extrema (that are too close to each other) epsilon = max(histoX)*.02; i = 1; while(i <= length(maxIndex)) @@ -179,7 +179,7 @@ Histogram.max(i).y = histoY(maxIndex(i)); if(length(minIndex)>=1) % If there is at least a minimum, power = distance between - % maximum and adjacent minima + % maximum and adjacent minimum Histogram.max(i).power = histoY(maxIndex(i)) - (histoY(minIndex(max(1, i-1))) + histoY(minIndex(min(length(minIndex), i))))./2; else % Else power = maximum value @@ -223,26 +223,28 @@ defaultWhite = round(interp1(unikCumulFncY, unikFncX, .8)); Histogram.bgLevel = defaultBg; Histogram.whiteLevel = defaultWhite; - % Detect if the background has already been removed : - % ie. if there is a unique 0 valued interval a the beginning of the Histogram - % Practically : - nzero = length of the first 0-valued interval - % - nnonzero = length of the first non-0-valued interval - % - bg removed if : (nzero > 1) and (nnonzero > nzero) - nzero = find(Histogram.fncY(2:length(Histogram.fncY)) ~= 0); - nnonzero = find(Histogram.fncY((nzero(1)+1):length(Histogram.fncY)) == 0); - if ((nzero(1)>2) && ~isempty(nnonzero) && (nnonzero(1) > nzero(1))) - Histogram.bgLevel = nzero(1); + % Detect if the background has already been removed, i.e. if a range of low intensity values + % were replaced by zeros in the volume, producing an interval of empty bins at the beginning + % of the Histogram, after the first (zero-intensity) bin. Require also that the zero bin be + % the largest, as it seems denoising can produce this background removal effect but with a + % very low threshold which is not appropriate. + [~, iMaxBin] = max(Histogram.fncY); + % First non-empty bin after first bin. + iNonZero = find(Histogram.fncY(2:end) ~= 0, 1) + 1; + if iMaxBin == 1 && ~isempty(iNonZero) && iNonZero > 2 + % There was an interval of empty bins. Set threshold at last empty bin intensity. + Histogram.bgLevel = Histogram.fncX(iNonZero - 1); % Else, background has not been removed yet - % If there is less than two maxima : use the default background threshold + % If there are fewer than two maxima : use the default background threshold elseif (length(cat(1,Histogram.max.x)) < 2) Histogram.bgLevel = defaultBg; Histogram.whiteLevel = defaultWhite; - % Else if there is more than one maxima : + % Else if there is more than one maximum : else - % If the highest maxima is > (3*second highest maxima) : - % it is a background maxima : use the first minima after the - % background maxima as background threshold - % (and if this minima exist) + % If the highest maximum is > (3*second highest maximum) : + % it is a background maximum : use the first minimum after the + % background maximum as background threshold + % (and if this minimum exists) [orderedMaxVal, orderedMaxInd] = sort(cat(1,Histogram.max.y), 'descend'); if ((orderedMaxVal(1) > 3*orderedMaxVal(2)) && (length(Histogram.min) >= orderedMaxInd(1))) Histogram.bgLevel = Histogram.min(orderedMaxInd(1)).x; @@ -251,7 +253,20 @@ Histogram.bgLevel = defaultBg; end end - + + case 'headgrad' + dX = mean(diff(Histogram.smoothFncX)); + %Deriv = gradient(Histogram.smoothFncY, dX); + %SecondDeriv = gradient(Deriv, dX); + RemainderCumul = Histogram.smoothFncY ./ cumsum(Histogram.smoothFncY, 2, 'reverse'); + DerivRC = gradient(RemainderCumul, dX); + %DerivRC2 = Deriv ./ cumsum(Histogram.smoothFncY, 2, 'reverse'); + %figure; plot(Histogram.smoothFncX', [RemainderCumul', DerivRC']); legend({'hist/remaining', 'derivative'}); + % Pick point where things flatten. + Histogram.bgLevel = Histogram.smoothFncX(find(DerivRC > -0.005 & DerivRC < DerivRC([2:end, end]), 1) + 2); + % Can't get white matter with gradient. + Histogram.whiteLevel = 0; + case 'brain' % Determine an intensity value for the background/gray matter limit % and the gray matter/white matter level @@ -328,5 +343,4 @@ end - \ No newline at end of file diff --git a/toolbox/anatomy/mri_reslice.m b/toolbox/anatomy/mri_reslice.m index e3144613f..0dd0b9318 100644 --- a/toolbox/anatomy/mri_reslice.m +++ b/toolbox/anatomy/mri_reslice.m @@ -274,7 +274,7 @@ % Update comment sMriReg.Comment = file_unique(sMriReg.Comment, {sSubject.Anatomy.Comment}); % Add history entry - sMriReg = bst_history('add', sMriReg, 'resample', ['MRI co-registered on default file: ' MriFileRef]); + sMriReg = bst_history('add', sMriReg, 'resample', ['MRI resliced to default file: ' MriFileRef]); % Save new file MriFileRegFull = file_unique(strrep(file_fullpath(MriFileSrc), '.mat', [fileTag '.mat'])); MriFileReg = file_short(MriFileRegFull); diff --git a/toolbox/anatomy/mri_reslice_mni.m b/toolbox/anatomy/mri_reslice_mni.m index e09bea32c..ba91f406a 100644 --- a/toolbox/anatomy/mri_reslice_mni.m +++ b/toolbox/anatomy/mri_reslice_mni.m @@ -74,7 +74,7 @@ newCube = interp3(Y1, X1, Z1, sMriMni.Cube, Xgrid2mni, Ygrid2mni, Zgrid2mni, 'nearest', NaN); % Cubic interpolation for floating point values else - newCube = single(interp3(Y1, X1, Z1, double(sMriSrc.Cube), Xgrid2mni, Ygrid2mni, Zgrid2mni, 'cubic', 0)); + newCube = single(interp3(Y1, X1, Z1, double(sMriMni.Cube), Xgrid2mni, Ygrid2mni, Zgrid2mni, 'cubic', 0)); end % Replace bad values with 0 (points that do not have MNI coordinates) newCube(isnan(newCube)) = 0; diff --git a/toolbox/anatomy/mri_skullstrip.m b/toolbox/anatomy/mri_skullstrip.m new file mode 100644 index 000000000..ec7282ed7 --- /dev/null +++ b/toolbox/anatomy/mri_skullstrip.m @@ -0,0 +1,215 @@ +function [MriFileMask, errMsg, fileTag, binBrainMask] = mri_skullstrip(MriFileSrc, MriFileRef, Method) +% MRI_SKULLSTRIP: Skull stripping on 'MriFileSrc' using 'MriFileRef' as reference MRI. +% Both volumes must have the same Cube and Voxel size +% +% USAGE: [MriFileMask, errMsg, fileTag, binBrainMask] = mri_skullstrip(MriFileSrc, MriFileRef, Method) +% [sMriMask, errMsg, fileTag, binBrainMask] = mri_skullstrip(sMriSrc, sMriRef, Method) +% +% INPUTS: +% - MriFileSrc : MRI structure or MRI file to apply skull stripping on +% - MriFileRef : MRI structure or MRI file to find brain masking for skull stripping +% If empty, the Default MRI for that Subject with 'MriFileSrc' is used +% - Method : If 'BrainSuite', use BrainSuite's Brain Surface Extractor (BSE) +% If 'SPM', use SPM Tissue Segmentation +% +% OUTPUTS: +% - MriFileMask : MRI structure or MRI file after skull stripping +% - errMsg : Error message. Empty if no error +% - fileTag : Tag added to the comment and filename +% - binBrainMask : Volumetric binary mask of the skull stripped 'MriFileRef' reference MRI +% +% @============================================================================= +% This function is part of the Brainstorm software: +% https://neuroimage.usc.edu/brainstorm +% +% Copyright (c) University of Southern California & McGill University +% This software is distributed under the terms of the GNU General Public License +% as published by the Free Software Foundation. Further details on the GPLv3 +% license can be found at http://www.gnu.org/copyleft/gpl.html. +% +% FOR RESEARCH PURPOSES ONLY. THE SOFTWARE IS PROVIDED "AS IS," AND THE +% UNIVERSITY OF SOUTHERN CALIFORNIA AND ITS COLLABORATORS DO NOT MAKE ANY +% WARRANTY, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO WARRANTIES OF +% MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE, NOR DO THEY ASSUME ANY +% LIABILITY OR RESPONSIBILITY FOR THE USE OF THIS SOFTWARE. +% +% For more information type "brainstorm license" at command prompt. +% =============================================================================@ +% +% Authors: Raymundo Cassani, 2024 +% Chinmay Chinara, 2024 + +% ===== PARSE INPUTS ===== +% Parse inputs +if (nargin < 3) + Method = []; +end + +% Initialize outputs +MriFileMask = []; +errMsg = ''; +fileTag = ''; +binBrainMask = []; + +% Return if invalid Method +if isempty(Method) || strcmpi(Method, 'Skip') + return; +end + +% Progress bar +isProgress = bst_progress('isVisible'); +if ~isProgress + bst_progress('start', 'MRI skull stripping', 'Loading input volumes...'); +end +% USAGE: mri_reslice(sMriSrc, sMriRef) +if isstruct(MriFileSrc) + sMriSrc = MriFileSrc; + sMriRef = MriFileRef; + MriFileSrc = []; + MriFileRef = []; +% USAGE: mri_reslice(MriFileSrc, MriFileRef) +elseif ischar(MriFileSrc) + % Get the default MRI for this subject + if isempty(MriFileRef) + sSubject = bst_get('MriFile', MriFileSrc); + MriFileRef = sSubject.Anatomy(sSubject.iAnatomy).FileName; + end + % Load MRI volumes + sMriSrc = in_mri_bst(MriFileSrc); + sMriRef = in_mri_bst(MriFileRef); +else + error('Invalid call.'); +end + +% Check that same size +refSize = size(sMriRef.Cube(:,:,:,1)); +srcSize = size(sMriSrc.Cube(:,:,:,1)); +if ~all(refSize == srcSize) || ~all(round(sMriRef.Voxsize(1:3) .* 1000) == round(sMriSrc.Voxsize(1:3) .* 1000)) + errMsg = 'Skull stripping cannot be performed if the reference MRI has different size'; + return +end + +% === SKULL STRIPPING === +% Reset any previous logo +bst_plugin('SetProgressLogo', []); +switch lower(Method) + case 'brainsuite' + % Check for BrainSuite Installation + [~, errMsg] = process_dwi2dti('CheckBrainSuiteInstall'); + if ~isempty(errMsg) + bst_progress('text', 'Skipping skull stripping. BrainSuite not installed.'); + return + end + % Set the BrainSuite logo + bst_progress('setimage', bst_fullfile(bst_get('BrainstormDocDir'), 'plugins', 'brainsuite_logo.png')); + % Get temporary folder + TmpDir = bst_get('BrainstormTmpDir', 0, 'brainsuite'); + % Save reference MRI in .nii format + NiiRefFile = bst_fullfile(TmpDir, 'mri_ref.nii'); + out_mri_nii(sMriRef, NiiRefFile); + % Perform skull stripping using Brain Surface Extractor (BSE) + bst_progress('text', 'Skull Stripping: BrainSuite Brain Surface Extractor...'); + strCall = [... + 'bse -i "' NiiRefFile '" --auto' ... + ' -o "' fullfile(TmpDir, 'skull_stripped_mri.nii.gz"') ... + ' --trim --mask "' fullfile(TmpDir, 'bse_smooth_brain.mask.nii.gz"') ... + ' --hires "' fullfile(TmpDir, 'bse_detailled_brain.mask.nii.gz"') ... + ' --cortex "' fullfile(TmpDir, 'bse_cortex_file.nii.gz"')]; + disp(['BST> System call: ' strCall]); + status = system(strCall); + % Error handling + if (status ~= 0) + errMsg = ['BrainSuite failed at step BSE.', 10, 'Check the Matlab command window for more information.']; + return + end + % Get the brain mask + NiiBrainMaskFile = bst_fullfile(TmpDir, 'bse_smooth_brain.mask.nii.gz'); + sMriBrainMask = in_mri(NiiBrainMaskFile, 'ALL', 0, 0); + % Make it a binary mask + sMriBrainMask.Cube = sMriBrainMask.Cube/255; + % Some erosion to reduce any artefacts + sMriBrainMask.Cube = sMriBrainMask.Cube & ~mri_dilate(~sMriBrainMask.Cube, 3); + % Logic brain mask cube + binBrainMask = sMriBrainMask.Cube > 0; + % Temporary files to delete + filesDel = TmpDir; + + case 'spm' + % Check for SPM12 installation + [isInstalledSpm, errMsg] = bst_plugin('Install', 'spm12'); + if ~isInstalledSpm + bst_progress('text', 'Skipping skull stripping. SPM not installed.'); + return; + end + % Set the SPM logo + bst_plugin('SetProgressLogo', 'spm12'); + % Perform skull stripping using SPM Tissue Segmentation + bst_progress('text', 'Skull Stripping: SPM Segment...'); + % Reset matlabbatch to start fresh + clear matlabbatch; + % Get the TPM atlas + TpmFile = bst_get('SpmTpmAtlas', 'SPM'); + % Get the SPM tissue segments + [~, TpmFiles] = mri_normalize_segment(sMriRef, TpmFile); + % Compute brain mask: union(GM, WM, CSF) + sGm = in_mri_nii(TpmFiles{2}, 0, 0, 0); + sWm = in_mri_nii(TpmFiles{1}, 0, 0, 0); + sCsf = in_mri_nii(TpmFiles{3}, 0, 0, 0); + binBrainMask = (sGm.Cube + sWm.Cube + sCsf.Cube) > 0; + % Temporary files to delete + filesDel = bst_fileparts(TpmFiles{1}); + + otherwise + errMsg = ['Invalid skull stripping method: ' Method]; + return +end +% Reset logo +bst_progress('removeimage'); + +% Apply brain mask +sMriMask = sMriSrc; +sMriMask.Cube(~binBrainMask) = 0; +% File tag +fileTag = sprintf('_masked_%s', lower(Method)); + +% ===== SAVE NEW FILE ===== +% Add file tag +sMriMask.Comment = [sMriSrc.Comment, fileTag]; +% Save output +if ~isempty(MriFileSrc) + bst_progress('text', 'Saving new file...'); + % Get subject + [sSubject, iSubject] = bst_get('MriFile', MriFileSrc); + % Update comment + sMriMask.Comment = file_unique(sMriMask.Comment, {sSubject.Anatomy.Comment}); + % Add history entry + sMriMask = bst_history('add', sMriMask, 'resample', ['Skull stripping with "' Method '" using on default file: ' MriFileRef]); + % Save new file + MriFileMaskFull = file_unique(strrep(file_fullpath(MriFileSrc), '.mat', [fileTag '.mat'])); + MriFileMask = file_short(MriFileMaskFull); + % Save new MRI in Brainstorm format + sMriMask = out_mri_bst(sMriMask, MriFileMaskFull); + + % Register new MRI + iAnatomy = length(sSubject.Anatomy) + 1; + sSubject.Anatomy(iAnatomy) = db_template('Anatomy'); + sSubject.Anatomy(iAnatomy).FileName = MriFileMask; + sSubject.Anatomy(iAnatomy).Comment = sMriMask.Comment; + % Update subject structure + bst_set('Subject', iSubject, sSubject); + % Refresh tree + panel_protocols('UpdateNode', 'Subject', iSubject); + panel_protocols('SelectNode', [], 'anatomy', iSubject, iAnatomy); + % Save database + db_save(); +else + % Return output structure + MriFileMask = sMriMask; +end + +% Delete the temporary files +file_delete(filesDel, 1, 1); +% Close progress bar +if ~isProgress + bst_progress('stop'); +end \ No newline at end of file diff --git a/toolbox/anatomy/tess_check.m b/toolbox/anatomy/tess_check.m new file mode 100644 index 000000000..a3bc191a1 --- /dev/null +++ b/toolbox/anatomy/tess_check.m @@ -0,0 +1,201 @@ +function [isOk, Info] = tess_check(Vertices, Faces, isVerbose, isOpenOk, isShow) +% TESS_CHECK: Check the integrity of a tesselation. +% +% USAGE: [isOk, Details] = tess_check(Vertices, Faces, Verbose) +% +% DESCRIPTION: +% Check if a surface mesh is simple, closed, non self-intersecting, well oriented, duplicate +% vertices or faces, etc. There are some custom checks, and if available, use the Matlab Lidar +% toolbox. Could add meshcheckrepair from iso2mesh toolbox, and possibly others. +% +% INPUTS: +% - Vertices : Mx3 double matrix +% - Faces : Nx3 double matrix +% - isOpenOk : An open surface is considered ok, otherwise flag non-closed as an issue. +% - isVerbose : Write details to command window if any unexpected features. +% - isShow : Display surface in new figure +% OUTPUTS: +% - isOk : All checks look good +% - Details : Structure with all check statuses + +% @============================================================================= +% This function is part of the Brainstorm software: +% https://neuroimage.usc.edu/brainstorm +% +% Copyright (c) University of Southern California & McGill University +% This software is distributed under the terms of the GNU General Public License +% as published by the Free Software Foundation. Further details on the GPLv3 +% license can be found at http://www.gnu.org/copyleft/gpl.html. +% +% FOR RESEARCH PURPOSES ONLY. THE SOFTWARE IS PROVIDED "AS IS," AND THE +% UNIVERSITY OF SOUTHERN CALIFORNIA AND ITS COLLABORATORS DO NOT MAKE ANY +% WARRANTY, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO WARRANTIES OF +% MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE, NOR DO THEY ASSUME ANY +% LIABILITY OR RESPONSIBILITY FOR THE USE OF THIS SOFTWARE. +% +% For more information type "brainstorm license" at command prompt. +% =============================================================================@ +% +% Authors: Marc Lalancette, 2025 + +% Parse inputs +if nargin < 4 || isempty(isOpenOk) + isOpenOk = false; +end +if nargin < 3 || isempty(isVerbose) + isVerbose = true; +end +% Check matrices orientation +if (size(Vertices, 2) ~= 3) || (size(Faces, 2) ~= 3) + error('Faces and Vertices must have 3 columns (X,Y,Z).'); +end + +isOk = true; +Info = []; + +% Some custom checks first based on face edge connectivity. +% First check duplicate faces (based on vertex indices, not coordinates) +[~, FaceConn3] = tess_faceconn(Faces, 3); % 3 vertices in common = duplicate +nAdjFaces = sum(FaceConn3, 2) - 1; +Info.nDuplicate = sum(nAdjFaces > 0) / 2; +% Then check for other issues +[~, FaceConn] = tess_faceconn(Faces, 2); +nAdjFaces = sum(FaceConn, 2) - 1; +Info.nEdgeDisconnectedFaces = sum(nAdjFaces == 0); +Info.nEdgeOverConnectedFaces = sum(nAdjFaces > 3); % 5, 7, 9 found after reducepatch +% If there is an even number of adjacent faces > 3, there's something strange. An odd number could +% be two lobes of the same surface just touching on an edge or face, not necessarily intersecting. +Info.nEvenEdgeOverConnectedFaces = sum(nAdjFaces > 3 & ~mod(nAdjFaces,2)); +Info.nBoundaryFaces = sum(nAdjFaces == 1) + sum(nAdjFaces == 2); + +if Info.nDuplicate > 0 + isOk = false; + if isVerbose + fprintf('BST>Surface has %d duplicate faces.\n', Info.nDuplicate); + end +end +if Info.nEdgeDisconnectedFaces > 0 % yes many after reducepatch + isOk = false; + if isVerbose + fprintf('BST>Surface has %d disconnected faces (no shared edges, but possibly one shared vertex).\n', Info.nEdgeDisconnectedFaces); + end +end +if Info.nEdgeOverConnectedFaces > 0 + isOk = false; + if isVerbose + fprintf('BST>Surface intersects or has touching/duplicate edges or faces (%d).\n', Info.nEdgeOverConnectedFaces); + if Info.nEvenEdgeOverConnectedFaces > 0 + fprintf('BST>Surface has edges shared by 3,5,7,... faces, indicating strange topology (%d).\n', Info.nEvenEdgeOverConnectedFaces); + end + end +end +% Note openness if ok so far +if isOk + if Info.nBoundaryFaces > 0 + Info.isOpen = true; + else + Info.isOpen = false; + end +else % don't bother defining if surface is weird + Info.isOpen = []; +end +% Check for boundary faces either way if we want closed +if Info.nBoundaryFaces > 0 && ~isOpenOk + isOk = false; + if isVerbose + fprintf('BST>Surface has %d boundary edges.\n', Info.nBoundaryFaces); + end +end + +% Check orientation if ok so far; so we should have each edge shared by 2 faces, or 1 if open. +Info.isOriented = []; +if isOk + Edges = [Faces(:), [Faces(:, 2); Faces(:, 3); Faces(:, 1)]]; + isEdgeFlip = Edges(:, 1) > Edges(:, 2); + [~, ~, iE] = unique(sort(Edges, 2), 'rows'); + % Look for boundaries of open surface. + isBoundE = false(size(Edges, 1), 1); + [iE, iSort] = sort(iE); + % Add one more row for the loop to also evaluate the last real edge. + iE(end+1,:) = 0; + n = 1; + for i = 2:numel(iE) + if iE(i) ~= iE(i-1) + % Evaluate previous edge + if n == 1 + % Only one copy, boundary edge. + isBoundE(iE(i-1)) = true; + elseif n == 2 + % Two faces, were the orientations different? + if sum(isEdgeFlip(iSort([i-1,i-2]))) ~= 1 % should be sum([0,1]) + isOk = false; + if isVerbose + fprintf('BST>Surface not well oriented (face normals are mixed pointing in and out).\n'); + end + break; + end + % Reset for new edge + n = 1; + else + % Previously undetected issue with edge shared among more than 2 faces. + isOk = false; + if isVerbose + fprintf('BST>Surface has edge shared among more than 2 faces.\n'); + end + break; + end + else + n = n + 1; + end + end +end + +if isShow + FigureId = db_template('FigureId'); + FigureId.Type = '3DViz'; + hFig = figure_3d('CreateFigure', FigureId); + figure_3d('PlotSurface', hFig, Faces, Vertices, [1,1,1], 0); % color, transparency required + figure_3d('ViewAxis', hFig, true); % isVisible + hFig.Visible = "on"; +end + + +% ------------------------------------------- +% Check if Lidar Toolbox is installed (requires image processing + computer vision) +isLidarToolbox = exist('surfaceMesh', 'file') == 2; +if ~isLidarToolbox + fprintf('BST>tess_downsize method "simplify" requires Matlab''s Lidar Toolbox, which was not found.\n'); + return; +end +% Create mesh object +oMesh = surfaceMesh(Vertices, Faces); + +% Check all mesh features Lidar Toolbox offers +Info.isVertexManifold = isVertexManifold(oMesh); % Check if surface mesh is vertex-manifold +Info.isEdgeManifold = isEdgeManifold(oMesh, true); % allow boundary edges +Info.isClosedManifold = isEdgeManifold(oMesh, false); % allow boundary edges +Info.isOrientable = isOrientable(oMesh); % Check if surface mesh is orientable +% Self-intersecting test is slow (not sure how long, didn't wait more than 15 minutes, uses one core only) +%Info.isSelfIntersecting = isSelfIntersecting(oMesh); % Check if surface mesh is self-intersecting +Info.isWatertight = isWatertight(oMesh); % Check if surface mesh is watertight +% removeDefects could be used in tess_clean + +if isVerbose + if (isOpenOk && ~Info.isEdgeManifold) || (~isOpenOk && ~Info.isClosedManifold) + fprintf('BST>Surface not "edge manifold" (each edge has at most one face on each side).\n'); + end + if ~Info.isVertexManifold + fprintf('BST>Surface not "vertex manifold" (like a "fan" at each vertex).\n'); + end + if ~Info.isOrientable + fprintf('BST>Surface not well oriented (face normals are mixed pointing in and out).\n'); + end + % if Info.isSelfIntersecting + % fprintf('BST>Surface self-intersects.\n'); + % end +end + +end + + + diff --git a/toolbox/anatomy/tess_deface.m b/toolbox/anatomy/tess_deface.m new file mode 100644 index 000000000..e16c0ce4c --- /dev/null +++ b/toolbox/anatomy/tess_deface.m @@ -0,0 +1,62 @@ +function [head_surface] = tess_deface(head_surface) +% TESS_DEFACE: Removing non-essential vertices (bottom half of the subject's face in mesh) to deface the 3D mesh +% +% USAGE: [head_surface] = tess_deface(head_surface); +% +% INPUT: +% - head_surface: Brainstorm tesselation structure with fields: +% |- Vertices : {[nVertices x 3] double}, in millimeters +% |- Faces : {[nFaces x 3] double} +% |- Color : {[nColors x 3] double}, normalized between 0-1 (optional) +% +% OUTPUT: +% - head_surface: Brainstorm tesselation structure with fields: +% |- Vertices : {[nVertices x 3] double}, in millimeters +% |- Faces : {[nFaces x 3] double} +% |- Color : {[nColors x 3] double}, normalized between 0-1 (optional) +% +% @============================================================================= +% This function is part of the Brainstorm software: +% https://neuroimage.usc.edu/brainstorm +% +% Copyright (c) University of Southern California & McGill University +% This software is distributed under the terms of the GNU General Public License +% as published by the Free Software Foundation. Further details on the GPLv3 +% license can be found at http://www.gnu.org/copyleft/gpl.html. +% +% FOR RESEARCH PURPOSES ONLY. THE SOFTWARE IS PROVIDED "AS IS," AND THE +% UNIVERSITY OF SOUTHERN CALIFORNIA AND ITS COLLABORATORS DO NOT MAKE ANY +% WARRANTY, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO WARRANTIES OF +% MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE, NOR DO THEY ASSUME ANY +% LIABILITY OR RESPONSIBILITY FOR THE USE OF THIS SOFTWARE. +% +% For more information type "brainstorm license" at command prompt. +% =============================================================================@ +% +% Authors: Yash Shashank Vakilna, 2024 +% Chinmay Chinara, 2024 + +% Identify vertices to remove from the surface mesh +% Spherical coordinates +[TH,PHI,R] = cart2sph(head_surface.Vertices(:,1), head_surface.Vertices(:,2), head_surface.Vertices(:,3)); +% Flat projection +R = 1 - PHI ./ pi*2; + +% Remove the identified vertices from the surface mesh +iRemoveVert = find(R > 1.1); +if ~isempty(iRemoveVert) + [head_surface.Vertices, head_surface.Faces] = tess_remove_vert(head_surface.Vertices, head_surface.Faces, iRemoveVert); + if isfield(head_surface, 'Color') + head_surface.Color(iRemoveVert, :) = []; + end +end + +head_surface.VertConn = tess_vertconn(head_surface.Vertices, head_surface.Faces); +head_surface.VertNormals = tess_normals(head_surface.Vertices, head_surface.Faces, head_surface.VertConn); +head_surface.Curvature = tess_curvature(head_surface.Vertices, head_surface.VertConn, head_surface.VertNormals, .1); +[~, head_surface.VertArea] = tess_area(head_surface.Vertices, head_surface.Faces); +head_surface.SulciMap = tess_sulcimap(head_surface); +head_surface.Comment = [head_surface.Comment '_defaced']; +head_surface = bst_history('add', head_surface, 'deface_mesh', 'mesh defaced'); + +end \ No newline at end of file diff --git a/toolbox/anatomy/tess_downsize.m b/toolbox/anatomy/tess_downsize.m index 972c7ac15..5d4b57df1 100644 --- a/toolbox/anatomy/tess_downsize.m +++ b/toolbox/anatomy/tess_downsize.m @@ -2,9 +2,12 @@ % TESS_DOWNSIZE: Reduces the number of vertices in a surface file. % % USAGE: [NewTessFile, iSurface, I, J] = tess_downsize(TessFile, newNbVertices=[ask], Method=[ask]); +% [NewTessMat, iSurface, I, J] = tess_downsize(TessMat, newNbVertices=[ask], Method=[ask]); % % INPUT: % - TessFile : Full path to surface file to decimate +% - TessMat : Already loaded surface structure, a structure is returned in that case and no +% file is saved. % - newNbVertices : Desired number of vertices % - Method : {'reducepatch', 'reducepatch_subdiv', 'iso2mesh', 'iso2mesh_project'} % OUTPUT: @@ -56,11 +59,17 @@ I = []; J = []; +% Surface structure now accepted as input +isTessInput = isstruct(TessFile); %% ===== ASK FOR MISSING OPTIONS ===== % Get the number of vertices -VarInfo = whos('-file',file_fullpath(TessFile),'Vertices'); -oldNbVertices = VarInfo.size(1); +if isTessInput + oldNbVertices = size(TessFile.Vertices, 1); +else + VarInfo = whos('-file',file_fullpath(TessFile),'Vertices'); + oldNbVertices = VarInfo.size(1); +end % If new number of vertices was not provided: ask user if isempty(newNbVertices) % Ask user the new number of vertices @@ -82,23 +91,39 @@ return; end +% Check if Lidar Toolbox is installed (requires image processing + computer vision) +isLidarToolbox = exist('surfaceMesh', 'file') == 2; + % Ask for resampling method if isempty(Method) + % Downsize methods strings + methods_str = {['Matlab''s reducepatch:
' ... + '   | - Inhomogeneous mesh: large faces at the top of the gyri
' ... + '   | - Keeps the atlases and the subjects co-registration'], ... + ['Matlab''s reducepatch + subdivide large faces:
' ... + '   | - The large faces at the top of the gyri are subdivided in three
' ... + '   | - Deletes the atlases and the subjects co-registration'], ... + ['iso2mesh/CGAL library:
' ... + '   | - Homogeneous mesh: all the faces have similar sizes
' ... + '   | - Deletes the atlases and the subject co-registration
' ... + '   | - If the downsample looks dark, right-click > Swap faces']}; + % ['iso2mesh/CGAL + project on the original surface:
' ... + % '   | - Homogeneous mesh but possible topological problems
' ... + % '   | - Damages the atlases and the subject co-registration']}, + + if isLidarToolbox + methods_str{end+1} = ... + ['Matlab''s simplify, from Lidar Toolbox:
' ... + '   | - Possibly better at preserving shape topology than reducepatch
' ... + '   | - Deletes the atlases and the subjects co-registration']; + end + % Identify textured surfaces (color info is present) and show available methods for them + VarInfo = whos('-file',file_fullpath(TessFile), 'Color'); + if all(VarInfo.size ~= 0) + methods_str = methods_str(1); % Inhomogeneous mesh + end % Ask method - ind = java_dialog('radio', 'Select the resampling method:', 'Resample surface', [], ... - {['Matlab''s reducepatch:
' ... - '   | - Inhomogeneous mesh: large faces at the top of the gyri
' ... - '   | - Keeps the atlases and the subjects co-registration'], ... - ['Matlab''s reducepatch + subdivide large faces:
' ... - '   | - The large faces at the top of the gyri are subdivided in three
' ... - '   | - Deletes the atlases and the subjects co-registration'], ... - ['iso2mesh/CGAL library:
' ... - '   | - Homogeneous mesh: all the faces have similar sizes
' ... - '   | - Deletes the atlases and the subject co-registration
' ... - '   | - If the downsample looks dark, right-click > Swap faces']}, 1); -% ['iso2mesh/CGAL + project on the original surface:
' ... -% '   | - Homogeneous mesh but possible topological problems
' ... -% '   | - Damages the atlases and the subject co-registration']}, 1); + ind = java_dialog('radio', 'Select the resampling method:', 'Resample surface', [], methods_str, 1); if isempty(ind) return end @@ -107,7 +132,8 @@ case 1, Method = 'reducepatch'; case 2, Method = 'reducepatch_subdiv'; case 3, Method = 'iso2mesh'; - case 4, Method = 'iso2mesh_project'; + case 4, Method = 'simplify'; + % case 4, Method = 'iso2mesh_project'; end end @@ -121,16 +147,27 @@ end %% ===== LOAD FILE ===== -% Progress bar -bst_progress('start', 'Resample surface', 'Loading file...'); -% Load file -TessMat = in_tess_bst(TessFile); -% Prepare variables +if isTessInput + TessMat = TessFile; + if ~isfield(TessMat, 'Comment') + TessMat.Comment = 'iso head'; + end +else + % Progress bar + bst_progress('start', 'Resample surface', 'Loading file...'); + % Load file + TessMat = in_tess_bst(TessFile); + % Prepare variables +end TessMat.Faces = double(TessMat.Faces); TessMat.Vertices = double(TessMat.Vertices); +if isfield(TessMat, 'Color') + TessMat.Color = double(TessMat.Color); +else + TessMat.Color = []; +end dsFactor = newNbVertices / size(TessMat.Vertices, 1); - %% ===== RESAMPLE ===== bst_progress('start', 'Resample surface', ['Resampling surface: ' TessMat.Comment '...']); % Resampling methods @@ -145,6 +182,9 @@ % Re-order the vertices so that they are in the same order in the output surface [I, iSort] = sort(I); NewTessMat.Vertices = TessMat.Vertices(I,:); + if ~isempty(TessMat.Color) + NewTessMat.Color = TessMat.Color(I,:); + end J = J(iSort); % Re-order the vertices in the faces iSortFaces(J) = 1:length(J); @@ -411,8 +451,29 @@ NewTessMat.Faces = Faces; NewTessMat.Vertices = Vertices; MethodTag = '_iso2mesh_proj'; + + % ===== SIMPLIFY ===== + % Matlab's Lidar Toolbox simplify (since R2022b) + case 'simplify' + if ~isLidarToolbox + fprintf('BST>tess_downsize method "simplify" requires Matlab''s Lidar Toolbox, which was not found.\n'); + end + % Create mesh object + oMesh = surfaceMesh(TessMat.Vertices, TessMat.Faces); + + % Reduce number of vertices + simplify(oMesh, 'TargetNumFaces', (newNbVertices - 2) * 2); % no output variable for this toolbox! + NewTessMat.Faces = oMesh.Faces; + NewTessMat.Vertices = oMesh.Vertices; + end +if isTessInput + % This should go after "remove folded faces" if that step was desired... skipping for now for + % tess_isohead as it does its own checks and corrections. + NewTessFile = NewTessMat; + return; +end %% ===== REMOVE FOLDED FACES ===== % Find equal faces @@ -529,15 +590,18 @@ %% ===== UPDATE DATABASE ===== -% Save downsized surface file -bst_save(NewTessFile, NewTessMat, 'v7'); -% Make output filename relative -NewTessFile = file_short(NewTessFile); -% Get subject -[sSubject, iSubject] = bst_get('SurfaceFile', TessFile); -% Register this file in Brainstorm database -iSurface = db_add_surface(iSubject, NewTessFile, NewComment); - +% Save downsized surface file, or return structure +if isTessInput + NewTessFile = NewTessMat; +else + bst_save(NewTessFile, NewTessMat, 'v7'); + % Make output filename relative + NewTessFile = file_short(NewTessFile); + % Get subject + [~, iSubject] = bst_get('SurfaceFile', TessFile); + % Register this file in Brainstorm database + iSurface = db_add_surface(iSubject, NewTessFile, NewComment); +end % Close progress bar bst_progress('stop'); @@ -548,17 +612,11 @@ % Resample a surface using iso2mesh/CGAL library % Author: Qianqian Fang (fangq nmr.mgh.harvard.edu) function NewTessMat = iso2mesh_resample(TessMat, dsFactor) - % Check if iso2mesh is installed - if ~exist('meshresample', 'file') - bst_error(['Please install iso2mesh on your computer:

' ... - ' 1) Visit the website: http://iso2mesh.sourceforge.net
' ... - ' 2) Download the iso2mesh package for your operating system.
' ... - ' 3) Unzip it on your local hard drive.
' ... - ' 4) Add the iso2mesh folder to your Matlab path.
' ... - ' 5) Try again downsampling the surface.

'], 'Install iso2mesh', 0); - web('http://sourceforge.net/projects/iso2mesh/files/iso2mesh/1.5.0%20%28iso2mesh%202013%29/', '-browser'); - NewTessMat = []; - return; + % Install/load iso2mesh plugin + isInteractive = 1; + [isInstalled, errInstall] = bst_plugin('Install', 'iso2mesh', isInteractive); + if ~isInstalled + error('Plugin "iso2mesh" not available.'); end % Running iso2mesh routine [Vertices,Faces] = meshresample(TessMat.Vertices, TessMat.Faces, dsFactor); diff --git a/toolbox/anatomy/tess_faceconn.m b/toolbox/anatomy/tess_faceconn.m index 98b999ad5..d22f6d26b 100644 --- a/toolbox/anatomy/tess_faceconn.m +++ b/toolbox/anatomy/tess_faceconn.m @@ -1,12 +1,15 @@ -function [VertFacesConn, FaceConn] = tess_faceconn(Faces) +function [VertFacesConn, FaceConn] = tess_faceconn(Faces, nVert) % TESS_FACECONN: Computes faces connectivity. % -% USAGE: [VertFacesConn, FaceConn] = tess_faceconn(Faces); +% USAGE: [VertFacesConn, FaceConn] = tess_faceconn(Faces, nVert=1); % % INPUT: % - Faces : Nx3 double matrix +% - nVert : Number of vertices that a pair of faces need to share to be considered connected. +% nVert=1 by default, but for finding only edge-adjacent faces, use 2. % OUTPUT: -% - FacesConn : sparse matrix [nVertices x nFaces] +% - VertFacesConn : sparse matrix [nVertices x nFaces] +% - FacesConn : sparse matrix [nFaces x nFaces] % @============================================================================= % This function is part of the Brainstorm software: @@ -28,6 +31,11 @@ % % Authors: Anand Joshi, Dimitrios Pantazis, November 2007 % Francois Tadel, 2008-2010 +% Marc Lalancette, 2025 + +if nargin < 2 || isempty(nVert) + nVert = 1; +end % Check matrices orientation if (size(Faces, 2) ~= 3) @@ -43,7 +51,7 @@ % Build FacesConn if (nargout > 1) - FaceConn = (VertFacesConn' * VertFacesConn) > 0; + FaceConn = (VertFacesConn' * VertFacesConn) >= nVert; end diff --git a/toolbox/anatomy/tess_interp_tess2tess.m b/toolbox/anatomy/tess_interp_tess2tess.m index 5ac2f6114..04f590af9 100644 --- a/toolbox/anatomy/tess_interp_tess2tess.m +++ b/toolbox/anatomy/tess_interp_tess2tess.m @@ -318,24 +318,24 @@ % ===== USE FREESURFER SPHERES ===== % Interpolate using the sphere and the Shepard's algorithm if isCortexL && isFreeSurfer - Wmat(sScoutDest.Vertices, sScoutSrc.Vertices) = bst_shepards(vertSphLdest, vertSphLsrc, nbNeighbors, 0); + Wmat(sScoutDest.Vertices, sScoutSrc.Vertices) = bst_shepards(vertSphLdest, vertSphLsrc, nbNeighbors, 0, [], isInteractive); elseif isCortexR && isFreeSurfer - Wmat(sScoutDest.Vertices, sScoutSrc.Vertices) = bst_shepards(vertSphRdest, vertSphRsrc, nbNeighbors, 0); + Wmat(sScoutDest.Vertices, sScoutSrc.Vertices) = bst_shepards(vertSphRdest, vertSphRsrc, nbNeighbors, 0, [], isInteractive); % ===== USE BRAINSUITE SQUARES ===== % Interpolate using the Brainsuite squares and the Shepard's algorithm elseif isCortexL && isBrainSuite % Interpolation: Subject => BrainSuiteAtlas1 - Wsrc2atlas = bst_shepards(vertAtlasLsrc, vertSquareLsrc, nbNeighbors, 0); + Wsrc2atlas = bst_shepards(vertAtlasLsrc, vertSquareLsrc, nbNeighbors, 0, [], isInteractive); % Interpolation: BrainSuiteAtlas1 => Default anatomy - Watlas2dest = bst_shepards(vertSquareLdest, vertAtlasLdest, nbNeighbors, 0); + Watlas2dest = bst_shepards(vertSquareLdest, vertAtlasLdest, nbNeighbors, 0, [], isInteractive); % Combined: Subject => Default anatomy Wmat(sScoutDest.Vertices, sScoutSrc.Vertices) = Watlas2dest * Wsrc2atlas; elseif isCortexR && isBrainSuite % Interpolation: Subject => BrainSuiteAtlas1 - Wsrc2atlas = bst_shepards(vertAtlasRsrc, vertSquareRsrc, nbNeighbors, 0); + Wsrc2atlas = bst_shepards(vertAtlasRsrc, vertSquareRsrc, nbNeighbors, 0, [], isInteractive); % Interpolation: BrainSuiteAtlas1 => Default anatomy - Watlas2dest = bst_shepards(vertSquareRdest, vertAtlasRdest, nbNeighbors, 0); + Watlas2dest = bst_shepards(vertSquareRdest, vertAtlasRdest, nbNeighbors, 0, [], isInteractive); % Combined: Subject => Default anatomy Wmat(sScoutDest.Vertices, sScoutSrc.Vertices) = Watlas2dest * Wsrc2atlas; @@ -386,7 +386,7 @@ % === INTERPOLATION === % Compute Shepard's interpolation - Wmat(sScoutDest.Vertices, sScoutSrc.Vertices) = bst_shepards(vertDest, vertSrcFit, nbNeighbors, 0); + Wmat(sScoutDest.Vertices, sScoutSrc.Vertices) = bst_shepards(vertDest, vertSrcFit, nbNeighbors, 0, [], isInteractive); % === DISPLAY ALIGNMENT === if isInteractive && ~isSameSubject diff --git a/toolbox/anatomy/tess_isohead.m b/toolbox/anatomy/tess_isohead.m index ed1c25137..df44a1fd4 100644 --- a/toolbox/anatomy/tess_isohead.m +++ b/toolbox/anatomy/tess_isohead.m @@ -1,205 +1,710 @@ -function [HeadFile, iSurface] = tess_isohead(iSubject, nVertices, erodeFactor, fillFactor, Comment) -% TESS_GENERATE: Reconstruct a head surface based on the MRI, based on an isosurface -% -% USAGE: [HeadFile, iSurface] = tess_isohead(iSubject, nVertices=10000, erodeFactor=0, fillFactor=2, Comment) -% [HeadFile, iSurface] = tess_isohead(MriFile, nVertices=10000, erodeFactor=0, fillFactor=2, Comment) -% [Vertices, Faces] = tess_isohead(sMri, nVertices=10000, erodeFactor=0, fillFactor=2) -% -% If input is loaded MRI structure, no surface file is created and the surface vertices and faces are returned instead. - -% @============================================================================= -% This function is part of the Brainstorm software: -% https://neuroimage.usc.edu/brainstorm -% -% Copyright (c) University of Southern California & McGill University -% This software is distributed under the terms of the GNU General Public License -% as published by the Free Software Foundation. Further details on the GPLv3 -% license can be found at http://www.gnu.org/copyleft/gpl.html. -% -% FOR RESEARCH PURPOSES ONLY. THE SOFTWARE IS PROVIDED "AS IS," AND THE -% UNIVERSITY OF SOUTHERN CALIFORNIA AND ITS COLLABORATORS DO NOT MAKE ANY -% WARRANTY, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO WARRANTIES OF -% MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE, NOR DO THEY ASSUME ANY -% LIABILITY OR RESPONSIBILITY FOR THE USE OF THIS SOFTWARE. -% -% For more information type "brainstorm license" at command prompt. -% =============================================================================@ -% -% Authors: Francois Tadel, 2012-2022 - -%% ===== PARSE INPUTS ===== -% Initialize returned variables -HeadFile = []; -iSurface = []; -isSave = true; -% Parse inputs -if (nargin < 5) || isempty(Comment) - Comment = []; -end -% MriFile instead of subject index -sMri = []; -if ischar(iSubject) - MriFile = iSubject; - [sSubject, iSubject] = bst_get('MriFile', MriFile); -elseif isnumeric(iSubject) - % Get subject - sSubject = bst_get('Subject', iSubject); - MriFile = sSubject.Anatomy(sSubject.iAnatomy).FileName; -elseif isstruct(iSubject) - sMri = iSubject; - MriFile = sMri.FileName; - [sSubject, iSubject] = bst_get('MriFile', MriFile); - % Don't save a surface file, instead return surface directly. - isSave = false; -else - error('Wrong input type.'); -end +function [HeadFile, iSurface] = tess_isohead(iSubject, nVertices, erodeFactor, fillFactor, bgLevel, Comment, isGradient, Method) + % TESS_GENERATE: Reconstruct a head surface based on the MRI, based on an isosurface + % + % USAGE: [HeadFile, iSurface] = tess_isohead(iSubject, nVertices=10000, erodeFactor=0, fillFactor=2, bgLevel=GuessFromHistorgram, Comment) + % [HeadFile, iSurface] = tess_isohead(MriFile, nVertices=10000, erodeFactor=0, fillFactor=2, bgLevel=GuessFromHistorgram, Comment) + % [Vertices, Faces] = tess_isohead(sMri, nVertices=10000, erodeFactor=0, fillFactor=2) + % + % If input is loaded MRI structure, no surface file is created and the surface vertices and faces are returned instead. -%% ===== LOAD MRI ===== -isProgress = ~bst_progress('isVisible'); -if isempty(sMri) - % Load MRI - bst_progress('start', 'Generate head surface', 'Loading MRI...'); - sMri = bst_memory('LoadMri', MriFile); - if isProgress - bst_progress('stop'); + % @============================================================================= + % This function is part of the Brainstorm software: + % https://neuroimage.usc.edu/brainstorm + % + % Copyright (c) University of Southern California & McGill University + % This software is distributed under the terms of the GNU General Public License + % as published by the Free Software Foundation. Further details on the GPLv3 + % license can be found at http://www.gnu.org/copyleft/gpl.html. + % + % FOR RESEARCH PURPOSES ONLY. THE SOFTWARE IS PROVIDED "AS IS," AND THE + % UNIVERSITY OF SOUTHERN CALIFORNIA AND ITS COLLABORATORS DO NOT MAKE ANY + % WARRANTY, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO WARRANTIES OF + % MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE, NOR DO THEY ASSUME ANY + % LIABILITY OR RESPONSIBILITY FOR THE USE OF THIS SOFTWARE. + % + % For more information type "brainstorm license" at command prompt. + % =============================================================================@ + % + % Authors: Francois Tadel, 2012-2022 + % Marc Lalancette, 2022-2025 + + % To visualize steps for debugging. + isDebugVis = false; + nDebugVisSlices = 9; %#ok + + %% ===== PARSE INPUTS ===== + % Initialize returned variables + HeadFile = []; + iSurface = []; + isSave = true; + % Parse inputs + if (nargin < 8) || isempty(Method) + Method = 'simplify'; + end + if strcmpi(Method, 'simplify') + % Check if Lidar Toolbox is installed (requires image processing + computer vision) + isLidarToolbox = exist('surfaceMesh', 'file') == 2; + if ~isLidarToolbox + bst_error('Lidar toolbox required for method ''simplify''.'); + end + end + if (nargin < 7) || isempty(isGradient) + isGradient = false; + end + if (nargin < 6) + if nargin == 5 + % Handle legacy call: tess_isohead(iSubject, nVertices, erodeFactor, fillFactor, Comment) + if ischar(bgLevel) + Comment = bgLevel; + bgLevel = []; + % Parameter 'bgLevel' is provided: tess_isohead(iSubject, nVertices, erodeFactor, fillFactor, bgLevel) + else + Comment = []; + end + % Call tess_isohead(iSubject, nVertices, erodeFactor, fillFactor) + else + bgLevel = []; + Comment = []; + end + end + % MriFile instead of subject index + sMri = []; + if ischar(iSubject) + MriFile = file_short(iSubject); + [sSubject, iSubject] = bst_get('MriFile', MriFile); + elseif isnumeric(iSubject) + % Get subject + sSubject = bst_get('Subject', iSubject); + MriFile = sSubject.Anatomy(sSubject.iAnatomy).FileName; + elseif isstruct(iSubject) + sMri = iSubject; + MriFile = sMri.FileName; + [sSubject, iSubject] = bst_get('MriFile', MriFile); + % Don't save a surface file, instead return surface directly. + isSave = false; + else + error('Wrong input type.'); end -end -% Save current scouts modifications -panel_scout('SaveModifications'); -% If subject is using the default anatomy: use the default subject instead -if sSubject.UseDefaultAnat - iSubject = 0; -end -% Check layers -if isempty(sSubject.iAnatomy) || isempty(sSubject.Anatomy) - bst_error('The generate of the head surface requires at least the MRI of the subject.', 'Head surface', 0); - return -end -% Check that everything is there -if ~isfield(sMri, 'Histogram') || isempty(sMri.Histogram) || isempty(sMri.SCS) || isempty(sMri.SCS.NAS) || isempty(sMri.SCS.LPA) || isempty(sMri.SCS.RPA) - bst_error('You need to set the fiducial points in the MRI first.', 'Head surface', 0); - return -end -%% ===== ASK PARAMETERS ===== -% Ask user to set the parameters if they are not set -if (nargin < 4) || isempty(erodeFactor) || isempty(nVertices) - res = java_dialog('input', {'Number of vertices [integer]:', 'Erode factor [0,1,2,3]:', 'Fill holes factor [0,1,2,3]:', 'Background threshold:
(guessed from MRI histogram)'}, 'Generate head surface', [], {'10000', '0', '2', num2str(sMri.Histogram.bgLevel)}); - % If user cancelled: return - if isempty(res) + %% ===== LOAD MRI ===== + isProgress = ~bst_progress('isVisible'); + if isempty(sMri) + % Load MRI + bst_progress('start', 'Generate head surface', 'Loading MRI...'); + sMri = bst_memory('LoadMri', MriFile); + if isProgress + bst_progress('stop'); + end + end + % Save current scouts modifications + panel_scout('SaveModifications'); + % If subject is using the default anatomy: use the default subject instead + if sSubject.UseDefaultAnat + iSubject = 0; + end + % Check layers + if isempty(sSubject.iAnatomy) || isempty(sSubject.Anatomy) + bst_error('The generate of the head surface requires at least the MRI of the subject.', 'Head surface', 0); + return + end + % Check that everything is there + if ~isfield(sMri, 'Histogram') || isempty(sMri.Histogram) || isempty(sMri.SCS) || isempty(sMri.SCS.NAS) || isempty(sMri.SCS.LPA) || isempty(sMri.SCS.RPA) + bst_error('You need to set the fiducial points in the MRI first.', 'Head surface', 0); return end - % Get new values - nVertices = str2num(res{1}); - erodeFactor = str2num(res{2}); - fillFactor = str2num(res{3}); - bgLevel = str2num(res{4}); - if isempty(bgLevel) + % Guess background level + if isempty(bgLevel) && ~isGradient bgLevel = sMri.Histogram.bgLevel; end -else - bgLevel = sMri.Histogram.bgLevel; -end -% Check parameters values -if isempty(nVertices) || (nVertices < 50) || (nVertices ~= round(nVertices)) || isempty(erodeFactor) || ~ismember(erodeFactor,[0,1,2,3]) || isempty(fillFactor) || ~ismember(fillFactor,[0,1,2,3]) - bst_error('Invalid parameters.', 'Head surface', 0); - return -end + %% ===== ASK PARAMETERS ===== + % Ask user to set the parameters if they are not set + if (nargin < 4) || isempty(erodeFactor) || isempty(nVertices) + res = java_dialog('input', {'Number of vertices [integer]:', 'Erode factor [0,1,2,3]:', 'Fill holes factor [0,1,2,3]:', 'Background threshold:
(guessed from MRI histogram)'}, 'Generate head surface', [], {'15000', '0', '0', num2str(bgLevel)}); + % If user cancelled: return + if isempty(res) + return + end + % Get new values + nVertices = str2double(res{1}); + erodeFactor = str2double(res{2}); + fillFactor = str2double(res{3}); + bgLevel = str2double(res{4}); + if isempty(bgLevel) + bgLevel = sMri.Histogram.bgLevel; + end + end + % Check parameters values + if isempty(nVertices) || (nVertices < 50) || (nVertices ~= round(nVertices)) || isempty(erodeFactor) || ~ismember(erodeFactor,[0,1,2,3]) || isempty(fillFactor) || ~ismember(fillFactor,[0,1,2,3]) + bst_error('Invalid parameters.', 'Head surface', 0); + return + end -%% ===== CREATE HEAD MASK ===== -% Progress bar -bst_progress('start', 'Generate head surface', 'Creating head mask...', 0, 100); -% Threshold mri to the level estimated in the histogram -headmask = (sMri.Cube(:,:,:,1) > bgLevel); -% Closing all the faces of the cube -headmask(1,:,:) = 0*headmask(1,:,:); -headmask(end,:,:) = 0*headmask(1,:,:); -headmask(:,1,:) = 0*headmask(:,1,:); -headmask(:,end,:) = 0*headmask(:,1,:); -headmask(:,:,1) = 0*headmask(:,:,1); -headmask(:,:,end) = 0*headmask(:,:,1); -% Erode + dilate, to remove small components -if (erodeFactor > 0) - headmask = headmask & ~mri_dilate(~headmask, erodeFactor); - headmask = mri_dilate(headmask, erodeFactor); -end -bst_progress('inc', 10); -% Fill holes -bst_progress('text', 'Filling holes...'); -headmask = (mri_fillholes(headmask, 1) & mri_fillholes(headmask, 2) & mri_fillholes(headmask, 3)); -bst_progress('inc', 10); - -% view_mri_slices(headmask, 'x', 20) - - -%% ===== CREATE SURFACE ===== -% Compute isosurface -bst_progress('text', 'Creating isosurface...'); -[sHead.Faces, sHead.Vertices] = mri_isosurface(headmask, 0.5); -bst_progress('inc', 10); -% Downsample to a maximum number of vertices -maxIsoVert = 60000; -if (length(sHead.Vertices) > maxIsoVert) - bst_progress('text', 'Downsampling isosurface...'); - [sHead.Faces, sHead.Vertices] = reducepatch(sHead.Faces, sHead.Vertices, maxIsoVert./length(sHead.Vertices)); + + %% ===== CREATE HEAD MASK ===== + % Progress bar + bst_progress('start', 'Generate head surface', 'Creating head mask...', 0, 100); + % Threshold mri to the level estimated in the histogram + if isGradient + isGradLocalMax = false; + % Compute gradient + % Find appropriate threshold from gradient histogram + % TODO: need to find a robust way to do this. Only verified on one + % relatively bad MRI sequence with preprocessing (debias, denoise). + [Grad, VectGrad] = NormGradient(sMri.Cube(:,:,:,1)); %#ok + if isempty(bgLevel) + Hist = mri_histogram(Grad, [], 'headgrad'); + bgLevel = Hist.bgLevel; + end + if isGradLocalMax + % Index gymnastics... Is there a simpler way to do this (other than looping)? + nVol = [1, cumprod(size(Grad))]'; + [unused, UpDir] = max(abs(reshape(VectGrad, nVol(4), [])), [], 2); % (nVol, 1) + UpDirSign = sign(VectGrad((1:nVol(4))' + (UpDir-1) * nVol(4))); + % Get neighboring value of the gradient in the increasing gradiant direction. + % Using linear indices shaped as 3d array, which will give back a 3d array. + iUpGrad = zeros(size(Grad)); + iUpGrad(:) = UpDirSign .* nVol(UpDir); % change in index: +-1 along appropriate dimension for each voxel, in linear indices + % Removing problematic indices at edges. + iUpGrad([1, end], :, :) = 0; + iUpGrad(:, [1, end], :) = 0; + iUpGrad(:, :, [1, end]) = 0; + iUpGrad(:) = iUpGrad(:) + (1:nVol(4))'; % adding change to each element index + UpGrad = Grad(iUpGrad); + headmask = Grad > bgLevel & Grad >= UpGrad; + else + headmask = Grad > bgLevel; + end + else + headmask = sMri.Cube(:,:,:,1) > bgLevel; + end + % Closing all the faces of the cube + headmask(1,:,:) = 0; + headmask(end,:,:) = 0; + headmask(:,1,:) = 0; + headmask(:,end,:) = 0; + headmask(:,:,1) = 0; + headmask(:,:,end) = 0; + if isDebugVis + view_mri_slices(headmask, 'x', nDebugVisSlices); %#ok<*UNRCH> + end + + % Fill neck holes (bones, etc.) where it is cut at edge of volume. + bst_progress('text', 'Filling holes and removing disconnected parts...'); + % Brainstorm reorients MRI so voxels are in RAS. But do all faces in case the bounding box was too + % small and another part is cut (e.g. nose). + + % Number of slices to average to smooth out noise in low SNR regions (e.g. around neck and chin). + % 4,3 worked ok in noisy scan, but probably best to denoise entire scan first. + nSlices = 1; % 1 = no averaging. + FillThresh = 1; %min(nSlices, floor(nSlices/2)+1); + if FillThresh > nSlices || FillThresh < floor(nSlices/2) + error('Bad hard-coded FillThresh.'); + end + for iDim = 1:3 + % Swap slice dimension into first position. For a single swap, the permutation is it's own inverse. + switch iDim + case 1 + Perm = 1:3; + case 2 + Perm = [2, 1, 3]; + case 3 + Perm = [3, 2, 1]; + end + TempMask = permute(headmask, Perm); + % Edit second and second-to-last slices. Flip the array to reuse code with same indices. + for isFlip = [false, true] + if isFlip + TempMask = flip(TempMask, 1); + end + % Skip if just background (e.g. above or behind head) + if ~any(any(squeeze(TempMask(2, :, :)))) + % Flip back and move on + if isFlip + TempMask = flip(TempMask, 1); + end + continue; + end + if isDebugVis + figure; imagesc(squeeze(TempMask(2,:,:))); colormap('gray'); axis equal; title(sprintf('Dim %d, Flip %d', iDim, isFlip)); + end + Slice = sum(TempMask(2:2+nSlices-1, :, :), 1); + if isDebugVis && nSlices > 1 + figure; imagesc(squeeze(Slice)); colormap('gray'); axis equal; title(sprintf('Dim %d, Flip %d, Avg %d slices', iDim, isFlip, nSlices)); + end + Slice = Slice >= FillThresh; + % Skip if just background (previous check had just some noise) + if ~any(any(squeeze(Slice))) + if isFlip + TempMask = flip(TempMask, 1); + end + continue; + end + Slice = FillConcaveVolume(Slice, true); % isClean + % Slice = Slice | (Fill(Slice, 2) & Fill(Slice, 3)); + if isDebugVis + figure; imagesc(squeeze(Slice)); colormap('gray'); axis equal; title(sprintf('Dim %d, Flip %d, Filled', iDim, isFlip)); + end + [Slice, isFail] = CenterSpread(Slice); + % Avoid warnings for slices other than neck. + if isFail && iDim == 3 && isFlip == false % inferior slice + warning('CenterSpread failed for filling "neck" slice. Resulting head surface may be problematic.'); + % Keep original. + else + % Keep filled in slice. + TempMask(2, :, :) = Slice; + end + if isDebugVis + figure; imagesc(squeeze(Slice)); colormap('gray'); axis equal; title(sprintf('Dim %d, Flip %d, Center spread', iDim, isFlip)); + end + % Flip back this dimension + if isFlip + TempMask = flip(TempMask, 1); + end + end + % Permute back dimensions to original order. + headmask = permute(TempMask, Perm); + end + % Fill holes + headmask = FillConcaveVolume(headmask, true); % clean, which may remove a few more original 1-voxel-wide or noise bits + if isDebugVis + view_mri_slices(headmask, 'x', nDebugVisSlices); title('Filled'); + end + % Keep only central connected volume (trim "beard" or bubbles) + headmask = CenterSpread(headmask); + bst_progress('inc', 15); + + if isDebugVis + view_mri_slices(headmask, 'x', nDebugVisSlices); title('Center spread'); + end + + + %% ===== CREATE SURFACE ===== + % Compute isosurface + bst_progress('text', 'Creating isosurface...'); + + switch Method + case 'iso2mesh' + method = 'cgalsurf'; + opt.radbound = 4; % max radius of the Delaunay sphere - adjust to get desired vertex numbers + opt.distbound = 1; % max distance from isosurface + dofix = 1; % don't know if needed + + [sHead.Vertices, sHead.Faces, regions, holes] = vol2surf(headmask, ... + 1:size(headmask,1), 1:size(headmask,2), 1:size(headmask,3), opt, dofix, method); % ,isovalues + if size(regions, 1) > 1 + bst_error('Multiple regions returned.\n'); + return; + elseif ~isempty(holes) + bst_error('Holes present.\n'); + return; + end + % Remove region label + sHead.Faces(:,4) = []; + if isDebugVis + fprintf('iso2mesh surface\n'); + [isOk, Info] = tess_check(sHead.Vertices, sHead.Faces, true, false, true); % verbose, not open, show + title('iso2mesh surface', 'color', 'white'); + end + bst_progress('inc', 45); + + case {'reducepatch', 'simplify'} + % Could have avoided x-y flip by specifying XYZ in isosurface... + [sHead.Faces, sHead.Vertices] = mri_isosurface(headmask, 0.5); + % Flip x-y back to our voxel coordinates + sHead.Vertices = sHead.Vertices(:, [2, 1, 3]); + % Flip to have desired face orientations (seems inconsistent if needed or not). + % sHead.Faces = sHead.Faces(:, [2, 1, 3]); + if isDebugVis + fprintf('mri_isohead surface\n'); + [isOk, Info] = tess_check(sHead.Vertices, sHead.Faces, true, false, true); % verbose, not open, show + title('isosurface', 'color', 'white'); + end + bst_progress('inc', 10); + + % Remove small objects + bst_progress('text', 'Removing small patches...'); + nVertTemp = size(sHead.Vertices, 1); + [sHead.Vertices, sHead.Faces] = tess_remove_small(sHead.Vertices, sHead.Faces); + if isDebugVis && nVertTemp > size(sHead.Vertices, 1) % only if something was removed in previous step + fprintf('BST>Some disconnected small patches removed (%d vertices).\n', nVertTemp - size(sHead.Vertices, 1)); + [isOk, Info] = tess_check(sHead.Vertices, sHead.Faces, true, false, true); %#ok % verbose, not open, show + title('isosurface & small removed', 'color', 'white'); + end + bst_progress('inc', 15); + + % TODO: No existing functions, including from iso2mesh, correctly fix topology issues, which + % are present after downsampling with all methods tested (including again iso2mesh). + % tess_clean is very strange, it doesn't look at face locations, only the normals. And after + % isosurface, many faces are parallel. + + % Smooth voxel artefacts, but preserve shape and volume. + bst_progress('text', 'Smoothing voxel artefacts...'); + % Should normally use 1 as voxel size, but using a larger value smooths. + % Restrict iterations to make it faster, smooth a bit more (normal to surface + % only) after downsampling. + sHead.Vertices = SurfaceSmooth(sHead.Vertices, sHead.Faces, 2, [], 5, [], false); % voxel/smoothing size, iterations, verbose + if isDebugVis + fprintf('mildly smoothed isosurface\n'); + [isOk, Info] = tess_check(sHead.Vertices, sHead.Faces, true, false, true); % verbose, not open, show + title('mildly smoother isosurface', 'color', 'white'); + end + bst_progress('inc', 20); + end + + % Downsampling surface + if (length(sHead.Vertices) > 1.5* nVertices) + bst_progress('text', 'Downsampling surface...'); + % Modified tess_downsize to accept sHead + sHead = tess_downsize(sHead, nVertices, Method); + if isDebugVis + fprintf('reduced surface (%s)\n', Method); + [isOk, Info] = tess_check(sHead.Vertices, sHead.Faces, true, false, true); % verbose, not open, show + title('reduced', 'color', 'white') + end + % Fix this patch + if ~strcmpi(Method, 'iso2mesh') % I don't think iso2mesh returns multiple disconnected regions. + nVertTemp = size(sHead.Vertices, 1); + [sHead.Vertices, sHead.Faces] = tess_remove_small(sHead.Vertices, sHead.Faces); + if isDebugVis && nVertTemp > size(sHead.Vertices, 1) % only if something was removed in previous step + fprintf('BST>Some disconnected small patches removed (%d vertices).\n', nVertTemp - size(sHead.Vertices, 1)); + fprintf('reduced surface (small disconnected parts removed)\n'); + [isOk, Info] = tess_check(sHead.Vertices, sHead.Faces, true, false, true); %#ok % verbose, not open, show + title('reduced & small removed', 'color', 'white') + end + end + end + bst_progress('inc', 15); + + bst_progress('text', 'Smoothing...'); + sHead.Vertices = SurfaceSmooth(sHead.Vertices, sHead.Faces, 1.5, [], 45, 0, false); % voxel/smoothing size, iterations, freedom (normal), verbose + if isDebugVis + fprintf('final smoothed surface\n'); + [isOk, Info] = tess_check(sHead.Vertices, sHead.Faces, true, false, true); % verbose, not open, show + end bst_progress('inc', 10); + + % Convert to SCS + sHead.Vertices = cs_convert(sMri, 'voxel', 'scs', sHead.Vertices); + % Flip face order to Brainstorm convention + sHead.Faces = sHead.Faces(:,[2,1,3]); + + + %% ===== SAVE FILES ===== + if isSave + bst_progress('text', 'Saving new file...'); + % Create output filenames + ProtocolInfo = bst_get('ProtocolInfo'); + SurfaceDir = bst_fullfile(ProtocolInfo.SUBJECTS, bst_fileparts(MriFile)); + HeadFile = file_unique(bst_fullfile(SurfaceDir, 'tess_head_mask.mat')); + % Save head + if ~isempty(Comment) + sHead.Comment = Comment; + else + sHead.Comment = sprintf('head mask (%d,%d,%d,%d)', nVertices, erodeFactor, fillFactor, round(bgLevel)); + end + sHead = bst_history('add', sHead, 'bem', 'Head surface generated with Brainstorm'); + bst_save(HeadFile, sHead, 'v7'); + iSurface = db_add_surface( iSubject, HeadFile, sHead.Comment); + else + % Return surface + HeadFile = sHead.Vertices; + iSurface = sHead.Faces; + end + + % Close, success + if isProgress + bst_progress('stop'); + end end -% Remove small objects -bst_progress('text', 'Removing small patches...'); -[sHead.Vertices, sHead.Faces] = tess_remove_small(sHead.Vertices, sHead.Faces); -bst_progress('inc', 10); - -% Downsampling isosurface -if (length(sHead.Vertices) > nVertices) - bst_progress('text', 'Downsampling surface...'); - [sHead.Faces, sHead.Vertices] = reducepatch(sHead.Faces, sHead.Vertices, nVertices./length(sHead.Vertices)); - bst_progress('inc', 10); + +%% ===== Subfunctions ===== +function mask = FillConcaveVolume(mask, isClean) + % Try to fill the interior of a concave volume. For a head, expects the neck "cut" to be + % previously filled. This method still depends on the orientation of the object vs the volume + % dimensions (x,y,z axes). But for a head shape, not too important. Mostly noticeable behind + % ears or in nostrils for example, depending on their angles. + + if nargin < 2 || isempty(isClean) + % Default to not remove any of the original mask. But called with true in this file. + isClean = false; + end + + % First, fill thin holes with boolean kernel convolution + % This fills voxels surrounded by 1s with specific patterns. + mask = KernelClean(mask, true); + + % Main filling step + % Fill from first to last 1-voxels along each direction and keep intersection across 3 dimensions. + % This can result in very narrow deep "tunnels", which we fix with the "surround" fill again next. + mask = (Fill(mask, 1, true) & Fill(mask, 2, true) & Fill(mask, 3, true)); + + % "Surround" and "sandwich" fills again + mask = KernelClean(mask, true); + % Repeat for filling intersections. Twice ok for 2d or 3d. + mask = KernelClean(mask, true); + + if isClean + % Apply inverse "surround" to remove noise and small protrusions. + % Erase voxel if surrounded by 0 in a plane. Could do before "first to last" above to clean + % noise first, but could also erase more parts in low SNR areas. We later keep only + % connected central part so no need to iterate this step. + mask = KernelClean(mask, false); + end end -% Convert to millimeters -sHead.Vertices = sHead.Vertices(:,[2,1,3]); -sHead.Faces = sHead.Faces(:,[2,1,3]); -sHead.Vertices = bst_bsxfun(@times, sHead.Vertices, sMri.Voxsize); -% Convert to SCS -sHead.Vertices = cs_convert(sMri, 'mri', 'scs', sHead.Vertices ./ 1000); - -% Reduce the final size of the meshed volume -erodeFinal = 3; -% Fill holes in surface -%if (fillFactor > 0) - bst_progress('text', 'Filling holes...'); - [sHead.Vertices, sHead.Faces] = tess_fillholes(sMri, sHead.Vertices, sHead.Faces, fillFactor, erodeFinal); - bst_progress('inc', 30); + +function mask = KernelClean(mask, Value) + % Boolean convolution, with predetermined kernels, to fill in thin strands or cracks. + % If Value = false, inverts the mask before and after filling, essentially eroding away thin + % structures instead of filling thin holes. Works for 3d volume or 2d slice, with a different + % set of kernel patterns for each. + if nargin < 2 || isempty(Value) + Value = true; + end + % Flip the mask if we're looking for false. + if ~Value + mask = ~mask; + end + + % Dimensions that have thickness, enough for convolution with 3-wide kernel. + isThk = size(mask, [1,2,3]) > 2; + nD = sum(isThk); + if nD == 1 + error('Unexpected 1d "volume".'); + end + + % All kernels should have mirror symmetry on one plane through the middle, since they're only + % applied in one orientation for each dimension. For now only 3x3x3 kernels, but should work + % with larger "square" ones as well. + switch nD + case 3 + % Kernels for 3d + % "Surround": to get rid of thin tunnels, "hairs" + % 4 adjacent voxels in a plane (not diagonals) are 1s. + Kernels{1} = zeros(3,3,3); Kernels{1}(:,:,2) = [0,1,0;1,0,1;0,1,0]; + % "Sandwich"/"Tie fighter": to remove thin cracks, wedges + % both side planes (3x3) are 1s + Kernels{2} = ones(3,3,3); Kernels{2}(:,:,2) = zeros(3,3); + case 2 + % Kernels for 2d + % "Surround"/"sandwich" (same for 2d) + Kernels{1} = zeros(3,3); Kernels{1}(2,:) = [1,0,1]; + otherwise + error('Unexpected multi-dim (>3) mask.'); + end + nK = numel(Kernels); + + switch nD + case 3 + for iK = 1:nK + % To avoid cumulative effects that would depend on the order in which we orient the + % kernel, we apply all dimensions on the original mask before "adding" them (with "or"). + FilledMask = mask; + for iD = 1:3 + % Re-orient kernel along each dimension + K = permute(Kernels{iK}, circshift(1:3, iD-1)); + N = sum(K(:)); + FilledMask = FilledMask | convn(mask, K, 'same') == N; + end + % Because of the shapes of our kernels, we don't have to enforce keeping "zero" on + % our mask volume boundary slices. + % mask(2:end-1,2:end-1,2:end-1) = FilledMask(2:end-1,2:end-1,2:end-1); + mask = FilledMask; + end + case 2 + % Rotate mask to have flat dimension last + iD = 1:3; + Perm = [iD(isThk), iD(~isThk)]; % This is not always a single swap, so we need the inverse. + [~, PermInv] = sort(Perm); + mask = permute(mask, Perm); + for iK = 1:nK + FilledMask = mask; + for iD = 1:2 + % Re-orient kernel along each dimension + if iD == 1 + K = Kernels{iK}; + else % iD == 2 permute + K = Kernels{iK}'; + end + N = sum(K(:)); + FilledMask = FilledMask | convn(mask, K, 'same') == N; + end + mask = FilledMask; + end + mask = permute(mask, PermInv); + end + % Inverse mask back if needed. + if ~Value + mask = ~mask; + end +end + + +% function mask = Surrounded(mask, Value) +% % Find voxels that are surrounded by a value and add them (if Value=true) or remove them (false). +% % 4 adjacent voxels in a plane (not diagonals) for 3d, 2 adjacent voxels in a line for 2d. +% +% if nargin < 2 || isempty(Value) +% Value = true; +% end +% % Flip the mask if we're looking for false. +% if ~Value +% mask = ~mask; +% end +% +% % Indices for dimensions excluding ends (2:end-1), except if thin dimension, then it's just 1 or [1 2]. +% nVox = size(mask, [1,2,3]); +% iVox = {min(2, nVox(1)):max(nVox(1)-1, 1), min(2, nVox(2)):max(nVox(2)-1, 1), min(2, nVox(3)):max(nVox(3)-1, 1)}; +% S = zeros(nVox - (nVox > 2)*2); +% +% % Loop so it works on 2d or 3d +% nDim = 0; +% for iDim = 1:3 +% if size(mask, iDim) > 2 % Skip singleton dimensions +% nDim = nDim + 1; +% switch iDim +% case 1 +% S = S + (mask(1:end-2,iVox{2},iVox{3}) & mask(3:end,iVox{2},iVox{3})); +% case 2 +% S = S + (mask(iVox{1},1:end-2,iVox{3}) & mask(iVox{1},3:end,iVox{3})); +% case 3 +% S = S + (mask(iVox{1},iVox{2},1:end-2) & mask(iVox{1},iVox{2},3:end)); +% end +% end +% end +% +% % Modify original mask by adding (true) or removing (false) +% mask(iVox{1},iVox{2},iVox{3}) = mask(iVox{1},iVox{2},iVox{3}) | S >= nDim - 1; +% if ~Value +% mask = ~mask; +% end % end -%% ===== SAVE FILES ===== -if isSave - bst_progress('text', 'Saving new file...'); - % Create output filenames - ProtocolInfo = bst_get('ProtocolInfo'); - SurfaceDir = bst_fullfile(ProtocolInfo.SUBJECTS, bst_fileparts(MriFile)); - HeadFile = file_unique(bst_fullfile(SurfaceDir, 'tess_head_mask.mat')); - % Save head - if ~isempty(Comment) - sHead.Comment = Comment; - else - sHead.Comment = sprintf('head mask (%d,%d,%d,%d)', nVertices, erodeFactor, fillFactor, round(bgLevel)); - end - sHead = bst_history('add', sHead, 'bem', 'Head surface generated with Brainstorm'); - bst_save(HeadFile, sHead, 'v7'); - iSurface = db_add_surface( iSubject, HeadFile, sHead.Comment); -else - % Return surface - HeadFile = sHead.Vertices; - iSurface = sHead.Faces; +function mask = Fill(mask, dim, isFullSingl) + % Modified to exclude boundaries, so we can get rid of external junk as well as + % internal holes easily. + if nargin < 3 || isempty(isFullSingl) + % Return original mask for singleton dim by default. + isFullSingl = false; + end + + % Initialize two accumulators, for the two directions + acc1 = mask; + acc2 = mask; + n = size(mask,dim); + % Skip singleton dimensions + if n == 1 + if isFullSingl + % Return all true, e.g. to combine with Fill in other directions. + mask = true(size(mask)); + end + return; + end + + % Process in required direction + switch dim + case 1 + for i = 2:n + acc1(i,:,:) = acc1(i,:,:) | acc1(i-1,:,:); + end + for i = n-1:-1:1 + acc2(i,:,:) = acc2(i,:,:) | acc2(i+1,:,:); + end + case 2 + for i = 2:n + acc1(:,i,:) = acc1(:,i,:) | acc1(:,i-1,:); + end + for i = n-1:-1:1 + acc2(:,i,:) = acc2(:,i,:) | acc2(:,i+1,:); + end + case 3 + for i = 2:n + acc1(:,:,i) = acc1(:,:,i) | acc1(:,:,i-1); + end + for i = n-1:-1:1 + acc2(:,:,i) = acc2(:,:,i) | acc2(:,:,i+1); + end + end + % Combine two accumulators + mask = acc1 & acc2; end -% Close, success -if isProgress - bst_progress('stop'); + +% function mask = Dilate(mask) +% % Dilate by 1 voxel in 6 directions, except at volume edges +% % Indices for dimensions excluding ends (2:end-1), except if thin dimension, then it's just 1 or [1 2]. +% nVox = size(mask, [1,2,3]); +% iVox = {min(2, nVox(1)):max(nVox(1)-1, 1), min(2, nVox(2)):max(nVox(2)-1, 1), min(2, nVox(3)):max(nVox(3)-1, 1)}; +% +% % Loop so it works on 2d or 3d +% for iDim = 1:3 +% if nVox(iDim) > 2 % Skip thin dimensions (size 1 or 2) +% switch iDim +% case 1 +% DilateMask = mask(1:end-2,iVox{2},iVox{3}) | mask(3:end,iVox{2},iVox{3}); +% case 2 +% DilateMask = mask(iVox{1},1:end-2,iVox{3}) | mask(iVox{1},3:end,iVox{3}); +% case 3 +% DilateMask = mask(iVox{1},iVox{2},1:end-2) | mask(iVox{1},iVox{2},3:end); +% end +% mask(iVox{1},iVox{2},iVox{3}) = mask(iVox{1},iVox{2},iVox{3}) | DilateMask; +% end +% end +% end + + +function [OutMask, isFail] = CenterSpread(InMask) + % Similar to Fill, but from a central starting point and intersecting with the input "reference" mask. + % This should work on slices as well as volumes. + isFail = false; + OutMask = false(size(InMask)); + iStart = max(1,round(size(OutMask)/2)); + nVox = size(OutMask); + % Force starting center point to be 1, and spread from there. But this will still fail if it's fully + % surrounded by 0s. + OutMask(iStart(1), iStart(2), iStart(3)) = true; + nPrev = 0; + nOut = 1; + while nOut > nPrev + % Dilation loop was very slow. + % OutMask = OutMask | (Dilate(OutMask) & InMask); + % Instead, propagate as far as possible in each direction (3 dim, forward & back) at each step + % of the main loop. + for x = 2:nVox(1) + OutMask(x,:,:) = OutMask(x,:,:) | (OutMask(x-1,:,:) & InMask(x,:,:)); + end + for x = nVox(1)-1:-1:1 + OutMask(x,:,:) = OutMask(x,:,:) | (OutMask(x+1,:,:) & InMask(x,:,:)); + end + for y = 2:nVox(2) + OutMask(:,y,:) = OutMask(:,y,:) | (OutMask(:,y-1,:) & InMask(:,y,:)); + end + for y = nVox(2)-1:-1:1 + OutMask(:,y,:) = OutMask(:,y,:) | (OutMask(:,y+1,:) & InMask(:,y,:)); + end + for z = 2:nVox(3) + OutMask(:,:,z) = OutMask(:,:,z) | (OutMask(:,:,z-1) & InMask(:,:,z)); + end + for z = nVox(3)-1:-1:1 + OutMask(:,:,z) = OutMask(:,:,z) | (OutMask(:,:,z+1) & InMask(:,:,z)); + end + nPrev = nOut; + nOut = sum(OutMask(:)); + end + if nOut == 1 + % Remove "forced" initial vertex, everything else is now gone. + OutMask(iStart(1), iStart(2), iStart(3)) = false; + isFail = true; + % warning('CenterSpread failed: starting center point is not part of the mask.'); + end end +function [Vol, Vect] = NormGradient(Vol) + % Norm of the spatial gradient vector field in a regular 3D volume. + [x,y,z] = gradient(Vol); + Vect = cat(4,x,y,z); + Vol = sqrt(sum(Vect.^2, 4)); +end diff --git a/toolbox/anatomy/tess_isosurface.m b/toolbox/anatomy/tess_isosurface.m new file mode 100644 index 000000000..03eef30e3 --- /dev/null +++ b/toolbox/anatomy/tess_isosurface.m @@ -0,0 +1,188 @@ +function [MeshFile, iSurface] = tess_isosurface(iSubject, isoValue, Comment) +% TESS_ISOSURFACE: Reconstruct a thresholded surface mesh from a CT +% +% USAGE: [MeshFile, iSurface] = tess_isosurface(iSubject, isoValue, Comment) +% [MeshFile, iSurface] = tess_isosurface(iSubject) +% [MeshFile, iSurface] = tess_isosurface(CtFile, isoValue, Comment) +% [MeshFile, iSurface] = tess_isosurface(CtFile) +% [Vertices, Faces] = tess_isosurface(sMri, isoValue) +% [Vertices, Faces] = tess_isosurface(sMri) +% +% INPUT: +% - iSubject : Indice of the subject where to add the surface +% - isoValue : The value in Housefield Unit to set for thresholding the CT. If this parameter is empty, then a GUI pops up asking the user for the desired value +% - Comment : Surface description +% OUTPUT: +% - MeshFile : indice of the surface that was created in the sSubject structure +% - iSurface : indice of the surface that was created in the sSubject structure +% - Vertices : The vertices of the mesh +% - Faces : The faces of the mesh +% +% If input is loaded CT structure, no surface file is created and the surface vertices and faces are returned instead. +% +% @============================================================================= +% This function is part of the Brainstorm software: +% https://neuroimage.usc.edu/brainstorm +% +% Copyright (c) University of Southern California & McGill University +% This software is distributed under the terms of the GNU General Public License +% as published by the Free Software Foundation. Further details on the GPLv3 +% license can be found at http://www.gnu.org/copyleft/gpl.html. +% +% FOR RESEARCH PURPOSES ONLY. THE SOFTWARE IS PROVIDED "AS IS," AND THE +% UNIVERSITY OF SOUTHERN CALIFORNIA AND ITS COLLABORATORS DO NOT MAKE ANY +% WARRANTY, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO WARRANTIES OF +% MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE, NOR DO THEY ASSUME ANY +% LIABILITY OR RESPONSIBILITY FOR THE USE OF THIS SOFTWARE. +% +% For more information type "brainstorm license" at command prompt. +% =============================================================================@ +% +% Inspired by tess_isohead.m +% +% Authors: Chinmay Chinara, 2023-2024 + +%% ===== PARSE INPUTS ===== +% Initialize returned variables +MeshFile = []; +iSurface = []; +isSave = true; + +% Parse inputs +if (nargin < 3) || isempty(Comment) + Comment = []; +end +% CtFile instead of subject index +sMri = []; +if ischar(iSubject) + CtFile = iSubject; + [sSubject, iSubject] = bst_get('MriFile', CtFile); +elseif isnumeric(iSubject) + % Get subject + sSubject = bst_get('Subject', iSubject); + CtFile = sSubject.Anatomy(sSubject.iAnatomy).FileName; +elseif isstruct(iSubject) + sMri = iSubject; + CtFile = sMri.FileName; + [sSubject, iSubject] = bst_get('MriFile', CtFile); + % Don't save a surface file, instead return surface directly. + isSave = false; +else + error('Wrong input type.'); +end + +%% ===== LOAD CT ===== +isProgress = ~bst_progress('isVisible'); +if isempty(sMri) + % Load CT + bst_progress('start', 'Generate thresholded isosurface from CT', 'Loading CT...'); + sMri = bst_memory('LoadMri', CtFile); + if isProgress + bst_progress('stop'); + end +end +% Save current scouts modifications +panel_scout('SaveModifications'); +% If subject is using the default anatomy: use the default subject instead +if sSubject.UseDefaultAnat + iSubject = 0; +end +% Check layers +if isempty(sSubject.iAnatomy) || isempty(sSubject.Anatomy) + bst_error('The surface generation requires at least the CT of the subject.', 'Generate isosurface', 0); + return +end +% Check that everything is there +if ~isfield(sMri, 'Histogram') || isempty(sMri.Histogram) || isempty(sMri.SCS) || isempty(sMri.SCS.NAS) || isempty(sMri.SCS.LPA) || isempty(sMri.SCS.RPA) + bst_error('You need to set the fiducial points in the MRI first.', 'Generate isosurface', 0); + return +end + +%% ===== ASK PARAMETERS ===== +% Ask user to set the parameters if they are not set +if (nargin < 2) || isempty(isoValue) + res = java_dialog('input', ['Background level guessed from MRI histogram (HU):
', num2str(round(sMri.Histogram.bgLevel)), ... + '
White level guessed from MRI histogram (HU):
', num2str(round(sMri.Histogram.whiteLevel)), ... + '
Max intensity level guessed from MRI histogram (HU):
', num2str(round(sMri.Histogram.intensityMax)), ... + '

Set isoValue for thresholding (HU):' ... + '
(estimate below is mean of whitelevel and max intensity)'], ... + 'Generate isosurface', [], num2str(round((sMri.Histogram.whiteLevel+sMri.Histogram.intensityMax)/2))); + + % If user cancelled: return + if isempty(res) + return + end + % Get new value isoValue + isoValue = round(str2double(res)); +end + +% Check parameters values +% isoValue cannot be < 0 as there cannot be negative intensity in the CT +% isoValue=0 does not makes sense as it means we do not want to do any thresholding +% isoValue cannot be > the maximum intensity of the CT as it means there is nothing to generate or threshold on +if isempty(isoValue) || isoValue <= 0 || isoValue > round(sMri.Histogram.intensityMax) + bst_error('Invalid ''isoValue''. Enter proper values.', 'Mesh surface', 0); + return +end + + +%% ===== CREATE SURFACE ===== +% Compute isosurface +bst_progress('start', 'Generate thresholded isosurface from CT', 'Creating isosurface...'); +[sMesh.Faces, sMesh.Vertices] = mri_isosurface(sMri.Cube, isoValue); +bst_progress('inc', 10); +% Downsample to a maximum number of vertices +maxIsoVert = 60000; +if (length(sMesh.Vertices) > maxIsoVert) + bst_progress('text', 'Downsampling isosurface...'); + [sMesh.Faces, sMesh.Vertices] = reducepatch(sMesh.Faces, sMesh.Vertices, maxIsoVert./length(sMesh.Vertices)); + bst_progress('inc', 10); +end + +% Convert to millimeters +sMesh.Vertices = sMesh.Vertices(:,[2,1,3]); +sMesh.Faces = sMesh.Faces(:,[2,1,3]); +sMesh.Vertices = bst_bsxfun(@times, sMesh.Vertices, sMri.Voxsize); +% Convert to SCS +sMesh.Vertices = cs_convert(sMri, 'mri', 'scs', sMesh.Vertices ./ 1000); + +%% ===== SAVE FILES ===== +if isSave + bst_progress('text', 'Saving new file...'); + % Create output filenames + ProtocolInfo = bst_get('ProtocolInfo'); + SurfaceDir = bst_fullfile(ProtocolInfo.SUBJECTS, bst_fileparts(CtFile)); + % Get the mesh file + MeshFile = bst_fullfile(SurfaceDir, 'tess_isosurface.mat'); + + % Replace existing isoSurface surface (tess_isosurface.mat) + [sSubjectTmp, iSubjectTmp, iSurfaceTmp] = bst_get('SurfaceFile', MeshFile); + if ~isempty(iSurfaceTmp) + file_delete(file_fullpath(MeshFile), 1); + sSubjectTmp.Surface(iSurfaceTmp) = []; + bst_set('Subject', iSubjectTmp, sSubjectTmp); + end + + % Save isosurface + sMesh.Comment = sprintf('isoSurface (ISO_%d)', isoValue); + sMesh = bst_history('add', sMesh, 'threshold_ct', ['Thresholded CT: ' sMri.FileName ' threshold = ' num2str(isoValue)]); + bst_save(MeshFile, sMesh, 'v7'); + iSurface = db_add_surface(iSubject, MeshFile, sMesh.Comment); + % Display mesh with 3D orthogonal slices of the default MRI + MriFile = sSubject.Anatomy(1).FileName; + hFig = bst_figures('GetFiguresByType', '3DViz'); + if isempty(hFig) + hFig = view_mri_3d(MriFile, [], 0.3, []); + end + view_surface(MeshFile, 0.6, [], hFig, []); + panel_surface('SetIsoValue', isoValue); +else + % Return surface + MeshFile = sMesh.Vertices; + iSurface = sMesh.Faces; +end + +% Close, success +if isProgress + bst_progress('stop'); +end \ No newline at end of file diff --git a/toolbox/anatomy/tess_smooth_sources.m b/toolbox/anatomy/tess_smooth_sources.m index 3342bdf66..d025261fb 100644 --- a/toolbox/anatomy/tess_smooth_sources.m +++ b/toolbox/anatomy/tess_smooth_sources.m @@ -1,21 +1,16 @@ -function W = tess_smooth_sources(Vertices, Faces, VertConn, FWHM, Method) +function W = tess_smooth_sources(SurfaceMat, FWHM, Method) % TESS_SMOOTH_SOURCES: Gaussian smoothing matrix over a mesh. % -% USAGE: W = tess_smooth_sources(Vertices, Faces, VertConn=[], FWHM=0.010, Method='average') +% USAGE: W = tess_smooth_sources(SurfaceMat, FWHM=0.010, Method='geodesic_dist') % % INPUT: -% - Vertices : Vertices positions ([X(:) Y(:) Z(:)]) -% - Faces : Triangles matrix -% - VertConn : Vertices connectivity, logical sparse matrix [Nvert,Nvert] -% - FWHM : Full width at half maximum, in meters (default=0.010) -% - Method : {'euclidian', 'path', 'average', 'surface'} +% - SurfaceMat : Cortical surface matrix +% - FWHM : Full Width at Half Maximum, in m (default = 0.010m = 10mm) +% - Method : {'euclidian', 'geodesic_edge', 'geodesic_dist' (default)} % OUPUT: -% - W: smoothing matrix (sparse) +% - W : Smoothing matrix (sparse) % % DESCRIPTION: -% - The distance between two points is an average of: -% - the direct euclidian between the two points and -% - the number of edges between the two points * the average length of an edge % - Gaussian smoothing function on the euclidian distance: % f(r) = 1 / sqrt(2*pi*sigma^2) * exp(-(r.^2/(2*sigma^2))) % - Full Width at Half Maximum (FWHM) is related to sigma by: @@ -40,159 +35,47 @@ % =============================================================================@ % % Authors: Francois Tadel, 2010-2013 +% Edouard Delaire, 2023 % ===== PARSE INPUTS ===== -if (nargin < 5) || isempty(Method) - Method = 'average'; +if (nargin < 3) || isempty(Method) + Method = 'geodesic_dist'; end -if (nargin < 4) || isempty(FWHM) +if (nargin < 2) || isempty(FWHM) FWHM = 0.010; end -if (nargin < 3) || isempty(VertConn) - VertConn = tess_vertconn(Vertices, Faces); -end -if ~islogical(VertConn) - error('Invalid vertices connectivity matrix.'); -end -nv = size(Vertices,1); - - -% ===== ANALYZE INPUT ===== -% Calculate Gaussian kernel properties -Sigma = FWHM / (2 * sqrt(2*log2(2))); -% FWTM = 2 * sqrt(2*log2(10)) * Sigma; -% Get the average edge length -[vi,vj] = find(VertConn); -meanDist = mean(sqrt((Vertices(vi,1) - Vertices(vj,1)).^2 + (Vertices(vi,2) - Vertices(vj,2)).^2 + (Vertices(vi,3) - Vertices(vj,3)).^2)); -% Guess the number of iterations -nIter = min(10, ceil(FWHM / meanDist)); - -% ===== COMPUTE DISTANCE ===== -switch lower(Method) - % === METHOD 1: USE EUCLIDIAN DISTANCE === - case 'euclidian' - % Get the neighborhood around each vertex - VertConn = mpower(VertConn, nIter); - [vi,vj] = find(VertConn); - % Use Euclidean distance - d = sqrt((Vertices(vi,1) - Vertices(vj,1)).^2 + (Vertices(vi,2) - Vertices(vj,2)).^2 + (Vertices(vi,3) - Vertices(vj,3)).^2); - Dist = sparse(vi, vj, d, nv, nv); - % === METHOD 2: USE NUMBER OF CONNECTIONS ===== - % === METHOD 3: AVERAGE METHOD 1+2 === - case {'path', 'average'} - % Initialize loop variables - VertConnGrow = speye(nv); - VertIter = sparse(nv,nv); - vall = []; +Method = lower(Method); +Vertices = SurfaceMat.Vertices; +VertConn = SurfaceMat.VertConn; +Faces = SurfaceMat.Faces; +Dist = SurfaceMat.VertDist; +nVertices = size(Vertices,1); - % Grow and keep track of the layers - for iter = 1:nIter - disp(sprintf('SMOOTH> Iteration %d/%d', iter, nIter)); - % Grow selection of vertices - VertConnPrev = VertConnGrow; - VertConnGrow = double(VertConnGrow * VertConn > 0); - % Find all the new connections - vind = find(VertConnGrow - VertConnPrev > 0); - [vi,vj] = ind2sub([nv,nv], vind); - VertIter = VertIter + iter * sparse(vi, vj, ones(size(vi)), nv, nv); - end - % Use distance in number of connections - Dist = VertIter * meanDist; - Dist(1:nv+1:nv*nv) = 0; - - % == AVERAGE WITH METHOD 1 == - if strcmpi(Method, 'average') - % Calculate Euclidean distance - [vi,vj] = find(VertConnGrow); - d = sqrt((Vertices(vi,1) - Vertices(vj,1)).^2 + (Vertices(vi,2) - Vertices(vj,2)).^2 + (Vertices(vi,3) - Vertices(vj,3)).^2); - % Average with results of method #2 - Dist = (0.5 .* Dist + 0.5 .* sparse(vi, vj, d, nv, nv)); - end - - % ===== METHOD 4: CALCULATE SURFACE DISTANCE ===== - % WARNING: NOT FINISHED!!!! - case 'surface' - % Initialize loop variables - VertConnGrow = speye(nv); - Dist = sparse([], [], [], nv, nv, 3*nnz(VertConn)); - vall = []; - nIter = 2; - % Grow until we reach an accepteable distance - for iter = 1:nIter - disp(sprintf('Iteration %d', iter)); - % Get neighbors - VertConnGrow = VertConnGrow * VertConn; - % Get all the existing edges in the surface - vind = find(VertConnGrow); - % Remove all the previously processed connections - vind = setdiff(vind, vall); - [vi,vj] = ind2sub([nv,nv], vind); - % Remove diagonal - iDel = (vi == vj); - vi(iDel) = []; - vj(iDel) = []; - % Calculate the distance to the neighbor nodes - if (iter == 1) - % Calculate all the distances for all the pairs of edges - d = sqrt((Vertices(vi,1) - Vertices(vj,1)).^2 + (Vertices(vi,2) - Vertices(vj,2)).^2 + (Vertices(vi,3) - Vertices(vj,3)).^2); - else - % Initialize d matrix - d = zeros(size(vi)); - % Process each new connection separately - for i = 1:length(vi) - % Find nodes that are connected to both nodes - iMid = find((Dist(vi(i),:) & VertConn(vj(i),:)) | (VertConn(vi(i),:) & Dist(vj(i),:))); - % Find nodes for which we know one connection at least - iMid0 = (Dist(vi(i),iMid) & Dist(vj(i),iMid)); - iMid1 = (Dist(vi(i),iMid) & ~iMid0); - iMid2 = (Dist(vj(i),iMid) & ~iMid0); - % Compute distances - dMid = 0*iMid; - dMid(iMid0) = Dist(vi(i),iMid0) + Dist(vj(i),iMid0); - dMid(iMid1) = Dist(vi(i),iMid1) + sqrt((Vertices(vj(i),1) - Vertices(iMid1,1)).^2 + (Vertices(vj(i),2) - Vertices(iMid1,2)).^2 + (Vertices(vj(i),3) - Vertices(iMid1,3)).^2)'; - dMid(iMid2) = Dist(vj(i),iMid2) + sqrt((Vertices(vi(i),1) - Vertices(iMid2,1)).^2 + (Vertices(vi(i),2) - Vertices(iMid2,2)).^2 + (Vertices(vi(i),3) - Vertices(iMid2,3)).^2)'; - dMid(dMid == 0) = Inf; - % Find the shortest path - d(i) = min(dMid); - if isinf(d(i)) - error('???'); - end - end - end - % Add to processed vertices - vall = union(vind, vall); - % Create a sparse distance matrix - Dist = Dist + sparse(vi, vj, d, nv, nv); - end +% Calculate Gaussian kernel properties +if strcmp(Method,'geodesic_edge') % Sigma given in (integer) number of edges + [vi, vj] = find(VertConn); + meanDist = mean(sqrt((Vertices(vi,1) - Vertices(vj,1)).^2 + (Vertices(vi,2) - Vertices(vj,2)).^2 + (Vertices(vi,3) - Vertices(vj,3)).^2)); + Sigma = ceil(ceil(FWHM./ meanDist) / (2 * sqrt(2*log2(2)))); +else % Sigma given in meters + Sigma = FWHM / (2 * sqrt(2*log2(2))); end - -% ===== APPLY GAUSSIAN FUNCTION ===== -% Gaussian function -fun = inline('1 / sqrt(2*pi*sigma2) * exp(-(x.^2/(2*sigma2)))', 'x', 'sigma2'); +% Ignore long distances +Dist(Dist > 10 * Sigma) = 0; % Calculate interpolation as a function of distance -[vi,vj] = find(Dist>0); -vind = sub2ind([nv,nv], vi, vj); -w = fun(Dist(vind), Sigma^2); -% Build final symmetric matrix -%W = sparse([vi;vj], [vj;vi], [w;w], nv, nv); -W = sparse(vi, vj, w, nv, nv); -% Add the diagonal -W = W + fun(0,Sigma^2) * speye(nv); +[vi, vj, x] = find(Dist); +W = sparse(vi, vj, GaussianKernel(x,Sigma^2), nVertices, nVertices) + ... + speye (nVertices) .* GaussianKernel(0,Sigma^2); % Normalize columns -W = bst_bsxfun(@rdivide, W, sum(W,1)); -% Remove insignificant values -[vi,vj] = find(W>0.005); -vind = sub2ind([nv,nv], vi, vj); -W = sparse(vi, vj, W(vind), nv, nv); - +W = bst_bsxfun(@rdivide, W, sum(W,1)); % ===== FIX BAD TRIANGLES ===== % Only for methods including neighbor distance -if ismember(lower(Method), {'path', 'average'}) +% Todo: check what this is doing :) +if contains(Method, 'geodesic') % Configurations to detect: % - One face divided in 3 with a point in the middle of the face % - Square divided into 4 triangles with one point in the middle @@ -208,5 +91,11 @@ W(:,iVert) = AvgConn; end - +% ===== APPLY GAUSSIAN FUNCTION ===== +% Gaussian function +function y = GaussianKernel(x,sigma2) + y = 1 / sqrt(2*pi*sigma2); + y = y .* exp(-(x.^2/(2*sigma2))); +end +end diff --git a/toolbox/connectivity/bst_connectivity.m b/toolbox/connectivity/bst_connectivity.m index 8b079b680..a977d9fde 100644 --- a/toolbox/connectivity/bst_connectivity.m +++ b/toolbox/connectivity/bst_connectivity.m @@ -153,9 +153,10 @@ % Load kernel-based results as kernel+data for coherence and phase metrics only. % This is always for 1xN, i.e. the B side has all sources and the A side has only one signal. % Kernel on the A side is not implemented. +methodsKernelBased = {'plv', 'ciplv', 'wpli', 'dwpli', 'pli', 'cohere'}; LoadOptionsA.LoadFull = 1; % ~isempty(OPTIONS.TargetA) || ~ismember(OPTIONS.Method, {'cohere','plv','ciplv','wpli'}); LoadOptionsB = LoadOptionsA; -LoadOptionsB.LoadFull = ~isempty(OPTIONS.TargetB) || ~ismember(OPTIONS.Method, {'cohere','plv','ciplv','wpli'}); +LoadOptionsB.LoadFull = ~isempty(OPTIONS.TargetB) || ~ismember(OPTIONS.Method, methodsKernelBased); % Use the signal processing toolbox? if bst_get('UseSigProcToolbox') hilbert_fcn = @hilbert; @@ -404,7 +405,7 @@ nWinLenSamples = []; % Loop over input files -for iFile = 1:nFiles +for iFile = 1 : length(FilesA) % Increments here, and in LoadAll above. 100 points are assigned per process (in bst_process('run')) bst_progress('set', round(startValue + (iFile-1) / nFiles * 100)); %% ===== LOAD SIGNALS ===== @@ -472,6 +473,10 @@ sfreq = round(sfreq * 1e6) * 1e-6; nA = size(sInputA.Data,1); nB = size(sInputB.Data,1); + % Number of sources if B is kernel-based + if ~isempty(sInputB.ImagingKernel) && ismember(OPTIONS.Method, methodsKernelBased) + nB = size(sInputB.ImagingKernel, 1); + end % ===== CHECK UNCONSTRAINED SOURCES ===== % Unconstrained models? @@ -541,8 +546,35 @@ DisplayUnits = 'Correlation'; bst_progress('text', sprintf('Calculating: Correlation [%dx%d]...', nA, nB)); Comment = 'Corr'; - % All the correlations with one call - R = bst_corrn(sInputA.Data, sInputB.Data, OPTIONS.RemoveMean); + % Verify WinLen argument for windowed metric + if strcmpi(OPTIONS.TimeRes, 'windowed') + % Window length and overlap in samples + nWinLenSamples = round(OPTIONS.WinLen * sfreq); + nWinOvelapSamples = round(OPTIONS.WinOverlap * nWinLenSamples); + if nWinLenSamples >= nTime + Message = 'File time duration too short wrt requested window length. Only computing one estimate across all time.'; + bst_report('Warning', OPTIONS.ProcessName, unique({FilesA{iFile}, FilesB{iFile}}), Message); + % Avoid further checks and error messages. + OPTIONS.TimeRes = 'none'; + end + end + % Compute correlation + if strcmpi(OPTIONS.TimeRes, 'windowed') + Comment = [Comment '-time']; + % Get [start, end] indices for windows + [~, ixs] = bst_epoching(1 : length(sInputA.Time), nWinLenSamples, nWinOvelapSamples); + nTimeOut = size(ixs,1); + % Center of the time window (sample 1 = 0 s) + Time = reshape((mean(ixs, 2)-1) ./ sfreq, 1, []); + % Initialize R + R = zeros(nA, nB, nTimeOut); + for iWin = 1 : size(ixs, 1) + R(:,:,iWin) = bst_corrn(sInputA.Data(:, ixs(iWin,1) : ixs(iWin,2)), sInputB.Data(:, ixs(iWin,1): ixs(iWin,2)), OPTIONS.RemoveMean); + end + else + % All the correlations with one call + R = bst_corrn(sInputA.Data, sInputB.Data, OPTIONS.RemoveMean); + end % ==== GRANGER ==== case 'granger' @@ -838,6 +870,10 @@ HB = morlet_transform(sInputB.Data, sInputB.Time, OPTIONS.Freqs(iBand), OPTIONS.MorletFc, OPTIONS.MorletFwhmTc, 'n'); end end + % Apply kernel if needed + if ~isConnNN && ~isempty(sInputB.ImagingKernel) + HB = sInputB.ImagingKernel * HB; + end % PLV: Normalize first, keep only phase info. if ismember(OPTIONS.Method, {'plv', 'ciplv'}) HA = HA ./ abs(HA); @@ -923,6 +959,14 @@ end % Add the number of averaged windows & files to the report nWinLenSamples = nWinLenAvg; + elseif strcmp(OPTIONS.TimeRes, 'none') + % Add time dimension + for f = 1:numel(Terms) + % Insert a singleton second-to-last dimension + order = 1 : (length(size(S.(Terms{f}))) + 1); + newOrder = [order(1:end-2), order(end:-1:end-1)]; + S.(Terms{f}) = permute(S.(Terms{f}), newOrder); + end end % Initial R or Accumulate R if isempty(R) || strcmpi(OPTIONS.OutputMode, 'input') @@ -932,12 +976,15 @@ end nWin = 0; % Add the number of averaged windows & files to the report (only once per output file) - if TimeRes == 0 - nAvgLen = nWinFile; - else - nAvgLen = nWinLenAvg; + switch OPTIONS.TimeRes + case 'full' + nAvgLen = 1; + case 'windowed' + nAvgLen = nWinLenAvg; + case 'none' + nAvgLen = nWinFile; end - Message = sprintf('Estimating across %d windows of %d samples each', nAvgLen, round(OPTIONS.WinLen * sfreq)); + Message = sprintf('Estimating across %d windows of %d samples each', nAvgLen, round(OPTIONS.StftWinLen * sfreq)); if ~strcmpi(OPTIONS.OutputMode, 'input') && nFiles > 1 Message = [Message sprintf(' per file, across %d files', nFiles)]; end @@ -1054,7 +1101,7 @@ end OutputFiles{iFile} = Finalize(OrigFilesB{iFile}); R = []; - else + elseif strcmpi(OPTIONS.OutputMode, 'avg') % Sum terms and continue file loop. if isnumeric(R) if isempty(Ravg) @@ -1067,7 +1114,8 @@ end % Else R is a struct and terms are already being summed into its fields directly. end - + else % case 'concat' + Ravg = R; end end @@ -1100,11 +1148,11 @@ end +%% ===== ASSEMBLE CONNECTIVITY METRIC FROM ACCUMULATED TERMS ===== function NewFile = Finalize(DataFile) if nargin < 1 DataFile = []; end - %% ===== ASSEMBLE CONNECTIVITY METRIC FROM ACCUMULATED TERMS ===== if isstruct(R) switch OPTIONS.Method case 'plv' @@ -1152,11 +1200,6 @@ R(isnan(R(:))) = 0; end end - % Static measures may need to be reshaped to add singleton time dimension. - if ndims(R) == 3 - % Push freq to 4th dim. - R = permute(R, [1,2,4,3]); - end end %% ===== APPLY FINAL MEASURE ===== diff --git a/toolbox/connectivity/bst_xspectrum.m b/toolbox/connectivity/bst_xspectrum.m index db71b010c..3a26a3577 100644 --- a/toolbox/connectivity/bst_xspectrum.m +++ b/toolbox/connectivity/bst_xspectrum.m @@ -44,12 +44,12 @@ % @============================================================================= % This function is part of the Brainstorm software: % https://neuroimage.usc.edu/brainstorm -% +% % Copyright (c) University of Southern California & McGill University % This software is distributed under the terms of the GNU General Public License % as published by the Free Software Foundation. Further details on the GPLv3 % license can be found at http://www.gnu.org/copyleft/gpl.html. -% +% % FOR RESEARCH PURPOSES ONLY. THE SOFTWARE IS PROVIDED "AS IS," AND THE % UNIVERSITY OF SOUTHERN CALIFORNIA AND ITS COLLABORATORS DO NOT MAKE ANY % WARRANTY, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO WARRANTIES OF diff --git a/toolbox/connectivity/private/direct_pac_mex.mexmaca64 b/toolbox/connectivity/private/direct_pac_mex.mexmaca64 new file mode 100755 index 000000000..4688651ec Binary files /dev/null and b/toolbox/connectivity/private/direct_pac_mex.mexmaca64 differ diff --git a/toolbox/connectivity/private/direct_pac_mex.mexmaci64 b/toolbox/connectivity/private/direct_pac_mex.mexmaci64 old mode 100644 new mode 100755 index adc2ec53d..3071d68df Binary files a/toolbox/connectivity/private/direct_pac_mex.mexmaci64 and b/toolbox/connectivity/private/direct_pac_mex.mexmaci64 differ diff --git a/toolbox/core/bst_colormaps.m b/toolbox/core/bst_colormaps.m index 13fb57e56..4a7917e69 100644 --- a/toolbox/core/bst_colormaps.m +++ b/toolbox/core/bst_colormaps.m @@ -376,18 +376,30 @@ function SetMaxCustom(ColormapType, DisplayUnits, newMin, newMax) case {'3DViz', 'MriViewer'} % Get surfaces defined in this figure TessInfo = getappdata(sFigure.hFigure, 'Surface'); - % Find 1st surface that match this ColormapType - iTess = find(strcmpi({TessInfo.ColormapType}, ColormapType), 1); + % Find surfaces that match this ColormapType + iSurfaces = find(strcmpi({TessInfo.ColormapType}, ColormapType)); DataFig = []; - if ~isempty(iTess) && ~isempty(TessInfo(iTess).DataSource.Type) - DataFig = TessInfo(iTess).DataMinMax; - DataType = TessInfo(iTess).DataSource.Type; - % For Data: use the modality instead - if strcmpi(DataType, 'Data') && ~isempty(ColormapInfo.Type) && ismember(ColormapInfo.Type, {'eeg', 'meg', 'nirs'}) - DataType = upper(ColormapInfo.Type); - % sLORETA: Do not use regular source scaling (pAm) - elseif strcmpi(DataType, 'Source') && ~isempty(strfind(lower(TessInfo(iTess).DataSource.FileName), 'sloreta')) - DataType = 'sLORETA'; + for i = 1:length(iSurfaces) + iTess = iSurfaces(i); + if ~isempty(TessInfo(iTess).DataSource.Type) + DataFig = [min([DataFig(:); TessInfo(iTess).DataMinMax(:)]), ... + max([DataFig(:); TessInfo(iTess).DataMinMax(:)])]; + % We'll keep the last non-empty DataType + DataType = TessInfo(iTess).DataSource.Type; + % For Data: use the modality instead + if strcmpi(DataType, 'Data') && ~isempty(ColormapInfo.Type) && ismember(ColormapInfo.Type, {'eeg', 'meg', 'nirs'}) + DataType = upper(ColormapInfo.Type); + % sLORETA: Do not use regular source scaling (pAm) + elseif strcmpi(DataType, 'Source') + if ~isempty(strfind(lower(TessInfo(iTess).DataSource.FileName), 'sloreta')) + DataType = 'sLORETA'; + else + [~, iResult] = bst_memory('LoadResultsFile', TessInfo(iTess).DataSource.FileName, 0); + if ~isempty(strfind(lower(GlobalData.DataSet(iDS).Results(iResult).Function), 'sloreta')); + DataType = 'sLORETA'; + end + end + end end end if isempty(DataFig) @@ -1329,7 +1341,7 @@ function SetColormapRealMin(ColormapType, status) % Fire change notificiation to all figures (3DViz and Topography) FireColormapChanged(ColormapType); end -function SetMaxMode(ColormapType, maxmode, DisplayUnits) +function SetMaxMode(ColormapType, maxmode, DisplayUnits, varargin) % Parse inputs if (nargin < 3) || isempty(DisplayUnits) DisplayUnits = []; @@ -1340,7 +1352,7 @@ function SetMaxMode(ColormapType, maxmode, DisplayUnits) end % Custom: ask for custom values if strcmpi(maxmode, 'custom') - SetMaxCustom(ColormapType, DisplayUnits); + SetMaxCustom(ColormapType, DisplayUnits, varargin{:}); else % Update colormap sColormap = GetColormap(ColormapType); @@ -1442,7 +1454,10 @@ function ConfigureColorbar(hFig, ColormapType, DataType, DisplayUnits) %#ok= 706) + % [RamTotal_MiB, RamAvailable_MiB] = bst_get('SystemMemory') + RamTotal_MiB = []; + RamAvailable_MiB = []; + tmp = regexp(bst_get('OsType'), '^[a-z]+', 'match', 'ignorecase'); + if ~isempty(tmp) + osFamily = tmp{1}; + end + if strcmpi(osFamily, 'win') && (bst_get('MatlabVersion') >= 706) try % Get memory info - usermem = memory(); - maxvar = round(usermem.MaxPossibleArrayBytes / 1024 / 1024); - totalmem = round(usermem.MemAvailableAllArrays / 1024 / 1024); + [usermem, systemmem] = memory(); + RamTotal_MiB = round(systemmem.PhysicalMemory.Total / 1024 / 1024); + RamAvailable_MiB = round(usermem.MemAvailableAllArrays / 1024 / 1024); + catch + % Whatever... + end + + elseif strcmpi(osFamily, 'linux') + try + meminfoRes = fileread('/proc/meminfo'); + ramTotalkB = regexp(meminfoRes, '(?<=MemTotal:)(.*?)(?=kB)', 'match'); + ramAvailablekB = regexp(meminfoRes, '(?<=MemAvailable:)(.*?)(?=kB)', 'match'); + if ~isempty(ramAvailablekB) && ~isempty(ramTotalkB) + ramTotalkB = str2double(strtrim(ramTotalkB{1})); + RamTotal_MiB = round(ramTotalkB /1024); + ramAvailablekB = str2double(strtrim(ramAvailablekB{1})); + RamAvailable_MiB = round(ramAvailablekB /1024); + end + catch + % Whatever... + end + elseif strcmpi(osFamily, 'mac') + try + [~, mem_pressure] = system('memory_pressure'); + if ~isempty(mem_pressure) + ramTotalB = regexp(mem_pressure, '(?<=The system has)(.*?)(?= )', 'match'); + prcFree = regexp(mem_pressure, '(?<=System-wide memory free percentage:)(.*?)(?=%)', 'match'); + if ~isempty(ramTotalB) && ~isempty(prcFree) + ramTotalB = str2double(ramTotalB{1}); + RamTotal_MiB = round(ramTotalB / 1024 / 1024); + ramAvailableB = ramTotalB * str2double(prcFree{1}) / 100; + RamAvailable_MiB = round(ramAvailableB / 1024 / 1024); + end + end catch % Whatever... end end - argout1 = maxvar; - argout2 = totalmem; + argout1 = RamTotal_MiB; + argout2 = RamAvailable_MiB; case 'BrainstormHomeDir' argout1 = GlobalData.Program.BrainstormHomeDir; @@ -501,6 +538,69 @@ case 'BrainstormDbFile' argout1 = bst_fullfile(bst_get('BrainstormUserDir'), 'brainstorm.mat'); + case 'Pipelines' + argout1 = GlobalData.Processes.Pipelines; + + case 'OsType' + switch (mexext) + case 'mexglx', argout1 = 'linux32'; + case 'mexa64', argout1 = 'linux64'; + case 'mexmaci', argout1 = 'mac32'; + case 'mexmaci64', argout1 = 'mac64'; + case 'mexmaca64', argout1 = 'mac64arm'; + case 'mexs64', argout1 = 'sol64'; + case 'mexw32', argout1 = 'win32'; + case 'mexw64', argout1 = 'win64'; + otherwise, error('Unsupported extension.'); + end + % CALL: bst_get('OsType', isMatlab=0) + if (nargin >= 2) && isequal(varargin{2}, 0) + if strcmpi(argout1, 'win32') && (~isempty(strfind(java.lang.System.getProperty('java.home'), '(x86)')) || ~isempty(strfind(java.lang.System.getenv('ProgramFiles(x86)'), '(x86)'))) + argout1 = 'win64'; + end + end + + case 'OsName' + argout1 = ''; + osFamily = []; + tmp = regexp(bst_get('OsType'), '^[a-z]+', 'match', 'ignorecase'); + if ~isempty(tmp) + osFamily = tmp{1}; + end + switch osFamily + case 'win' + [~, system_info] = system('ver'); + argout1 = strtrim(system_info); + + case 'linux' + os_release = fileread('/etc/os-release'); + osName = regexp(os_release, '(?<=PRETTY_NAME=")(.*?)(?=")', 'match'); + if ~isempty(osName) + osName = strtrim(osName{1}); + else + osName = regexp(os_release, '(?<=NAME=")(.*?)(?=")', 'match'); + if ~isempty(osName) + osName = strtrim(osName{1}); + else + osName = 'Linux unknow distribution'; + end + end + [~, kernelVer] = system('uname -r'); + kernelVer = strtrim(kernelVer); + argout1 = [osName, ' (' kernelVer, ')']; + + case 'mac' + [~, sw_vers] = system('sw_vers'); + osName = regexp(sw_vers, '(?<=ProductName:)(.*?)(?=\n)', 'match'); + osName = strtrim(osName{1}); + osVer = regexp(sw_vers, '(?<=ProductVersion:)(.*?)(?=\n)', 'match'); + osVer = strtrim(osVer{1}); + [~, osHw] = system('uname -m'); + osHw = strtrim(osHw); + argout1 = [osName, ' ' osVer, ' (', osHw, ')']; + end + + %% ==== PROTOCOL ==== case 'iProtocol' if isempty(GlobalData.DataBase.iProtocol) @@ -868,7 +968,7 @@ % Usage: [sAnalStudy, iAnalStudy] = bst_get('AnalysisIntraStudy', iSubject) case 'AnalysisIntraStudy' % Parse inputs - if (nargin == 2) && isnumeric(varargin{2}) + if (nargin == 2) iSubject = varargin{2}; else error('Invalid call to bst_get()'); @@ -2284,6 +2384,10 @@ sTemplates(end+1).FilePath = 'http://neuroimage.usc.edu/bst/getupdate.php?t=Colin27_2016'; sTemplates(end).Name = 'Colin27_2016'; end + if ~ismember('colin27_4nirs_2024', lower({sTemplates.Name})) + sTemplates(end+1).FilePath = 'http://neuroimage.usc.edu/bst/getupdate.php?t=Colin27_4NIRS_2024'; + sTemplates(end).Name = 'Colin27_4NIRS_2024'; + end if ~ismember('colin27_brainsuite_2016', lower({sTemplates.Name})) sTemplates(end+1).FilePath = 'http://neuroimage.usc.edu/bst/getupdate.php?t=Colin27_BrainSuite_2016'; sTemplates(end).Name = 'Colin27_BrainSuite_2016'; @@ -2397,6 +2501,11 @@ end % Get defaults from internet + if ~ismember('aal1', lower({sTemplates.Name})) + sTemplates(end+1).FilePath = 'http://neuroimage.usc.edu/bst/getupdate.php?t=mni_AAL1'; + sTemplates(end).Name = 'AAL1'; + sTemplates(end).Info = 'https://www.gin.cnrs.fr/en/tools/aal/'; + end if ~ismember('aal2', lower({sTemplates.Name})) sTemplates(end+1).FilePath = 'http://neuroimage.usc.edu/bst/getupdate.php?t=mni_AAL2'; sTemplates(end).Name = 'AAL2'; @@ -2804,16 +2913,22 @@ end case 'SpmTpmAtlas' + preferSpm = 0; + % CALL: bst_get('SpmTpmAtlas', 'SPM') + if (nargin >= 2) && strcmpi(varargin{2}, 'SPM') + preferSpm = 1; + end + % Get template file tpmUser = bst_fullfile(bst_get('BrainstormUserDir'), 'defaults', 'spm', 'TPM.nii'); - if file_exist(tpmUser) + if file_exist(tpmUser) && ~preferSpm argout1 = tpmUser; disp(['BST> SPM12 template found: ' tpmUser]); return; end % If it does not exist: check in brainstorm3 folder tpmDistrib = bst_fullfile(bst_get('BrainstormHomeDir'), 'defaults', 'spm', 'TPM.nii'); - if file_exist(tpmDistrib) + if file_exist(tpmDistrib) && ~preferSpm argout1 = tpmDistrib; disp(['BST> SPM12 template found: ' tpmDistrib]); return; @@ -2826,6 +2941,9 @@ argout1 = tpmSpm; disp(['BST> SPM12 template found: ' tpmSpm]); return; + elseif preferSpm + argout1 = bst_get('SpmTpmAtlas'); + return end else tpmSpm = ''; @@ -2952,6 +3070,13 @@ else argout1 = [.33 .0042 .33 .88 .93]; end + + case 'ShowHiddenFiles' + if isfield(GlobalData, 'Preferences') && isfield(GlobalData.Preferences, 'ShowHiddenFiles') + argout1 = GlobalData.Preferences.ShowHiddenFiles; + else + argout1 = 0; + end case 'LastUsedDirs' defPref = struct(... @@ -3008,25 +3133,6 @@ 'MontageOut', '', ... 'FibersIn', ''); argout1 = FillMissingFields(contextName, defPref); - - case 'OsType' - switch (mexext) - case 'mexglx', argout1 = 'linux32'; - case 'mexa64', argout1 = 'linux64'; - case 'mexmaci', argout1 = 'mac32'; - case 'mexmaci64', argout1 = 'mac64'; - case 'mexmaca64', argout1 = 'mac64arm'; - case 'mexs64', argout1 = 'sol64'; - case 'mexw32', argout1 = 'win32'; - case 'mexw64', argout1 = 'win64'; - otherwise, error('Unsupported extension.'); - end - % CALL: bst_get('OsType', isMatlab=0) - if (nargin >= 2) && isequal(varargin{2}, 0) - if strcmpi(argout1, 'win32') && (~isempty(strfind(java.lang.System.getProperty('java.home'), '(x86)')) || ~isempty(strfind(java.lang.System.getenv('ProgramFiles(x86)'), '(x86)'))) - argout1 = 'win64'; - end - end case 'ImportDataOptions' defPref = db_template('ImportOptions'); @@ -3095,6 +3201,7 @@ case 'TopoLayoutOptions' defPref = struct(... 'TimeWindow', [], ... + 'FreqWindow', [], ... 'WhiteBackground', 0, ... 'ShowRefLines', 1, ... 'ShowLegend', 1, ... @@ -3127,9 +3234,9 @@ case 'ProcessOptions' defPref = struct(... - 'SavedParam', struct(), ... - 'MaxBlockSize', 100 / 8 * 1024 * 1024, ... % 100Mb - 'LastMaxBlockSize', 100 / 8 * 1024 * 1024); % 100Mb + 'SavedParam', struct(), ... + 'MaxBlockSize', 100 * 1024 * 1024 / 8, ... % 100MiB == 13,107,200 doubles + 'LastMaxBlockSize', 100 * 1024 * 1024 / 8); % 100MiB == 13,107,200 doubles argout1 = FillMissingFields(contextName, defPref); case 'ImportEegRawOptions' @@ -3357,21 +3464,27 @@ case 'DigitizeOptions' defPref = struct(... + 'PatientId', 'S001', ... 'ComPort', 'COM1', ... 'ComRate', 9600, ... 'ComByteCount', 94, ... % 47 bytes * 2 receivers 'UnitType', 'fastrak', ... - 'PatientId', 'S001', ... + 'ConfigCommands', [], ... % setup-specific device configuration commands, e.g. hemisphere of operation 'nFidSets', 2, ... + 'Fids', {{'NAS', 'LPA', 'RPA'}}, ... % 3 anat points (required) and any other, e.g. MEG coils, in desired digitization order + 'DistThresh', 0.005, ... % 5 mm distance threshold between repeated measures of fid positions 'isBeep', 1, ... 'isMEG', 1, ... 'isSimulate', 0, ... 'Montages', [... struct('Name', 'No EEG', ... - 'Labels', []), ... + 'Labels', [], ... + 'ChannelFile', []), ... struct('Name', 'Default', ... - 'Labels', [])], ... - 'iMontage', 1); + 'Labels', [], ... + 'ChannelFile', [])], ... + 'iMontage', 1, ... + 'Version', '2024'); % Version of the Digitize panel: 'legacy' or '2024' argout1 = FillMissingFields(contextName, defPref); case 'PcaOptions' @@ -3526,6 +3639,7 @@ {'.gii'}, 'GIfTI / World coordinates (*.gii)', 'GII-WORLD'; ... {'.fif'}, 'MNE (*.fif)', 'FIF'; ... {'.obj'}, 'MNI OBJ (*.obj)', 'MNIOBJ'; ... + {'.obj'}, 'Wavefront OBJ (*.obj)', 'WFTOBJ'; ... {'.msh'}, 'SimNIBS3/headreco Gmsh4 (*.msh)', 'SIMNIBS3'; ... {'.msh'}, 'SimNIBS4/charm Gmsh4 (*.msh)', 'SIMNIBS4'; ... {'.tri'}, 'TRI (*.tri)', 'TRI'; ... @@ -3539,7 +3653,8 @@ argout1 = {... {'.mesh'}, 'BrainVISA (*.mesh)', 'MESH'; ... {'.dfs'}, 'BrainSuite (*.dfs)', 'DFS'; ... - {'.fs'}, 'FreeSurfer (*.fs)', 'FS' + {'.fs'}, 'FreeSurfer (*.fs)', 'FS'; ... + {'.obj'}, 'Wavefront OBJ (*.obj)', 'OBJ'; ... {'.off'}, 'Geomview OFF (*.off)', 'OFF'; ... {'.gii'}, 'GIfTI (*.gii)', 'GII'; ... {'.tri'}, 'TRI (*.tri)', 'TRI'; ... @@ -3577,6 +3692,7 @@ {'.rda'}, 'EEG: Compumedics ProFusion Sleep (*.rda)', 'EEG-COMPUMEDICS-PFS'; ... {'.bin'}, 'EEG: Deltamed Coherence-Neurofile (*.bin)', 'EEG-DELTAMED'; ... {'.edf','.rec'}, 'EEG: EDF / EDF+ (*.rec;*.edf)', 'EEG-EDF'; ... + {'.edf','.rec'}, 'EEG EDF / EDF+ FieldTrip reader (*.rec;*.edf)', 'EEG-EDF-FT'; ... {'.set'}, 'EEG: EEGLAB (*.set)', 'EEG-EEGLAB'; ... {'.raw'}, 'EEG: EGI Netstation RAW (*.raw)', 'EEG-EGI-RAW'; ... {'.mff','.bin'}, 'EEG: EGI-Philips (*.mff)', 'EEG-EGI-MFF'; ... @@ -3640,6 +3756,7 @@ {'.dat','.cdt'}, 'EEG: Curry (*.dat;*.cdt)', 'EEG-CURRY'; ... {'.bin'}, 'EEG: Deltamed Coherence-Neurofile (*.bin)', 'EEG-DELTAMED'; ... {'.edf','.rec'}, 'EEG: EDF / EDF+ (*.rec;*.edf)', 'EEG-EDF'; ... + {'.edf','.rec'}, 'EEG EDF / EDF+ FieldTrip reader (*.rec;*.edf)', 'EEG-EDF-FT'; ... {'.set'}, 'EEG: EEGLAB (*.set)', 'EEG-EEGLAB'; ... {'.raw'}, 'EEG: EGI Netstation RAW (*.raw)', 'EEG-EGI-RAW'; ... {'.mff','.bin'}, 'EEG: EGI-Philips (*.mff)', 'EEG-EGI-MFF'; ... @@ -3744,6 +3861,7 @@ {'.txt'}, 'Array of samples (*.txt)', 'ARRAY-SAMPLES'; ... {'.txt','.csv'}, 'CSV text file: label, time, duration (*.txt;*.csv)', 'CSV-TIME'; ... {'.txt'}, 'CTF Video Times (*.txt)', 'CTFVIDEO'; ... + {'.tsv'}, 'BIDS events: onset, duration, trial_type (*.tsv)', 'BIDS'; ... }; case 'channel' argout1 = {... @@ -3767,7 +3885,7 @@ {'.tsv'}, 'EEG: BIDS electrodes.tsv, CapTrak space mm (*.tsv)', 'BIDS-CAPTRAK-MM'; ... {'.els','.xyz'}, 'EEG: Cartool (*.els;*.xyz)', 'CARTOOL'; ... {'.eeg'}, 'EEG: MegDraw (*.eeg)', 'MEGDRAW'; ... - {'.res','.rs3','.pom'}, 'EEG: Curry (*.res;*.rs3;*.pom)', 'CURRY'; ... + {'.res','.rs3','.pom'}, 'EEG: Curry, LPS (*.res;*.rs3;*.pom)', 'CURRY'; ... {'.ced','.xyz','.set'}, 'EEG: EEGLAB (*.ced;*.xyz;*.set)', 'EEGLAB'; ... {'.elc'}, 'EEG: EETrak (*.elc)', 'EETRAK'; ... {'.sfp'}, 'EEG: EGI (*.sfp)', 'EGI'; ... @@ -3823,7 +3941,7 @@ {'.txt'}, 'EEG/NIRS: ASCII: XYZ,Name (*.txt)', 'ASCII_XYZN-EEG'; ... {'.txt'}, 'EEG/NIRS: ASCII: XYZ_MNI,Name (*.txt)', 'ASCII_XYZN_MNI-EEG'; ... {'.txt'}, 'EEG/NIRS: ASCII: XYZ_World,Name (*.txt)', 'ASCII_XYZN_WORLD-EEG'; ... - {'.txt'}, 'NIRS: Brainsight (*.txt)', 'BRAINSIGHT-TXT'; ... + {'.txt'}, 'EEG/NIRS: Brainsight (*.txt)', 'BRAINSIGHT-TXT'; ... {'.tsv'}, 'NIRS: BIDS optrodes.tsv, subject space mm (*.tsv)', 'BIDS-NIRS-SCANRAS-MM'; ... {'.tsv'}, 'NIRS: BIDS optrodes.tsv, MNI space mm (*.tsv)', 'BIDS-NIRS-MNI-MM'; ... {'.tsv'}, 'NIRS: BIDS optrodes.tsv, ALS/SCS/CTF space mm (*.tsv)', 'BIDS-NIRS-ALS-MM'; ... diff --git a/toolbox/core/bst_memory.m b/toolbox/core/bst_memory.m index b2071e459..e99fd8d8e 100644 --- a/toolbox/core/bst_memory.m +++ b/toolbox/core/bst_memory.m @@ -304,6 +304,7 @@ sSurf.Comment = surfMat.Comment; sSurf.Faces = double(surfMat.Faces); sSurf.Vertices = double(surfMat.Vertices); + sSurf.Color = double(surfMat.Color); sSurf.VertConn = surfMat.VertConn; sSurf.VertNormals = surfMat.VertNormals; [tmp, sSurf.VertArea] = tess_area(surfMat.Vertices, surfMat.Faces); @@ -795,9 +796,6 @@ function LoadChannelFile(iDS, ChannelFile) GlobalData.DataSet(iDS).DataFile = file_short(DataFile); GlobalData.DataSet(iDS).Measures = Measures; - % ===== LOAD CHANNEL FILE ===== - LoadChannelFile(iDS, ChannelFile); - % ===== Check time window consistency with previously loaded data ===== if isTimeCheck % Update time window @@ -814,25 +812,18 @@ function LoadChannelFile(iDS, ChannelFile) return; % Otherwise: unload all the other datasets else - % Save newly created dataset - bakDS = GlobalData.DataSet(iDS); - % Unload everything - UnloadAll('Forced'); - % If not everything was unloaded correctly (eg. the user cancelled half way when asked to save the modifications) - if ~isempty(GlobalData.DataSet) - % Unload the new dataset - UnloadDataSets(iDS); - iDS = []; - return; + iDS = UnloadOtherDs(iDS); + if isempty(iDS) + return end - % Restore new dataset - GlobalData.DataSet = bakDS; - iDS = 1; % Update time window isTimeCoherent = CheckTimeWindows(); end end end + + % ===== LOAD CHANNEL FILE ===== + LoadChannelFile(iDS, ChannelFile); % ===== UPDATE TOOL TABS ===== if ~isempty(iDS) && strcmpi(GlobalData.DataSet(iDS).Measures.DataType, 'raw') @@ -1141,7 +1132,7 @@ function ReloadStatDataSets() %#ok SamplingRate = []; if any(strcmpi('ImageGridAmp', {File_whos.name})) % Load results .Mat - ResultsMat = in_bst_results(ResultsFullFile, 0, 'Comment', 'Time', 'ChannelFlag', 'SurfaceFile', 'HeadModelType', 'ColormapType', 'DisplayUnits', 'GoodChannel', 'Atlas'); + ResultsMat = in_bst_results(ResultsFullFile, 0, 'Comment', 'Time', 'ChannelFlag', 'SurfaceFile', 'HeadModelType', 'ColormapType', 'DisplayUnits', 'GoodChannel', 'Atlas', 'Function'); % Raw file: Use only the loaded time window if ~isempty(DataFile) && strcmpi(GlobalData.DataSet(iDS).Measures.DataType, 'raw') && ~isempty(strfind(ResultsFullFile, '_KERNEL_')) Time = GlobalData.DataSet(iDS).Measures.Time; @@ -1226,7 +1217,11 @@ function ReloadStatDataSets() %#ok else Results.GoodChannel = ResultsMat.GoodChannel; end - + % If Results structure has Function field + if isfield(ResultsMat, 'Function') + Results.Function = ResultsMat.Function; + end + % Store new Results structure in GlobalData iResult = length(GlobalData.DataSet(iDS).Results) + 1; GlobalData.DataSet(iDS).Results(iResult) = Results; @@ -1247,14 +1242,24 @@ function ReloadStatDataSets() %#ok isTimeCoherent = CheckTimeWindows(); % If loaded results are not coherent with previous data if ~isTimeCoherent - % Remove it - GlobalData.DataSet(iDS).Results(iResult) = []; - iDS = []; - iResult = []; - bst_error(['Time definition for this file is not compatible with the other files' 10 ... - 'already loaded in Brainstorm.' 10 10 ... - 'Close existing windows before opening this file, or use the Navigator.'], 'Load results', 0); - return + res = java_dialog('question', [... + 'The time definition is not compatible with previously loaded files.' 10 ... + 'Unload all the other files first?' 10 10], 'Load results', [], {'Unload other files', 'Cancel'}); + % Cancel: Unload the new dataset + if isempty(res) || strcmpi(res, 'Cancel') + UnloadDataSets(iDS); + iDS = []; + return; + % Otherwise: unload all the other datasets + else + iDS = UnloadOtherDs(iDS); + if isempty(iDS) + iResult = []; + return + end + % Update time window + isTimeCoherent = CheckTimeWindows(); + end end end % Update TimeWindow panel, if it exists @@ -1978,13 +1983,31 @@ function LoadResultsMatrix(iDS, iResult) if isempty(iDS) && isempty(Mat.Events) iDS = GetDataSetStudy(sStudy.FileName); end - % Create dataset - if isempty(iDS) + % Check time against existing DS + isTimeOkDs = 1; + if ~isempty(iDS) && (length(Mat.Time) >= 2) + % Save measures information if no DataFile is available + if isempty(GlobalData.DataSet(iDS).Measures) || isempty(GlobalData.DataSet(iDS).Measures.Time) + GlobalData.DataSet(iDS).Measures.Time = double(Mat.Time([1, end])); + GlobalData.DataSet(iDS).Measures.SamplingRate = double(Mat.Time(2) - Mat.Time(1)); + GlobalData.DataSet(iDS).Measures.NumberOfSamples = length(Mat.Time); + elseif (abs(Mat.Time(1) - GlobalData.DataSet(iDS).Measures.Time(1)) > 1e-5) || ... + (abs(Mat.Time(end) - GlobalData.DataSet(iDS).Measures.Time(2)) > 1e-5) || ... + ~isequal(length(Mat.Time), GlobalData.DataSet(iDS).Measures.NumberOfSamples) + isTimeOkDs = 0; + end + end + % Create dataset if not existent or different time definition + if isempty(iDS) || ~isTimeOkDs % Create a new DataSet only for results iDS = length(GlobalData.DataSet) + 1; GlobalData.DataSet(iDS) = db_template('DataSet'); GlobalData.DataSet(iDS).SubjectFile = file_short(sStudy.BrainStormSubject); GlobalData.DataSet(iDS).StudyFile = file_short(sStudy.FileName); + % Save measures information + GlobalData.DataSet(iDS).Measures.Time = double(Mat.Time([1, end])); + GlobalData.DataSet(iDS).Measures.SamplingRate = double(Mat.Time(2) - Mat.Time(1)); + GlobalData.DataSet(iDS).Measures.NumberOfSamples = length(Mat.Time); end % Make sure that there is only one dataset selected iDS = iDS(1); @@ -1992,26 +2015,28 @@ function LoadResultsMatrix(iDS, iResult) % ===== CHECK TIME ===== % If there time in this file if (length(Mat.Time) >= 2) - isTimeOkDs = 1; - % Save measures information if no DataFile is available - if isempty(GlobalData.DataSet(iDS).Measures) || isempty(GlobalData.DataSet(iDS).Measures.Time) - GlobalData.DataSet(iDS).Measures.Time = double(Mat.Time([1, end])); - GlobalData.DataSet(iDS).Measures.SamplingRate = double(Mat.Time(2) - Mat.Time(1)); - GlobalData.DataSet(iDS).Measures.NumberOfSamples = length(Mat.Time); - elseif (abs(Mat.Time(1) - GlobalData.DataSet(iDS).Measures.Time(1)) > 1e-5) || ... - (abs(Mat.Time(end) - GlobalData.DataSet(iDS).Measures.Time(2)) > 1e-5) || ... - ~isequal(length(Mat.Time), GlobalData.DataSet(iDS).Measures.NumberOfSamples) - isTimeOkDs = 0; - end % Update time window isTimeCoherent = CheckTimeWindows(); % If loaded file are not coherent with previous data if ~isTimeCoherent || ~isTimeOkDs - iDS = []; - bst_error(['Time definition for this file is not compatible with the other files' 10 ... - 'already loaded in Brainstorm.' 10 10 ... - 'Close existing windows before opening this file, or use the Navigator.'], 'Load matrix', 0); - return + res = java_dialog('question', [... + 'The time definition is not compatible with previously loaded files.' 10 ... + 'Unload all the other files first?' 10 10], 'Load matrix', [], {'Unload other files', 'Cancel'}); + % Cancel: Unload the new dataset + if isempty(res) || strcmpi(res, 'Cancel') + UnloadDataSets(iDS); + iDS = []; + return; + % Otherwise: unload all the other datasets + else + iDS = UnloadOtherDs(iDS); + if isempty(iDS) + iMatrix = []; + return + end + % Update time window + isTimeCoherent = CheckTimeWindows(); + end end % Update TimeWindow panel panel_time('UpdatePanel'); @@ -3258,6 +3283,7 @@ function CheckFrequencies() GlobalData.Program.ProcessMenuCache = struct(); % Clear some display options GlobalData.Preferences.TopoLayoutOptions.TimeWindow = []; + GlobalData.Preferences.TopoLayoutOptions.FreqWindow = []; end % Close all unecessary tabs when forced, or when no data left if isForced || isempty(GlobalData.DataSet) @@ -3551,5 +3577,24 @@ function SaveChannelFile(iDS) end end +%% ===== UNLOAD OTHER DS ===== +function iDS = UnloadOtherDs(iDS) +% Unload Brainstorm datasets except for iDS. It returns the new iDS (iDS=1) for the kept DS + global GlobalData; + % Save dataset to keep + bakDS = GlobalData.DataSet(iDS); + % Unload everything + UnloadAll('Forced'); + % If not everything was unloaded correctly (eg. the user cancelled half way when asked to save the modifications) + if ~isempty(GlobalData.DataSet) + % Unload also dataset to keep + UnloadDataSets(iDS); + iDS = []; + return; + end + % Restore dataset + GlobalData.DataSet = bakDS; + iDS = 1; +end diff --git a/toolbox/core/bst_plugin.m b/toolbox/core/bst_plugin.m index a33a43c2f..b69da8b1e 100644 --- a/toolbox/core/bst_plugin.m +++ b/toolbox/core/bst_plugin.m @@ -11,11 +11,13 @@ % ReadmeFile = bst_plugin('GetReadmeFile', PlugDesc) % Get full path to plugin readme file % LogoFile = bst_plugin('GetLogoFile', PlugDesc) % Get full path to plugin logo file % Version = bst_plugin('CompareVersions', v1, v2) % Compare two version strings +% [isOk, errMsg] = bst_plugin('AddUserDefDesc', RegMethod, jsonLocation=[]) % Register user-defined plugin definition +% [isOk, errMsg] = bst_plugin('RemoveUserDefDesc' PlugName) % Remove user-defined plugin definition % [isOk, errMsg, PlugDesc] = bst_plugin('Load', PlugName/PlugDesc, isVerbose=1) % [isOk, errMsg, PlugDesc] = bst_plugin('LoadInteractive', PlugName/PlugDesc) % [isOk, errMsg, PlugDesc] = bst_plugin('Unload', PlugName/PlugDesc, isVerbose=1) % [isOk, errMsg, PlugDesc] = bst_plugin('UnloadInteractive', PlugName/PlugDesc) -% [isOk, errMsg, PlugDesc] = bst_plugin('Install', PlugName, isInteractive=0, minVersion=[]) +% [isOk, errMsg, PlugDesc] = bst_plugin('Install', PlugName, isInteractive=0, minVersion=[]) % Install and Load a plugin and its dependencies % [isOk, errMsg, PlugDesc] = bst_plugin('InstallMultipleChoice',PlugNames, isInteractive=0) % Install at least one of the input plugins % [isOk, errMsg, PlugDesc] = bst_plugin('InstallInteractive', PlugName) % [isOk, errMsg] = bst_plugin('Uninstall', PlugName, isInteractive=0, isDependencies=1) @@ -27,6 +29,7 @@ % bst_plugin('MenuCreate', jMenu) % bst_plugin('MenuUpdate', jMenu) % bst_plugin('LinkCatSpm', Action) % 0=Delete/1=Create/2=Check a symbolic link for CAT12 in SPM12 toolbox folder +% bst_plugin('UpdateDescription', PlugDesc, doDelete=0) % Update plugin description after load % % % PLUGIN DEFINITION @@ -105,7 +108,7 @@ % For more information type "brainstorm license" at command prompt. % =============================================================================@ % -% Authors: Francois Tadel 2021-2023 +% Authors: Francois Tadel, 2021-2023 eval(macro_method); end @@ -114,8 +117,12 @@ %% ===== GET SUPPORTED PLUGINS ===== % USAGE: PlugDesc = bst_plugin('GetSupported') % List all the plugins supported by Brainstorm % PlugDesc = bst_plugin('GetSupported', PlugName/PlugDesc) % Get only one specific supported plugin -function PlugDesc = GetSupported(SelPlug) +% PlugDesc = bst_plugin('GetSupported', ..., UserDefVerbose) % Print info on user-defined plugins +function PlugDesc = GetSupported(SelPlug, UserDefVerbose) % Parse inputs + if (nargin < 2) || isempty(UserDefVerbose) + UserDefVerbose = 0; + end if (nargin < 1) || isempty(SelPlug) SelPlug = []; end @@ -124,6 +131,7 @@ % Get OS OsType = bst_get('OsType', 0); + % Add new curated plugins by 'CATEGORY:' and alphabetic order % ================================================================================================================ % === ANATOMY: BRAIN2MESH === PlugDesc(end+1) = GetStruct('brain2mesh'); @@ -153,31 +161,45 @@ PlugDesc(end).UninstalledFcn = 'LinkCatSpm(0);'; PlugDesc(end).LoadedFcn = 'LinkCatSpm(2);'; PlugDesc(end).ExtraMenus = {'Online tutorial', 'web(''https://neuroimage.usc.edu/brainstorm/Tutorials/SegCAT12'', ''-browser'')'}; + + % === ANATOMY: CT2MRIREG === + PlugDesc(end+1) = GetStruct('ct2mrireg'); + PlugDesc(end).Version = 'github-master'; + PlugDesc(end).Category = 'Anatomy'; + PlugDesc(end).AutoUpdate = 1; + PlugDesc(end).URLzip = 'https://github.com/ajoshiusc/USCCleveland/archive/master.zip'; + PlugDesc(end).URLinfo = 'https://github.com/ajoshiusc/USCCleveland/tree/master/ct2mrireg'; + PlugDesc(end).TestFile = 'ct2mrireg.m'; + PlugDesc(end).ReadmeFile = 'ct2mrireg/README.md'; + PlugDesc(end).CompiledStatus = 2; + PlugDesc(end).LoadFolders = {'ct2mrireg'}; + PlugDesc(end).DeleteFiles = {'fmri_analysis', 'for_clio', 'mixed_atlas', 'process_script', 'reg_prepost', 'visualize_channels', '.gitignore', 'README.md'}; % === ANATOMY: ISO2MESH === PlugDesc(end+1) = GetStruct('iso2mesh'); - PlugDesc(end).Version = '1.9.6'; + PlugDesc(end).Version = '1.9.8'; PlugDesc(end).Category = 'Anatomy'; PlugDesc(end).AutoUpdate = 1; - PlugDesc(end).URLzip = 'https://github.com/fangq/iso2mesh/releases/download/v1.9.6/iso2mesh-1.9.6-allinone.zip'; + PlugDesc(end).URLzip = 'https://github.com/fangq/iso2mesh/archive/refs/tags/v1.9.8.zip'; PlugDesc(end).URLinfo = 'http://iso2mesh.sourceforge.net'; PlugDesc(end).TestFile = 'iso2meshver.m'; PlugDesc(end).ReadmeFile = 'README.txt'; PlugDesc(end).CompiledStatus = 2; PlugDesc(end).LoadedFcn = 'assignin(''base'', ''ISO2MESH_TEMP'', bst_get(''BrainstormTmpDir''));'; - PlugDesc(end).DeleteFiles = {'doc', 'tools', '.git_filters', 'sample', ... - 'bin/cgalmesh.exe', 'bin/cgalmesh.mexglx', 'bin/cgalmesh.mexmaci', ... - 'bin/cgalpoly.exe', 'bin/cgalpoly.mexglx', 'bin/cgalpoly.mexmaci', 'bin/cgalpoly.mexa64', 'bin/cgalpoly.mexmaci64', 'bin/cgalpoly_x86-64.exe', ... % Removing cgalpoly completely (not used) - 'bin/cgalsimp2.exe', 'bin/cgalsimp2.mexglx', 'bin/cgalsimp2.mexmaci', 'bin/cgalsimp2.mexmac', ... - 'bin/cgalsurf.exe', 'bin/cgalsurf.mexglx', 'bin/cgalsurf.mexmaci', ... - 'bin/cork.exe', ... - 'bin/gtsrefine.mexglx', 'bin/gtsrefine.mexmaci', 'bin/gtsrefine.mexarmhf', 'bin/gtsrefine.exe', 'bin/gtsrefine.mexmaci64', ... % Removing gtsrefine completely (not used) - 'bin/jmeshlib.exe', 'bin/jmeshlib.mexglx', 'bin/jmeshlib.mexmaci', 'bin/jmeshlib.mexmac', 'bin/jmeshlib.mexarmhf', ... - 'bin/meshfix.exe', 'bin/meshfix.mexglx', 'bin/meshfix.mexmaci', 'bin/meshfix.mexarmhf', ... - 'bin/tetgen.mexglx', 'bin/tetgen.mexmac', 'bin/tetgen.mexarmhf', ... - 'bin/tetgen1.5.mexglx'}; - PlugDesc(end).DeleteFilesBin = {'bin/tetgen.exe', 'bin/tetgen.mexa64', 'bin/tetgen.mexmaci', 'bin/tetgen.mexmaci64', 'bin/tetgen_x86-64.exe', ... % Removing older tetgen completely (very sparsely used) - 'bin/tetgen1.5.exe'}; + PlugDesc(end).UnloadPlugs = {'easyh5','jsnirfy'}; + + % === ANATOMY: NEUROMAPS === + PlugDesc(end+1) = GetStruct('neuromaps'); + PlugDesc(end).Version = 'github-main'; + PlugDesc(end).Category = 'Anatomy'; + PlugDesc(end).AutoUpdate = 0; + PlugDesc(end).AutoLoad = 0; + PlugDesc(end).CompiledStatus = 2; + PlugDesc(end).URLzip = 'https://github.com/thuy-n/bst-neuromaps/archive/refs/heads/main.zip'; + PlugDesc(end).URLinfo = 'https://github.com/thuy-n/bst-neuromaps'; + PlugDesc(end).ReadmeFile = 'README.md'; + PlugDesc(end).LoadFolders = {'*'}; + PlugDesc(end).TestFile = 'process_nmp_fetch_maps.m'; % === ANATOMY: ROAST === PlugDesc(end+1) = GetStruct('roast'); @@ -192,6 +214,20 @@ PlugDesc(end).UnloadPlugs = {'spm12', 'iso2mesh'}; PlugDesc(end).LoadFolders = {'lib/spm12', 'lib/iso2mesh', 'lib/cvx', 'lib/ncs2daprox', 'lib/NIFTI_20110921'}; + % === ANATOMY: ZEFFIRO === + PlugDesc(end+1) = GetStruct('zeffiro'); + PlugDesc(end).Version = 'github-main_development_branch'; + PlugDesc(end).Category = 'Anatomy'; + PlugDesc(end).AutoUpdate = 1; + PlugDesc(end).URLzip = 'https://github.com/sampsapursiainen/zeffiro_interface/archive/main_development_branch.zip'; + PlugDesc(end).URLinfo = 'https://github.com/sampsapursiainen/zeffiro_interface'; + PlugDesc(end).TestFile = 'zeffiro_downloader.m'; + PlugDesc(end).ReadmeFile = 'README.md'; + PlugDesc(end).CompiledStatus = 0; + PlugDesc(end).LoadFolders = {'*'}; + PlugDesc(end).DeleteFiles = {'.gitignore'}; + + % === FORWARD: OPENMEEG === PlugDesc(end+1) = GetStruct('openmeeg'); PlugDesc(end).Version = '2.4.1'; @@ -204,6 +240,10 @@ case 'mac64' PlugDesc(end).URLzip = 'https://files.inria.fr/OpenMEEG/download/OpenMEEG-2.4.1-MacOSX.tar.gz'; PlugDesc(end).TestFile = 'libOpenMEEG.1.1.0.dylib'; + case 'mac64arm' + PlugDesc(end).Version = '2.5.8'; + PlugDesc(end).URLzip = ['https://github.com/openmeeg/openmeeg/releases/download/', PlugDesc(end).Version, '/OpenMEEG-', PlugDesc(end).Version, '-', 'macOS_M1.tar.gz']; + PlugDesc(end).TestFile = 'libOpenMEEG.1.1.0.dylib'; case 'win32' PlugDesc(end).URLzip = 'https://files.inria.fr/OpenMEEG/download/release-2.2/OpenMEEG-2.2.0-win32-x86-cl-OpenMP-shared.tar.gz'; PlugDesc(end).TestFile = 'om_assemble.exe'; @@ -290,6 +330,33 @@ 'NPMK/Dependent Functions/.svn', 'NPMK/Dependent Functions/.DS_Store', 'NPMK/Dependent Functions/bnsx.dat', 'NPMK/Dependent Functions/syncPatternDetectNEV.m', ... 'NPMK/Dependent Functions/syncPatternDetectNSx.m', 'NPMK/Dependent Functions/syncPatternFinderNSx.m'}; + % === I/O: EASYH5 === + PlugDesc(end+1) = GetStruct('easyh5'); + PlugDesc(end).Version = 'github-master'; + PlugDesc(end).Category = 'I/O'; + PlugDesc(end).URLzip = 'https://github.com/NeuroJSON/easyh5/archive/master.zip'; + PlugDesc(end).URLinfo = 'https://github.com/NeuroJSON/easyh5'; + PlugDesc(end).TestFile = 'loadh5.m'; + PlugDesc(end).CompiledStatus = 2; + PlugDesc(end).LoadFolders = {'*'}; + PlugDesc(end).DeleteFiles = {'examples'}; + PlugDesc(end).ReadmeFile = 'README.md'; + PlugDesc(end).UnloadPlugs = {'iso2mesh'}; + + % === I/O: JSNIRF === + PlugDesc(end+1) = GetStruct('jsnirfy'); + PlugDesc(end).Version = 'github-master'; + PlugDesc(end).Category = 'I/O'; + PlugDesc(end).URLzip = 'https://github.com/NeuroJSON/jsnirfy/archive/master.zip'; + PlugDesc(end).URLinfo = 'https://github.com/NeuroJSON/jsnirfy'; + PlugDesc(end).TestFile = 'loadsnirf.m'; + PlugDesc(end).CompiledStatus = 2; + PlugDesc(end).LoadFolders = {'*'}; + PlugDesc(end).DeleteFiles = {'loadjsnirf.m', 'savejsnirf.m'}; + PlugDesc(end).ReadmeFile = 'README.md'; + PlugDesc(end).RequiredPlugs = {'easyh5'}; + PlugDesc(end).UnloadPlugs = {'iso2mesh'}; + % === I/O: MFF === PlugDesc(end+1) = GetStruct('mff'); PlugDesc(end).Version = 'github-master'; @@ -319,6 +386,17 @@ 'f=fopen(''private' filesep 'eeg_checkset.m'',''wt''); fprintf(f,''function EEG=eeg_checkset(EEG)''); fclose(f);' ... 'cd(d);']; + % === I/O: npy-matlab === + PlugDesc(end+1) = GetStruct('npy-matlab'); + PlugDesc(end).Version = 'github-master'; + PlugDesc(end).Category = 'I/O'; + PlugDesc(end).URLzip = 'https://github.com/kwikteam/npy-matlab/archive/refs/heads/master.zip'; + PlugDesc(end).URLinfo = 'https://github.com/kwikteam/npy-matlab'; + PlugDesc(end).TestFile = 'constructNPYheader.m'; + PlugDesc(end).LoadFolders = {'*'}; + PlugDesc(end).ReadmeFile = 'README.md'; + PlugDesc(end).CompiledStatus = 0; + % === I/O: NWB === PlugDesc(end+1) = GetStruct('nwb'); PlugDesc(end).Version = 'github-master'; @@ -393,6 +471,17 @@ PlugDesc(end).CompiledStatus = 0; PlugDesc(end).RequiredPlugs = {'fieldtrip', '20200911'}; + + % === STATISTICS: FASTICA === + PlugDesc(end+1) = GetStruct('fastica'); + PlugDesc(end).Version = '2.5'; + PlugDesc(end).Category = 'Statistics'; + PlugDesc(end).URLzip = 'https://research.ics.aalto.fi/ica/fastica/code/FastICA_2.5.zip'; + PlugDesc(end).URLinfo = 'https://research.ics.aalto.fi/ica/fastica/'; + PlugDesc(end).TestFile = 'fastica.m'; + PlugDesc(end).ReadmeFile = 'Contents.m'; + PlugDesc(end).CompiledStatus = 2; + % === STATISTICS: LIBSVM === PlugDesc(end+1) = GetStruct('libsvm'); PlugDesc(end).Version = 'github-master'; @@ -406,15 +495,17 @@ PlugDesc(end).LoadFolders = {'*'}; PlugDesc(end).InstalledFcn = 'd=pwd; cd(fileparts(which(''make''))); make; cd(d);'; - % === STATISTICS: FASTICA === - PlugDesc(end+1) = GetStruct('fastica'); - PlugDesc(end).Version = '2.5'; + % === STATISTICS: mTRF === + PlugDesc(end+1) = GetStruct('mtrf'); + PlugDesc(end).Version = '2.4'; PlugDesc(end).Category = 'Statistics'; - PlugDesc(end).URLzip = 'https://research.ics.aalto.fi/ica/fastica/code/FastICA_2.5.zip'; - PlugDesc(end).URLinfo = 'https://research.ics.aalto.fi/ica/fastica/'; - PlugDesc(end).TestFile = 'fastica.m'; - PlugDesc(end).ReadmeFile = 'Contents.m'; - PlugDesc(end).CompiledStatus = 2; + PlugDesc(end).URLzip = 'https://github.com/mickcrosse/mTRF-Toolbox/archive/refs/tags/v2.4.zip'; + PlugDesc(end).URLinfo = 'https://github.com/mickcrosse/mTRF-Toolbox'; + PlugDesc(end).TestFile = 'mTRFtrain.m'; + PlugDesc(end).ReadmeFile = 'README.md'; + PlugDesc(end).CompiledStatus = 0; + PlugDesc(end).LoadFolders = {'mtrf'}; + PlugDesc(end).DeleteFiles = {'.gitattributes', '.github/ISSUE_TEMPLATE', 'data', 'doc', 'examples', 'img'}; % === STATISTICS: PICARD === PlugDesc(end+1) = GetStruct('picard'); @@ -477,17 +568,6 @@ PlugDesc(end).CompiledStatus = 0; PlugDesc(end).RequiredPlugs = {'npy-matlab'}; - % === ELECTROPHYSIOLOGY: npy-matlab === - PlugDesc(end+1) = GetStruct('npy-matlab'); - PlugDesc(end).Version = 'github-master'; - PlugDesc(end).Category = 'e-phys'; - PlugDesc(end).URLzip = 'https://github.com/kwikteam/npy-matlab/archive/refs/heads/master.zip'; - PlugDesc(end).URLinfo = 'https://github.com/kwikteam/npy-matlab'; - PlugDesc(end).TestFile = 'constructNPYheader.m'; - PlugDesc(end).LoadFolders = {'*'}; - PlugDesc(end).ReadmeFile = 'README.md'; - PlugDesc(end).CompiledStatus = 0; - % === ELECTROPHYSIOLOGY: ultramegasort2000 === PlugDesc(end+1) = GetStruct('ultramegasort2000'); PlugDesc(end).Version = 'github-master'; @@ -510,7 +590,7 @@ PlugDesc(end).ReadmeFile = 'README.md'; PlugDesc(end).CompiledStatus = 0; - % === NIRSTORM === + % === fNIRS: NIRSTORM === PlugDesc(end+1) = GetStruct('nirstorm'); PlugDesc(end).Version = 'github-master'; PlugDesc(end).Category = 'fNIRS'; @@ -527,31 +607,31 @@ PlugDesc(end).MinMatlabVer = 803; % 2014a PlugDesc(end).DeleteFiles = {'scripts', 'test', 'run_tests.m', 'test_suite_bak.m', '.gitignore'}; - % === MCXLAB CUDA === + % === fNIRS: MCXLAB CUDA === PlugDesc(end+1) = GetStruct('mcxlab-cuda'); - PlugDesc(end).Version = '2021.12.04'; + PlugDesc(end).Version = '2024.07.23'; PlugDesc(end).Category = 'fNIRS'; PlugDesc(end).AutoUpdate = 1; - PlugDesc(end).URLzip = 'http://mcx.space/nightly/release/v2020/lite/mcxlab-allinone-x86_64-v2020.zip'; + PlugDesc(end).URLzip = 'https://mcx.space/nightly/release/git20240723/mcxlab-allinone-git20240723.zip'; PlugDesc(end).TestFile = 'mcxlab.m'; - PlugDesc(end).URLinfo = 'http://mcx.space/wiki/'; + PlugDesc(end).URLinfo = 'https://mcx.space/wiki/'; PlugDesc(end).CompiledStatus = 0; PlugDesc(end).LoadFolders = {'*'}; PlugDesc(end).UnloadPlugs = {'mcxlab-cl'}; - % === MCXLAB CL === + % === fNIRS: MCXLAB CL === PlugDesc(end+1) = GetStruct('mcxlab-cl'); - PlugDesc(end).Version = '2020'; + PlugDesc(end).Version = '2024.07.23'; PlugDesc(end).Category = 'fNIRS'; PlugDesc(end).AutoUpdate = 0; - PlugDesc(end).URLzip = 'http://mcx.space/nightly/release/v2020/lite/mcxlabcl-allinone-x86_64-v2020.zip'; + PlugDesc(end).URLzip = 'https://mcx.space/nightly/release/git20240723/mcxlabcl-allinone-git20240723.zip'; PlugDesc(end).TestFile = 'mcxlabcl.m'; - PlugDesc(end).URLinfo = 'http://mcx.space/wiki/'; + PlugDesc(end).URLinfo = 'https://mcx.space/wiki/'; PlugDesc(end).CompiledStatus = 2; PlugDesc(end).LoadFolders = {'*'}; PlugDesc(end).UnloadPlugs = {'mcxlab-cuda'}; - % === MIA === + % === sEEG: MIA === PlugDesc(end+1) = GetStruct('mia'); PlugDesc(end).Version = 'github-master'; PlugDesc(end).Category = 'sEEG'; @@ -570,7 +650,7 @@ PlugDesc(end+1) = GetStruct('fieldtrip'); PlugDesc(end).Version = 'latest'; PlugDesc(end).AutoUpdate = 0; - PlugDesc(end).URLzip = 'https://download.fieldtriptoolbox.org/fieldtrip-lite-20220228.zip'; + PlugDesc(end).URLzip = 'https://download.fieldtriptoolbox.org/fieldtrip-lite-20240405.zip'; PlugDesc(end).URLinfo = 'http://www.fieldtriptoolbox.org'; PlugDesc(end).TestFile = 'ft_defaults.m'; PlugDesc(end).ReadmeFile = 'README'; @@ -580,6 +660,7 @@ PlugDesc(end).GetVersionFcn = 'ft_version'; PlugDesc(end).LoadedFcn = ['global ft_default; ' ... 'ft_default = []; ' ... + 'clear ft_defaults; ' ... 'if exist(''filtfilt'', ''file''), ft_default.toolbox.signal=''matlab''; end; ' ... 'if exist(''nansum'', ''file''), ft_default.toolbox.stats=''matlab''; end; ' ... 'if exist(''rgb2hsv'', ''file''), ft_default.toolbox.images=''matlab''; end; ' ... @@ -589,7 +670,14 @@ PlugDesc(end+1) = GetStruct('spm12'); PlugDesc(end).Version = 'latest'; PlugDesc(end).AutoUpdate = 0; - PlugDesc(end).URLzip = 'https://www.fil.ion.ucl.ac.uk/spm/download/restricted/eldorado/spm12.zip'; + switch(OsType) + case 'mac64arm' + PlugDesc(end).URLzip = 'https://github.com/spm/spm12/archive/refs/heads/maint.zip'; + PlugDesc(end).Version = 'github-maint'; + otherwise + PlugDesc(end).Version = 'latest'; + PlugDesc(end).URLzip = 'https://www.fil.ion.ucl.ac.uk/spm/download/restricted/eldorado/spm12.zip'; + end PlugDesc(end).URLinfo = 'https://www.fil.ion.ucl.ac.uk/spm/'; PlugDesc(end).TestFile = 'spm.m'; PlugDesc(end).ReadmeFile = 'README.md'; @@ -598,6 +686,43 @@ PlugDesc(end).LoadFolders = {'matlabbatch'}; PlugDesc(end).GetVersionFcn = 'bst_getoutvar(2, @spm, ''Ver'')'; PlugDesc(end).LoadedFcn = 'spm(''defaults'',''EEG'');'; + + % === USER DEFINED PLUGINS === + plugJsonFiles = dir(fullfile(bst_get('UserPluginsDir'), 'plugin_*.json')); + badJsonFiles = {}; + plugUserDefNames = {}; + for ix = 1:length(plugJsonFiles) + plugJsonText = fileread(fullfile(plugJsonFiles(ix).folder, plugJsonFiles(ix).name)); + try + PlugUserDesc = bst_jsondecode(plugJsonText); + catch + badJsonFiles{end+1} = plugJsonFiles(ix).name; + continue + end + % Reshape fields "ExtraMenus" + if isfield(PlugUserDesc, 'ExtraMenus') && ~isempty(PlugUserDesc.ExtraMenus) && iscell(PlugUserDesc.ExtraMenus{1}) + PlugUserDesc.ExtraMenus = cat(2, PlugUserDesc.ExtraMenus{:})'; + end + % Reshape fields "RequiredPlugs" + if isfield(PlugUserDesc, 'RequiredPlugs') && ~isempty(PlugUserDesc.RequiredPlugs) && iscell(PlugUserDesc.RequiredPlugs{1}) + PlugUserDesc.RequiredPlugs = cat(2, PlugUserDesc.RequiredPlugs{:})'; + end + % Check for uniqueness for user-defined plugin + if ~ismember(PlugUserDesc.Name, {PlugDesc.Name}) + plugUserDefNames{end+1} = PlugUserDesc.Name; + PlugDesc(end+1) = struct_copy_fields(GetStruct(PlugUserDesc.Name), PlugUserDesc); + end + end + % Print info on user-defined plugins + if UserDefVerbose + if ~isempty(plugUserDefNames) + fprintf(['BST> User-defined plugins... ' strjoin(plugUserDefNames, ' ') '\n']); + end + for iBad = 1 : length(badJsonFiles) + fprintf(['BST> User-defined plugins, error reading .json file... ' badJsonFiles{iBad} '\n']); + end + end + % ================================================================================================================ % Select only one plugin @@ -626,6 +751,142 @@ end +%% ===== ADD USER DEFINED PLUGIN DESCRIPTION ===== +function [isOk, errMsg] = AddUserDefDesc(RegMethod, jsonLocation) + isOk = 1; + errMsg = ''; + isInteractive = strcmp(RegMethod, 'manual') || nargin < 2 || isempty(jsonLocation); + + % Get json file location from user + if ismember(RegMethod, {'file', 'url'}) && isInteractive + if strcmp(RegMethod, 'file') + jsonLocation = java_getfile('open', 'Plugin description JSON file...', '', 'single', 'files', {{'.json'}, 'Brainstorm plugin description (*.json)', 'JSON'}, 1); + elseif strcmp(RegMethod, 'url') + jsonLocation = java_dialog('input', 'Enter the URL the plugin description file (.json)', 'Plugin description JSON file...', [], ''); + end + if isempty(jsonLocation) + return + end + res = java_dialog('question', ['Warning: This plugin has not been verified.' 10 ... + 'Malicious plugins can alter your database, proceed with caution and only install plugins from trusted sources.' 10 ... + 'If any unusual behavior occurs after installation, start by uninstalling the plugins.' 10 ... + 'Are you sure you want to proceed?'], ... + 'Warning', [], {'yes', 'no'}); + if strcmp(res, 'no') + return + end + end + + % Get plugin description + switch RegMethod + case 'file' + jsonText = fileread(jsonLocation); + try + PlugDesc = bst_jsondecode(jsonText); + catch + errMsg = sprintf(['Could not parse JSON file:' 10 '%s'], jsonLocation); + end + + case 'url' + % Handle GitHub links, convert the link to load the raw content + if strcmp(jsonLocation(1:4),'http') && strcmp(jsonLocation(end-4:end),'.json') + if ~isempty(regexp(jsonLocation, '^http[s]*://github.com', 'once')) + jsonLocation = strrep(jsonLocation, 'github.com','raw.githubusercontent.com'); + jsonLocation = strrep(jsonLocation, 'blob/', ''); + end + end + jsonText = bst_webread(jsonLocation); + try + PlugDesc = bst_jsondecode(jsonText); + catch + errMsg = sprintf(['Could not parse JSON file at:' 10 '%s'], jsonLocation); + end + + case 'manual' + % Get info for user-defined plugin description from user + res = java_dialog('input', { ['Provide the mandatory fields for a user defined Brainstorm plugin
' ... + 'See this page for further details:
' ... + 'https://neuroimage.usc.edu/brainstorm/Tutorials/Plugins' ... + '

' ... + 'Plugin name
' ... + 'EXAMPLE: bst-users'], ... + ['Version
' ... + 'EXAMPLE: github-main or 3.1.4'], ... + ['URL for zip
' ... + 'EXAMPLE: https://github.com/brainstorm-tools/bst-users/archive/refs/heads/master.zip'], ... + ['URL for information
' ... + 'EXAMPLE: https://github.com/brainstorm-tools/bst-users']}, ... + 'User defined plugin', [], {'', '', '', ''}); + if isempty(res) || any(cellfun(@isempty,res)) + return + end + PlugDesc.Name = lower(res{1}); + PlugDesc.Version = res{2}; + PlugDesc.URLzip = res{3}; + PlugDesc.URLinfo = res{4}; + end + if ~isempty(errMsg) + bst_error(errMsg); + isOk = 0; + return; + end + + % Validate retrieved plugin description + if length(PlugDesc) > 1 + errMsg = 'JSON file should contain only one plugin description'; + elseif ~all(ismember({'Name', 'Version', 'URLzip', 'URLinfo'}, fieldnames(PlugDesc))) + errMsg = 'Plugin description must contain the fields ''Name'', ''Version'', ''URLzip'' and ''URLinfo'''; + else + PlugDesc.Name = lower(PlugDesc.Name); + PlugDescs = GetSupported(); + if ismember(PlugDesc.Name, {PlugDescs.Name}) + errMsg = sprintf('Plugin ''%s'' already exist in Brainstorm', PlugDesc.Name); + end + end + if ~isempty(errMsg) + bst_error(errMsg); + isOk = 0; + return; + end + % Override category + PlugDesc.Category = 'User defined'; + + % Write validated JSON file + pluginJsonFileOut = fullfile(bst_get('UserPluginsDir'), sprintf('plugin_%s.json', file_standardize(PlugDesc.Name))); + fid = fopen(pluginJsonFileOut, 'wt'); + jsonText = bst_jsonencode(PlugDesc, 0); + fprintf(fid, jsonText); + fclose(fid); + + fprintf(1, 'BST> Plugin ''%s'' was added to ''User defined'' plugins\n', PlugDesc.Name); +end + + +%% ===== REMOVE USER DEFINED PLUGIN DESCRIPTION ===== +function [isOk, errMsg] = RemoveUserDefDesc(PlugName) + isOk = 1; + errMsg = ''; + if nargin < 1 || isempty(PlugName) + PlugDescs = GetSupported(); + PlugDescs = PlugDescs(ismember({PlugDescs.Category}, 'User defined')); + PlugName = java_dialog('combo', 'Indicate the name of the plugin to remove:', 'Remove plugin from ''User defined'' list', [], {PlugDescs.Name}); + end + if isempty(PlugName) + return + end + PlugDesc = GetSupported(PlugName); + if ~isempty(PlugDesc.Path) || file_exist(bst_fullfile(bst_get('UserPluginsDir'), PlugDesc.Name)) + [isOk, errMsg] = Uninstall(PlugDesc.Name, 0); + end + % Delete json file + if isOk + isOk = file_delete(fullfile(bst_get('UserPluginsDir'), sprintf('plugin_%s.json', file_standardize(PlugDesc.Name))), 1); + end + + fprintf(1, 'BST> Plugin ''%s'' was removed from ''User defined'' plugins\n', PlugDesc.Name); +end + + %% ===== CONFIGURE PLUGIN ===== function Configure(PlugDesc) switch (PlugDesc.Name) @@ -766,8 +1027,11 @@ function Configure(PlugDesc) %% ===== GET GITHUB COMMIT ===== % Get SHA of the GitHub HEAD commit function sha = GetGithubCommit(URLzip) + zipUri = matlab.net.URI(URLzip); + % Primary branch name: master or main + [~, primaryBranch] = bst_fileparts(char(zipUri.Path(end))); % Default result - sha = 'github-master'; + sha = ['github-', primaryBranch]; % Only available after Matlab 2016b (because of matlab.net.http.RequestMessage) if (bst_get('MatlabVersion') < 901) return; @@ -779,7 +1043,7 @@ function Configure(PlugDesc) gitUser = char(zipUri.Path(2)); gitRepo = char(zipUri.Path(3)); % Request last commit SHA with GitHub API - apiUri = matlab.net.URI(['https://api.github.com/repos/' gitUser '/' gitRepo '/commits/master']); + apiUri = matlab.net.URI(['https://api.github.com/repos/' gitUser '/' gitRepo '/commits/' primaryBranch]); request = matlab.net.http.RequestMessage; request = request.addFields(matlab.net.http.HeaderField('Accept', 'application/vnd.github.VERSION.sha')); r = send(request, apiUri); @@ -1100,10 +1364,11 @@ function Configure(PlugDesc) if ~isempty(p) && ~isempty(strfind(TestFilePath, bst_fileparts(p))) TestFilePath = []; end - % SPM12: Ignore if found embedded in ROAST + % SPM12: Ignore if found embedded in ROAST or in FieldTrip elseif strcmpi(PlugDesc.Name, 'spm12') p = which('roast.m'); - if ~isempty(p) && ~isempty(strfind(TestFilePath, bst_fileparts(p))) + q = which('ft_defaults.m'); + if (~isempty(p) && ~isempty(strfind(TestFilePath, bst_fileparts(p)))) || (~isempty(q) && ~isempty(strfind(TestFilePath, bst_fileparts(q)))) TestFilePath = []; end % Iso2mesh: Ignore if found embedded in ROAST @@ -1112,6 +1377,12 @@ function Configure(PlugDesc) if ~isempty(p) && ~isempty(strfind(TestFilePath, bst_fileparts(p))) TestFilePath = []; end + % easyh5 and jsnirfy: Ignore if found embedded in iso2mesh + elseif strcmpi(PlugDesc.Name, 'easyh5') || strcmpi(PlugDesc.Name, 'jsnirfy') + p = which('iso2meshver.m'); + if ~isempty(p) && ~isempty(strfind(TestFilePath, bst_fileparts(p))) + TestFilePath = []; + end end else TestFilePath = []; @@ -1216,9 +1487,16 @@ function Configure(PlugDesc) if ~isempty(errMsg) return; end + % Check if plugin is supported on Apple silicon + OsType = bst_get('OsType', 0); + if strcmpi(OsType, 'mac64arm') && ismember(PlugName, PluginsNotSupportAppleSilicon()) + errMsg = ['Plugin ', PlugName ' is not supported on Apple silicon yet.']; + PlugDesc = []; + return; + end % Check if there is a URL to download if isempty(PlugDesc.URLzip) - errMsg = ['No download URL for ', bst_get('OsType', 0), ': ', PlugName '']; + errMsg = ['No download URL for ', OsType, ': ', PlugName '']; return; end % Compiled version @@ -1275,7 +1553,7 @@ function Configure(PlugDesc) '

Brainstorm will now install these plugins.' 10 10], 'Plugin manager'); end for iPlug = 1:length(installPlugs) - [isInstalled, errMsg] = Install(installPlugs{iPlug}, isInteractive, installPlugs{iPlug}); + [isInstalled, errMsg] = Install(installPlugs{iPlug}, isInteractive, installVer{iPlug}); if ~isInstalled errMsg = ['Error processing dependency: ' PlugDesc.RequiredPlugs{iPlug,1} 10 errMsg]; return; @@ -1460,9 +1738,69 @@ function Configure(PlugDesc) bst_progress('removeimage'); return; end + % Update plugin description after first load, and delete unwanted files + [isOk, errMsg, PlugDesc] = UpdateDescription(PlugDesc, 1); + if ~isOk + return; + end + % === SHOW PLUGIN INFO === + % Log install + bst_webread(['http://neuroimage.usc.edu/bst/pluglog.php?c=K8Yda7B&plugname=' PlugDesc.Name '&action=install']); + % Show plugin information (interactive mode only) + if isInteractive + % Hide progress bar + isProgress = bst_progress('isVisible'); + if isProgress + bst_progress('hide'); + end + % Message box: aknowledgements + java_dialog('msgbox', ['Plugin ' PlugName ' was sucessfully installed.

' ... + 'This software is not distributed by the Brainstorm developers.
' ... + 'Please take a few minutes to read the license information,
' ... + 'check the authors'' website and register online if recommended.

' ... + 'Cite the authors in your publications if you are using their software.

'], 'Plugin manager'); + % Show the readme file + if ~isempty(PlugDesc.ReadmeFile) + view_text(PlugDesc.ReadmeFile, ['Installed plugin: ' PlugName], 1, 1); + end + % Open the website + if ~isempty(PlugDesc.URLinfo) + web(PlugDesc.URLinfo, '-browser') + end + % Restore progress bar + if isProgress + bst_progress('show'); + end + end + % Remove logo + bst_progress('removeimage'); + % Return success + isOk = 1; +end + + +%% ===== UPDATE DESCRIPTION ===== +% USAGE: [isOk, errMsg, PlugDesc] = bst_plugin('UpdateDescription', PlugDesc, doDelete=0) +function [isOk, errMsg, PlugDesc] = UpdateDescription(PlugDesc, doDelete) + isOk = 1; + errMsg = ''; + PlugPath = PlugDesc.Path; + PlugName = PlugDesc.Name; + + if nargin < 2 + doDelete = 0; + end + + % Plug in needs to be installed + if isempty(bst_plugin('GetInstalled', PlugDesc.Name)) + isOk = 0; + errMsg = ['Cannot update description, plugin ''' PlugDesc.Name ''' needs to be installed']; + return + end + % === DELETE UNWANTED FILES === - if ~isempty(PlugDesc.DeleteFiles) && iscell(PlugDesc.DeleteFiles) + if doDelete && ~isempty(PlugDesc.DeleteFiles) && iscell(PlugDesc.DeleteFiles) warning('off', 'MATLAB:RMDIR:RemovedFromPath'); for iDel = 1:length(PlugDesc.DeleteFiles) if ~isempty(PlugDesc.SubFolder) @@ -1498,8 +1836,10 @@ function Configure(PlugDesc) % Get readme and logo PlugDesc.ReadmeFile = GetReadmeFile(PlugDesc); PlugDesc.LogoFile = GetLogoFile(PlugDesc); - % Update plugin.mat after loading + % Update plugin.mat + excludedFields = {'LoadedFcn', 'UnloadedFcn', 'DownloadedFcn', 'InstalledFcn', 'UninstalledFcn', 'Path', 'isLoaded', 'isManaged'}; PlugDescSave = rmfield(PlugDesc, excludedFields); + PlugMatFile = bst_fullfile(PlugDesc.Path, 'plugin.mat'); bst_save(PlugMatFile, PlugDescSave, 'v6'); % === CALLBACK: POST-INSTALL === @@ -1528,43 +1868,8 @@ function Configure(PlugDesc) bst_save(PlugMatFile, PlugDescSave, 'v6'); end end - - % === SHOW PLUGIN INFO === - % Log install - bst_webread(['http://neuroimage.usc.edu/bst/pluglog.php?c=K8Yda7B&plugname=' PlugDesc.Name '&action=install']); - % Show plugin information (interactive mode only) - if isInteractive - % Hide progress bar - isProgress = bst_progress('isVisible'); - if isProgress - bst_progress('hide'); - end - % Message box: aknowledgements - java_dialog('msgbox', ['Plugin ' PlugName ' was sucessfully installed.

' ... - 'This software is not distributed by the Brainstorm developers.
' ... - 'Please take a few minutes to read the license information,
' ... - 'check the authors'' website and register online if recommended.

' ... - 'Cite the authors in your publications if you are using their software.

'], 'Plugin manager'); - % Show the readme file - if ~isempty(PlugDesc.ReadmeFile) - view_text(PlugDesc.ReadmeFile, ['Installed plugin: ' PlugName], 1, 1); - end - % Open the website - if ~isempty(PlugDesc.URLinfo) - web(PlugDesc.URLinfo, '-browser') - end - % Restore progress bar - if isProgress - bst_progress('show'); - end - end - % Remove logo - bst_progress('removeimage'); - % Return success - isOk = 1; end - %% ===== INSTALL INTERACTIVE ===== % USAGE: [isOk, errMsg, PlugDesc] = bst_plugin('InstallInteractive', PlugName) function [isOk, errMsg, PlugDesc] = InstallInteractive(PlugName) @@ -1815,6 +2120,12 @@ function Configure(PlugDesc) if ~isempty(errMsg) return; end + % Check if plugin is supported on Apple silicon + OsType = bst_get('OsType', 0); + if strcmpi(OsType, 'mac64arm') && ismember(PlugDesc.Name, PluginsNotSupportAppleSilicon()) + errMsg = ['Plugin ', PlugDesc.Name ' is not supported on Apple silicon yet.']; + return; + end % Minimum Matlab version if ~isempty(PlugDesc.MinMatlabVer) && (PlugDesc.MinMatlabVer > 0) && (bst_get('MatlabVersion') < PlugDesc.MinMatlabVer) strMinVer = sprintf('%d.%d', ceil(PlugDesc.MinMatlabVer / 100), mod(PlugDesc.MinMatlabVer, 100)); @@ -1822,6 +2133,15 @@ function Configure(PlugDesc) return; end + % === PROCESS DEPENDENCIES === + % Unload incompatible plugins + if ~isempty(PlugDesc.UnloadPlugs) + for iPlug = 1:length(PlugDesc.UnloadPlugs) + % disp(['BST> Unloading incompatible plugin: ' PlugDesc.UnloadPlugs{iPlug}]); + Unload(PlugDesc.UnloadPlugs{iPlug}, isVerbose); + end + end + % === ALREADY LOADED === % If plugin is already full loaded if isequal(PlugDesc.isLoaded, 1) && ~isempty(PlugDesc.Path) @@ -1894,14 +2214,6 @@ function Configure(PlugDesc) bst_progress('setimage', LogoFile); end - % === PROCESS DEPENDENCIES === - % Unload incompatible plugins - if ~isempty(PlugDesc.UnloadPlugs) - for iPlug = 1:length(PlugDesc.UnloadPlugs) - % disp(['BST> Unloading incompatible plugin: ' PlugDesc.UnloadPlugs{iPlug}]); - Unload(PlugDesc.UnloadPlugs{iPlug}, isVerbose); - end - end % Load required plugins if ~isempty(PlugDesc.RequiredPlugs) for iPlug = 1:size(PlugDesc.RequiredPlugs,1) @@ -1944,11 +2256,16 @@ function Configure(PlugDesc) if isequal(filesep, '\') subDir = strrep(subDir, '/', '\'); end - if isdir([PlugHomeDir, filesep, subDir]) + if ~isempty(dir([PlugHomeDir, filesep, subDir])) if isVerbose disp(['BST> Adding plugin ' PlugDesc.Name ' to path: ', PlugHomeDir, filesep, subDir]); end - addpath([PlugHomeDir, filesep, subDir]); + if regexp(subDir, '\*[/\\]*$') + subDir = regexprep(subDir, '\*[/\\]*$', ''); + addpath(genpath([PlugHomeDir, filesep, subDir])); + else + addpath([PlugHomeDir, filesep, subDir]); + end end end end @@ -2261,17 +2578,34 @@ function Configure(PlugDesc) %% ===== MENUS: CREATE ===== -function j = MenuCreate(jMenu, fontSize) +function j = MenuCreate(jMenu, jPlugsPrev, PlugDesc, fontSize) import org.brainstorm.icon.*; % Get all the supported plugins - PlugDesc = GetSupported(); + if isempty(PlugDesc) + PlugDesc = GetSupported(); + end % Get Matlab version MatlabVersion = bst_get('MatlabVersion'); isCompiled = bst_iscompiled(); - % Submenus + % Submenus array jSub = {}; + % Generate submenus array from existing menu + if ~isCompiled && jMenu.getMenuComponentCount > 0 + for iItem = 0 : jMenu.getItemCount-1 + if ~isempty(regexp(jMenu.getMenuComponent(iItem).class, 'JMenu$', 'once')) + jSub(end+1,1:2) = {char(jMenu.getMenuComponent(iItem).getText), jMenu.getMenuComponent(iItem)}; + end + end + end + % Editing an existing menu? + if isempty(jPlugsPrev) + isNewMenu = 1; + j = repmat(struct(), 0); + else + isNewMenu = 0; + j = repmat(jPlugsPrev(1), 0); + end % Process each plugin - j = repmat(struct(), 0); for iPlug = 1:length(PlugDesc) Plug = PlugDesc(iPlug); % Skip if Matlab is too old @@ -2282,6 +2616,18 @@ function Configure(PlugDesc) if isCompiled && (Plug.CompiledStatus == 0) continue; end + % === Add menus for each plugin === + % One menu per plugin + ij = length(j) + 1; + j(ij).name = Plug.Name; + % Skip if it is already a menu item + if ~isNewMenu + iPlugPrev = ismember({jPlugsPrev.name}, Plug.Name); + if any(iPlugPrev) + j(ij) = jPlugsPrev(iPlugPrev); + continue + end + end % Category=submenu if ~isempty(Plug.Category) if isempty(jSub) || ~ismember(Plug.Category, jSub(:,1)) @@ -2294,9 +2640,6 @@ function Configure(PlugDesc) else jParent = jMenu; end - % One menu per plugin - ij = length(j) + 1; - j(ij).name = Plug.Name; % Compiled and included: Simple static menu if isCompiled && (Plug.CompiledStatus == 2) j(ij).menu = gui_component('MenuItem', jParent, [], Plug.Name, [], [], [], fontSize); @@ -2339,8 +2682,51 @@ function Configure(PlugDesc) end end end + % === Remove menus for plugins with description === + if ~isempty(jPlugsPrev) + [~, iOld] = setdiff({jPlugsPrev.name}, {PlugDesc.Name}); + for ix = 1 : length(iOld) + % Find category menu component + jMenuCat = jPlugsPrev(iOld(ix)).menu.getParent.getInvoker; + % Find index in parent + iDel = []; + for ic = 0 : jMenuCat.getMenuComponentCount-1 + if jPlugsPrev(iOld(ix)).menu == jMenuCat.getMenuComponent(ic) + iDel = ic; + break + end + end + % Remove from parent + if ~isempty(iDel) + jMenuCat.remove(iDel); + end + end + end + % Create options for adding user-defined plugins + if ~isCompiled && isNewMenu + menuCategory = 'User defined'; + jMenuUserDef = []; + for iMenuItem = 0 : jMenu.getItemCount-1 + if ~isempty(regexp(jMenu.getMenuComponent(iMenuItem).class, 'JMenu$', 'once')) && strcmp(char(jMenu.getMenuComponent(iMenuItem).getText), menuCategory) + jMenuUserDef = jMenu.getMenuComponent(iMenuItem); + end + end + if isempty(jMenuUserDef) + jMenuUserDef = gui_component('Menu', jMenu, [], menuCategory, IconLoader.ICON_FOLDER_OPEN, [], [], fontSize); + end + jAddUserDefMan = gui_component('MenuItem', [], [], 'Add manually', IconLoader.ICON_EDIT, [], @(h,ev)AddUserDefDesc('manual'), fontSize); + jAddUserDefFile = gui_component('MenuItem', [], [], 'Add from file', IconLoader.ICON_EDIT, [], @(h,ev)AddUserDefDesc('file'), fontSize); + jAddUserDefUrl = gui_component('MenuItem', [], [], 'Add from URL', IconLoader.ICON_EDIT, [], @(h,ev)AddUserDefDesc('url'), fontSize); + jRmvUserDefMan = gui_component('MenuItem', [], [], 'Remove plugin', IconLoader.ICON_DELETE, [], @(h,ev)RemoveUserDefDesc, fontSize); + % Insert "Add" options at the begining of the 'User defined' menu + jMenuUserDef.insert(jAddUserDefMan, 0); + jMenuUserDef.insert(jAddUserDefFile, 1); + jMenuUserDef.insert(jAddUserDefUrl, 2); + jMenuUserDef.insert(jRmvUserDefMan, 3); + jMenuUserDef.insertSeparator(4); + end % List - if ~isCompiled + if ~isCompiled && isNewMenu jMenu.addSeparator(); gui_component('MenuItem', jMenu, [], 'List', IconLoader.ICON_EDIT, [], @(h,ev)List('Installed', 1), fontSize); end @@ -2348,8 +2734,17 @@ function Configure(PlugDesc) %% ===== MENUS: UPDATE ===== -function MenuUpdate(jPlugs) +function MenuUpdate(jMenu, fontSize) import org.brainstorm.icon.*; + global GlobalData + % Get installed and supported plugins + [PlugsInstalled, PlugsSupported]= GetInstalled(); + % Get previous menu entries + jPlugs = GlobalData.Program.GUI.pluginMenus; + % Regenerate plugin menu to look for new plugins + jPlugs = MenuCreate(jMenu, jPlugs, PlugsSupported, fontSize); + % Update menu entries + GlobalData.Program.GUI.pluginMenus = jPlugs; % If compiled: disable most menus isCompiled = bst_iscompiled(); % Interface scaling @@ -2358,9 +2753,9 @@ function MenuUpdate(jPlugs) for iPlug = 1:length(jPlugs) j = jPlugs(iPlug); PlugName = j.name; + Plug = PlugsInstalled(ismember({PlugsInstalled.Name}, PlugName)); + PlugRef = PlugsSupported(ismember({PlugsSupported.Name}, PlugName)); % Is installed? - PlugRef = GetSupported(PlugName); - Plug = GetInstalled(PlugName); if ~isempty(Plug) isInstalled = 1; elseif ~isempty(PlugRef) @@ -2644,7 +3039,18 @@ function Archive(OutputFile) envPlug = bst_fullfile(envPlugins, PlugDesc(iPlug).Name); isOk = file_copy(PlugDesc(iPlug).Path, envPlug); if ~isOk - error(['Cannot copy folder: "' userProc '" into "' envProc '"']); + error(['Cannot copy folder: "' PlugDesc(iPlug).Path '" into "' envProc '"']); + end + end + % Copy user-defined JSON files + PlugJson = dir(fullfile(bst_get('UserPluginsDir'), 'plugin_*.json')); + for iPlugJson = 1:length(PlugJson) + bst_progress('text', ['Copying use-defined plugin JSON file: ' PlugJson(iPlugJson).name '...']); + plugJsonFile = bst_fullfile(PlugJson(iPlugJson).folder, PlugJson(iPlugJson).name); + envPlugJson = bst_fullfile(envPlugins, PlugJson(iPlugJson).name); + isOk = file_copy(plugJsonFile, envPlugJson); + if ~isOk + error(['Cannot copy file: "' plugJsonFile '" into "' envProc '"']); end end @@ -2739,6 +3145,10 @@ function LinkCatSpm(Action) if isempty(PlugCat) || ~PlugCat.isLoaded error('Plugin CAT12 is not loaded.'); end + % Return if installation is not complete yet (first load before installation ends) + if isempty(PlugCat.InstallDate) + return + end % Define source and target for the link if ~isempty(PlugCat.SubFolder) linkTarget = bst_fullfile(PlugCat.Path, PlugCat.SubFolder); @@ -2789,3 +3199,9 @@ function SetProgressLogo(PlugDesc) end end + +%% ===== NOT SUPPORTED APPLE SILICON ===== +% Return list of plugins not supported on Apple silicon +function pluginNames = PluginsNotSupportAppleSilicon() + pluginNames = { 'duneuro', 'mcxlab-cuda'}; +end diff --git a/toolbox/core/bst_set.m b/toolbox/core/bst_set.m index a27e257df..831cb2a55 100644 --- a/toolbox/core/bst_set.m +++ b/toolbox/core/bst_set.m @@ -85,6 +85,7 @@ function bst_set( varargin ) % - bst_set('PlotlyCredentials', Username, ApiKey, Domain) % - bst_set('KlustersExecutable', ExecutablePath) % - bst_set('ExportBidsOptions'), ExportBidsOptions) +% - bst_set('Pipelines') Saved Pipelines stored % % SEE ALSO bst_get @@ -134,6 +135,8 @@ function bst_set( varargin ) GlobalData.DataBase.BrainstormDbDir = contextValue; case 'BrainstormTmpDir' GlobalData.Preferences.BrainstormTmpDir = contextValue; + case 'Pipelines' + GlobalData.Processes.Pipelines = contextValue; %% ==== PROTOCOL ==== case 'iProtocol' @@ -274,7 +277,7 @@ function bst_set( varargin ) 'MagneticExtrapOptions', 'MriOptions', 'ConnectGraphOptions', 'NodelistOptions', 'IgnoreMemoryWarnings', 'SystemCopy', ... 'TimefreqOptions_morlet', 'TimefreqOptions_hilbert', 'TimefreqOptions_fft', 'TimefreqOptions_psd', 'TimefreqOptions_stft', 'TimefreqOptions_plv', ... 'OpenMEEGOptions', 'DuneuroOptions', 'DigitizeOptions', 'PcaOptions', 'CustomColormaps', 'PluginCustomPath', 'BrainSuiteDir', 'PythonExe', ... - 'GridOptions_headmodel', 'GridOptions_dipfit', 'LastPsdDisplayFunction', 'KlustersExecutable', 'ExportBidsOptions'} + 'GridOptions_headmodel', 'GridOptions_dipfit', 'LastPsdDisplayFunction', 'KlustersExecutable', 'ExportBidsOptions', 'ShowHiddenFiles'} GlobalData.Preferences.(contextName) = contextValue; case 'ReadOnly' diff --git a/toolbox/core/bst_startup.m b/toolbox/core/bst_startup.m index b727b3120..0a4cacf17 100644 --- a/toolbox/core/bst_startup.m +++ b/toolbox/core/bst_startup.m @@ -1,4 +1,4 @@ -function bst_startup(BrainstormHomeDir, GuiLevel, BrainstormDbDir) +function bst_startup(BrainstormHomeDir, GuiLevel, BrainstormDbDir, TemplateName) % BST_STARTUP: Start a new Brainstorm Session. % % USAGE: bst_startup(BrainstormHomeDir, GuiLevel=1, BrainstormDbDir=[]) @@ -7,6 +7,7 @@ function bst_startup(BrainstormHomeDir, GuiLevel, BrainstormDbDir) % - BrainstormHomeDir : Path to the brainstorm3 folder % - GuiLevel : -1=server, 0=nogui, 1=normal, 2=autopilot % - BrainstormDbDir : Database folder to use by default in this session +% - TemplateName : Default anatomy template % @============================================================================= % This function is part of the Brainstorm software: @@ -35,6 +36,9 @@ function bst_startup(BrainstormHomeDir, GuiLevel, BrainstormDbDir) if (nargin < 3) || isempty(BrainstormDbDir) BrainstormDbDir = []; end +if (nargin < 4) || isempty(TemplateName) + TemplateName = ''; +end % If version is too old MatlabVersion = bst_get('MatlabVersion'); if (MatlabVersion < 701) @@ -148,6 +152,17 @@ function bst_startup(BrainstormHomeDir, GuiLevel, BrainstormDbDir) disp('BST> Warning: For better graphics, use Matlab >= 2014b'); end +% Check for New Matlab Desktop (started with R2023a) +if (MatlabVersion >= 914) && panel_options('isJSDesktop') + disp('BST> Warning: Brainstorm is not fully tested and supported on the New Matlab Desktop.'); +end + +% Check for Apple silicon (started with R2023b) +if (MatlabVersion >= 2302) && strcmp(bst_get('OsType', 0), 'mac64arm') + disp(['BST> Warning: Running on Apple silicon, some functions and plugins are not supported yet:' 10 ... + ' Use Matlab < 2023b or Matlab for Intel processor for full support']); +end + %% ===== FORCE COMPILATION OF SOME INTERFACE FILES ===== if (GuiLevel == 1) @@ -300,22 +315,28 @@ function bst_startup(BrainstormHomeDir, GuiLevel, BrainstormDbDir) end +%% ===== INTERNET CONNECTION ===== +% Check internet connection +fprintf(1, 'BST> Checking internet connectivity... '); +[GlobalData.Program.isInternet, onlineRel] = bst_check_internet(); +if GlobalData.Program.isInternet + disp('ok'); +else + disp('failed'); +end + + %% ===== AUTOMATIC UPDATES ===== -% Automatic updates disabled: do not check for internet connection +% Automatic updates disabled if ~bst_get('AutoUpdates') disp('BST> Warning: Automatic updates are disabled.'); disp('BST> Warning: Make sure your version of Brainstorm is up to date.'); % Matlab is running: check for updates elseif ~isCompiled && (GuiLevel == 1) - % Check internect connection - fprintf(1, 'BST> Checking internet connectivity... '); - [GlobalData.Program.isInternet, onlineRel] = bst_check_internet(); % If no internet connection if ~GlobalData.Program.isInternet - disp('failed'); disp('BST> Could not check for Brainstorm updates.') else - disp('ok'); % Determine if release is old (local version > 30 days older than online version) daysOnline = onlineRel.year*365 + onlineRel.month*30 + onlineRel.day; daysLocal = localRel.year*365 + localRel.month*30 + localRel.day; @@ -418,6 +439,11 @@ function bst_startup(BrainstormHomeDir, GuiLevel, BrainstormDbDir) end +%% ===== USER-DEFINED PLUGINS ===== +% Print information about user-defined plugins +bst_plugin('GetSupported', [], 1); + + %% ===== LOAD PLUGINS ===== % Get installed plugins [InstPlugs, AllPlugs] = bst_plugin('GetInstalled'); @@ -453,10 +479,9 @@ function bst_startup(BrainstormHomeDir, GuiLevel, BrainstormDbDir) %% ===== INSTALL ANATOMY TEMPLATE ===== -% Download ICBM152 template if missing (e.g. when cloning from GitHub) +% Download ICBM152 template if missing TemplateDir = fullfile(BrainstormHomeDir, 'defaults', 'anatomy', 'ICBM152'); -if ~isCompiled && ~exist(TemplateDir, 'file') - TemplateName = 'ICBM152_2023b'; +if ~isCompiled && ~exist(TemplateDir, 'file') && ~isempty(TemplateName) isSkipTemplate = 0; % Template file ZipFile = bst_fullfile(bst_get('UserDefaultsDir'), 'anatomy', [TemplateName '.zip']); diff --git a/toolbox/core/bst_systeminfo.m b/toolbox/core/bst_systeminfo.m new file mode 100644 index 000000000..df6dbd7bc --- /dev/null +++ b/toolbox/core/bst_systeminfo.m @@ -0,0 +1,102 @@ +function systemInfoText = bst_systeminfo(showInfo) +% BST_SYSTEMNINFO: Get general information about Brainstorm, Matlab and the Computer +% +% USAGE: bst_systeminfo(showInfo) +% INPUTS: +% - showInfo : 0 (default) only return system info text +% 1 with GUI, open window with system info +% without GUI, print information in console +% +% @============================================================================= +% This function is part of the Brainstorm software: +% https://neuroimage.usc.edu/brainstorm +% +% Copyright (c) University of Southern California & McGill University +% This software is distributed under the terms of the GNU General Public License +% as published by the Free Software Foundation. Further details on the GPLv3 +% license can be found at http://www.gnu.org/copyleft/gpl.html. +% +% FOR RESEARCH PURPOSES ONLY. THE SOFTWARE IS PROVIDED "AS IS," AND THE +% UNIVERSITY OF SOUTHERN CALIFORNIA AND ITS COLLABORATORS DO NOT MAKE ANY +% WARRANTY, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO WARRANTIES OF +% MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE, NOR DO THEY ASSUME ANY +% LIABILITY OR RESPONSIBILITY FOR THE USE OF THIS SOFTWARE. +% +% For more information type "brainstorm license" at command prompt. +% =============================================================================@ +% +% Authors: Raymundo Cassani, 2024 + +% Parse inputs +if (nargin < 1) || isempty(showInfo) + showInfo = 0; +end + +summaryPairs = cell(0,2); +% == Brainstorm +summaryPairs = [summaryPairs; {'=== Brainstorm ===', ''}]; +% Version +bst_version = bst_get('Version'); +summaryPairs = [summaryPairs; {'Version ', bst_version.Version}]; +summaryPairs = [summaryPairs; {'Release ', bst_version.Release}]; +bst_variant = 'source'; +if bst_iscompiled() + bst_variant = 'standalone'; +end +summaryPairs = [summaryPairs; {'Variant ', bst_variant}]; +% Plugins +pluginTextPairs = cell(0,2); +pluginTextPairs = [pluginTextPairs, {'Plugins ', 'No installed plugins.'}]; +InstPlugs = bst_plugin('GetInstalled'); +nInstPlugs = length(InstPlugs); +iPluginRow = 0; +for ix = 1 : nInstPlugs + plugName = InstPlugs(ix).Name; + if InstPlugs(ix).isLoaded + plugName = [plugName, '*']; + end + if mod(ix-1, 8) == 0 + iPluginRow = iPluginRow + 1; + pluginTextPairs{iPluginRow, 2} = ''; + end + pluginTextPairs{iPluginRow, 2} = strtrim([pluginTextPairs{iPluginRow, 2}, ' ', plugName]); +end +summaryPairs = [summaryPairs; pluginTextPairs]; +summaryPairs = [summaryPairs; {'', ''}]; + +% == Directories +summaryPairs = [summaryPairs; {'=== Brainstorm directories ===', ''}]; +summaryPairs = [summaryPairs; {'*** Directory paths may contain sensitive information, check before sharing ***', ''}]; +summaryPairs = [summaryPairs; {'Brainstorm ', bst_get('BrainstormHomeDir')}]; +summaryPairs = [summaryPairs; {'DataBase ', bst_get('BrainstormDbDir')}]; +summaryPairs = [summaryPairs; {'Bst_User ', bst_get('BrainstormUserDir')}]; +summaryPairs = [summaryPairs; {'Temporary ', bst_get('BrainstormTmpDir')}]; +summaryPairs = [summaryPairs; {'', ''}]; + +% === Matlab and Java +summaryPairs = [summaryPairs; {'=== Matlab ===', ''}]; +summaryPairs = [summaryPairs; {'Matlab version ', [bst_get('MatlabReleaseName') ' (' num2str(bst_get('MatlabVersion')/100) ')']}]; +summaryPairs = [summaryPairs; {'Java version ', num2str(bst_get('JavaVersion'))}]; +summaryPairs = [summaryPairs; {'', ''}]; + +% == System +summaryPairs = [summaryPairs; {'=== System ===', ''}]; +summaryPairs = [summaryPairs; {'OS name ', bst_get('OsName')}]; +summaryPairs = [summaryPairs; {'OS type ', bst_get('OsType')}]; +[memTotal, memAvail] = bst_get('SystemMemory'); +summaryPairs = [summaryPairs; {'Mem total ', [num2str(memTotal) ' MiB']}]; +summaryPairs = [summaryPairs; {'Mem avail ', [num2str(memAvail) ' MiB']}]; + +% Format string +iFields = find(~cellfun(@isempty, summaryPairs(:,2))); +maxField = max([cellfun(@length, summaryPairs(iFields,1))]); +summaryPairs(iFields,1) = cellfun(@(x) [' ', x, ':', repmat(' ', 1, maxField-length(x))], summaryPairs(iFields,1), 'UniformOutput', 0); +summaryRows = cellfun(@(x,y) strjoin({x,y}, ' '), summaryPairs(:,1), summaryPairs(:,2), 'UniformOutput', 0); +systemInfoText = strjoin(summaryRows, char(10)); +if showInfo + if bst_get('isGUI') + view_text(systemInfoText, 'System info'); + else + fprintf([systemInfoText, '\n']); + end +end diff --git a/toolbox/core/bst_userstat.m b/toolbox/core/bst_userstat.m index bc39790af..7d7d4d90c 100644 --- a/toolbox/core/bst_userstat.m +++ b/toolbox/core/bst_userstat.m @@ -77,6 +77,9 @@ function bst_userstat(isSave, PlugName) if isempty(PlugName) % Read list of users str = bst_webread('http://neuroimage.usc.edu/bst/get_logs.php?c=J7rTwq'); + % Replace actions ['i' and 'j'] with 'x' so it is not read as imaginary in textscan + str = strrep(str, 'i', 'x'); + str = strrep(str, 'j', 'x'); % Extract values c = textscan(str, '%02d%02d%c'); dates = double([c{1}, c{2}]); @@ -85,19 +88,21 @@ function bst_userstat(isSave, PlugName) % Create histograms iUpdate = find((action == 'A') | (action == 'L') | (action == 'D')); [nUpdate,xUpdate] = hist(dates(iUpdate), length(unique(dates(iUpdate)))); - % Look for all dates in the current year (exclude current month) - year = 2023; - iAvg = find((xUpdate >= year) & (xUpdate < year+1)); + % Look for all dates in last 12 months (exclude current month) + t = datetime('today'); + finRollAvg = t.Year + ((t.Month -1) ./12); + iniRollAvg = finRollAvg - 1; + iAvg = find((xUpdate >= iniRollAvg) & (xUpdate < finRollAvg)); % Remove invalid data iBad = ((nUpdate < 100) | (nUpdate > 4000)); nUpdate(iBad) = interp1(xUpdate(~iBad), nUpdate(~iBad), xUpdate(iBad), 'pchip'); % Plot number of downloads [hFig(end+1), hAxes] = fig_report(xUpdate(1:end-1), nUpdate(1:end-1), 0, ... [2005, max(xUpdate(1:end-1))], [], ... - sprintf('Downloads per month: Avg(%d)=%d', year, round(mean(nUpdate(iAvg)))), [], 'Downloads per month', ... + sprintf('Downloads per month: 12-month Avg=%d', round(mean(nUpdate(iAvg)))), [], 'Downloads per month', ... [100, Hs(2) - (length(hFig)+1)*hf], isSave, bst_fullfile(ImgDir, 'download.png')); % String for MoinMoin website - fprintf('Number of software downloads per month: (average %d = %s%d/month%s)\r', year, boldMoinMoin, round(mean(nUpdate(iAvg))), boldMoinMoin); + fprintf('Number of software downloads per month: (12-month average = %s%d/month%s)\r', boldMoinMoin, round(mean(nUpdate(iAvg))), boldMoinMoin); end @@ -123,11 +128,42 @@ function bst_userstat(isSave, PlugName) % ===== PUBLICATIONS ===== if isempty(PlugName) - % Hard coded list of publications - year = [2000 2001 2002 2003 2004 2005 2006 2007 2008 2009 2010 2011 2012 2013 2014 2015 2016 2017 2018 2019 2020 2021 2022]; - nPubli = [ 2 2 1 1 3 5 5 11 10 20 20 32 38 55 78 94 133 214 224 290 382 393 478]; - nPubliCurYear = 118; % Updated March 2023 - strPubDate = 'Up to March 2023'; + % Up to December 2022, citation count was manually curated + year_man = [2000 2001 2002 2003 2004 2005 2006 2007 2008 2009 2010 2011 2012 2013 2014 2015 2016 2017 2018 2019 2020 2021 2022]; + nPubli_man = [ 2 2 1 1 3 5 5 11 10 20 20 32 38 55 78 94 133 214 224 290 382 393 478]; + % nPubliCurYear = 118; % January to March 2023 + % strPubDate = 'Up to March 2023'; + + % From January 2023 onwards, citation count is obtained from Google Scholar, and posted in: + % https://neuroimage.usc.edu/bst/citations_count.html + % Read list of users + str = bst_webread('https://neuroimage.usc.edu/bst/citations_count.html'); + % Extract values YYYY#nPubli + year_Npubli_gs = regexp(str, '[0-9]+#[0-9]+', 'match'); + year_Npubli_gs = sort(year_Npubli_gs); + year_gs = []; + nPubli_gs = []; + for iRow = 1 : length(year_Npubli_gs) + C = textscan(year_Npubli_gs{iRow}, '%d#%d'); + if C{1} >= 2023 + year_gs(end+1) = C{1}; + nPubli_gs(end+1) = C{2}; + end + end + % Publications current year (last year in array) + nPubliCurYear = nPubli_gs(end); + % Remove current year from graph + nPubli_gs(end) = []; + year_gs(end) = []; + + % Get month of last update + dateCount = regexp(str, 'UpdatedOn#([^<]*)', 'tokens', 'once'); + C = str_split(dateCount{1}, '-'); + strPubDate = sprintf('Up to %s %s', C{2}, C{3}); + + % Aggregate manual and automatic citation counts + year = [year_man, year_gs]; + nPubli = [nPubli_man, nPubli_gs]; % Plot figure hFig(end+1) = fig_report(year, nPubli, 1, ... [2000 max(year)], [], ... @@ -143,6 +179,11 @@ function bst_userstat(isSave, PlugName) % Download statistics url = sprintf('https://neuroimage.usc.edu/bst/pluglog.php?c=K8Yda7B&plugname=%s&action=install&list=1', PlugName); str = bst_webread(url); + + if isempty(str) + bst_progress('stop'); + return; + end % Process report str = str_split(str, char(10)); nTotal = length(str); diff --git a/toolbox/db/db_add.m b/toolbox/db/db_add.m index 2751860c6..b877f083c 100644 --- a/toolbox/db/db_add.m +++ b/toolbox/db/db_add.m @@ -91,8 +91,8 @@ end % Surfaces subtypes if ismember(fileType, {'fibers', 'fem'}) - fileType = 'tess'; fileSubType = [fileType, '_']; + fileType = 'tess'; end isAnatomy = ismember(fileType, {'subjectimage', 'tess'}); % Spikes: file tag is data diff --git a/toolbox/db/db_group_conditions.m b/toolbox/db/db_group_conditions.m index 87dc67004..4183b0104 100644 --- a/toolbox/db/db_group_conditions.m +++ b/toolbox/db/db_group_conditions.m @@ -107,6 +107,7 @@ function db_group_conditions( ConditionsPaths, newConditionName ) % === PROCESS ALL STUDIES === isChanCopied = 0; oldCondPath = {}; + badDataFiles = {}; for iStud = 1:length(iCondStudies) % Increment progressbar bst_progress('inc', 1); @@ -123,6 +124,8 @@ function db_group_conditions( ConditionsPaths, newConditionName ) dirStudy = bst_fullfile(ProtocolInfo.STUDIES, bst_fileparts(sStudy.FileName)); studyFiles = dir(dirStudy); iNonTrial = 1; + % Bad trials + badDataFiles = [badDataFiles, {sStudy.Data(find([sStudy.Data.BadTrial])).FileName}]; % Copy all files in the target study for iFile = 1:length(studyFiles) % Ignore if directory @@ -157,6 +160,11 @@ function db_group_conditions( ConditionsPaths, newConditionName ) srcFilename = bst_fullfile(dirStudy, studyFiles(iFile).name); % Move file physically file_move(srcFilename, destFilename); + % Get new FileName for bad trial + iBadDataFile = find(ismember(badDataFiles, file_short(srcFilename))); + if ~isempty(iBadDataFile) + badDataFiles{iBadDataFile} = file_short(destFilename); + end end end end @@ -182,6 +190,8 @@ function db_group_conditions( ConditionsPaths, newConditionName ) end % Reload modified studies db_reload_studies(iAllDestStudies); +% Update bad trials info +process_detectbad('SetTrialStatus', badDataFiles, 1); % Repaint node panel_protocols('UpdateNode', 'Study', iAllDestStudies); % Save database diff --git a/toolbox/db/db_set_channel.m b/toolbox/db/db_set_channel.m index d5d0764bc..d866171e4 100644 --- a/toolbox/db/db_set_channel.m +++ b/toolbox/db/db_set_channel.m @@ -183,12 +183,10 @@ [ChannelMat, R, T, isSkip, isUserCancel, strReport, Tolerance] = channel_align_auto(OutputFile, [], 0, isConfirm, Tolerance); % User validated: keep this answer for the next round (force alignment for next call) if ~isSkip - if isUserCancel + if isUserCancel || isempty(ChannelMat) ChannelAlign = 0; - elseif ~isempty(ChannelMat) + elseif ChannelAlign < 2 ChannelAlign = 2; - else - ChannelAlign = 0; end end end diff --git a/toolbox/db/db_template.m b/toolbox/db/db_template.m index 058bccb71..2166c7774 100644 --- a/toolbox/db/db_template.m +++ b/toolbox/db/db_template.m @@ -102,6 +102,7 @@ 'Comment', '', ... 'Vertices', [], ... 'Faces', [], ... + 'Color', [], ... 'VertConn', [], ... 'VertNormals', [], ... 'Curvature', [], ... @@ -272,7 +273,8 @@ 'Components', [], ... 'CompMask', [], ... 'Status', 0, ... % 0: not applied; 1: applied on the fly; 2: saved in the file, not revertible : ADDITIONAL VALUES = EEG REFERENCES - 'SingVal', []); + 'SingVal', [], ... + 'Method', ''); case 'matrixmat' template = struct(... @@ -605,7 +607,8 @@ 'Atlas', [], ... 'StatClusters', [], ... 'StatThreshUnder', [], ... - 'StatThreshOver', []); + 'StatThreshOver', [], ... + 'Function', ''); case 'loadeddipoles' template = struct(... @@ -671,6 +674,7 @@ 'Comment', '', ... 'Vertices', [], ... 'Faces', [], ... + 'Color', [], ... 'VertConn', [], ... 'VertNormals', [], ... 'VertArea', [], ... @@ -734,7 +738,12 @@ 'ElecDiameter', [], ... 'ElecLength', [], ... 'Visible', 1); - + + case 'intracontact' + template = struct(... + 'Name', '', ... % Identification) + 'Loc', []); % [3x1] position for contact + case 'dataset' template = struct(... 'DataFile', '', ... @@ -1048,11 +1057,12 @@ 'SizeThreshold', 1, ... % Threshold to apply to color coding of data values 'DataLimitValue', [], ... % Relative limits for colormapping 'CutsPosition', [0 0 0], ... % Position of the three orthogonal MRI slices - 'Resect', 'none', ... % Either [x,y,z] resect values, or {'left', 'right', 'none'} + 'Resect', [], ... % 2 cells: Resect values [x,y,z] and resect sections {'left', 'right', 'struct', 'none'} 'MipAnatomy', [], ... % 3 cells: Maximum intensity power in each direction (MRI amplitudes) 'MipFunctional', [], ... % 3 cells: Maximum intensity power in each direction (sources amplitudes) 'StatThreshOver', [], ... 'StatThreshUnder', []); + template.Resect = {[0,0,0], 'none'}; template.MipAnatomy = cell(3,1); template.MipFunctional = cell(3,1); diff --git a/toolbox/db/private/db_parse_subject.m b/toolbox/db/private/db_parse_subject.m index 7a41b17ce..8dce8f8db 100644 --- a/toolbox/db/private/db_parse_subject.m +++ b/toolbox/db/private/db_parse_subject.m @@ -202,7 +202,9 @@ % ==== ANATOMY ==== % By default : use the first anatomy in list (which is not a volume atlas) - if ~isempty(sSubject(1).Anatomy) + if ~isempty(sSubject(1).Anatomy) && isempty(strfind(sSubject(1).Anatomy(1).FileName, '_volatlas')) ... + && isempty(strfind(sSubject(1).Anatomy(1).FileName, '_tissues')) ... + && isempty(strfind(sSubject(1).Anatomy(1).FileName, '_volct')) sSubject(1).iAnatomy = 1; else sSubject(1).iAnatomy = []; diff --git a/toolbox/forward/panel_headmodel.m b/toolbox/forward/panel_headmodel.m index 8682b018b..611466f45 100644 --- a/toolbox/forward/panel_headmodel.m +++ b/toolbox/forward/panel_headmodel.m @@ -29,7 +29,7 @@ %% ===== CREATE PANEL ===== -function [bstPanelNew, panelName] = CreatePanel(isMeg, isEeg, isEcog, isSeeg, isMixed) %#ok +function [bstPanelNew, panelName] = CreatePanel(isMeg, isEeg, isEcog, isSeeg, isMixed, isNirs) %#ok panelName = 'HeadmodelOptions'; % Java initializations import java.awt.*; @@ -41,6 +41,10 @@ isSeeg = 0; isMixed = 0; end + % Call without 'isNIRS' + if (nargin < 6) + isNirs = 0; + end % Create main main panel jPanelNew = gui_river([8,10], [0,15,20,15]); @@ -124,6 +128,17 @@ jCheckMethodSEEG = []; jComboMethodSEEG = []; end + % === NIRS === + if isNirs + % Checkbox + jCheckMethodNIRS = gui_component('CheckBox', jPanelMethod, 'br', 'NIRS: '); + jCheckMethodNIRS.setSelected(0); + jCheckMethodNIRS.setEnabled(0) + % Label + gui_component('Label', jPanelMethod, 'tab hfill', ['To compute head model for NIRS, use process:
' ... + 'NIRS > Sources > Compute head model from fluence
' ... + '(NIRSTORM plugin is required)']); + end % Attach sub panel to NewPanel jPanelNew.add('br hfill', jPanelMethod); @@ -329,15 +344,16 @@ function UpdateComment(varargin) isEeg = any(strcmpi(sStudies(i).Channel.DisplayableSensorTypes, 'EEG')); isEcog = any(strcmpi(sStudies(i).Channel.DisplayableSensorTypes, 'ECOG')); isSeeg = any(strcmpi(sStudies(i).Channel.DisplayableSensorTypes, 'SEEG')); - isNIRS = any(strcmpi(sStudies(i).Channel.DisplayableSensorTypes, 'NIRS')); + isNirs = any(strcmpi(sStudies(i).Channel.DisplayableSensorTypes, 'NIRS')); end end % Check that at least one modality is available - if isNIRS - errMessage = ['To compute head model for NIRS, use process:' 10 'NIRS > Sources > Compute head model from fluence' 10 'NIRSTORM plugin is required']; - return; - elseif ~isMeg && ~isEeg && ~isEcog && ~isSeeg - errMessage = 'No valid sensor types to estimate a head model.'; + if ~isMeg && ~isEeg && ~isEcog && ~isSeeg + if isNirs + errMessage = ['To compute head model for NIRS, use process:' 10 'NIRS > Sources > Compute head model from fluence' 10 'NIRSTORM plugin is required']; + else + errMessage = 'No valid sensor types to estimate a head model.'; + end return; end % Check if the first subject has a "Source model" atlas @@ -356,7 +372,7 @@ function UpdateComment(varargin) end % Display options panel if (nargin < 2) || isempty(sMethod) - sMethod = gui_show_dialog('Compute head model', @panel_headmodel, 1, [], isMeg, isEeg, isEcog, isSeeg, isMixed); + sMethod = gui_show_dialog('Compute head model', @panel_headmodel, 1, [], isMeg, isEeg, isEcog, isSeeg, isMixed, isNirs); if isempty(sMethod) return; end diff --git a/toolbox/gui/figure_3d.m b/toolbox/gui/figure_3d.m index 176571088..a07e434ad 100644 --- a/toolbox/gui/figure_3d.m +++ b/toolbox/gui/figure_3d.m @@ -514,6 +514,8 @@ function FigureMouseMoveCallback(hFig, varargin) posXYZ = [NaN, NaN, NaN]; posXYZ(moveAxis) = newPos; panel_surface('PlotMri', hFig, posXYZ, 1); + % Update sliders in surface panel + panel_surface('UpdateSurfaceProperties'); end end end @@ -609,9 +611,24 @@ function FigureMouseUpCallback(hFig, varargin) % === SELECTING POINT === elseif isSelectingCoordinates - % Selecting from Coordinates panel - if gui_brainstorm('isTabVisible', 'Coordinates') - panel_coordinates('SelectPoint', hFig); + % Selecting from Coordinates or iEEG panels + if gui_brainstorm('isTabVisible', 'Coordinates') || gui_brainstorm('isTabVisible', 'iEEG') + if gui_brainstorm('isTabVisible', 'iEEG') + % For SEEG, making sure centroid calculation for plotting contacts is active + [iTess, TessInfo, hFig, sSurf] = panel_surface('GetSurface', hFig, [], 'Other'); + if ~isempty(sSurf) + iIsoSurf = find(cellfun(@(x) ~isempty(regexp(x, '_isosurface', 'match')), {sSurf.FileName})); + if ~isempty(iIsoSurf) + panel_coordinates('SelectPoint', hFig, 0, 1); + else + panel_coordinates('SelectPoint', hFig); + end + else + panel_coordinates('SelectPoint', hFig); + end + else + panel_coordinates('SelectPoint', hFig); + end % Selecting fiducials linked with MRI viewer else hView3DHeadFig = findobj(0, 'Type', 'Figure', 'Tag', 'View3DHeadFig', '-depth', 1); @@ -820,7 +837,9 @@ function FigureMouseUpCallback(hFig, varargin) % If there are intra electrodes defined, and if the channels are SEEG/ECOG: try to select the electrode in panel_ieeg if ~isempty(GlobalData.DataSet(iDS).IntraElectrodes) && all(~cellfun(@isempty, {GlobalData.DataSet(iDS).Channel(iSelChan).Group})) selGroup = unique({GlobalData.DataSet(iDS).Channel(iSelChan).Group}); + % Highlight the electrode and contacts panel_ieeg('SetSelectedElectrodes', selGroup); + panel_ieeg('SetSelectedContacts', SelChan); end end end @@ -954,7 +973,7 @@ function FigureMouseWheelCallback(hFig, event, target) %% ===== KEYBOARD CALLBACK ===== function FigureKeyPressedCallback(hFig, keyEvent) - global GlobalData TimeSliderMutex; + global GlobalData TimeSliderMutex Digitize; % Prevent multiple executions hAxes = findobj(hFig, '-depth', 1, 'Tag', 'Axes3D'); set([hFig hAxes], 'BusyAction', 'cancel'); @@ -1075,7 +1094,19 @@ function FigureKeyPressedCallback(hFig, keyEvent) case 'a' if ismember('control', keyEvent.Modifier) ViewAxis(hFig); - end + end + % C : Collect point + case 'c' + % for 3DScanner + if gui_brainstorm('isTabVisible', 'Digitize') && strcmpi(Digitize.Type, '3DScanner') + % Get Digitize options + DigitizeOptions = bst_get('DigitizeOptions'); + panel_fun = @panel_digitize; + if isfield(DigitizeOptions, 'Version') && strcmpi(DigitizeOptions.Version, '2024') + panel_fun = @panel_digitize_2024; + end + panel_fun('ManualCollect_Callback'); + end % CTRL+D : Dock figure case 'd' if ismember('control', keyEvent.Modifier) @@ -1168,6 +1199,20 @@ function FigureKeyPressedCallback(hFig, keyEvent) % M : Jump to maximum case 'm' JumpMaximum(hFig); + % CTRL+P : Toggle point selection mode + case 'p' + if ismember('control', keyEvent.Modifier) + tmp = bst_get('PanelControls', 'Coordinates'); + if isempty(tmp) + gui_brainstorm('ShowToolTab', 'Coordinates'); + end + tmp = bst_get('PanelControls', 'iEEG'); + if ~isempty(tmp) + gui_brainstorm('ShowToolTab', 'iEEG'); + end + pause(0.01); + panel_coordinates('SetSelectionState', ~panel_coordinates('GetSelectionState')); + end % CTRL+R : Recordings time series case 'r' if ismember('control', keyEvent.Modifier) && ~isempty(GlobalData.DataSet(iDS).DataFile) && ~strcmpi(FigureId.Modality, 'MEG GRADNORM') @@ -1282,7 +1327,11 @@ function ResetView(hFig) % 2D LAYOUT: separate function if strcmpi(GlobalData.DataSet(iDS).Figure(iFig).Id.SubType, '2DLayout') GlobalData.DataSet(iDS).Figure(iFig).Handles.DisplayFactor = 1; - GlobalData.Preferences.TopoLayoutOptions.TimeWindow = abs(GlobalData.UserTimeWindow.Time(2) - GlobalData.UserTimeWindow.Time(1)) .* [-1, 1]; + if ~getappdata(hFig, 'isStaticFreq') + GlobalData.Preferences.TopoLayoutOptions.FreqWindow = [GlobalData.UserFrequencies.Freqs(1), GlobalData.UserFrequencies.Freqs(end)]; + else + GlobalData.Preferences.TopoLayoutOptions.TimeWindow = abs(GlobalData.UserTimeWindow.Time(2) - GlobalData.UserTimeWindow.Time(1)) .* [-1, 1]; + end figure_topo('UpdateTopo2dLayout', iDS, iFig); return % 3D figures @@ -1411,13 +1460,12 @@ function SetStandardView(hFig, viewNames) end end - %% ===== GET COORDINATES ===== function GetCoordinates(varargin) % Show Coordinates panel gui_show('panel_coordinates', 'JavaWindow', 'Get coordinates', [], 0, 1, 0); - % Start point selection - panel_coordinates('SetSelectionState', 1); + % Toggle point selection mode + panel_coordinates('SetSelectionState', ~panel_coordinates('GetSelectionState')); end @@ -1605,6 +1653,10 @@ function DisplayFigurePopup(hFig) jItem = gui_component('MenuItem', jPopup, [], 'View sources', IconLoader.ICON_RESULTS, [], @(h,ev)bst_figures('ViewResults',hFig)); jItem.setAccelerator(KeyStroke.getKeyStroke(KeyEvent.VK_S, KeyEvent.CTRL_MASK)); end + % === VIEW SPECTRUM === + if strcmpi(FigureType, 'Topography') && strcmpi(GlobalData.DataSet(iDS).Figure(iFig).Id.SubType, '2DLayout') && getappdata(hFig, 'isStatic') + jItem = gui_component('MenuItem', jPopup, [], [Modality ' Spectrum'], IconLoader.ICON_SPECTRUM, [], @(h,ev)view_spectrum(TfFile, 'Spectrum')); + end % === VIEW PAC/TIME-FREQ === if strcmpi(FigureType, 'Topography') && ~isempty(SelChan) && ~isempty(Modality) && (Modality(1) ~= '$') if ~isempty(strfind(TfFile, '_pac_fullmaps')) @@ -1629,7 +1681,11 @@ function DisplayFigurePopup(hFig) TopoLayoutOptions = bst_get('TopoLayoutOptions'); % Create menu jMenu = gui_component('Menu', jPopup, [], '2DLayout options', IconLoader.ICON_2DLAYOUT); - gui_component('MenuItem', jMenu, [], 'Set time window...', [], [], @(h,ev)figure_topo('SetTopoLayoutOptions', 'TimeWindow')); + if ~getappdata(hFig, 'isStatic') + gui_component('MenuItem', jMenu, [], 'Set time window...', [], [], @(h,ev)figure_topo('SetTopoLayoutOptions', 'TimeWindow')); + else + gui_component('MenuItem', jMenu, [], 'Set frequency window...', [], [], @(h,ev)figure_topo('SetTopoLayoutOptions', 'FreqWindow')); + end jItem = gui_component('CheckBoxMenuItem', jMenu, [], 'White background', [], [], @(h,ev)figure_topo('SetTopoLayoutOptions', 'WhiteBackground', ~TopoLayoutOptions.WhiteBackground)); jItem.setSelected(TopoLayoutOptions.WhiteBackground); jItem = gui_component('CheckBoxMenuItem', jMenu, [], 'Show reference lines', [], [], @(h,ev)figure_topo('SetTopoLayoutOptions', 'ShowRefLines', ~TopoLayoutOptions.ShowRefLines)); @@ -1939,7 +1995,9 @@ function DisplayFigurePopup(hFig) % ==== MENU: GET COORDINATES ==== if ~strcmpi(FigureType, 'Topography') - gui_component('MenuItem', jPopup, [], 'Get coordinates...', IconLoader.ICON_SCOUT_NEW, [], @GetCoordinates); + jItem = gui_component('checkboxmenuitem', jPopup, [], 'Get coordinates...', IconLoader.ICON_SCOUT_NEW, [], @GetCoordinates); + jItem.setSelected(panel_coordinates('GetSelectionState')); + jItem.setAccelerator(KeyStroke.getKeyStroke(KeyEvent.VK_P, KeyEvent.CTRL_MASK)); end % ==== MENU: SNAPSHOT ==== @@ -1949,8 +2007,8 @@ function DisplayFigurePopup(hFig) DefaultOutputDir = LastUsedDirs.ExportImage; % Is there a time window defined isTime = ~isempty(GlobalData) && ~isempty(GlobalData.UserTimeWindow.CurrentTime) && ~isempty(GlobalData.UserTimeWindow.Time) ... - && (~isempty(DataFile) || ~isempty(ResultsFile) || ~isempty(Dipoles) || ~isempty(TfFile)); - isFreq = ~isempty(GlobalData) && ~isempty(GlobalData.UserFrequencies.iCurrentFreq) && ~isempty(TfFile); + && (~isempty(DataFile) || ~isempty(ResultsFile) || ~isempty(Dipoles) || ~isempty(TfFile)) && ~getappdata(hFig, 'isStatic'); + isFreq = ~isempty(GlobalData) && ~isempty(GlobalData.UserFrequencies.iCurrentFreq) && ~isempty(TfFile) && ~getappdata(hFig, 'isStaticFreq'); % === SAVE AS IMAGE === jItem = gui_component('MenuItem', jMenuSave, [], 'Save as image', IconLoader.ICON_SAVE, [], @(h,ev)out_figure_image(hFig)); jItem.setAccelerator(KeyStroke.getKeyStroke(KeyEvent.VK_I, KeyEvent.CTRL_MASK)); @@ -2024,6 +2082,12 @@ function DisplayFigurePopup(hFig) gui_component('MenuItem', jMenuSave, [], 'Time contact sheet: Sagittal', IconLoader.ICON_CONTACTSHEET, [], @(h,ev)view_contactsheet(hFig, 'time', 'x', DefaultOutputDir)); gui_component('MenuItem', jMenuSave, [], 'Time contact sheet: Axial', IconLoader.ICON_CONTACTSHEET, [], @(h,ev)view_contactsheet(hFig, 'time', 'z', DefaultOutputDir)); end + if isFreq + jMenuSave.addSeparator(); + gui_component('MenuItem', jMenuSave, [], 'Frequency contact sheet: Coronal', IconLoader.ICON_CONTACTSHEET, [], @(h,ev)view_contactsheet(hFig, 'freq', 'y', DefaultOutputDir)); + gui_component('MenuItem', jMenuSave, [], 'Frequency contact sheet: Sagittal', IconLoader.ICON_CONTACTSHEET, [], @(h,ev)view_contactsheet(hFig, 'freq', 'x', DefaultOutputDir)); + gui_component('MenuItem', jMenuSave, [], 'Frequency contact sheet: Axial', IconLoader.ICON_CONTACTSHEET, [], @(h,ev)view_contactsheet(hFig, 'freq', 'z', DefaultOutputDir)); + end jMenuSave.addSeparator(); gui_component('MenuItem', jMenuSave, [], 'Volume contact sheet: Coronal', IconLoader.ICON_CONTACTSHEET, [], @(h,ev)view_contactsheet(hFig, 'volume', 'y', DefaultOutputDir)); gui_component('MenuItem', jMenuSave, [], 'Volume contact sheet: Sagittal', IconLoader.ICON_CONTACTSHEET, [], @(h,ev)view_contactsheet(hFig, 'volume', 'x', DefaultOutputDir)); @@ -2756,12 +2820,17 @@ function UpdateSurfaceColor(hFig, iTess) SulciMap = zeros(TessInfo(iTess).nVertices, 1); end % Compute RGB values - FaceVertexCdata = BlendAnatomyData(SulciMap, ... % Anatomy: Sulci map - TessInfo(iTess).AnatomyColor([1,end], :), ... % Anatomy: color - DataSurf, ... % Data: values map - TessInfo(iTess).DataLimitValue, ... % Data: limit value - TessInfo(iTess).DataAlpha,... % Data: transparency - sColormap); % Colormap + if ~isempty(regexp(TessInfo(iTess).SurfaceFile, 'tess_textured', 'match')) + FaceVertexCdata = TessInfo(iTess).AnatomyColor(TessInfo(iTess).nVertices+1:end, :); + else + FaceVertexCdata = BlendAnatomyData(SulciMap, ... % Anatomy: Sulci map + TessInfo(iTess).AnatomyColor([1,end], :), ... % Anatomy: color + DataSurf, ... % Data: values map + TessInfo(iTess).DataLimitValue, ... % Data: limit value + TessInfo(iTess).DataAlpha,... % Data: transparency + sColormap); % Colormap + end + % Edge display : on/off if ~TessInfo(iTess).SurfShowEdges EdgeColor = 'none'; @@ -2861,7 +2930,6 @@ function UpdateSurfaceColor(hFig, iTess) end end - %% ===== PLOT 3D ELECTRODES ===== function [hElectrodeGrid, ChanLoc] = PlotSensors3D(iDS, iFig, Channel, ChanLoc, TopoType) %#ok global GlobalData; @@ -3339,7 +3407,7 @@ function UpdateSurfaceAlpha(hFig, iTess) % Apply current smoothing SmoothSurface(hFig, iTess, Surface.SurfSmoothValue); % Apply structures selection - if isequal(Surface.Resect, 'struct') + if isequal(Surface.Resect{2}, 'struct') SetStructLayout(hFig, iTess); end % Get surfaces vertices @@ -3355,7 +3423,7 @@ function UpdateSurfaceAlpha(hFig, iTess) FaceVertexAlphaData = ones(length(sSurf.Faces),1) * (1-Surface.SurfAlpha); % ===== HEMISPHERE SELECTION (CHAR) ===== - if ischar(Surface.Resect) && ~strcmpi(Surface.Resect, 'none') + if ischar(Surface.Resect{2}) && ~strcmpi(Surface.Resect{2}, 'none') % Detect hemispheres if strcmpi(Surface.Name, 'FEM') isConnected = 1; @@ -3365,13 +3433,15 @@ function UpdateSurfaceAlpha(hFig, iTess) % If there is no separation between left and right: use the numeric split if isConnected iHideVert = []; - switch (Surface.Resect) - case 'right', Surface.Resect = [0 0.0000001 0]; - case 'left', Surface.Resect = [0 -0.0000001 0]; + switch (Surface.Resect{2}) + case 'right' + Surface.Resect{1}(2) = max( 0.0000001, Surface.Resect{1}(2)); + case 'left' + Surface.Resect{1}(2) = min(-0.0000001, Surface.Resect{1}(2)); end % If there is a structural separation between left and right: usr else - switch (Surface.Resect) + switch (Surface.Resect{2}) case 'right', iHideVert = lH; case 'left', iHideVert = rH; otherwise, iHideVert = []; @@ -3385,7 +3455,7 @@ function UpdateSurfaceAlpha(hFig, iTess) end % ===== RESECT (DOUBLE) ===== - if isnumeric(Surface.Resect) && (length(Surface.Resect) == 3) && (~all(Surface.Resect == 0) || strcmpi(Surface.Name, 'FEM')) + if isnumeric(Surface.Resect{1}) && (length(Surface.Resect{1}) == 3) && (~all(Surface.Resect{1} == 0) || strcmpi(Surface.Name, 'FEM')) % Regular triangular surface if ~strcmpi(Surface.Name, 'FEM') iNoModif = []; @@ -3393,12 +3463,12 @@ function UpdateSurfaceAlpha(hFig, iTess) meanVertx = mean(Vertices, 1); maxVertx = max(abs(Vertices), [], 1); % Limit values - resectVal = Surface.Resect .* maxVertx + meanVertx; + resectVal = Surface.Resect{1} .* maxVertx + meanVertx; % Get vertices that are kept in all the cuts for iCoord = 1:3 - if Surface.Resect(iCoord) > 0 + if Surface.Resect{1}(iCoord) > 0 iNoModif = union(iNoModif, find(Vertices(:,iCoord) < resectVal(iCoord))); - elseif Surface.Resect(iCoord) < 0 + elseif Surface.Resect{1}(iCoord) < 0 iNoModif = union(iNoModif, find(Vertices(:,iCoord) > resectVal(iCoord))); end end @@ -3423,7 +3493,7 @@ function UpdateSurfaceAlpha(hFig, iTess) % For the projected vertices: get the distance from each cut distToCut = abs(Vertices(iVerticesToProject, :) - repmat(resectVal, [length(iVerticesToProject), 1])); % Set the distance to the cuts that are not required to infinite - distToCut(:,(Surface.Resect == 0)) = Inf; + distToCut(:,(Surface.Resect{1} == 0)) = Inf; % Get the closest cut [minDist, closestCut] = min(distToCut, [], 2); @@ -3452,7 +3522,7 @@ function UpdateSurfaceAlpha(hFig, iTess) else % Create a surface for the outside surface of this tissue Elements = get(Surface.hPatch, 'UserData'); - Faces = tess_voledge(Vertices, Elements, Surface.Resect); + Faces = tess_voledge(Vertices, Elements, Surface.Resect{1}); % Update patch set(Surface.hPatch, 'Faces', Faces); end @@ -3993,18 +4063,22 @@ function ViewAxis(hFig, isVisible) isVisible = isempty(findobj(hAxes, 'Tag', 'AxisXYZ')); end if isVisible + % Works but now default zoom is too tight >:( % Get dimensions of current axes + set(hAxes, 'XLimitMethod', 'tight', 'YLimitMethod', 'tight', 'ZLimitMethod', 'tight'); XLim = get(hAxes, 'XLim'); - YLim = get(hAxes, 'XLim'); - ZLim = get(hAxes, 'XLim'); - d = max(abs([XLim(:); YLim(:); ZLim(:)])); + YLim = get(hAxes, 'YLim'); + ZLim = get(hAxes, 'ZLim'); % Draw axis lines + d = XLim(2) * 1.04; line([0 d], [0 0], [0 0], 'Color', [1 0 0], 'Marker', '>', 'Parent', hAxes, 'Tag', 'AxisXYZ'); + text(d * 1.04, 0, 0, 'X', 'Color', [1 0 0], 'Parent', hAxes, 'Tag', 'AxisXYZ'); + d = YLim(2) * 1.04; line([0 0], [0 d], [0 0], 'Color', [0 1 0], 'Marker', '>', 'Parent', hAxes, 'Tag', 'AxisXYZ'); + text(0, d * 1.04, 0, 'Y', 'Color', [0 1 0], 'Parent', hAxes, 'Tag', 'AxisXYZ'); + d = ZLim(2) * 1.04; line([0 0], [0 0], [0 d], 'Color', [0 0 1], 'Marker', '>', 'Parent', hAxes, 'Tag', 'AxisXYZ'); - text(d+0.002, 0, 0, 'X', 'Color', [1 0 0], 'Parent', hAxes, 'Tag', 'AxisXYZ'); - text(0, d+0.002, 0, 'Y', 'Color', [0 1 0], 'Parent', hAxes, 'Tag', 'AxisXYZ'); - text(0, 0, d+0.002, 'Z', 'Color', [0 0 1], 'Parent', hAxes, 'Tag', 'AxisXYZ'); + text(0, 0, d * 1.04, 'Z', 'Color', [0 0 1], 'Parent', hAxes, 'Tag', 'AxisXYZ'); % Enforce camera target at (0,0,0) % camtarget(hAxes, [0,0,0]); else diff --git a/toolbox/gui/figure_connect.m b/toolbox/gui/figure_connect.m index 211953aa4..008003fa3 100644 --- a/toolbox/gui/figure_connect.m +++ b/toolbox/gui/figure_connect.m @@ -52,6 +52,26 @@ 'WindowButtonUpFcn', @FigureMouseUpCallback, ... 'WindowScrollWheelFcn', @(h, ev)FigureMouseWheelCallback(h, ev), ... bst_get('ResizeFunction'), @(h, ev)ResizeCallback(h, ev)); + + % === CREATE AXES === + hAxeT = axes('Parent', hFig, ... + 'Units', 'normalized', ... + 'Position', [0 .95 1 .05], ... + 'Tag', 'AxesTitle', ... + 'FontName', 'Default', ... + 'Visible', 'off', ... + 'BusyAction', 'queue', ... + 'Interruptible', 'off'); + + hTxtT = text(0.5, 0.5, '', ... + 'Parent', hAxeT, ... + 'color', [1,1,1], ... + 'Units', 'normalized', ... + 'FontSize', 1.1 * bst_get('FigFont') , ... % title() is 1.1 FontSize + 'FontWeight', 'bold', ... + 'HorizontalAlignment', 'center', ... + 'Tag', 'TextTitle', ... + 'ButtonDownFcn', @TitleButtonDownFcn); % === CREATE AXES === hAxes = axes('Parent', hFig, ... @@ -62,6 +82,7 @@ 'BusyAction', 'queue', ... 'Interruptible', 'off'); + % === APPDATA STRUCTURE === setappdata(hFig, 'FigureId', FigureId); setappdata(hFig, 'hasMoved', 0); @@ -1410,6 +1431,8 @@ function LoadFigurePlot(hFig) %#ok RefreshTextDisplay(hFig); % Display region and hem lobes? SetHierarchyNodeIsVisible(hFig, DispOptions.HierarchyNodeIsVisible); + % Set title + SetTitle(hFig, TfInfo.DisplayUnits); % Position camera DefaultCamera(hFig); end @@ -2471,7 +2494,7 @@ function UpdateColormap(hFig) end % Get figure colormap ColormapInfo = getappdata(hFig, 'Colormap'); - ColormapInfo.DisplayUnits = TfInfo.DisplayUnits; + ColormapInfo.DisplayUnits = 'No units'; sColormap = bst_colormaps('GetColormap', ColormapInfo.Type); % Set figure colormap set(hFig, 'Colormap', sColormap.CMap); @@ -3976,4 +3999,19 @@ function DeleteAllNodes(hFig) bst_figures('SetFigureHandleField', hFig, 'NumberOfLevels', NumberOfLevels); % bst_figures('SetFigureHandleField', hFig, 'Levels', Levels); -end \ No newline at end of file +end + +%% ===== SET TITLE ===== +function SetTitle(hFig, title) + hTxtT = findobj(hFig, '-depth', 2, 'Tag', 'TextTitle'); + set(hTxtT, 'String', title); + TitleButtonDownFcn(hFig, 0); +end + +%% Callbacks for Title +% Set current axes to AxesConnect +function TitleButtonDownFcn(src, ~) + hFig = ancestor(src, 'figure'); + hAxes = findobj(hFig, '-depth', 1, 'Tag', 'AxesConnect'); + axes(hAxes); +end diff --git a/toolbox/gui/figure_mri.m b/toolbox/gui/figure_mri.m index 873264af0..0d4fecc3e 100644 --- a/toolbox/gui/figure_mri.m +++ b/toolbox/gui/figure_mri.m @@ -84,7 +84,7 @@ 'Renderer', rendererName, ... 'BusyAction', 'cancel', ... 'Interruptible', 'off', ... - 'CloseRequestFcn', @(h,ev)ButtonCancel_Callback(h,ev), ... + 'CloseRequestFcn', @(h,ev)bst_figures('DeleteFigure',h,ev), ... 'KeyPressFcn', @FigureKeyPress_Callback, ... 'WindowButtonDownFcn', [], ... 'WindowButtonMotionFcn', [], ... @@ -874,7 +874,7 @@ function ButtonView3DHead_Callback(hFig) sMri.SCS.RPA = [1, 0.5, 0.5] .* size(sMri.Cube) .* sMri.Voxsize; [Transf, sMri] = cs_compute(sMri, 'scs'); % Generate head surface - [Vertices, Faces] = tess_isohead(sMri, 20000, 0, 0, ''); + [Vertices, Faces] = tess_isohead(sMri, 20000, 0, 0, [], ''); % Convert coordinates back to MRI (mm) so that the head surface doesn't have % to be updated when we move fiducials Vertices = cs_convert(sMri, 'scs', 'mri', Vertices) * 1000; @@ -1061,9 +1061,9 @@ function DisplayFigurePopup(hFig) jItem = gui_component('MenuItem', jMenuElec, [], 'Set electrode position', IconLoader.ICON_CHANNEL, [], @(h,ev)SetElectrodePosition(hFig)); jItem.setAccelerator(KeyStroke.getKeyStroke(KeyEvent.VK_S, KeyEvent.CTRL_MASK)); elseif isequal(GlobalData.DataSet(iDS).Figure(iFig).Id.Modality, 'SEEG') - gui_component('MenuItem', jMenuElec, [], 'SEEG contacts', IconLoader.ICON_CHANNEL, [], @(h,ev)LoadElectrodes(hFig, GlobalData.DataSet(iDS).ChannelFile, 'SEEG')); + gui_component('MenuItem', jMenuElec, [], 'SEEG contacts', IconLoader.ICON_CHANNEL, [], @(h,ev)panel_ieeg('LoadElectrodes', hFig, GlobalData.DataSet(iDS).ChannelFile, 'SEEG')); elseif isequal(GlobalData.DataSet(iDS).Figure(iFig).Id.Modality, 'ECOG') - gui_component('MenuItem', jMenuElec, [], 'ECOG contacts', IconLoader.ICON_CHANNEL, [], @(h,ev)LoadElectrodes(hFig, GlobalData.DataSet(iDS).ChannelFile, 'ECOG')); + gui_component('MenuItem', jMenuElec, [], 'ECOG contacts', IconLoader.ICON_CHANNEL, [], @(h,ev)panel_ieeg('LoadElectrodes', hFig, GlobalData.DataSet(iDS).ChannelFile, 'ECOG')); end end @@ -1099,6 +1099,13 @@ function DisplayFigurePopup(hFig) gui_component('MenuItem', jMenuSave, [], 'Time contact sheet: Sagittal', IconLoader.ICON_CONTACTSHEET, [], @(h,ev)view_contactsheet(hFig, 'time', 'x', DefaultOutputDir)); gui_component('MenuItem', jMenuSave, [], 'Time contact sheet: Axial', IconLoader.ICON_CONTACTSHEET, [], @(h,ev)view_contactsheet(hFig, 'time', 'z', DefaultOutputDir)); end + if ~getappdata(hFig, 'isStaticFreq') + % Separator + jMenuSave.addSeparator(); + gui_component('MenuItem', jMenuSave, [], 'Frequency contact sheet: Coronal', IconLoader.ICON_CONTACTSHEET, [], @(h,ev)view_contactsheet(hFig, 'freq', 'y', DefaultOutputDir)); + gui_component('MenuItem', jMenuSave, [], 'Frequency contact sheet: Sagittal', IconLoader.ICON_CONTACTSHEET, [], @(h,ev)view_contactsheet(hFig, 'freq', 'x', DefaultOutputDir)); + gui_component('MenuItem', jMenuSave, [], 'Frequency contact sheet: Axial', IconLoader.ICON_CONTACTSHEET, [], @(h,ev)view_contactsheet(hFig, 'freq', 'z', DefaultOutputDir)); + end jMenuSave.addSeparator(); gui_component('MenuItem', jMenuSave, [], 'Volume contact sheet: Coronal', IconLoader.ICON_CONTACTSHEET, [], @(h,ev)view_contactsheet(hFig, 'volume', 'y', DefaultOutputDir)); gui_component('MenuItem', jMenuSave, [], 'Volume contact sheet: Sagittal', IconLoader.ICON_CONTACTSHEET, [], @(h,ev)view_contactsheet(hFig, 'volume', 'x', DefaultOutputDir)); @@ -1959,42 +1966,6 @@ function MouseMovePoint(hAxes, sMri, Handles, iChannel) end end - -%% ===== LOAD ELECTRODES ===== -function LoadElectrodes(hFig, ChannelFile, Modality) %#ok - global GlobalData; - % Get figure and dataset - [hFig,iFig,iDS] = bst_figures('GetFigure', hFig); - if isempty(iDS) - return; - end - % Check that the channel is not already defined - if ~isempty(GlobalData.DataSet(iDS).ChannelFile) && ~file_compare(GlobalData.DataSet(iDS).ChannelFile, ChannelFile) - error('There is already another channel file loaded for this MRI. Close the existing figures.'); - end - % Load channel file in the dataset - bst_memory('LoadChannelFile', iDS, ChannelFile); - % If iEEG channels: load both SEEG and ECOG - if ismember(Modality, {'SEEG', 'ECOG', 'ECOG+SEEG'}) - iChannels = channel_find(GlobalData.DataSet(iDS).Channel, 'SEEG, ECOG'); - else - iChannels = channel_find(GlobalData.DataSet(iDS).Channel, Modality); - end - % Set the list of selected sensors - GlobalData.DataSet(iDS).Figure(iFig).SelectedChannels = iChannels; - GlobalData.DataSet(iDS).Figure(iFig).Id.Modality = Modality; - % Plot electrodes - if ~isempty(iChannels) - GlobalData.DataSet(iDS).Figure(iFig).Handles = PlotElectrodes(iDS, iFig, GlobalData.DataSet(iDS).Figure(iFig).Handles); - PlotSensors3D(iDS, iFig); - end - % Set EEG flag - SetFigureStatus(hFig, [], [], [], 1, 1); - % Update figure name - bst_figures('UpdateFigureName', hFig); -end - - %% ===== PLOT 3D ELECTRODES ===== function PlotSensors3D(iDS, iFig, Channel, ChanLoc) global GlobalData; @@ -2305,7 +2276,7 @@ function showPt(hPoint, locPoint) if Handles.isEeg % Hide all the points that we will never show iEegHide = Handles.HiddenChannels; - iEegShow = setdiff(1:length(Handles.hPointEEG), iEegHide); + iEegShow = setdiff(1:size(Handles.hPointEEG,1), iEegHide); if ~isempty(iEegHide) set(Handles.hPointEEG(iEegHide(:),:), 'Visible', 'off'); end @@ -2557,10 +2528,22 @@ function ButtonSave_Callback(hFig, varargin) %% ===== SAVE MRI ===== +% Update MRI fiducials and adjust surfaces. +% Input can be figure handle or sMri. function [isCloseAccepted, MriFile] = SaveMri(hFig) ProtocolInfo = bst_get('ProtocolInfo'); % Get MRI - sMri = panel_surface('GetSurfaceMri', hFig); + if ishandle(hFig) + sMri = panel_surface('GetSurfaceMri', hFig); + isUser = true; + elseif isstruct(hFig) + sMri = hFig; + isUser = false; + else + bst_error('SaveMri: Unexpected input: %s.', class(hFig)); + isCloseAccepted = 0; + return; + end MriFile = sMri.FileName; MriFileFull = bst_fullfile(ProtocolInfo.SUBJECTS, MriFile); % Do not accept "Save" if user did not select all the fiducials @@ -2578,21 +2561,26 @@ function ButtonSave_Callback(hFig, varargin) warning('off', 'MATLAB:load:variableNotFound'); sMriOld = load(MriFileFull, 'SCS'); warning('on', 'MATLAB:load:variableNotFound'); - % If the fiducials were modified + % Check if the fiducials were modified (> 1um), to realign surfaces below if isfield(sMriOld, 'SCS') && all(isfield(sMriOld.SCS,{'NAS','LPA','RPA'})) ... && ~isempty(sMriOld.SCS.NAS) && ~isempty(sMriOld.SCS.LPA) && ~isempty(sMriOld.SCS.RPA) ... - && ((max(sMri.SCS.NAS - sMriOld.SCS.NAS) > 1e-3) || ... - (max(sMri.SCS.LPA - sMriOld.SCS.LPA) > 1e-3) || ... - (max(sMri.SCS.RPA - sMriOld.SCS.RPA) > 1e-3)) + && ((max(abs(sMri.SCS.NAS - sMriOld.SCS.NAS)) > 1e-3) || ... + (max(abs(sMri.SCS.LPA - sMriOld.SCS.LPA)) > 1e-3) || ... + (max(abs(sMri.SCS.RPA - sMriOld.SCS.RPA)) > 1e-3)) % Nothing to do... else + % sMri.SCS.R, T and Origin are updated before calling this function. sMriOld = []; end % === HISTORY === % History: Edited the fiducials if ~isfield(sMriOld, 'SCS') || ~isequal(sMriOld.SCS, sMri.SCS) || ~isfield(sMriOld, 'NCS') || ~isequal(sMriOld.NCS, sMri.NCS) - sMri = bst_history('add', sMri, 'edit', 'User edited the fiducials'); + if isUser + sMri = bst_history('add', sMri, 'edit', 'User edited the fiducials'); + else + sMri = bst_history('add', sMri, 'edit', 'Applied digitized anatomical fiducials'); % string used to verify elsewhere + end end % ==== SAVE MRI ==== diff --git a/toolbox/gui/figure_timefreq.m b/toolbox/gui/figure_timefreq.m index 4f7c5c3b5..ee4e227ac 100644 --- a/toolbox/gui/figure_timefreq.m +++ b/toolbox/gui/figure_timefreq.m @@ -970,18 +970,18 @@ function DisplayFigurePopup(hFig) % Keep only the selected (good) channels % This won't apply when displaying a single channel (e.g. FOOOF overlay mode) % or when displaying connectivity matrices, or any PSD not computed directly on sensor data - [hFig, iFig] = bst_figures('GetFigure', hFig); - if ~isempty(GlobalData.DataSet(iDS).Figure(iFig).SelectedChannels) && ... - numel(GlobalData.DataSet(iDS).Figure(iFig).SelectedChannels) < numel(iRow) && ... + [hFig, iFig, iDSFig] = bst_figures('GetFigure', hFig); + if ~isempty(GlobalData.DataSet(iDSFig).Figure(iFig).SelectedChannels) && ... + numel(GlobalData.DataSet(iDSFig).Figure(iFig).SelectedChannels) < numel(iRow) && ... strcmpi(DataType, 'data') && isempty(GlobalData.DataSet(iDS).Timefreq(iTimefreq).RefRowNames) % Grad norm: need to return both gradiometers - if strcmpi(GlobalData.DataSet(iDS).Figure(iFig).Id.Modality, 'MEG GRADNORM') - selBaseName = cellfun(@(c)cat(2, {[c(1:end-1) '2']}, {[c(1:end-1) '3']}), {GlobalData.DataSet(iDS).Channel(GlobalData.DataSet(iDS).Figure(iFig).SelectedChannels).Name}, 'UniformOutput', false); + if strcmpi(GlobalData.DataSet(iDSFig).Figure(iFig).Id.Modality, 'MEG GRADNORM') + selBaseName = cellfun(@(c)cat(2, {[c(1:end-1) '2']}, {[c(1:end-1) '3']}), {GlobalData.DataSet(iDS).Channel(GlobalData.DataSet(iDSFig).Figure(iFig).SelectedChannels).Name}, 'UniformOutput', false); selBaseName = [selBaseName{:}]; iSelected = ismember(RowNames, selBaseName); % Regular sensor selection else - iSelected = ismember(RowNames, {GlobalData.DataSet(iDS).Channel(GlobalData.DataSet(iDS).Figure(iFig).SelectedChannels).Name}); + iSelected = ismember(RowNames, {GlobalData.DataSet(iDS).Channel(GlobalData.DataSet(iDSFig).Figure(iFig).SelectedChannels).Name}); end % Return only selected sensors TF = TF(iSelected,:,:); @@ -1063,9 +1063,20 @@ function UpdateFigurePlot(hFig, isForced) % Get figure colormap ColormapInfo = getappdata(hFig, 'Colormap'); sColormap = bst_colormaps('GetColormap', ColormapInfo.Type); - % Displaying LOG values: always use the "RealMin" display - if strcmpi(TfInfo.Function, 'log') - sColormap.isRealMin = 1; + % Displaying LOG values : always use the "RealMin" display and not absolutes values + % Displaying Power values : always use absolutes values + if ~isempty(TfInfo) && strcmpi(ColormapInfo.Type, 'timefreq') + isAbsoluteValues = sColormap.isAbsoluteValues; + if strcmpi(TfInfo.Function, 'log') + sColormap.isRealMin = 1; + isAbsoluteValues = 0; + elseif strcmpi(TfInfo.Function, 'power') + isAbsoluteValues = 1; + end + if isAbsoluteValues ~= sColormap.isAbsoluteValues + sColormap.isAbsoluteValues = isAbsoluteValues; + bst_colormaps('SetColormap', ColormapInfo.Type, sColormap); + end end % Get figure maximum MinMaxVal = bst_colormaps('GetMinMax', sColormap, TF, TopoHandles.DataMinMax); diff --git a/toolbox/gui/figure_timeseries.m b/toolbox/gui/figure_timeseries.m index 446b4876b..3b92f9eca 100644 --- a/toolbox/gui/figure_timeseries.m +++ b/toolbox/gui/figure_timeseries.m @@ -3250,6 +3250,15 @@ function SetTimeVisible(hFig, isVisible) %#ok<*DEFNU> if (TsInfo.ShowLegend) if isempty(LinesColor) ColorOrder = panel_scout('GetScoutsColorTable'); + iHbO = find(cellfun(@(x)~isempty(x), strfind(LinesLabels, 'HbO'))); + iHbR = find(cellfun(@(x)~isempty(x), strfind(LinesLabels, 'HbR'))); + iHbT = find(cellfun(@(x)~isempty(x), strfind(LinesLabels, 'HbT'))); + if (~isempty(iHbO) && ~isempty(iHbR)) || (~isempty(iHbO) && ~isempty(iHbT)) || (~isempty(iHbT) && ~isempty(iHbR)) + ColorsGRB = ColorOrder(1:3,:); + ColorOrder(iHbO,:) = repmat(ColorsGRB(2,:), length(iHbO),1); % Red + ColorOrder(iHbR,:) = repmat(ColorsGRB(3,:), length(iHbR),1); % Blue + ColorOrder(iHbT,:) = repmat(ColorsGRB(1,:), length(iHbT),1); % Green + end else ColorOrder = []; end @@ -3364,10 +3373,11 @@ function SetTimeVisible(hFig, isVisible) %#ok<*DEFNU> iLinesGroup = find(strcmpi(LinesLabels{1}, LinesLabels)); % Get montage sMontage = panel_montage('GetMontage', TsInfo.MontageName); + [~, ~, iMatrixDisp] = panel_montage('GetMontageChannels', sMontage, {GlobalData.DataSet(iDS).Channel.Name}, GlobalData.DataSet(iDS).Measures.sFile.channelflag); % Get groups in which each sensor belongs, to map group and color GroupNames = {}; for i = 1:length(iLinesGroup) - ChanName = sMontage.ChanNames{find(sMontage.Matrix(iLinesGroup(i),:), 1)}; + ChanName = sMontage.ChanNames{find(sMontage.Matrix(iMatrixDisp(iLinesGroup(i)),:), 1)}; GroupNames{i} = GlobalData.DataSet(iDS).Channel(strcmpi(ChanName, {GlobalData.DataSet(iDS).Channel.Name})).Group; end % Create legend diff --git a/toolbox/gui/figure_topo.m b/toolbox/gui/figure_topo.m index f92a3d0aa..00f1a9ef1 100644 --- a/toolbox/gui/figure_topo.m +++ b/toolbox/gui/figure_topo.m @@ -42,13 +42,16 @@ function CurrentFreqChangedCallback(iDS, iFig) %#ok global GlobalData; % Get figure appdata hFig = GlobalData.DataSet(iDS).Figure(iFig).hFigure; + % Get topography type requested (3DSensorCap, 2DDisc, 2DSensorCap, 2DLayout) + TopoType = GlobalData.DataSet(iDS).Figure(iFig).Id.SubType; + % Get TimeFreq info TfInfo = getappdata(hFig, 'Timefreq'); - % If no frequencies in this figure + % If no frequencies (time series) in this figure if getappdata(hFig, 'isStaticFreq') return; end % Update frequency to display - if ~isempty(TfInfo) + if ~isempty(TfInfo) && ~(strcmpi(TopoType, '2DLayout') && getappdata(hFig, 'isStatic')) TfInfo.iFreqs = GlobalData.UserFrequencies.iCurrentFreq; setappdata(hFig, 'Timefreq', TfInfo); end @@ -147,9 +150,20 @@ function UpdateTopoPlot(iDS, iFig) % Get figure colormap ColormapInfo = getappdata(hFig, 'Colormap'); sColormap = bst_colormaps('GetColormap', ColormapInfo.Type); - % Displaying LOG values: always use the "RealMin" display - if ~isempty(TfInfo) && strcmpi(TfInfo.Function, 'log') - sColormap.isRealMin = 1; + % Displaying LOG values : always use the "RealMin" display and not absolutes values + % Displaying Power values : always use absolutes values + if ~isempty(TfInfo) && strcmpi(ColormapInfo.Type, 'timefreq') + isAbsoluteValues = sColormap.isAbsoluteValues; + if strcmpi(TfInfo.Function, 'log') + sColormap.isRealMin = 1; + isAbsoluteValues = 0; + elseif strcmpi(TfInfo.Function, 'power') + isAbsoluteValues = 1; + end + if isAbsoluteValues ~= sColormap.isAbsoluteValues + sColormap.isAbsoluteValues = isAbsoluteValues; + bst_colormaps('SetColormap', ColormapInfo.Type, sColormap); + end end % Get figure maximum CLim = bst_colormaps('GetMinMax', sColormap, DataToPlot, TopoHandles.DataMinMax); @@ -237,12 +251,14 @@ function UpdateTopoPlot(iDS, iFig) %% ===== GET FIGURE DATA ===== -% Warning: Time output is only defined for the time-frequency plots -function [F, Time, selChan, overlayLabels, dispNames, StatThreshUnder, StatThreshOver] = GetFigureData(iDS, iFig, isAllTime, isMultiOutput) +% Warning: xAxis output is only defined for the timefreq plots +% xAxis = 'Time' for TF maps +% xAxis = 'Freqs' for Spectra +function [F, xAxis, selChan, overlayLabels, dispNames, StatThreshUnder, StatThreshOver] = GetFigureData(iDS, iFig, isAllTime, isMultiOutput) global GlobalData; % Initialize returned values F = []; - Time = []; + xAxis = []; selChan = []; overlayLabels = {}; dispNames = {}; @@ -332,10 +348,23 @@ function UpdateTopoPlot(iDS, iFig) StatThreshUnder = GlobalData.DataSet(iDS).Measures.StatThreshUnder; end case 'timefreq' - % Get timefreq values + [sStudy, iStudy, iTimefreq] = bst_get('TimefreqFile', ReadFiles{iFile}); + if ~isempty(sStudy) + overlayLabels{iFile} = sStudy.Timefreq(iTimefreq).Comment; + end + % Get loaded timefreq values (only first file DS is the same as Fig) + TfInfo = getappdata(hFig, 'Timefreq'); + TfInfo.FileName = file_short(ReadFiles{iFile}); + setappdata(hFig, 'Timefreq', TfInfo); [Time, Freqs, TfInfo, TF, RowNames] = figure_timefreq('GetFigureData', hFig, TimeDef); + xAxis = Time; % TF map + isStatic = getappdata(hFig, 'isStatic'); + if isStatic + xAxis = Freqs; % Spectrum + end % Initialize returned matrix - F{iFile} = zeros(length(selChan), size(TF, 2)); + F{iFile} = zeros(length(selChan), length(xAxis)); + % Re-order channels for i = 1:length(selChan) selrow = GlobalData.DataSet(iDS).Channel(selChan(i)).Name; @@ -353,16 +382,25 @@ function UpdateTopoPlot(iDS, iFig) iRow = find(strcmpi(selrow, RowNames)); % If channel was found (if there is time-freq decomposition available for it) if ~isempty(iRow) - F{iFile}(i,:) = TF(iRow(1),:); + if isStatic + F{iFile}(i,:) = TF(iRow(1),1,:); % Spectrum + else + F{iFile}(i,:) = TF(iRow(1),:,1); % Freq slice in TF + end end end end end end + % Reset TfInfo with first TF file + if strcmpi(TopoInfo.FileType, 'timefreq') + TfInfo.FileName = file_short(ReadFiles{1}); + setappdata(hFig, 'Timefreq', TfInfo); + end end % Get time if required and not defined yet - if (nargout >= 2) && isempty(Time) - Time = bst_memory('GetTimeVector', iDS, [], TimeDef); + if (nargout >= 2) && isempty(xAxis) && ismember(lower(TopoInfo.FileType), {'data', 'pdata'}) + xAxis = bst_memory('GetTimeVector', iDS, [], TimeDef); end % ===== APPLY MONTAGE ===== @@ -758,66 +796,90 @@ function CreateTopo2dLayout(iDS, iFig, hAxes, Channel, Vertices, modChan) % ===== GET ALL DATA ===== % Get data - [F, Time, selChanGlobal, overlayLabels, dispNames] = GetFigureData(iDS, iFig, 1, 1); + [F, xAxis, selChanGlobal, overlayLabels, dispNames] = GetFigureData(iDS, iFig, 1, 1); selChan = bst_closest(selChanGlobal, modChan); if isempty(selChan) disp('2DLAYOUT> No good sensor to display...'); return; end - % Convert time bands in time vector - if iscell(Time) - nBands = size(Time,1); - TimeVector = zeros(1,nBands); - for i = 1:nBands - % Take the middle of each time band - TimeVector(i) = (Time{i,2} + Time{i,3}) / 2; - end + % Convert x axis (time or frequency) bands in time vector + if iscell(xAxis) + % Take the middle of each time band + xAxisVector = zeros(1, size(xAxis,1)); + xAxisVector(:) = mean(process_tf_bands('GetBounds', xAxis), 2); else - TimeVector = Time; + xAxisVector = xAxis; end + % Get 2DLayout display options TopoLayoutOptions = bst_get('TopoLayoutOptions'); + + hFig = GlobalData.DataSet(iDS).Figure(iFig).hFigure; + isStatic = getappdata(hFig, 'isStatic'); % Time static + isStaticFreq = getappdata(hFig, 'isStaticFreq'); % Freq static + % Flip Y axis if needed - if TopoLayoutOptions.FlipYAxis + if TopoLayoutOptions.FlipYAxis && isStaticFreq F = cellfun(@(c)times(c,-1), F, 'UniformOutput', 0); end - % Default time window: all the window - if isempty(TopoLayoutOptions.TimeWindow) - TopoLayoutOptions.TimeWindow = GlobalData.UserTimeWindow.Time; - % Otherwise, center the time window around the current time + + % Handle xAxis as Time + if ~isStatic + % Default time window: all the window + if isempty(TopoLayoutOptions.TimeWindow) + TopoLayoutOptions.TimeWindow = GlobalData.UserTimeWindow.Time; + % Otherwise, center the time window around the current time + else + winLen = (TopoLayoutOptions.TimeWindow(2) - TopoLayoutOptions.TimeWindow(1)); + TopoLayoutOptions.TimeWindow = bst_saturate(GlobalData.UserTimeWindow.CurrentTime + winLen ./ 2 .* [-1, 1] , GlobalData.UserTimeWindow.Time, 1); + end + xWindow = TopoLayoutOptions.TimeWindow; else - winLen = (TopoLayoutOptions.TimeWindow(2) - TopoLayoutOptions.TimeWindow(1)); - TopoLayoutOptions.TimeWindow = bst_saturate(GlobalData.UserTimeWindow.CurrentTime + winLen ./ 2 .* [-1, 1] , GlobalData.UserTimeWindow.Time, 1); + % Default freq window: all spectrum + if isempty(TopoLayoutOptions.FreqWindow) + TopoLayoutOptions.FreqWindow = [xAxisVector(1), xAxisVector(end)]; + end + xWindow = TopoLayoutOptions.FreqWindow; end - % Get only the 2DLayout time window - iTime = find((TimeVector >= TopoLayoutOptions.TimeWindow(1)) & (TimeVector <= TopoLayoutOptions.TimeWindow(2))); + + % Get only requested x axis window + ixAxis = find((xAxisVector >= xWindow(1)) & (xAxisVector <= xWindow(2))); % Check for errors - if isempty(iTime) - error('Invalid time window.'); - elseif (length(iTime) < 2) - if (iTime + 1 <= length(TimeVector)) - iTime = [iTime, iTime + 1]; - elseif (iTime >= 2) - iTime = [iTime - 1, iTime]; + if isempty(ixAxis) + error('Invalid x-axis window.'); + elseif (length(ixAxis) < 2) + if (ixAxis + 1 <= length(xAxisVector)) + ixAxis = [ixAxis, ixAxis + 1]; + elseif (ixAxis >= 2) + ixAxis = [ixAxis - 1, ixAxis]; else - error('Invalid time window.'); + error('Invalid x-axis window.'); end end % Keep only the selected time indices - TimeVector = TimeVector(iTime); - % Flip Time vector (it's the way the data will be represented too) - TimeVector = fliplr(TimeVector); - % Look for current time in TimeVector - %iCurrentTime = bst_closest(0, TimeVector); - iCurrentTime = bst_closest(GlobalData.UserTimeWindow.CurrentTime, TimeVector); - if isempty(iCurrentTime) - iCurrentTime = 1; - end - % Normalize time between 0 and 1 - TimeVector = (TimeVector - TimeVector(1)) ./ (TimeVector(end) - TimeVector(1)); + xAxisVector = xAxisVector(ixAxis); + % Flip x axis vector (it's the way the data will be represented too) + xAxisVector = fliplr(xAxisVector); + if ~isStatic + % Look for current time in TimeVector + iCurrentX = bst_closest(GlobalData.UserTimeWindow.CurrentTime, xAxisVector); + else + if iscell(GlobalData.UserFrequencies.Freqs) + bands = mean(process_tf_bands('GetBounds', xAxis), 2); + currentX = bands(GlobalData.UserFrequencies.iCurrentFreq); + else + currentX = GlobalData.UserFrequencies.Freqs(GlobalData.UserFrequencies.iCurrentFreq); + end + iCurrentX = bst_closest(currentX, xAxisVector); + end + % Current position + if isempty(iCurrentX) + iCurrentX = 1; + end + % Normalize xAxis between 0 and 1 + xAxisVector = (xAxisVector - xAxisVector(1)) ./ (xAxisVector(end) - xAxisVector(1)); % Get graphic objects handles PlotHandles = GlobalData.DataSet(iDS).Figure(iFig).Handles; - hFig = GlobalData.DataSet(iDS).Figure(iFig).hFigure; isDrawZeroLines = isempty(PlotHandles.hZeroLines) || any(~ishandle(PlotHandles.hZeroLines)); isDrawLines = isempty(PlotHandles.hLines) || any(~ishandle(PlotHandles.hLines{1})); isDrawLegend = isempty(PlotHandles.hLabelLegend); @@ -894,13 +956,28 @@ function CreateTopo2dLayout(iDS, iFig, hAxes, Channel, Vertices, modChan) DispFactor = PlotHandles.DisplayFactor; % * figure_timeseries('GetDefaultFactor', GlobalData.DataSet(iDS).Figure(iFig).Id.Modality); % Loop on multiple files + MinMaxs = zeros(2, length(F)); for iFile = 1:length(F) % Keep only selected time points - F{iFile} = F{iFile}(:, iTime); - % Normalize data - M = double(max(abs(F{iFile}(:)))); - F{iFile} = F{iFile} ./ M; + F{iFile} = F{iFile}(:, ixAxis); + % Find minimum and maximum + MinMaxs(1, iFile) = double(min(F{iFile}(:))); + MinMaxs(2, iFile) = double(max(F{iFile}(:))); end + % Get scale and offset to normalize data + TfInfo = getappdata(hFig, 'Timefreq'); + if isStatic && isfield(TfInfo, 'Function') && strcmpi(TfInfo.Function, 'log') && max(MinMaxs(:)) < 0 + % Data is dB + offset = max(MinMaxs(2,:)); + % Remove offset + M = max(abs(MinMaxs(1,:) - offset)); + F = cellfun(@(c)minus(c, offset), F, 'UniformOutput', 0); + else + offset = 0; + M = max(abs(MinMaxs(2,:))); + end + % Normalize data + F = cellfun(@(c)rdivide(c, M), F, 'UniformOutput', 0); % Draw each sensor displayedLabels = {}; @@ -918,7 +995,8 @@ function CreateTopo2dLayout(iDS, iFig, hAxes, Channel, Vertices, modChan) % Define lines to trace XData = plotSize(1) * dat(end:-1:1) * DispFactor + Xi; Xrange = plotSize(1) * [min(0,datMin), max(0,datMax)] * DispFactor + Xi; - YData = plotSize(2) * (TimeVector - 0.5) + Yi; + Xrange = Xrange + 0.2.*[-1, 1].*(abs(diff(Xrange))); + YData = plotSize(2) * (xAxisVector - 0.5) + Yi; ZData = 0; % === DATA LINE === @@ -955,13 +1033,13 @@ function CreateTopo2dLayout(iDS, iFig, hAxes, Channel, Vertices, modChan) PlotHandles.hZeroLines(i) = line([Xi, Xi], [YData(1), YData(end)], [ZData, ZData], ... 'Tag', '2DLayoutZeroLines', ... 'Parent', hAxes); - % Time cursor - PlotHandles.hCursors(i) = line([Xrange(1), Xrange(2)], [YData(iCurrentTime), YData(iCurrentTime)], [ZData, ZData], ... + % X axis cursor + PlotHandles.hCursors(i) = line([Xrange(1), Xrange(2)], [YData(iCurrentX), YData(iCurrentX)], [ZData, ZData], ... 'Tag', '2DLayoutTimeCursor', ... 'Parent', hAxes); else set(PlotHandles.hZeroLines(i), 'XData', [Xi, Xi], 'YData', [YData(1), YData(end)]); - set(PlotHandles.hCursors(i), 'XData', [Xrange(1), Xrange(2)], 'YData', [YData(iCurrentTime), YData(iCurrentTime)]); + set(PlotHandles.hCursors(i), 'XData', [Xrange(1), Xrange(2)], 'YData', [YData(iCurrentX), YData(iCurrentX)]); end else if ~isempty(PlotHandles.hZeroLines) @@ -1059,7 +1137,7 @@ function CreateTopo2dLayout(iDS, iFig, hAxes, Channel, Vertices, modChan) 'BusyAction', 'queue'); % Create label PlotHandles.hLabelLegend = text(... - 10 / figPos(3), .5, '', ... + 10 / figPos(3), .6, '', ... 'FontUnits', 'points', ... 'FontWeight', 'bold', ... 'FontSize', 8 .* Scaling, ... @@ -1100,19 +1178,29 @@ function CreateTopo2dLayout(iDS, iFig, hAxes, Channel, Vertices, modChan) end end % Get data type - if isappdata(hFig, 'Timefreq') - DataType = 'timefreq'; + if isappdata(hFig, 'Timefreq') && ~isStatic + DataType = 'Timefreq'; else DataType = GlobalData.DataSet(iDS).Figure(iFig).Id.Modality; end % Get data units and time window [fScaled, fFactor, fUnits] = bst_getunits( M, DataType ); - msTime = round(TopoLayoutOptions.TimeWindow * 1000); fUnits = strrep(fUnits, 'x10^{', 'e'); fUnits = strrep(fUnits, '10^{', 'e'); fUnits = strrep(fUnits, '}', ''); fUnits = strrep(fUnits, '\mu', 'u'); fUnits = strrep(fUnits, '\Delta', 'd'); + % Handle units for PSD + if isappdata(hFig, 'Timefreq') && isStatic + TfInfo = getappdata(hFig, 'Timefreq'); + if isempty(TfInfo.Normalized) && (~isfield(TfInfo, 'FreqUnits') || isempty(TfInfo.FreqUnits)) + switch lower(TfInfo.Function) + case 'power', fUnits = [fUnits '^2/Hz']; fScaled = fScaled * fFactor; + case 'magnitude', fUnits = [fUnits '/sqrt(Hz)']; + case 'log', fUnits = 'dB'; + end + end + end % Round values if large values if (fScaled > 5) strAmp = sprintf('%d', round(fScaled)); @@ -1122,9 +1210,22 @@ function CreateTopo2dLayout(iDS, iFig, hAxes, Channel, Vertices, modChan) strAmp = sprintf('%g', fScaled); end % Create legend text - strLegend = sprintf(['Max amplitude: %s %s\n' ... - 'Time window: [%d, %d] ms'], ... - strAmp, fUnits, msTime(1), msTime(2)); + strLegend = ''; + if offset ~= 0 + % Offset legend + strLegend = [sprintf('Offset level: %d %s', round(offset), fUnits)]; + end + % Amplitude legend + strLegend = [strLegend 10 sprintf('Max amplitude: %s %s', strAmp, fUnits)]; + % Time legend + if ~isStatic + msTime = round(TopoLayoutOptions.TimeWindow * 1000); + strLegend = [strLegend 10 sprintf('Time window: [%d, %d] ms', msTime(1), msTime(2))]; + % Frequency legend + else + hzFreq = round(TopoLayoutOptions.FreqWindow * 100) / 100; + strLegend = [strLegend 10 sprintf('Frequency range: [%s, %s] Hz', num2str(hzFreq(1)), num2str(hzFreq(2)))]; + end % Update legend set(PlotHandles.hLabelLegend, 'String', strLegend, 'Visible', 'on'); set(PlotHandles.hOverlayLegend, 'Visible', 'on'); @@ -1264,6 +1365,16 @@ function CreateButtons2dLayout(iDS, iFig) global GlobalData; % Get figure hFig = GlobalData.DataSet(iDS).Figure(iFig).hFigure; + % Callbacks to adjsut x axis + if ~getappdata(hFig, 'isStatic') + xAxisName = 'Time'; + xAxisOption = 'TimeWindow'; + UpdateTopoXWindow = @UpdateTopoTimeWindow; + else + xAxisName = 'Frequency'; + xAxisOption = 'FreqWindow'; + UpdateTopoXWindow = @UpdateTopoFreqWindow; + end % Create scale buttons h1 = bst_javacomponent(hFig, 'button', [], [], IconLoader.ICON_SCROLL_UP, ... '
Increase gain
Shortcuts:
  [+]
  [SHIFT + Mouse wheel]
', ... @@ -1272,14 +1383,14 @@ function CreateButtons2dLayout(iDS, iFig) '
Decrease gain
Shortcuts:
  [-]
  [SHIFT + Mouse wheel]
', ... @(h,ev)UpdateTimeSeriesFactor(hFig, .9091), 'ButtonGainMinus'); h3 = bst_javacomponent(hFig, 'button', [], '...', [], ... - 'Set time window manually', ... - @(h,ev)SetTopoLayoutOptions('TimeWindow'), 'ButtonSetTimeWindow'); + ['Set ' lower(xAxisName) ' window manually'], ... + @(h,ev)SetTopoLayoutOptions(xAxisOption), 'ButtonSetTimeWindow'); h4 = bst_javacomponent(hFig, 'button', [], [], IconLoader.ICON_SCROLL_LEFT, ... '
Horizontal zoom out
Shortcuts:
  [CTRL + Mouse wheel]
', ... - @(h,ev)UpdateTopoTimeWindow(hFig, .9091), 'ButtonZoomTimePlus'); + @(h,ev)UpdateTopoXWindow(hFig, .9091), 'ButtonZoomTimePlus'); h5 = bst_javacomponent(hFig, 'button', [], [], IconLoader.ICON_SCROLL_RIGHT, ... '
Horizontal zoom in
Shortcuts:
  [CTRL + Mouse wheel]
', ... - @(h,ev)UpdateTopoTimeWindow(hFig, 1.1), 'ButtonZoomTimeMinus'); + @(h,ev)UpdateTopoXWindow(hFig, 1.1), 'ButtonZoomTimeMinus'); % Visible / not visible TopoLayoutOptions = bst_get('TopoLayoutOptions'); if ~TopoLayoutOptions.ShowLegend @@ -1438,6 +1549,26 @@ function UpdateTopoTimeWindow(hFig, changeFactor) end +%% ===== UPDATE FREQUENCY AXIS FACTOR ===== +function UpdateTopoFreqWindow(hFig, changeFactor) + global GlobalData; + % Get current time window + TopoLayoutOptions = bst_get('TopoLayoutOptions'); + tmp = [GlobalData.UserFrequencies.Freqs(1), GlobalData.UserFrequencies.Freqs(end)]; + % If the window hasn't been changed yet: ignore + if isempty(TopoLayoutOptions.FreqWindow) + TopoLayoutOptions.FreqWindow = tmp; + end + % Apply zoom factor + Xlength = TopoLayoutOptions.FreqWindow(2) - TopoLayoutOptions.FreqWindow(1); + newFreqWindow = GlobalData.UserFrequencies.Freqs(GlobalData.UserFrequencies.iCurrentFreq) + Xlength/changeFactor/2 * [-1, 1]; + % New time window cannot exceed initial time window + newFreqWindow = bst_saturate(newFreqWindow, tmp, 1); + % Set new time window + SetTopoLayoutOptions('FreqWindow', newFreqWindow); +end + + %% ===== SET 2DLAYOUT OPTIONS ===== function SetTopoLayoutOptions(option, value) global GlobalData; @@ -1467,6 +1598,25 @@ function SetTopoLayoutOptions(option, value) % Save new time window TopoLayoutOptions.TimeWindow = newTimeWindow; isLayout = 1; + case 'FreqWindow' + tmp = [GlobalData.UserFrequencies.Freqs(1), GlobalData.UserFrequencies.Freqs(end)]; + % If frequency window is provided + if ~isempty(value) + newFreqWindow = value; + % Else: Ask user for new frequency window + else + newFreqWindow = panel_freq('InputSelectionWindow', tmp, 'Time window in the 2DLayout view:', 'Hz'); + if isempty(newFreqWindow) + return; + end + end + % Check frequency window consistency + newFreqWindow = bst_saturate(newFreqWindow, tmp, 1); + newFreqPosition = bst_saturate(GlobalData.UserFrequencies.Freqs(GlobalData.UserFrequencies.iCurrentFreq), newFreqWindow); + panel_freq('SetCurrentFreq', newFreqPosition, 0); + % Save new frequency window + TopoLayoutOptions.FreqWindow = newFreqWindow; + isLayout = 1; case 'WhiteBackground' TopoLayoutOptions.WhiteBackground = value; isLayout = 1; diff --git a/toolbox/gui/gui_brainstorm.m b/toolbox/gui/gui_brainstorm.m index e151bcc87..244ae04a2 100644 --- a/toolbox/gui/gui_brainstorm.m +++ b/toolbox/gui/gui_brainstorm.m @@ -147,7 +147,9 @@ jMenuFile.addSeparator(); end % === DIGITIZE === - gui_component('MenuItem', jMenuFile, [], 'Digitize', IconLoader.ICON_CHANNEL, [], @(h,ev)bst_call(@panel_digitize, 'Start'), fontSize); + jSubMenu = gui_component('Menu', jMenuFile, [], 'Digitize', IconLoader.ICON_CHANNEL,[],[], fontSize); + gui_component('MenuItem', jSubMenu, [], 'Digitizer', IconLoader.ICON_CHANNEL, [], @(h,ev)bst_call(@panel_digitize, 'Start'), fontSize); + gui_component('MenuItem', jSubMenu, [], '3D scanner', IconLoader.ICON_SNAPSHOT, [], @(h,ev)bst_call(@panel_digitize, 'Start', '3DScanner'), fontSize); gui_component('MenuItem', jMenuFile, [], 'Batch MRI fiducials', IconLoader.ICON_LOBE, [], @(h,ev)bst_call(@bst_batch_fiducials), fontSize); jMenuFile.addSeparator(); % === QUIT === @@ -171,8 +173,8 @@ % ==== Menu PLUGINS ==== jMenuPlugins = gui_component('Menu', jMenuBar, [], 'Plugins', [], [], [], fontSize); - jMenusPlug = bst_plugin('MenuCreate', jMenuPlugins, fontSize); - java_setcb(jMenuPlugins, 'MenuSelectedCallback', @(h,ev)bst_plugin('MenuUpdate', jMenusPlug)); + jMenusPlug = bst_plugin('MenuCreate', jMenuPlugins, [], [], fontSize); + java_setcb(jMenuPlugins, 'MenuSelectedCallback', @(h,ev)bst_plugin('MenuUpdate', jMenuPlugins, fontSize)); % ==== Menu HELP ==== jMenuSupport = gui_component('Menu', jMenuBar, [], ' Help ', [], [], [], fontSize); @@ -184,6 +186,7 @@ jMenuSupport.addSeparator(); % USAGE STATS gui_component('MenuItem', jMenuSupport, [], 'Usage statistics', IconLoader.ICON_TS_DISPLAY, [], @(h,ev)bst_userstat, fontSize); + gui_component('MenuItem', jMenuSupport, [], 'System info', IconLoader.ICON_SCREEN1, [], @(h,ev)bst_systeminfo(1), fontSize); jMenuSupport.addSeparator(); % LICENSE gui_component('MenuItem', jMenuSupport, [], 'License', IconLoader.ICON_EDIT, [], @(h,ev)bst_license(), fontSize); @@ -491,7 +494,8 @@ struct('name', 'tools', ... 'jHandle', jTabpaneTools)], ... 'panels', BstPanel(), ... % [0x0] array of BstPanel objects - 'nodelists', repmat(db_template('nodelist'), 0)); + 'nodelists', repmat(db_template('nodelist'), 0), ... + 'pluginMenus', jMenusPlug); %% ================================================================================= @@ -881,7 +885,7 @@ function UpdateProtocolsList() end % Set current protocol iProtocol = GlobalData.DataBase.iProtocol; - if ~isempty(iProtocol) && isnumeric(iProtocol) && (iProtocol > 0) && (iProtocol < length(GlobalData.DataBase.ProtocolInfo)) + if ~isempty(iProtocol) && isnumeric(iProtocol) && (iProtocol > 0) && (iProtocol <= length(GlobalData.DataBase.ProtocolInfo)) iSel = find(indProtocols == iProtocol); ctrl.jComboBoxProtocols.setSelectedIndex(iSel-1); end diff --git a/toolbox/gui/gui_hide.m b/toolbox/gui/gui_hide.m index 661e10f1f..df7df9da7 100644 --- a/toolbox/gui/gui_hide.m +++ b/toolbox/gui/gui_hide.m @@ -75,7 +75,12 @@ function gui_hide( varargin ) panel_coordinates('RemoveSelection'); end case 'Digitize' - isAccepted = panel_digitize('PanelHidingCallback'); + DigitizeOptions = bst_get('DigitizeOptions'); + if isfield(DigitizeOptions, 'Version') && strcmpi(DigitizeOptions.Version, '2024') + isAccepted = panel_digitize_2024('PanelHidingCallback'); + else + isAccepted = panel_digitize('PanelHidingCallback'); + end case 'Dipinfo' if gui_brainstorm('isTabVisible', 'Dipinfo') panel_dipinfo('RemoveSelection'); diff --git a/toolbox/gui/gui_initialize.m b/toolbox/gui/gui_initialize.m index 5cd9dd5e1..91c122afa 100644 --- a/toolbox/gui/gui_initialize.m +++ b/toolbox/gui/gui_initialize.m @@ -49,9 +49,7 @@ function gui_initialize() gui_show('panel_record', 'BrainstormTab', 'tools'); gui_show('panel_filter', 'BrainstormTab', 'tools'); gui_show('panel_surface', 'BrainstormTab', 'tools'); -if (GlobalData.Program.GuiLevel == 1) - gui_show('panel_scout', 'BrainstormTab', 'tools'); -end +gui_show('panel_scout', 'BrainstormTab', 'tools'); % gui_show('panel_cluster', 'BrainstormTab', 'tools'); % gui_show('panel_dipoles', 'BrainstormTab', 'tools'); diff --git a/toolbox/gui/gui_layout.m b/toolbox/gui/gui_layout.m index 3407903f4..20f60eb0a 100644 --- a/toolbox/gui/gui_layout.m +++ b/toolbox/gui/gui_layout.m @@ -77,8 +77,8 @@ function Update() %#ok jBstWindow.getBounds.getHeight() - jBstWindow.getRootPane.getBounds.getHeight() - jBstWindow.getRootPane.getBounds.getY(), ... 20, ...% jBstWindow.getJMenuBar.getSize.getHeight(), ... 28]; % TOOLBAR HEIGHT - % For windows 10 and macos, remove the borders of the figures (they are transparent) - if ispc && ~isempty(strfind(system_dependent('getos'), '10')) + % For Windows 10 and 11, remove the borders of the figures (they are transparent) + if ispc && (~isempty(strfind(system_dependent('getos'), '10')) || ~isempty(strfind(system_dependent('getos'), '11'))) decorationSize(1) = 0; decorationSize(2) = 31; decorationSize(3) = 2; diff --git a/toolbox/gui/java_getfile.m b/toolbox/gui/java_getfile.m index 3144d5e2e..cf1a7a4e2 100644 --- a/toolbox/gui/java_getfile.m +++ b/toolbox/gui/java_getfile.m @@ -140,6 +140,36 @@ % Set dialog callback java_setcb(jFileChooser, 'ActionPerformedCallback', @FileSelectorAction, ... 'PropertyChangeCallback', @FileSelectorPropertyChanged); +% Search for panel to add show/hide hidden menu +jObjects = jFileChooser; +jFilePane = []; +while ~isempty(jObjects) + switch class(jObjects(1)) + case 'sun.swing.FilePane' + jFilePane = jObjects(1); + break + case {'javax.swing.JPanel', 'javax.swing.JFileChooser'} + jObjects = [jObjects, jObjects(1).getComponents]; + otherwise + % do nothing + end + jObjects = jObjects(2:end); +end +% Linux and Windows have a JFilePane object with a PopupMenu +if ~isempty(jFilePane) + jPopup = jFilePane.getComponentPopupMenu; + jFont = jPopup.getFont; +% macOs does not have JFilePane object, add PopupMenu to jFileChooser +else + jPopup = java_create('javax.swing.JPopupMenu'); + jFont = []; + jFileChooser.setComponentPopupMenu(jPopup); +end +jCheckHidden = gui_component('CheckBoxMenuItem', jPopup, [], 'Show hidden files', [], [], @(h,ev)ToogleHiddenFiles(), jFont); +showHiddenFiles = bst_get('ShowHiddenFiles'); +jCheckHidden.setSelected(showHiddenFiles); +jFileChooser.setFileHidingEnabled(~showHiddenFiles); + drawnow; % Display file selector java_call(jBstSelector, 'showSameThread'); @@ -185,7 +215,7 @@ end % If file already exist - if file_exist(fileList) && ~isequal(suffix, '.folder') + if file_exist(fileList) && ~isequal(suffix, '.folder') && ~isdir(fileList) if ~java_dialog('confirm', sprintf('File already exist.\nDo you want to overwrite it?'), 'Save file') fileList = []; fileFormat = []; @@ -228,6 +258,12 @@ function FileSelectorAction(h, ev) function FileSelectorPropertyChanged(h, ev) import org.brainstorm.file.*; + % Release mutex if Dialog was closed + propertyName = char(java_call(ev, 'getPropertyName')); + if strcmpi(propertyName, 'JFileChooserDialogIsClosingProperty') && isempty(java_call(ev, 'getNewValue')) + bst_mutex('release', 'FileSelector'); + return + end % Only when saving if (DialogType == BstFileSelector.TYPE_SAVE) switch char(java_call(ev, 'getPropertyName')) @@ -269,6 +305,14 @@ function FileSelectorPropertyChanged(h, ev) end end end + + function ToogleHiddenFiles() + showHiddenFiles = bst_get('ShowHiddenFiles'); + showHiddenFiles = ~showHiddenFiles; + bst_set('ShowHiddenFiles', showHiddenFiles); + jFileChooser.setFileHidingEnabled(~showHiddenFiles); + jCheckHidden.setSelected(showHiddenFiles); + end end diff --git a/toolbox/gui/menu_default_eegcaps.m b/toolbox/gui/menu_default_eegcaps.m new file mode 100644 index 000000000..28c8772e1 --- /dev/null +++ b/toolbox/gui/menu_default_eegcaps.m @@ -0,0 +1,127 @@ +function menu_default_eegcaps(jMenu, iAllStudies, isAddLoc) +% MENU_DEFAULT_EEGCAPS: Generate Brainstorm available EEG caps menu +% +% USAGE: menu_default_eegcaps(jMenu, iAllStudies, isAddLoc) +% +% PARAMETERS: +% - jMenu : The handle for the parent menu where this menu will be added +% - iAllStudies : All studies in the protocol +% - isAddLoc : if 1 (SEEG/ECOG) or 2 (EEG), call 'channel_add_loc' +% if 0 call 'db_set_channel' + +% @============================================================================= +% This function is part of the Brainstorm software: +% https://neuroimage.usc.edu/brainstorm +% +% Copyright (c) University of Southern California & McGill University +% This software is distributed under the terms of the GNU General Public License +% as published by the Free Software Foundation. Further details on the GPLv3 +% license can be found at http://www.gnu.org/copyleft/gpl.html. +% +% FOR RESEARCH PURPOSES ONLY. THE SOFTWARE IS PROVIDED "AS IS," AND THE +% UNIVERSITY OF SOUTHERN CALIFORNIA AND ITS COLLABORATORS DO NOT MAKE ANY +% WARRANTY, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO WARRANTIES OF +% MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE, NOR DO THEY ASSUME ANY +% LIABILITY OR RESPONSIBILITY FOR THE USE OF THIS SOFTWARE. +% +% For more information type "brainstorm license" at command prompt. +% =============================================================================@ +% +% Authors: Raymundo Cassani, 2024 +% Chinmay Chinara, 2024 + +import org.brainstorm.icon.*; + +%% ===== PARSE INPUTS ===== +if (nargin < 1) || isempty(jMenu) + bst_error('Incorrect usage, first parameter ''jMenu'' is required', 'Menu EEG caps', 0); + return +end +if (nargin < 2) || isempty(iAllStudies) + iAllStudies = []; +end +if (nargin < 3) || isempty(isAddLoc) + isAddLoc = []; +end + +% Get the digitize options +DigitizeOptions = bst_get('DigitizeOptions'); +% Get registered Brainstorm EEG defaults +bstDefaults = bst_get('EegDefaults'); +if ~isempty(bstDefaults) + % Add a directory per template block available + for iDir = 1:length(bstDefaults) + jMenuDir = gui_component('Menu', jMenu, [], bstDefaults(iDir).name, IconLoader.ICON_FOLDER_CLOSE, [], []); + isMni = strcmpi(bstDefaults(iDir).name, 'ICBM152'); + % Create subfolder for cap manufacturer + jMenuOther = gui_component('Menu', [], [], 'Generic', IconLoader.ICON_FOLDER_CLOSE, [], []); + jMenuAnt = gui_component('Menu', [], [], 'ANT', IconLoader.ICON_FOLDER_CLOSE, [], []); + jMenuBs = gui_component('Menu', [], [], 'BioSemi', IconLoader.ICON_FOLDER_CLOSE, [], []); + jMenuBp = gui_component('Menu', [], [], 'BrainProducts', IconLoader.ICON_FOLDER_CLOSE, [], []); + jMenuEgi = gui_component('Menu', [], [], 'EGI', IconLoader.ICON_FOLDER_CLOSE, [], []); + jMenuNs = gui_component('Menu', [], [], 'NeuroScan', IconLoader.ICON_FOLDER_CLOSE, [], []); + jMenuWs = gui_component('Menu', [], [], 'WearableSensing', IconLoader.ICON_FOLDER_CLOSE, [], []); + % Add an item per Template available + fList = bstDefaults(iDir).contents; + % Sort in natural order + [tmp,I] = sort_nat({fList.name}); + fList = fList(I); + % Create an entry for each default + for iFile = 1:length(fList) + % Define callback function + if isempty(isAddLoc) + panel_fun = @panel_digitize; + if isfield(DigitizeOptions, 'Version') && strcmpi(DigitizeOptions.Version, '2024') + panel_fun = @panel_digitize_2024; + end + fcnCallback = @(h,ev)panel_fun('AddMontage', fList(iFile).fullpath); + else + if isAddLoc + fcnCallback = @(h,ev)channel_add_loc(iAllStudies, fList(iFile).fullpath, 1, isMni); + else + fcnCallback = @(h,ev)db_set_channel(iAllStudies, fList(iFile).fullpath, 1, 0); + end + end + % Find corresponding submenu + if ~isempty(strfind(fList(iFile).name, 'ANT')) + jMenuType = jMenuAnt; + elseif ~isempty(strfind(fList(iFile).name, 'BioSemi')) + jMenuType = jMenuBs; + elseif ~isempty(strfind(fList(iFile).name, 'BrainProducts')) + jMenuType = jMenuBp; + elseif ~isempty(strfind(fList(iFile).name, 'GSN')) || ~isempty(strfind(fList(iFile).name, 'U562')) + jMenuType = jMenuEgi; + elseif ~isempty(strfind(fList(iFile).name, 'Neuroscan')) + jMenuType = jMenuNs; + elseif ~isempty(strfind(fList(iFile).name, 'WearableSensing')) + jMenuType = jMenuWs; + else + jMenuType = jMenuOther; + end + % Create item + gui_component('MenuItem', jMenuType, [], fList(iFile).name, IconLoader.ICON_CHANNEL, [], fcnCallback); + end + % Add if not empty + if (jMenuOther.getMenuComponentCount() > 0) + jMenuDir.add(jMenuOther); + end + if (jMenuAnt.getMenuComponentCount() > 0) + jMenuDir.add(jMenuAnt); + end + if (jMenuBs.getMenuComponentCount() > 0) + jMenuDir.add(jMenuBs); + end + if (jMenuBp.getMenuComponentCount() > 0) + jMenuDir.add(jMenuBp); + end + if (jMenuEgi.getMenuComponentCount() > 0) + jMenuDir.add(jMenuEgi); + end + if (jMenuNs.getMenuComponentCount() > 0) + jMenuDir.add(jMenuNs); + end + if (jMenuWs.getMenuComponentCount() > 0) + jMenuDir.add(jMenuWs); + end + end +end diff --git a/toolbox/gui/panel_cluster.m b/toolbox/gui/panel_cluster.m index e09834fb3..e83678c65 100644 --- a/toolbox/gui/panel_cluster.m +++ b/toolbox/gui/panel_cluster.m @@ -66,6 +66,7 @@ gui_component('MenuItem', jMenuTs, [], 'FastPCA', [], [], @(h,ev)SetClusterFunction('FastPCA')); gui_component('MenuItem', jMenuTs, [], 'Max', [], [], @(h,ev)SetClusterFunction('Max')); gui_component('MenuItem', jMenuTs, [], 'Power', [], [], @(h,ev)SetClusterFunction('Power')); + gui_component('MenuItem', jMenuTs, [], 'RMS', [], [], @(h,ev)SetClusterFunction('RMS')); gui_component('MenuItem', jMenuTs, [], 'All', [], [], @(h,ev)SetClusterFunction('All')); jMenu.addSeparator(); gui_component('MenuItem', jMenu, [], 'Rename [Double-click]', IconLoader.ICON_EDIT, [], @(h,ev)EditClusterLabel); diff --git a/toolbox/gui/panel_command.m b/toolbox/gui/panel_command.m index 6a18ccbda..d0f80da7a 100644 --- a/toolbox/gui/panel_command.m +++ b/toolbox/gui/panel_command.m @@ -148,7 +148,12 @@ function ExecuteScript(ScriptFile, varargin) %#ok if ~ischar(varargin{iArg}) error('All arguments passed in command line must be strings.'); end - strSetArg = [strSetArg, argNames{iArg}, '=''', varargin{iArg}, ''';']; + strAround = ''''; + % Interpret string as matrices and cells + if ~isempty(varargin{iArg}) && ismember(varargin{iArg}(1), {'{', '['}) + strAround = ''; + end + strSetArg = [strSetArg, argNames{iArg}, '=', strAround, varargin{iArg}, strAround, ';']; end cellLines{i} = strSetArg; end diff --git a/toolbox/gui/panel_coordinates.m b/toolbox/gui/panel_coordinates.m index 6ab0c596f..3286d250a 100644 --- a/toolbox/gui/panel_coordinates.m +++ b/toolbox/gui/panel_coordinates.m @@ -22,6 +22,7 @@ % =============================================================================@ % % Authors: Francois Tadel, 2008-2020 +% Chinmay Chinara, 2024 eval(macro_method); end @@ -178,6 +179,7 @@ function UpdatePanel() if isempty(ctrl) return end + % Get current figure hFig = bst_figures('GetCurrentFigure', '3D'); % If a figure is available: get if a point select @@ -262,20 +264,28 @@ function CurrentFigureChanged_Callback() %#ok % Manual selection of a surface point : start(1), or stop(0) function SetSelectionState(isSelected) % Get panel controls - ctrl = bst_get('PanelControls', 'Coordinates'); - if isempty(ctrl) + ctrl1 = bst_get('PanelControls', 'Coordinates'); + ctrl2 = bst_get('PanelControls', 'iEEG'); + ctrls = {ctrl1, ctrl2}; + ctrls = ctrls(~cellfun(@isempty, ctrls)); + + if isempty(ctrls) return end % Get list of all figures hFigures = bst_figures('GetAllFigures'); if isempty(hFigures) - ctrl.jButtonSelect.setSelected(0); + for ic = 1 : length(ctrls) + ctrls{ic}.jButtonSelect.setSelected(0); + end return end % Start selection if isSelected % Push toolbar "Select" button - ctrl.jButtonSelect.setSelected(1); + for ic = 1 : length(ctrls) + ctrls{ic}.jButtonSelect.setSelected(1); + end % Set 3DViz figures in 'SelectingCorticalSpot' mode for hFig = hFigures % Keep only figures with surfaces @@ -287,8 +297,10 @@ function SetSelectionState(isSelected) end % Stop selection else - % Release toolbar "Select" button - ctrl.jButtonSelect.setSelected(0); + % Release toolbar "Select" button + for ic = 1 : length(ctrls) + ctrls{ic}.jButtonSelect.setSelected(0); + end % Exit 3DViz figures from SelectingCorticalSpot mode for hFig = hFigures set(hFig, 'Pointer', 'arrow'); @@ -298,16 +310,33 @@ function SetSelectionState(isSelected) end +%% ===== GET SELECTION STATE ===== +function isSelected = GetSelectionState() + % Get "Coordinates" panel controls + ctrl = bst_get('PanelControls', 'Coordinates'); + if isempty(ctrl) + isSelected = 0; + return + end + % Return status + isSelected = ctrl.jButtonSelect.isSelected(); +end + + %% ===== SELECT POINT ===== % Usage : SelectPoint(hFig) : Point location = user click in figure hFIg -function vi = SelectPoint(hFig, AcceptMri) %#ok +function vi = SelectPoint(hFig, AcceptMri, isCentroid) %#ok + if (nargin < 3) || isempty(isCentroid) + isCentroid = 0; + end if (nargin < 2) || isempty(AcceptMri) AcceptMri = 1; + isCentroid = 0; end % Get axes handle hAxes = findobj(hFig, '-depth', 1, 'Tag', 'Axes3D'); % Find the closest surface point that was selected - [TessInfo, iTess, pout, vout, vi, hPatch] = ClickPointInSurface(hFig); + [TessInfo, iTess, pout, vout, vi, hPatch] = ClickPointInSurface(hFig, [], isCentroid); if isempty(TessInfo) return end @@ -330,7 +359,11 @@ function SetSelectionState(isSelected) case {'Scalp', 'InnerSkull', 'OuterSkull', 'Cortex', 'Other', 'FEM'} sSurf = bst_memory('GetSurface', TessInfo(iTess).SurfaceFile); - scsLoc = sSurf.Vertices(vi,:); + if ~isCentroid + scsLoc = sSurf.Vertices(vi,:); + else + scsLoc = vout'; + end plotLoc = vout; iVertex = vi; % Get value @@ -375,6 +408,7 @@ function SetSelectionState(isSelected) CoordinatesSelector.MRI = cs_convert(sMri, 'scs', 'mri', scsLoc); CoordinatesSelector.MNI = cs_convert(sMri, 'scs', 'mni', scsLoc); CoordinatesSelector.World = cs_convert(sMri, 'scs', 'world', scsLoc); + CoordinatesSelector.Voxel = cs_convert(sMri, 'scs', 'voxel', scsLoc); CoordinatesSelector.iVertex = iVertex; CoordinatesSelector.Value = Value; CoordinatesSelector.hPatch = hPatch; @@ -384,7 +418,10 @@ function SetSelectionState(isSelected) % Remove previous mark delete(findobj(hAxes, '-depth', 1, 'Tag', 'ptCoordinates')); % Mark new point - line(plotLoc(1)*1.005, plotLoc(2)*1.005, plotLoc(3)*1.005, ... + if ~isCentroid + plotLoc = plotLoc.*1.005; + end + line(plotLoc(1), plotLoc(2), plotLoc(3), ... 'MarkerFaceColor', [1 1 0], ... 'MarkerEdgeColor', [1 1 0], ... 'Marker', '+', ... @@ -394,14 +431,22 @@ function SetSelectionState(isSelected) 'Tag', 'ptCoordinates'); % Update "Coordinates" panel UpdatePanel(); + % Open MRI viewer for SEEG + if isCentroid + ViewInMriViewer(); + end end %% ===== POINT SELECTION: Surface detection ===== -function [TessInfo, iTess, pout, vout, vi, hPatch] = ClickPointInSurface(hFig, SurfacesType) +function [TessInfo, iTess, pout, vout, vi, hPatch] = ClickPointInSurface(hFig, SurfacesType, isCentroid) % Parse inputs + if (nargin < 3) + isCentroid = 0; + end if (nargin < 2) SurfacesType = []; + isCentroid = 0; end iTess = []; pout = {}; @@ -415,12 +460,15 @@ function SetSelectionState(isSelected) % Get camera position CameraPosition = get(hAxes, 'CameraPosition'); % Get all the surfaces in the figure - TessInfo = getappdata(hFig, 'Surface'); + [iTess, TessInfo, hFig, sSurf] = panel_surface('GetSelectedSurface', hFig); if isempty(TessInfo) return end % === CHECK SURFACE TYPE === + if isCentroid + SurfacesType = 'Other'; + end % Keep only surfaces that are of the required type if ~isempty(SurfacesType) iAcceptableTess = find(strcmpi({TessInfo.Name}, SurfacesType)); @@ -437,6 +485,12 @@ function SetSelectionState(isSelected) [pout{i}, vout{i}, vi{i}] = select3d(hPatch(i)); if ~isempty(pout{i}) patchDist(i) = norm(pout{i}' - CameraPosition); + % Find centroid the blob mesh that contains the vertex 'vi' + if isCentroid + VertexList = FindCentroid(sSurf, find(sSurf.VertConn(vi{i},:)), [], 1, 6); + vout{i} = mean(sSurf.Vertices(VertexList(:), :)); % SCS of the centroid + vi{i} = []; % No surface vertex associated to centroid + end else patchDist(i) = Inf; end @@ -465,6 +519,22 @@ function SetSelectionState(isSelected) end end +%% ===== FIND CENTROID OF A MESH BLOB ===== +% Find the centroid of the selected contact blob from the isosurface using flood-fill alogrithm +% NOTE: currently used mainly for SEEG contact localization from thresholded isosurface +function VertexList = FindCentroid(Surface, VertConnList, VertexList, cnt, cntThresh) + if cnt == cntThresh + return; + end + for i=1:length(VertConnList) + if ~any(VertexList(:) == VertConnList(i)) + VertexList = [VertexList, VertConnList(i)]; + VertConnListTemp = find(Surface.VertConn(VertConnList(i),:)); + VertexList = FindCentroid(Surface, VertConnListTemp, VertexList, cnt, cntThresh); + cnt = cnt + 1; + end + end +end %% ===== REMOVE SELECTION ===== function RemoveSelection(varargin) @@ -505,7 +575,10 @@ function ViewInMriViewer(varargin) return end % Display subject's anatomy in MRI Viewer - hFig = view_mri(sSubject.Anatomy(sSubject.iAnatomy).FileName); + hFig = bst_figures('GetFiguresByType', 'MriViewer'); + if isempty(hFig) + hFig = view_mri(sSubject.Anatomy(sSubject.iAnatomy).FileName); + end % Select the required point figure_mri('SetLocation', 'mri', hFig, [], CoordinatesSelector.MRI); end diff --git a/toolbox/gui/panel_ieeg.m b/toolbox/gui/panel_ieeg.m index 9ec62e156..5c6b6f289 100644 --- a/toolbox/gui/panel_ieeg.m +++ b/toolbox/gui/panel_ieeg.m @@ -26,6 +26,7 @@ % =============================================================================@ % % Authors: Francois Tadel, 2017-2022 +% Chinmay Chinara, 2024 eval(macro_method); end @@ -52,6 +53,8 @@ % Add/remove gui_component('ToolbarButton', jToolbar,[],[], {IconLoader.ICON_PLUS, TB_DIM}, 'Add new electrode', @(h,ev)bst_call(@AddElectrode)); gui_component('ToolbarButton', jToolbar,[],[], {IconLoader.ICON_MINUS, TB_DIM}, 'Remove selected electrodes', @(h,ev)bst_call(@RemoveElectrode)); + % Button "Select vertex" + jButtonSelect = gui_component('ToolbarToggle', jToolbar, [], '', IconLoader.ICON_SCOUT_NEW, 'Select surface point', @(h,ev)panel_coordinates('SetSelectionState', ev.getSource.isSelected())); % Set color jToolbar.addSeparator(); gui_component('ToolbarButton', jToolbar,[],[], {IconLoader.ICON_COLOR_SELECTION, TB_DIM}, 'Select color for selected electrodes', @(h,ev)bst_call(@EditElectrodeColor)); @@ -70,18 +73,23 @@ % ===== PANEL MAIN ===== jPanelMain = gui_component('Panel'); jPanelMain.setBorder(BorderFactory.createEmptyBorder(7,7,7,7)); -% % ===== VERTICAL TOOLBAR ===== -% jToolbar2 = gui_component('Toolbar', jPanelMain, BorderLayout.EAST); -% jToolbar2.setOrientation(jToolbar.VERTICAL); -% jToolbar2.setPreferredSize(java_scaled('dimension',26,20)); -% jToolbar2.setBorder([]); % ===== FIRST PART ===== jPanelFirstPart = gui_component('Panel'); % ===== ELECTRODES LIST ===== jPanelElecList = gui_component('Panel'); - jBorder = java_scaled('titledborder', 'Electrodes'); + jBorder = java_scaled('titledborder', 'Electrodes & Contacts'); jPanelElecList.setBorder(jBorder); + % Coodinate radio buttons + jPanelModelCoord = gui_river([2,2], [0,0,0,0]); + gui_component('label', jPanelModelCoord, '', ' Coordinates (millimeters): '); + jButtonGroupCoord = ButtonGroup(); + jRadioScs = gui_component('radio', jPanelModelCoord, 'br', 'SCS ', jButtonGroupCoord, '', @(h,ev)UpdateContactList('SCS')); + jRadioScs.setSelected(1); + jRadioMri = gui_component('radio', jPanelModelCoord, '', 'MRI ', jButtonGroupCoord, '', @(h,ev)UpdateContactList('MRI')); + jRadioWorld = gui_component('radio', jPanelModelCoord, '', 'World ', jButtonGroupCoord, '', @(h,ev)UpdateContactList('World')); + jRadioMni = gui_component('radio', jPanelModelCoord, '', 'MNI ', jButtonGroupCoord, '', @(h,ev)UpdateContactList('MNI')); + jPanelElecList.add(jPanelModelCoord, BorderLayout.NORTH); % Electrodes list jListElec = java_create('org.brainstorm.list.BstClusterList'); jListElec.setBackground(Color(.9,.9,.9)); @@ -91,10 +99,26 @@ 'ValueChangedCallback', @(h,ev)bst_call(@ElecListValueChanged_Callback,h,ev), ... 'KeyTypedCallback', @(h,ev)bst_call(@ElecListKeyTyped_Callback,h,ev), ... 'MouseClickedCallback', @(h,ev)bst_call(@ElecListClick_Callback,h,ev)); - jPanelScrollList = JScrollPane(); - jPanelScrollList.getLayout.getViewport.setView(jListElec); - jPanelScrollList.setBorder([]); - jPanelElecList.add(jPanelScrollList); + jPanelScrollElecList = JScrollPane(); + jPanelScrollElecList.getLayout.getViewport.setView(jListElec); + jPanelScrollElecList.setBorder([]); + + % Contacts list + jListCont = java_create('org.brainstorm.list.BstClusterList'); + jListCont.setBackground(Color(.9,.9,.9)); + jListCont.setLayoutOrientation(jListCont.HORIZONTAL_WRAP); + jListCont.setVisibleRowCount(-1); + java_setcb(jListCont, ... + 'ValueChangedCallback', @(h,ev)bst_call(@ContListChanged_Callback,h,ev)); + jPanelScrollContList = JScrollPane(); + jPanelScrollContList.getLayout.getViewport.setView(jListCont); + jPanelScrollContList.setBorder([]); + + jSplitEvt = JSplitPane(JSplitPane.HORIZONTAL_SPLIT, jPanelScrollElecList, jPanelScrollContList); + jSplitEvt.setResizeWeight(0.2); + jSplitEvt.setDividerSize(4); + jSplitEvt.setBorder([]); + jPanelElecList.add(jSplitEvt, BorderLayout.CENTER); jPanelFirstPart.add(jPanelElecList, BorderLayout.CENTER); jPanelMain.add(jPanelFirstPart); @@ -122,9 +146,17 @@ % ComboBox change selection callback jModel = jComboModel.getModel(); java_setcb(jModel, 'ContentsChangedCallback', @(h,ev)bst_call(@ComboModelChanged_Callback,h,ev)); + % Actions + gui_component('label', jPanelModel, 'br', 'Actions: '); % Add/remove models - gui_component('button', jPanelModel,'right',[], {IconLoader.ICON_PLUS, java_scaled('dimension',22,22)}, 'Add new electrode model', @(h,ev)bst_call(@AddElectrodeModel)); - gui_component('button', jPanelModel,[],[], {IconLoader.ICON_MINUS, java_scaled('dimension',22,22)}, 'Remove electrode model', @(h,ev)bst_call(@RemoveElectrodeModel)); + gui_component('button', jPanelModel, [],[], {IconLoader.ICON_PLUS, TB_DIM}, 'Add new electrode model', @(h,ev)bst_call(@AddElectrodeModel)); + gui_component('button', jPanelModel,[],[], {IconLoader.ICON_MINUS, TB_DIM}, 'Remove electrode model', @(h,ev)bst_call(@RemoveElectrodeModel)); + % Save/load models + gui_component('button', jPanelModel, [],[], {IconLoader.ICON_SAVE, TB_DIM}, 'Save electrode model to file', @(h,ev)bst_call(@SaveElectrodeModel)); + gui_component('button', jPanelModel,[],[], {IconLoader.ICON_FOLDER_OPEN, TB_DIM}, 'Load electrode model from file', @(h,ev)bst_call(@LoadElectrodeModel)); + % Export/import models + gui_component('button', jPanelModel, [],[], {IconLoader.ICON_MATLAB_EXPORT, TB_DIM}, 'Export electrode model to Matlab', @(h,ev)bst_call(@ExportElectrodeModel)); + gui_component('button', jPanelModel,[],[], {IconLoader.ICON_MATLAB_IMPORT, TB_DIM}, 'Import electrode model from Matlab', @(h,ev)bst_call(@ImportElectrodeModel)); jPanelElecOptions.add('br hfill', jPanelModel); % Number of contacts @@ -173,7 +205,7 @@ jPanelMain.add(jPanelBottom, BorderLayout.SOUTH) jPanelNew.add(jPanelMain, BorderLayout.CENTER); - % Store electrode selection + % Store electrode and contacts selection jLabelSelectElec = JLabel(''); % Create the BstPanel object that is returned by the function bstPanelNew = BstPanel(panelName, ... @@ -182,11 +214,17 @@ 'jPanelElecList', jPanelElecList, ... 'jToolbar', jToolbar, ... 'jPanelElecOptions', jPanelElecOptions, ... + 'jButtonSelect', jButtonSelect, ... 'jButtonShow', jButtonShow, ... 'jRadioDispDepth', jRadioDispDepth, ... 'jRadioDispSphere', jRadioDispSphere, ... 'jMenuContacts', jMenuContacts, ... 'jListElec', jListElec, ... + 'jListCont', jListCont, ... + 'jRadioMri', jRadioMri, ... + 'jRadioScs', jRadioScs, ... + 'jRadioWorld', jRadioWorld, ... + 'jRadioMni', jRadioMni, ... 'jComboModel', jComboModel, ... 'jRadioSeeg', jRadioSeeg, ... 'jRadioEcog', jRadioEcog, ... @@ -261,7 +299,7 @@ function ComboModelChanged_Callback(varargin) UpdateFigures(); end - %% ===== LIST SELECTION CHANGED CALLBACK ===== + %% ===== ELECTRODE LIST SELECTION CHANGED CALLBACK ===== function ElecListValueChanged_Callback(h, ev) if ~ev.getValueIsAdjusting() UpdateElecProperties(); @@ -271,10 +309,14 @@ function ElecListValueChanged_Callback(h, ev) if (length(sSelElec) == 1) CenterMriOnElectrode(sSelElec); end + % Unselect all contacts in list + SetSelectedContacts(0); + % Update contact list + UpdateContactList(); end end - %% ===== LIST KEY TYPED CALLBACK ===== + %% ===== ELECTRODE LIST KEY TYPED CALLBACK ===== function ElecListKeyTyped_Callback(h, ev) switch(uint8(ev.getKeyChar())) % DELETE @@ -285,7 +327,7 @@ function ElecListKeyTyped_Callback(h, ev) end end - %% ===== LIST CLICK CALLBACK ===== + %% ===== ELECTRODE LIST CLICK CALLBACK ===== function ElecListClick_Callback(h, ev) % If DOUBLE CLICK if (ev.getClickCount() == 2) @@ -293,6 +335,14 @@ function ElecListClick_Callback(h, ev) EditElectrodeLabel(); end end + + %% ===== CONTACT LIST CHANGED CALLBACK ===== + function ContListChanged_Callback(h, ev) + ctrl = bst_get('PanelControls', 'iEEG'); + sContacts = GetSelectedContacts(); + bst_figures('SetSelectedRows', {sContacts.Name}); + SetMriCrosshair(sContacts); + end end @@ -312,12 +362,13 @@ function UpdatePanel() if isempty(ctrl) return; end - % Get current electrodes - [sElectrodes, iDS, iFig, hFig] = GetElectrodes(); + % Get current figure + hFig = bst_figures('GetCurrentFigure'); % If a surface is available for current figure if ~isempty(hFig) gui_enable([ctrl.jPanelElecList, ctrl.jToolbar], 1); ctrl.jListElec.setBackground(java.awt.Color(1,1,1)); + ctrl.jListCont.setBackground(java.awt.Color(1,1,1)); % Else: no figure associated with the panel : disable all controls else gui_enable([ctrl.jPanelElecList, ctrl.jToolbar], 0); @@ -336,6 +387,7 @@ function UpdatePanel() % gui_enable(ctrl.jPanelElecOptions, 0); % Update JList UpdateElecList(); + UpdateContactList('SCS'); end @@ -403,6 +455,102 @@ function UpdateElecList() java_setcb(ctrl.jListElec, 'ValueChangedCallback', callbackBak); end +%% ===== UPDATE CONTACT LIST ===== +function UpdateContactList(varargin) + import org.brainstorm.list.*; + global GlobalData + % Get panel controls + ctrl = bst_get('PanelControls', 'iEEG'); + if isempty(ctrl) + return; + end + % Get coordinate space from ctrls + if nargin < 1 || isempty(varargin{1}) + CoordSpace = 'scs'; + if ctrl.jRadioMni.isSelected() + CoordSpace = 'mni'; + elseif ctrl.jRadioMri.isSelected() + CoordSpace = 'mri'; + elseif ctrl.jRadioWorld.isSelected() + CoordSpace = 'world'; + end + else + CoordSpace = varargin{1}; + end + + % Get selected electrodes + [sSelElec, ~, iDS] = GetSelectedElectrodes(); + if isempty(sSelElec) + SelName = []; + sSelContacts = []; + else + SelName = sSelElec(end).Name; + % Get selected contacts + sSelContacts = GetSelectedContacts(); + end + + % Create a new empty list + listModel = java_create('javax.swing.DefaultListModel'); + % Get font with which the list is rendered + fontSize = round(11 * bst_get('InterfaceScaling') / 100); + jFont = java.awt.Font('Dialog', java.awt.Font.PLAIN, fontSize); + tk = java.awt.Toolkit.getDefaultToolkit(); + % Add an item in list for each electrode + Wmax = 0; + + % Get the contacts for selected electrodes + sContacts = GetContacts(SelName); + if isempty(sContacts) + ctrl.jListCont.setModel(listModel); + return; + end + % Convert contact coodinates + if ~strcmpi('scs', CoordSpace) + listModel.addElement(BstListItem('', [], 'Updating', 1)); + ctrl.jListCont.setModel(listModel); + sSubject = bst_get('Subject', GlobalData.DataSet(iDS(1)).SubjectFile); + MriFile = sSubject.Anatomy(sSubject.iAnatomy).FileName; + sMri = bst_memory('LoadMri', MriFile); + contacLocsMm = cs_convert(sMri, 'scs', lower(CoordSpace), [sContacts.Loc]') * 1000; + switch lower(CoordSpace) + case 'mni', ctrl.jRadioMni.setSelected(1); + case 'mri', ctrl.jRadioMri.setSelected(1); + case 'world', ctrl.jRadioWorld.setSelected(1); + end + listModel.clear(); + ctrl.jListCont.setModel(listModel); + else + contacLocsMm = [sContacts.Loc]' * 1000; + ctrl.jRadioScs.setSelected(1); + end + % Udpate list content + if isempty(contacLocsMm) + % Requested coordinates system is not available + itemText = 'Not available'; + listModel.addElement(BstListItem('', [], itemText, 1)); + Wmax = tk.getFontMetrics(jFont).stringWidth(itemText); + else + for i = 1:length(sContacts) + itemText = sprintf('%s %3.2f %3.2f %3.2f', sContacts(i).Name, contacLocsMm(i,:)); + listModel.addElement(BstListItem('', [], itemText, i)); + % Get longest string + W = tk.getFontMetrics(jFont).stringWidth(itemText); + if (W > Wmax) + Wmax = W; + end + end + end + + ctrl.jListCont.setModel(listModel); + % Update cell rederer based on longest channel name + ctrl.jListCont.setCellRenderer(java_create('org.brainstorm.list.BstClusterListRenderer', 'II', fontSize, Wmax + 28)); + ctrl.jListCont.repaint(); + drawnow; + % Seletect previously selected contacts + if ~isempty(sSelContacts) + SetSelectedContacts({sSelContacts.Name}); + end +end %% ===== UPDATE MODEL LIST ===== function UpdateModelList(elecType) @@ -608,10 +756,20 @@ function UpdateElecProperties(isUpdateModelList) ctrl.jLabelSelectElec.setText(num2str(iSelElec)); end +%% ===== SET CROSSHAIR POSITION ON MRI ===== +function SetMriCrosshair(sSelContacts) %#ok + % Get the handles + hFig = bst_figures('GetFiguresByType', {'MriViewer'}); + if isempty(hFig) || isempty(sSelContacts) + return + end + % Update the cross-hair position on the MRI + figure_mri('SetLocation', 'scs', hFig, [], [sSelContacts(end).Loc]); +end %% ===== GET SELECTED ELECTRODES ===== function [sSelElec, iSelElec, iDS, iFig, hFig] = GetSelectedElectrodes() - sSelElec = []; + sSelElec = repmat(db_template('intraelectrode'), 0); iSelElec = []; iDS = []; iFig = []; @@ -631,6 +789,25 @@ function UpdateElecProperties(isUpdateModelList) sSelElec = sElectrodes(iSelElec); end +%% ===== GET SELECTED CONTACTS ===== +function sSelContacts = GetSelectedContacts() + sSelContacts = repmat(db_template('intracontact'), 0); + % Get panel handles + ctrl = bst_get('PanelControls', 'iEEG'); + if isempty(ctrl) + return; + end + % Get all contacts + sSelElec = GetSelectedElectrodes(); + sContacts = GetContacts(sSelElec(end).Name); + if isempty(sContacts) + return + end + % Get JList selected indices + iSelCont = uint16(ctrl.jListCont.getSelectedIndices())' + 1; + sSelContacts = sContacts(iSelCont); +end + %% ===== SET SELECTED ELECTRODES ===== % USAGE: SetSelectedElectrodes(iSelElec) % array of indices @@ -691,8 +868,67 @@ function SetSelectedElectrodes(iSelElec) java_setcb(ctrl.jListElec, 'ValueChangedCallback', jListCallback_bak); % Update panel fields UpdateElecProperties(); + UpdateContactList(); end +%% ===== SET SELECTED CONTACT ===== +% USAGE: SetSelectedContacts(iSelElec) % array index +% SetSelectedContacts(SelElecNames) % cell array of name +% Limitation: perform operation on one contact not multiple +function SetSelectedContacts(iSelCont) + % === GET CONTACT INDICES === + % Get figure controls + ctrl = bst_get('PanelControls', 'iEEG'); + if isempty(ctrl) || isempty(ctrl.jListCont) + return + end + % No selection + if isempty(iSelCont) || (isnumeric(iSelCont) && any(iSelCont == 0)) + iSelItem = -1; + % Select by name + elseif iscell(iSelCont) || ischar(iSelCont) + % Get list of electrode names + if iscell(iSelCont) + SelContNames = iSelCont; + else + SelContNames = {iSelCont}; + end + % Find the requested channels in the JList + listModel = ctrl.jListCont.getModel(); + iSelItem = []; + for i = 1:listModel.getSize() + itemNameParts = str_split(char(listModel.getElementAt(i-1)), ' '); + if ismember(itemNameParts{1}, SelContNames) + iSelItem(end+1) = i - 1; + end + end + if isempty(iSelItem) + iSelItem = -1; + end + % Find the selected electrode in the JList + else + iSelItem = iSelCont - 1; + end + % === CHECK FOR MODIFICATIONS === + % Get previous selection + iPrevItems = ctrl.jListCont.getSelectedIndices(); + % If selection did not change: exit + if isequal(iPrevItems, iSelItem) || (isempty(iPrevItems) && isequal(iSelItem, -1)) + return + end + + % === UPDATE SELECTION === + % Select items in JList + ctrl.jListCont.setSelectedIndices(iSelItem); + % Scroll to see the last selected electrode in the list + if (length(iSelItem) >= 1) && ~isequal(iSelItem, -1) + selRect = ctrl.jListCont.getCellBounds(iSelItem(end), iSelItem(end)); + ctrl.jListCont.scrollRectToVisible(selRect); + ctrl.jListCont.repaint(); + end + sContacts = GetSelectedContacts(); + SetMriCrosshair(sContacts); +end %% ===== SHOW CONTACTS MENU ===== function ShowContactsMenu(jButton) @@ -713,6 +949,7 @@ function ShowContactsMenu(jButton) gui_component('MenuItem', jMenu, [], 'Project on cortex', IconLoader.ICON_SEEG_DEPTH, [], @(h,ev)bst_call(@ProjectContacts, iDS(1), iFig(1), 'cortexmask')); elseif strcmpi(sSelElec(1).Type, 'SEEG') gui_component('MenuItem', jMenu, [], 'Project on electrode', IconLoader.ICON_SEEG_DEPTH, [], @(h,ev)bst_call(@AlignContacts, iDS, iFig, 'project')); + gui_component('MenuItem', jMenu, [], 'Show/Hide line fit through contacts', IconLoader.ICON_SEEG_DEPTH, [], @(h,ev)bst_call(@AlignContacts, iDS, iFig, 'lineFit')); end % Menu: Save modifications jMenu.addSeparator(); @@ -945,6 +1182,15 @@ function SetElectrodeVisible(isVisible) % Update electrode color for i = 1:length(sSelElec) sSelElec(i).Visible = isVisible; + % show/hide any line fitting + hCoord = findobj(0, 'Tag', sSelElec(i).Name); + if ~isempty(hCoord) + if isVisible + set(hCoord, 'Visible', 'on'); + else + set(hCoord, 'Visible', 'off'); + end + end end % Save electrodes SetElectrodes(iSelElec, sSelElec); @@ -962,10 +1208,11 @@ function SetElectrodeVisible(isVisible) global GlobalData; % Get current figure [hFigall,iFigall,iDSall] = bst_figures('GetCurrentFigure'); + % Check if there are electrodes defined for this file - if isempty(hFigall) || isempty(GlobalData.DataSet(iDSall).IntraElectrodes) || isempty(GlobalData.DataSet(iDSall).ChannelFile) + if isempty(hFigall) || isempty(hFigall(end)) || isempty(GlobalData.DataSet(iDSall(end)).IntraElectrodes) || isempty(GlobalData.DataSet(iDSall(end)).ChannelFile) sElectrodes = []; - return; + return end % Return all the available electrodes sElectrodes = GlobalData.DataSet(iDSall).IntraElectrodes; @@ -987,6 +1234,29 @@ function SetElectrodeVisible(isVisible) end end +%% ===== GET CONTACTS FOR AN ELECTRODE ===== %% +function sContacts = GetContacts(selectedElecName) + global GlobalData; + + sContacts = repmat(db_template('intracontact'), 0); + % Get current figure + [hFigall,iFigall,iDSall] = bst_figures('GetCurrentFigure'); + % Check if there are electrodes defined for this file + if isempty(hFigall) || isempty(GlobalData.DataSet(iDSall).IntraElectrodes) || isempty(GlobalData.DataSet(iDSall).ChannelFile) || isempty(selectedElecName) + return; + end + % Get the channel data + ChannelData = GlobalData.DataSet(iDSall).Channel; + % Replace empty Group with '' + [ChannelData(cellfun('isempty', {ChannelData.Group})).Group] = deal(''); + % Get the contacts for the electrode + iChannels = find(ismember({ChannelData.Group}, selectedElecName)); + for i = 1:length(iChannels) + sContacts(i).Name = ChannelData(iChannels(i)).Name; + sContacts(i).Loc = ChannelData(iChannels(i)).Loc; + end +end + %% ===== SET ELECTRODES ===== % USAGE: iElec = SetElectrodes(iElec=[], sElect) @@ -999,6 +1269,7 @@ function SetElectrodeVisible(isVisible) [sElecOld, iDSall] = GetElectrodes(); % If there is no selected dataset if isempty(iDSall) + bst_error('Make sure the MRI Viewer is open with the CT loaded', 'Add Electrode', 0); return; end % Perform operations only once per dataset @@ -1185,6 +1456,11 @@ function RemoveElectrode() end % Mark channel file as modified (only the first one) GlobalData.DataSet(iDSall(1)).isChannelModified = 1; + % remove any line fitting + hCoord = findobj(0, 'Tag', sSelElec.Name); + if ~isempty(hCoord) + delete(hCoord); + end % Update list of electrodes UpdateElecList(); % Update figure @@ -1211,7 +1487,7 @@ function RemoveElectrode() sTemplate.ContactDiameter = 0.0008; sTemplate.ContactLength = 0.002; sTemplate.ElecDiameter = 0.0007; - sTemplate.ElecLength = 0.070; + sTemplate.ElecLength = 0.100; % All models sMod = repmat(sTemplate, 1, 6); sMod(1).Model = 'DIXI D08-05AM Microdeep'; @@ -1251,7 +1527,7 @@ function RemoveElectrode() sMod(5).ContactSpacing = 0.008; sModels = [sModels, sMod]; - % === AD TECH RD10R === + % === AD TECH MM16 SERIES === % Common values sTemplate = db_template('intraelectrode'); sTemplate.Type = 'SEEG'; @@ -1294,6 +1570,45 @@ function RemoveElectrode() sMod(5).ContactNumber = 8; sMod(5).ElecLength = 0.0545; sModels = [sModels, sMod]; + + % === PMT SEEG DEPTHALON ELECTRODES === + % Common values + sTemplate = db_template('intraelectrode'); + sTemplate.Type = 'SEEG'; + sTemplate.ContactDiameter = 0.0008; + sTemplate.ContactLength = 0.002; + sTemplate.ElecDiameter = 0.0007; + % All models + sMod = repmat(sTemplate, 1, 7); + sMod(1).Model = 'PMT 2102-08-091/2102-08-101'; + sMod(1).ContactNumber = 8; + sMod(1).ContactSpacing = 0.0035; + sMod(1).ElecLength = 0.0265; + sMod(2).Model = 'PMT 2102-10-091/2102-10-101'; + sMod(2).ContactNumber = 10; + sMod(2).ContactSpacing = 0.0035; + sMod(2).ElecLength = 0.0335; + sMod(3).Model = 'PMT 2102-12-091/2102-12-101'; + sMod(3).ContactNumber = 12; + sMod(3).ContactSpacing = 0.0035; + sMod(3).ElecLength = 0.0405; + sMod(4).Model = 'PMT 2102-14-091/2102-14-101'; + sMod(4).ContactNumber = 14; + sMod(4).ContactSpacing = 0.0035; + sMod(4).ElecLength = 0.0475; + sMod(5).Model = 'PMT 2102-16-091/2102-16-101'; + sMod(5).ContactNumber = 16; + sMod(5).ContactSpacing = 0.0035; + sMod(5).ElecLength = 0.0545; + sMod(6).Model = 'PMT 2102-16-092/2102-16-102'; + sMod(6).ContactNumber = 16; + sMod(6).ContactSpacing = 0.00397; + sMod(6).ElecLength = 0.0615; + sMod(7).Model = 'PMT 2102-16-093/2102-16-103'; + sMod(7).ContactNumber = 16; + sMod(7).ContactSpacing = 0.00443; + sMod(7).ElecLength = 0.0685; + sModels = [sModels, sMod]; end end @@ -1343,67 +1658,72 @@ function SetSelectedModel(selModel) end %% ===== ADD ELECTRODE MODEL ===== -function AddElectrodeModel() +function AddElectrodeModel(sNewModel) global GlobalData; % Get figure controls ctrl = bst_get('PanelControls', 'iEEG'); if isempty(ctrl) || isempty(ctrl.jListElec) return end - % === ECOG === - if ctrl.jRadioEcog.isSelected() || ctrl.jRadioEcogMid.isSelected() - % Ask for all the elecgtrode options - res = java_dialog('input', {... - 'Manufacturer and model (ECOG):', ... - 'Number of contacts:', ... - 'Contact spacing (mm):', ... - 'Contact height (mm):', ... - 'Contact diameter (mm):', ... - 'Wire width (points):'}, 'Add new model', [], ... - {'', '', '3.5', '0.8', '2', '0.5'}); - if isempty(res) || isempty(res{1}) - return; + % If sNewModel is not provided, ask the user + if (nargin < 1) || isempty(sNewModel) + % === ECOG === + if ctrl.jRadioEcog.isSelected() || ctrl.jRadioEcogMid.isSelected() + % Ask for all the electrode options + res = java_dialog('input', {... + 'Manufacturer and model (ECOG):', ... + 'Number of contacts:', ... + 'Contact spacing (mm):', ... + 'Contact height (mm):', ... + 'Contact diameter (mm):', ... + 'Wire width (points):'}, 'Add new model', [], ... + {'', '', '3.5', '0.8', '2', '0.5'}); + if isempty(res) || isempty(res{1}) + return; + end + % Get all the values + sNew = db_template('intraelectrode'); + % if ctrl.jRadioEcog.isSelected() + % sNew.Type = 'ECOG'; + % elseif ctrl.jRadioEcogMid.isSelected() + % sNew.Type = 'ECOG-mid'; + % end + sNew.Type = 'ECOG'; + sNew.Model = res{1}; + sNew.ContactNumber = str2num(res{2}); + sNew.ContactSpacing = str2num(res{3}) ./ 1000; + sNew.ContactLength = str2num(res{4}) ./ 1000; + sNew.ContactDiameter = str2num(res{5}) ./ 1000; + sNew.ElecDiameter = str2num(res{6}) ./ 1000; + sNew.ElecLength = 0; + % === SEEG === + else + % Ask for all the electrode options + res = java_dialog('input', {... + 'Manufacturer and model (SEEG):', ... + 'Number of contacts:', ... + 'Contact spacing (mm):', ... + 'Contact length (mm):', ... + 'Contact diameter (mm):', ... + 'Electrode diameter (mm):', ... + 'Electrode length (mm):'}, 'Add new model', [], ... + {'', '', '3.5', '2', '0.8', '0.7', '70'}); + if isempty(res) || isempty(res{1}) + return; + end + % Get all the values + sNew = db_template('intraelectrode'); + sNew.Model = res{1}; + sNew.Type = 'SEEG'; + sNew.ContactNumber = str2num(res{2}); + sNew.ContactSpacing = str2num(res{3}) ./ 1000; + sNew.ContactLength = str2num(res{4}) ./ 1000; + sNew.ContactDiameter = str2num(res{5}) ./ 1000; + sNew.ElecDiameter = str2num(res{6}) ./ 1000; + sNew.ElecLength = str2num(res{7}) ./ 1000; end - % Get all the values - sNew = db_template('intraelectrode'); -% if ctrl.jRadioEcog.isSelected() -% sNew.Type = 'ECOG'; -% elseif ctrl.jRadioEcogMid.isSelected() -% sNew.Type = 'ECOG-mid'; -% end - sNew.Type = 'ECOG'; - sNew.Model = res{1}; - sNew.ContactNumber = str2num(res{2}); - sNew.ContactSpacing = str2num(res{3}) ./ 1000; - sNew.ContactLength = str2num(res{4}) ./ 1000; - sNew.ContactDiameter = str2num(res{5}) ./ 1000; - sNew.ElecDiameter = str2num(res{6}) ./ 1000; - sNew.ElecLength = 0; - % === SEEG === else - % Ask for all the elecgtrode options - res = java_dialog('input', {... - 'Manufacturer and model (SEEG):', ... - 'Number of contacts:', ... - 'Contact spacing (mm):', ... - 'Contact length (mm):', ... - 'Contact diameter (mm):', ... - 'Electrode diameter (mm):', ... - 'Electrode length (mm):'}, 'Add new model', [], ... - {'', '', '3.5', '2', '0.8', '0.7', '70'}); - if isempty(res) || isempty(res{1}) - return; - end - % Get all the values - sNew = db_template('intraelectrode'); - sNew.Model = res{1}; - sNew.Type = 'SEEG'; - sNew.ContactNumber = str2num(res{2}); - sNew.ContactSpacing = str2num(res{3}) ./ 1000; - sNew.ContactLength = str2num(res{4}) ./ 1000; - sNew.ContactDiameter = str2num(res{5}) ./ 1000; - sNew.ElecDiameter = str2num(res{6}) ./ 1000; - sNew.ElecLength = str2num(res{7}) ./ 1000; + sNew = sNewModel; end % Get available models sModels = GetElectrodeModels(); @@ -1449,6 +1769,117 @@ function RemoveElectrodeModel() end +%% ===== SAVE ELECTRODE MODEL ===== +function SaveElectrodeModel() + % Get panel controls + ctrl = bst_get('PanelControls', 'iEEG'); + if isempty(ctrl) || isempty(ctrl.jListElec) + return + end + % Get selected model + [iModel, sModels] = GetSelectedModel(); + if isempty(iModel) + return; + end + % Build a default file name + LastUsedDirs = bst_get('LastUsedDirs'); + ModelFile = bst_fullfile(LastUsedDirs.ExportChannel, ['intraelectrode_', file_standardize(sModels(iModel).Model), '.mat']); + % Get filename where to store the filename + [ModelFile, FileFormat] = java_getfile('save', 'Save selected electrode model', ModelFile, ... + 'single', 'files', ... + {{'_model'}, 'Brainstorm intracranial electrode model (*intraelectrode*.mat)', 'BST'}, 1); + if isempty(ModelFile) + return; + end + % Save last used folder + LastUsedDirs.ExportChannel = bst_fileparts(ModelFile); + bst_set('LastUsedDirs', LastUsedDirs); + % Switch file format + switch (FileFormat) + case 'BST' + % Make sure that filename contains the 'intraelectrode' tag + if isempty(strfind(ModelFile, '_intraelectrode')) && isempty(strfind(ModelFile, 'intraelectrode_')) + [filePath, fileBase, fileExt] = bst_fileparts(ModelFile); + ModelFile = bst_fullfile(filePath, ['intraelectrode_' fileBase fileExt]); + end + % Save file + bst_save(ModelFile, sModels(iModel), 'v7'); + end +end + + +%% ===== LOAD ELECTRODE MODEL ===== +function LoadElectrodeModel() + % Get panel controls + ctrl = bst_get('PanelControls', 'iEEG'); + if isempty(ctrl) || isempty(ctrl.jListElec) + return + end + % Get last used folder + LastUsedDirs = bst_get('LastUsedDirs'); + % Get label files + [ModelFiles, FileFormat] = java_getfile( 'open', ... + 'Import intracranial electrode models...', ... % Window title + LastUsedDirs.ImportChannel, ... % Default directory + 'multiple', 'files', ... % Selection mode + {{'_intraelectrode'}, 'Brainstorm intracranial electrode model (*intraelectrode*.mat)', 'BST'}, ... + 'BST'); + % If no file was selected: exit + if isempty(ModelFiles) + return + end + % Save last used dir + LastUsedDirs.ImportChannel = bst_fileparts(ModelFiles{1}); + bst_set('LastUsedDirs', LastUsedDirs); + for iFile = 1 : length(ModelFiles) + switch FileFormat + case 'BST' + % Load file + sModel = load(ModelFiles{iFile}); + % Add electrode model + AddElectrodeModel(sModel); + fprintf(1, 'Intracranial electrode model "%s" was loaded\n', sModel.Model); + end + end +end + + +%% ===== EXPORT ELECTRODE MODEL ===== +function ExportElectrodeModel() + % Get panel controls + ctrl = bst_get('PanelControls', 'iEEG'); + if isempty(ctrl) || isempty(ctrl.jListElec) + return + end + % Get selected model + [iModel, sModels] = GetSelectedModel(); + if isempty(iModel) + return; + end + % Export to the base workspace + export_matlab(sModels(iModel), []); +end + + +%% ===== IMPORT ELECTRODE MODEL ===== +function ImportElectrodeModel() + % Import from base workspace + sModel = in_matlab_var([], 'struct'); + if isempty(sModel) + return; + end + % Check structure + sTemplate = db_template('intraelectrode'); + if ~isequal(fieldnames(sModel), fieldnames(sTemplate)) + bst_error('Invalid intracranial electrode model structure.', 'Import from Matlab', 0); + return; + end + % Add electrode model + AddElectrodeModel(sModel); + fprintf(1, 'Intracranial electrode model "%s" was imported\n', sModel.Model); +end + + %% ===== UPDATE FIGURE TYPE ===== function UpdateFigureModality(iDS, iFig) global GlobalData; @@ -1546,7 +1977,7 @@ function UpdateFigures(hFigTarget) %% ===== SET DISPLAY MODE ===== function SetDisplayMode(DisplayMode) % Get current figure - [sElectrodes, iDS, iFig, hFig] = GetElectrodes(); + hFig = bst_figures('GetCurrentFigure'); if isempty(hFig) return; end @@ -1559,7 +1990,7 @@ function SetDisplayMode(DisplayMode) end -%% ===== DETECT ELETRODES ===== +%% ===== DETECT ELECTRODES ===== function [ChannelMat, ChanOrient, ChanLocFix] = DetectElectrodes(ChannelMat, Modality, AllInd, isUpdate) %#ok % Parse inputs if (nargin < 4) || isempty(isUpdate) @@ -1769,8 +2200,9 @@ function SetDisplayMode(DisplayMode) % === SPHERE === if (strcmpi(ElectrodeDisplay.DisplayMode, 'sphere') || (strcmpi(sElec.Type, 'ECOG') && ~isSurface) || strcmpi(sElec.Type, 'ECOG-mid')) && ~isempty(sElec.ContactDiameter) && (sElec.ContactDiameter > 0) && ~isempty(sElec.ContactLength) && (sElec.ContactLength > 0) && isValidLoc % Contact size and orientation + % Define radius of the sphere; Using ctSize of half the length, makes the sphere to have the same diameters as the contact length, thus spacing between spheres is the same as the space between contacts if strcmpi(sElec.Type, 'SEEG') - ctSize = [1 1 1] .* sElec.ContactLength; + ctSize = [1 1 1] .* sElec.ContactLength ./ 2; else ctSize = [1 1 1] .* sElec.ContactDiameter ./ 2; end @@ -1942,7 +2374,6 @@ function SetDisplayMode(DisplayMode) % Force Gouraud lighting FaceLighting = 'gouraud'; end - % Create contacts geometry [ctVertex, ctFaces] = Plot3DContacts(tmpVertex, tmpFaces, ctSize, ChanLoc(iChanOther,:), ctOrient); % Display properties ctColor = [.9,.9,0]; % YELLOW @@ -2204,6 +2635,10 @@ function SetDisplayMode(DisplayMode) elecTip = sElectrodes(iElec).Loc(:,1); orient = (sElectrodes(iElec).Loc(:,2) - elecTip); orient = orient ./ sqrt(sum(orient .^ 2)); + % for line fitting + linePlot.X = []; + linePlot.Y = []; + linePlot.Z = []; % Process each contact for i = 1:length(iChan) switch (Method) @@ -2213,8 +2648,16 @@ function SetDisplayMode(DisplayMode) case 'project' % Project the existing contact on the depth electrode Channels(iChan(i)).Loc = elecTip + sum(orient .* (Channels(iChan(i)).Loc - elecTip)) .* orient; + case 'lineFit' + linePlot.X = [linePlot.X, Channels(iChan(i)).Loc(1)]; + linePlot.Y = [linePlot.Y, Channels(iChan(i)).Loc(2)]; + linePlot.Z = [linePlot.Z, Channels(iChan(i)).Loc(3)]; end end + + if strcmpi(Method, 'lineFit') + LineFit(linePlot, Channels(iChan(1)).Group); + end % === ECOG STRIPS === elseif (ismember(Modality, {'ECOG','ECOG-mid'}) && (length(sElectrodes(iElec).ContactNumber) == 1)) @@ -2438,28 +2881,54 @@ function ProjectContacts(iDS, iFig, SurfaceType) UpdateFigures(); end +%% ===== DRAW REFERENCE ELECTRODE ===== +% perform line fitting between contacts +function LineFit(plotLoc, Tag) + % Get axes handle + hFig = bst_figures('GetFiguresByType', '3DViz'); + hAxes = findobj(hFig, '-depth', 1, 'Tag', 'Axes3D'); + hCoord = findobj(hAxes, '-depth', 1, 'Tag', Tag); + if ~isempty(hCoord) + delete(hCoord); + else + % plot the reference line between tip and entry + line(plotLoc.X, plotLoc.Y, plotLoc.Z, ... + 'Color', [1 1 0], ... + 'LineWidth', 2, ... + 'Parent', hAxes, ... + 'Tag', Tag); + end +end %% ===== SET ELECTRODE LOCATION ===== function SetElectrodeLoc(iLoc, jButton) global GlobalData; + % Get selected electrodes [sSelElec, iSelElec, iDS, iFig, hFig] = GetSelectedElectrodes(); + MriIdx = 1; + if isempty(sSelElec) bst_error('No electrode seleced.', 'Set electrode position', 0); return; elseif (length(sSelElec) > 1) bst_error('Multiple electrodes selected.', 'Set electrode position', 0); return; - elseif ~strcmpi(GlobalData.DataSet(iDS(1)).Figure(iFig(1)).Id.Type, 'MriViewer') - bst_error('Position must be set from the MRI viewer.', 'Set electrode position', 0); - return; + elseif ~strcmpi(GlobalData.DataSet(iDS(MriIdx)).Figure(iFig(MriIdx)).Id.Type, 'MriViewer') + if length(hFig) == 1 + bst_error('MRI viewer must be open', 'Set electrode position', 0); + return; + end + MriIdx = 2; elseif (size(sSelElec.Loc, 2) < iLoc-1) - bst_error('Set the previous reference point first.', 'Set electrode position', 0); + bst_error('Set the previous reference point (the tip) first.', 'Set electrode position', 0); return; end - % Get selected coordinates - sMri = panel_surface('GetSurfaceMri', hFig(1)); - XYZ = figure_mri('GetLocation', 'scs', sMri, GlobalData.DataSet(iDS(1)).Figure(iFig(1)).Handles); + + + sMri = panel_surface('GetSurfaceMri', hFig(MriIdx)); + XYZ = figure_mri('GetLocation', 'scs', sMri, GlobalData.DataSet(iDS(MriIdx)).Figure(iFig(MriIdx)).Handles); + % If SCS coordinates are not available if isempty(XYZ) % Ask to compute MNI transformation @@ -2572,22 +3041,49 @@ function CenterMriOnElectrode(sElec, hFigTarget) end -%% ===== CREATE NEW IMPLANTATION ===== -function CreateNewImplantation(MriFile) %#ok - % Find subject - [sSubject,iSubject,iAnatomy] = bst_get('MriFile', MriFile); +%% ===== CREATE IMPLANTATION ===== +% USAGE: CreateImplantation(MriFile) % Implantation on given Volume file +% CreateImplantation(sSubject) % Ask user for Volume and Surface files for implantation +function CreateImplantation(MriFile) %#ok + % Parse input + if isstruct(MriFile) + sSubject = MriFile; + MriFiles = []; + else + sSubject = bst_get('MriFile', MriFile); + MriFiles = {MriFile}; + end % Get study for the new channel file switch (sSubject.UseDefaultChannel) case 0 - % Get new folder "Implantation" - ProtocolInfo = bst_get('ProtocolInfo'); - ImplantFolder = file_unique(bst_fullfile(ProtocolInfo.STUDIES, sSubject.Name, 'Implantation')); - [tmp, Condition] = bst_fileparts(ImplantFolder); - % Create new folder - iStudy = db_add_condition(sSubject.Name, Condition, 1); + % Get folder "Implantation" + conditionName = 'Implantation'; + [sStudy, iStudy] = bst_get('StudyWithCondition', bst_fullfile(sSubject.Name, conditionName)); + if ~isempty(sStudy) + [res, isCancel] = java_dialog('question', ['Warning: there is already an "Implantation" folder for this Subject.' 10 10 ... + 'What do you want to do with the existing implantation?'], ... + 'SEEG/ECOG implantation', [], {'Continue', 'Replace', 'Cancel'}, 'Continue'); + if strcmpi(res, 'cancel') || isCancel + return + elseif strcmpi(res, 'continue') + newCondition = 0; + elseif strcmpi(res, 'replace') + % Delete existing Implantation study + db_delete_studies(iStudy); + newCondition = 1; + end + else + newCondition = 1; + end + % Create new folder if needed + if newCondition + iStudy = db_add_condition(sSubject.Name, conditionName, 1); + end + % Get Implantation study + sStudy = bst_get('Study', iStudy); case 1 % Use default channel file - [sStudy, iStudy] = bst_get('AnalysisIntraStudy', iSubject); + [sStudy, iStudy] = bst_get('AnalysisIntraStudy', sSubject.Name); % The @intra study must not contain an existing channel file if ~isempty(sStudy.Channel) && ~isempty(sStudy.Channel(1).FileName) error(['There is already a channel file for this subject:' 10 sStudy.Channel(1).FileName]); @@ -2595,36 +3091,192 @@ function CreateNewImplantation(MriFile) %#ok case 2 error('The subject uses a shared channel file, it should not be edited in this way.'); end + + % Ask user about implantation volume and surface files + iVol1 = []; + iVol2 = []; + iSrf = []; + if isempty(MriFiles) + if isempty(sSubject.Anatomy) + return + end + iMriVol = sSubject.iAnatomy; + iCtVol = find(cellfun(@(x) ~isempty(regexp(x, '_volct', 'match')), {sSubject.Anatomy.FileName})); + iIsoSrf = find(cellfun(@(x) ~isempty(regexp(x, '_isosurface', 'match')), {sSubject.Surface.FileName})); + iMriVol = setdiff(iMriVol, iCtVol); + impOptions = {}; + if ~isempty(iMriVol) + impOptions = [impOptions, {'MRI'}]; + end + if ~isempty(iCtVol) + impOptions = [impOptions, {'CT'}]; + end + if ~isempty(iMriVol) && ~isempty(iCtVol) + impOptions = [impOptions, {'MRI+CT'}]; + end + if ~isempty(iCtVol) && ~isempty(iIsoSrf) + tmpOption = 'CT+IsoSurf'; + if ~isempty(iMriVol) + tmpOption = ['MRI+' tmpOption]; + end + impOptions = [impOptions, {tmpOption}]; + end + impOptions = [impOptions, {'Cancel'}]; + % User dialog + [res, isCancel] = java_dialog('question', ['There are multiple volumes for this Subject.' 10 10 ... + 'How do you want to continue with the existing implantation?'], ... + 'SEEG/ECOG implantation', [], impOptions, 'Cancel'); + if strcmpi(res, 'cancel') || isCancel + return + end + switch lower(res) + case 'mri' + iVol1 = iMriVol; + case 'ct' + iVol1 = iCtVol; + case 'mri+ct' + iVol1 = iMriVol; + iVol2 = iCtVol; + case 'mri+ct+isosurf' + iVol1 = iMriVol; + iVol2 = iCtVol; + iSrf = iIsoSrf; + case 'ct+isosurf' + iVol1 = iCtVol; + iVol2 = []; + iSrf = iIsoSrf; + end + % Get CT from IsoSurf % TODO do not assume there is only one IsoSurf + if ~isempty(iSrf) + sSurf = load(file_fullpath(sSubject.Surface(iSrf).FileName), 'History'); + if isfield(sSurf, 'History') && ~isempty(sSurf.History) + % Search for CT threshold in history + ctEntry = regexp(sSurf.History{:, 3}, '^Thresholded CT:\s(.*)\sthreshold.*$', 'tokens', 'once'); + % Return intersection of the found and then update iCtVol + if ~isempty(ctEntry) + [~, iCtIso] = ismember(ctEntry{1}, {sSubject.Anatomy.FileName}); + if iCtIso + iCtVol = intersect(iCtIso, iCtVol); + else + bst_error(sprintf(['The CT that was used to create the IsoSurface cannot be found. ' 10 ... + 'CT file : %s'], ctEntry{1}), 'Loading CT for IsoSurface'); + return + end + end + end + end + if ~strcmpi(res, 'mri') && length(iCtVol) > 1 + % Prompt for the CT file selection + ctComment = java_dialog('combo', 'Select the CT file:

', 'Choose CT file', [], {sSubject.Anatomy(iCtVol).Comment}); + if isempty(ctComment) + return + end + [~, ix] = ismember(ctComment, {sSubject.Anatomy(iCtVol).Comment}); + iCtVol = iCtVol(ix); + end + % Update vol1 or vol2 to have single CT + switch lower(res) + case {'mri+ct', 'mri+ct+isosurf'} + iVol2 = iCtVol; + case {'ct', 'ct+isosurf'} + iVol1 = iCtVol; + end + % Get Volume filenames + if ~isempty(iVol1) + MriFiles{1} = sSubject.Anatomy(iVol1).FileName; + end + if ~isempty(iVol2) + MriFiles{2} = sSubject.Anatomy(iVol2).FileName; + end + end + % Progress bar bst_progress('start', 'Implantation', 'Updating display...'); - % Create empty channel file structure - ChannelMat = db_template('channelmat'); - ChannelMat.Comment = 'SEEG/ECOG'; - ChannelMat.Channel = repmat(db_template('channeldesc'), 1, 0); - % Save new channel in the database - ChannelFile = db_set_channel(iStudy, ChannelMat, 0, 0); + % Channel file + if isempty(sStudy.Channel) || isempty(sStudy.Channel(1).FileName) + % Create empty channel file structure + ChannelMat = db_template('channelmat'); + ChannelMat.Comment = 'SEEG/ECOG'; + ChannelMat.Channel = repmat(db_template('channeldesc'), 1, 0); + % Save new channel in the database + ChannelFile = db_set_channel(iStudy, ChannelMat, 0, 0); + else + % Get channel file from existent study + ChannelFile = sStudy.Channel(1).FileName; + end % Switch to functional data gui_brainstorm('SetExplorationMode', 'StudiesSubj'); % Select new file panel_protocols('SelectNode', [], ChannelFile); - % Display channels - DisplayChannelsMri(ChannelFile, 'SEEG', iAnatomy); + % Display channels on MRI viewer + DisplayChannelsMri(ChannelFile, 'SEEG', MriFiles, 0); + if ~isempty(iSrf) + % Display isosurface + DisplayIsosurface(sSubject, iSrf, [], ChannelFile, 'SEEG'); + end % Close progress bar bst_progress('stop'); end +%% ===== LOAD ELECTRODES ===== +function LoadElectrodes(hFig, ChannelFile, Modality) %#ok + global GlobalData; + % Get figure and dataset + [hFig,iFig,iDS] = bst_figures('GetFigure', hFig); + if isempty(iDS) + return; + end + % Check that the channel is not already defined + if ~isempty(GlobalData.DataSet(iDS).ChannelFile) && ~file_compare(GlobalData.DataSet(iDS).ChannelFile, ChannelFile) + error('There is already another channel file loaded for this MRI. Close the existing figures.'); + end + % Load channel file in the dataset + bst_memory('LoadChannelFile', iDS, ChannelFile); + % If iEEG channels: load both SEEG and ECOG + if ismember(Modality, {'SEEG', 'ECOG', 'ECOG+SEEG'}) + iChannels = channel_find(GlobalData.DataSet(iDS).Channel, 'SEEG, ECOG'); + else + iChannels = channel_find(GlobalData.DataSet(iDS).Channel, Modality); + end + % Set the list of selected sensors + GlobalData.DataSet(iDS).Figure(iFig).SelectedChannels = iChannels; + GlobalData.DataSet(iDS).Figure(iFig).Id.Modality = Modality; + % Plot electrodes + if ~isempty(iChannels) + switch(GlobalData.DataSet(iDS).Figure(iFig).Id.Type) + case 'MriViewer' + GlobalData.DataSet(iDS).Figure(iFig).Handles = figure_mri('PlotElectrodes', iDS, iFig, GlobalData.DataSet(iDS).Figure(iFig).Handles); + figure_mri('PlotSensors3D', iDS, iFig); + case '3DViz' + figure_3d('PlotSensors3D', iDS, iFig); + end + end + + % Set EEG flag for MRI Viewer + if strcmpi(GlobalData.DataSet(iDS).Figure(iFig).Id.Type, 'MriViewer') + figure_mri('SetFigureStatus', hFig, [], [], [], 1, 1); + end + % Update figure name + bst_figures('UpdateFigureName', hFig); +end + + %% ===== DISPLAY CHANNELS (MRI VIEWER) ===== % USAGE: [hFig, iDS, iFig] = DisplayChannelsMri(ChannelFile, Modality, iAnatomy, isEdit=0) % [hFig, iDS, iFig] = DisplayChannelsMri(ChannelFile, Modality, MriFile, isEdit=0) +% [hFig, iDS, iFig] = DisplayChannelsMri(ChannelFile, Modality, {MriFiles}, isEdit=0) function [hFig, iDS, iFig] = DisplayChannelsMri(ChannelFile, Modality, iAnatomy, isEdit) % Parse inputs if (nargin < 4) || isempty(isEdit) isEdit = 0; end - % Get MRI to display - if ischar(iAnatomy) - MriFile = iAnatomy; + + % Get MRI files + if iscell(iAnatomy) + MriFiles = iAnatomy; + elseif ischar(iAnatomy) + MriFiles = {iAnatomy}; else % Get study sStudy = bst_get('ChannelFile', ChannelFile); @@ -2633,16 +3285,27 @@ function CreateNewImplantation(MriFile) %#ok if isempty(sSubject) || isempty(sSubject.Anatomy) bst_error('No MRI available for this subject.', 'Display electrodes', 0); end - % Get MRI file - MriFile = sSubject.Anatomy(iAnatomy).FileName; + % MRI volumes + MriFiles = {sSubject.Anatomy(iAnatomy).FileName}; + end + + % If MRI Viewer is open don't open another one + hFig = bst_figures('GetFiguresByType', 'MriViewer'); + if ~isempty(hFig) + return + end + + % == DISPLAY THE MRI VIEWER + if length(MriFiles) == 1 + [hFig, iDS, iFig] = view_mri(MriFiles{1}, [], [], 2); + else + [hFig, iDS, iFig] = view_mri(MriFiles{1}, MriFiles{2}, [], 2); end - % View MRI - [hFig, iDS, iFig] = view_mri(MriFile, [], [], 2); if isempty(hFig) return; end % Add channels to the figure - figure_mri('LoadElectrodes', hFig, ChannelFile, Modality); + LoadElectrodes(hFig, ChannelFile, Modality); % SEEG and ECOG: Open tab "iEEG" if ismember(Modality, {'SEEG', 'ECOG', 'ECOG+SEEG'}) gui_brainstorm('ShowToolTab', 'iEEG'); @@ -2653,6 +3316,23 @@ function CreateNewImplantation(MriFile) %#ok end end +%% ===== DISPLAY ISOSURFACE ===== +function [hFig, iDS, iFig] = DisplayIsosurface(sSubject, iSurface, hFig, ChannelFile, Modality) + % Parse inputs + if (nargin < 3) || isempty(hFig) + hFig = []; + end + if isempty(hFig) + hFig = view_mri_3d(sSubject.Anatomy(1).FileName, [], 0.3, []); + end + [hFig, iDS, iFig] = view_surface(sSubject.Surface(iSurface).FileName, 0.6, [], hFig, []); + % Add channels to the figure + LoadElectrodes(hFig, ChannelFile, Modality); + % SEEG and ECOG: Open tab "iEEG" + if ismember(Modality, {'SEEG', 'ECOG', 'ECOG+SEEG'}) + gui_brainstorm('ShowToolTab', 'iEEG'); + end +end %% ===== EXPORT CONTACT POSITIONS ===== function ExportChannelFile(isAtlas) diff --git a/toolbox/gui/panel_montage.m b/toolbox/gui/panel_montage.m index f59f4cfd4..9158dd5ae 100644 --- a/toolbox/gui/panel_montage.m +++ b/toolbox/gui/panel_montage.m @@ -1234,8 +1234,9 @@ function DeleteMontage(MontageName) if ismember(GlobalData.ChannelMontages.Montages(i).Name, {'Average reference (L -> R)', 'Scalp current density (L -> R)'}) && (~strcmpi(FigId.Type, 'DataTimeSeries') || (~isempty(FigId.Modality) && ~ismember(FigId.Modality, {'EEG','SEEG','ECOG','ECOG+SEEG'})) || ~Is1020Setup(FigChannels)) continue; end - % Not EEG or no 3D positions: Skip scalp current density - if ismember(GlobalData.ChannelMontages.Montages(i).Name, {'Scalp current density', 'Scalp current density (L -> R)'}) && ~isempty(FigId.Modality) && (~ismember(FigId.Modality, {'EEG'}) || any(cellfun(@isempty, {GlobalData.DataSet(iDS).Channel(iFigChannels).Loc}))) + % Not EEG or no 3D positions or less than 4 unique points: Skip scalp current density + if ismember(GlobalData.ChannelMontages.Montages(i).Name, {'Scalp current density', 'Scalp current density (L -> R)'}) && ~isempty(FigId.Modality) && ... + (~ismember(FigId.Modality, {'EEG'}) || any(cellfun(@isempty, {GlobalData.DataSet(iDS).Channel(iFigChannels).Loc})) || size(unique([GlobalData.DataSet(iDS).Channel(iFigChannels).Loc]', 'rows'), 1) < 4) continue; end % Not CTF-MEG: Skip head motion distance @@ -1478,6 +1479,11 @@ function DeleteMontage(MontageName) % Get surface of electrodes pnt = [Channels.Loc]'; + % Check for at least four unique channel positions + if size(unique(pnt, 'rows'), 1) < 4 + sMontage = []; + return; + end tri = channel_tesselate(pnt); % Compute the SCP (surface Laplacian) with FieldTrip function lapcal Lscp = lapcal(pnt, tri); @@ -2383,27 +2389,30 @@ function AddAutoMontagesProj(ChannelMat, isInteractive) if (length(sCat.Comment) < 3) || strcmpi(sCat.Comment(1:3), 'EEG') continue; end - % ICA - if isequal(sCat.SingVal, 'ICA') - % Field Components stores the mixing matrix W - W = sCat.Components(iChannels, :)'; - % Display name - strDisplay = 'IC'; - % SSP - else - % Field Components stores the spatial components U - U = sCat.Components(iChannels, :); - % SSP/PCA results - if ~isempty(sCat.SingVal) - Singular = sCat.SingVal ./ sum(sCat.SingVal); - % SSP/Mean results - else - Singular = eye(size(U,2)); - end - % Rebuild mixing matrix - W = diag(sqrt(Singular)) * pinv(U); - % Display name - strDisplay = 'SSP'; + componentType = sCat.Method(1:3); + switch lower(componentType) + % ICA + case 'ica' + % Field Components stores the mixing matrix W + W = sCat.Components(iChannels, :)'; + % Display name + strDisplay = 'IC'; + % SSP + case 'ssp' + % Field Components stores the spatial components U + U = sCat.Components(iChannels, :); + switch(sCat.Method) + case 'SSP_pca' + % SSP/PCA results + Singular = sCat.SingVal ./ sum(sCat.SingVal); + case 'SSP_mean' + % SSP/Mean results + Singular = eye(size(U,2)); + end + % Rebuild mixing matrix + W = diag(sqrt(Singular)) * pinv(U); + % Display name + strDisplay = 'SSP'; end % Create line labels LinesLabels = cell(size(W,1), 1); diff --git a/toolbox/gui/panel_options.m b/toolbox/gui/panel_options.m index 5c956acfc..8ab82fe1f 100644 --- a/toolbox/gui/panel_options.m +++ b/toolbox/gui/panel_options.m @@ -72,8 +72,13 @@ jButtonGroup.add(jRadioOpenNone); jButtonGroup.add(jRadioOpenSoft); jButtonGroup.add(jRadioOpenHard); + % On JS Desktop: opengl configuration is not applicable + if isJSDesktop() + jRadioOpenNone.setEnabled(0); + jRadioOpenSoft.setEnabled(0); + jRadioOpenHard.setEnabled(0); % On mac systems: opengl software is not supported - if strncmp(computer,'MAC',3) + elseif strncmp(computer,'MAC',3) jRadioOpenSoft.setEnabled(0); end jPanelLeft.add('br hfill', jPanelOpengl); @@ -130,8 +135,8 @@ % ===== RIGHT: SIGNAL PROCESSING ===== jPanelProc = gui_river([5 5], [0 15 15 15], 'Processing'); jCheckUseSigProc = gui_component('CheckBox', jPanelProc, 'br', 'Use Signal Processing Toolbox (Matlab)', [], 'If selected, some processes will use the Matlab''s Signal Processing Toolbox functions.
Else, use only the basic Matlab function.', []); - jBlockSizeLabel = gui_component('Label', jPanelProc, 'br', 'Memory block size in Mb (default: 100Mb): ', [], [], []); - blockSizeTooltip = 'Maximum size of data blocks to be read in memory, in megabytes.
Ensure this does not exceed the available RAM in your computer.'; + jBlockSizeLabel = gui_component('Label', jPanelProc, 'br', 'Memory block size in MiB (default: 100 MiB): ', [], [], []); + blockSizeTooltip = 'Maximum size of data blocks to be read in memory, in Mebibytes.
Ensure this does not exceed the available RAM in your computer.'; jBlockSize = gui_component('Text', jPanelProc, [], '', [], [], []); jBlockSizeLabel.setToolTipText(blockSizeTooltip); jBlockSize.setToolTipText(blockSizeTooltip); @@ -151,11 +156,11 @@ jPanelBottom = gui_river(); jPanelNew.add('br hfill', jPanelBottom); % MEMORY - [MaxVar, TotalMem] = bst_get('SystemMemory'); - if ~isempty(MaxVar) && ~isempty(TotalMem) + [TotalMem, AvailableMem] = bst_get('SystemMemory'); + if ~isempty(AvailableMem) && ~isempty(TotalMem) % Display memory info jPanelMem = gui_river([0 0], [0 15 8 15]); - labelBottom = sprintf('Max variable size: %d Mb Memory available: %d Mb', MaxVar, TotalMem); + labelBottom = sprintf('Memory total: %d MiB Memory available: %d MiB', TotalMem, AvailableMem); jPanelLeft.add('br hfill', jPanelMem); else labelBottom = ''; @@ -511,6 +516,24 @@ function PythonExe_Callback(varargin) DisableOpenGL = bst_get('DisableOpenGL'); isOpenGL = 1; isUnixWarning = 0; + + % ===== New JS MATLAB Desktop (Beta in R2023a and R2023b) ===== + if isJSDesktop() + info = rendererinfo(); + switch info.Details.HardwareSupportLevel + case 'Full' + disp('hardware'); + case 'Basic' + disp('hardware'); + disp('BST> Warning: OpenGL Hardware support is ''Basic'', this may cause the display to be slow and ugly.'); + otherwise + disp('software'); + disp('BST> Warning: OpenGL Hardware support is unavailable, this may cause the display to be slow and ugly.'); + end + % OpenGL is always available on New Desktop + DisableOpenGL = 0; + return + end % ===== MATLAB < 2014b ===== if (bst_get('MatlabVersion') < 804) @@ -732,3 +755,17 @@ function ButtonReset_Callback(varargin) end +%% ===== Check if running in New JS MATLAB Desktop ===== +function TF = isJSDesktop() + + % Fastest way to check for New JS Desktop is with undocumented + % "feature" command. If this command fails to run properly, it is safe + % to expect MATLAB is NOT running with New JS Desktop. + % This may need changes in R2024a or newer. + try + TF = feature('webui'); + catch + TF = 0; + end +end + diff --git a/toolbox/gui/panel_protocols.m b/toolbox/gui/panel_protocols.m index ac73a72d1..fc8fff787 100644 --- a/toolbox/gui/panel_protocols.m +++ b/toolbox/gui/panel_protocols.m @@ -1044,7 +1044,7 @@ function MarkUniqueNode( bstNode ) %#ok nodeType{i} = lower(char(bstNodes(i).getType())); end % Cannot copy multiple unique nodes - if ismember(nodeType{1}, {'channel', 'anatomy', 'volatlas', 'noisecov', 'ndatacov'}) && (length(bstNodes) > 1) + if ismember(nodeType{1}, {'channel', 'anatomy', 'volatlas', 'volct', 'noisecov', 'ndatacov'}) && (length(bstNodes) > 1) bst_error(['Cannot copy multiple ' nodeType{1} ' nodes.'], 'Clipboard', 0); return; % Can only copy data files @@ -1092,7 +1092,7 @@ function MarkUniqueNode( bstNode ) %#ok return end firstSrcType = lower(char(srcNodes(1).getType())); - isAnatomy = ismember(firstSrcType, {'anatomy','volatlas','cortex','scalp','innerskull','outerskull','fibers','fem','other'}); + isAnatomy = ismember(firstSrcType, {'anatomy','volatlas','volct','cortex','scalp','innerskull','outerskull','fibers','fem','other'}); % Get all target studies/subjects iTarget = []; for i = 1:length(targetNode) @@ -1146,7 +1146,7 @@ function MarkUniqueNode( bstNode ) %#ok srcType = lower(char(srcNodes(i).getType())); iSrcStudy = srcNodes(i).getStudyIndex(); % Cannot copy (channel/noisecov/MRI) or move to the same folder - if (isCut || ismember(srcType, {'channel', 'noisecov', 'ndatacov', 'anatomy', 'volatlas'})) && (iSrcStudy == iTarget) + if (isCut || ismember(srcType, {'channel', 'noisecov', 'ndatacov', 'anatomy', 'volatlas', 'volct'})) && (iSrcStudy == iTarget) bst_error('Source and destination folders are the same.', 'Clipboard', 0); destFile = {}; return; @@ -1192,7 +1192,7 @@ function MarkUniqueNode( bstNode ) %#ok sStudyTarget = bst_get('Study', iTarget); [sSubjectTargetRaw, iSubjectTargetRaw] = bst_get('Subject', sStudyTarget.BrainStormSubject, 1); end - isAnatomy = ismember(srcType, {'anatomy','volatlas','cortex','scalp','innerskull','outerskull','fibers','fem','other'}); + isAnatomy = ismember(srcType, {'anatomy','volatlas','volct','cortex','scalp','innerskull','outerskull','fibers','fem','other'}); % Get source subject if ~isAnatomy sStudySrc = bst_get('Study', iSrcStudy); diff --git a/toolbox/gui/panel_record.m b/toolbox/gui/panel_record.m index f608aca4e..a561423a3 100644 --- a/toolbox/gui/panel_record.m +++ b/toolbox/gui/panel_record.m @@ -1085,6 +1085,9 @@ function ReloadRecordings(isForced) end % Get epoch indice iEpoch = ctrl.jSpinnerEpoch.getValue(); + if iEpoch <= 0 + return + end Time = GlobalData.FullTimeWindow.Epochs(iEpoch).Time; % Get new time window iStart = double(ctrl.jSliderStart.getValue()); @@ -2060,6 +2063,7 @@ function EventTypesDuplicate() %% ===== CONVERT TO SIMPLE EVENTS ===== function EventConvertToSimple() + global GlobalData; % Get selected events [iEvents, tmp__, isExtended] = GetSelectedEvents(); if isempty(iEvents) @@ -2075,22 +2079,19 @@ function EventConvertToSimple() % Ask if we should keep only the first or the last sample of the extended event res = java_dialog('question', ... 'What part of the extended events do you want to keep?', ... - 'Convert event type', [], {'Start', 'Middle', 'End', 'Cancel'}, 'Middle'); + 'Convert event type', [], {'Start', 'Middle', 'End', 'Every sample', 'Cancel'}, 'Middle'); % User canceled operation if isempty(res) || strcmpi(res, 'Cancel') return end + % Get current dataset + [iDS, ~] = GetCurrentDataset(); + % Get sampling rate + sfreq = 1 / GlobalData.DataSet(iDS).Measures.SamplingRate; % Apply modificiation to each event type + Method = strrep(lower(res), ' ', '_'); + sEvents = process_evt_simple('Compute', sEvents, Method, sfreq); for i = 1:length(sEvents) - % Keep only one of the two time values - switch (res) - case 'Start' - sEvents(i).times = sEvents(i).times(1,:); - case 'Middle' - sEvents(i).times = mean(sEvents(i).times, 1); - case 'End' - sEvents(i).times = sEvents(i).times(2,:); - end % Update event SetEvents(sEvents(i), iEvents(i)); end @@ -2131,18 +2132,14 @@ function EventConvertToExtended() sfreq = 1 / GlobalData.DataSet(iDS).Measures.SamplingRate; % Get time window in seconds evtWindow = [-abs(str2num(res{1})), str2num(res{2})] ./ 1000; - % Align to samples - evtWindow = round(evtWindow .* sfreq) ./ sfreq; - % Apply modificiation to each event type if isempty(GlobalData.FullTimeWindow) || isempty(GlobalData.FullTimeWindow.CurrentEpoch) FullTimeWindow = GlobalData.DataSet(iDS).Measures.Time; else FullTimeWindow = GlobalData.FullTimeWindow.Epochs(GlobalData.FullTimeWindow.CurrentEpoch).Time([1, end]); end + sEvents = process_evt_extended('Compute', sEvents, evtWindow, FullTimeWindow, sfreq); for i = 1:length(sEvents) - sEvents(i).times = [max(FullTimeWindow(1), sEvents(i).times(1,:) + evtWindow(1)); ... - min(FullTimeWindow(2), sEvents(i).times(1,:) + evtWindow(2))]; % Update event SetEvents(sEvents(i), iEvents(i)); end @@ -3012,12 +3009,16 @@ function SetAcquisitionDate(iStudy, newDate) %#ok return; end vecDate = [str2num(res{1}), str2num(res{2}), str2num(res{3})]; - if (length(vecDate) < 3) || (vecDate(1) <= 0) || (vecDate(1) >= 31) || (vecDate(2) <= 0) || (vecDate(2) >= 12) || (vecDate(3) < 1700) + try + if (length(vecDate) < 3) || (vecDate(3) < 1700) + error('Invalid year'); + end + % Get a new date string + newDate = datetime(sprintf('%02d%02d%04d', vecDate), 'InputFormat', 'ddMMyyyy'); + catch bst_error('Invalid date.', 'Set date', 0); return; end - % Get a new date string - newDate = datestr(datenum(vecDate(3), vecDate(2), vecDate(1))); else % Fix data format newDate = str_date(newDate); diff --git a/toolbox/gui/panel_scout.m b/toolbox/gui/panel_scout.m index 83501396e..acab47627 100644 --- a/toolbox/gui/panel_scout.m +++ b/toolbox/gui/panel_scout.m @@ -201,6 +201,10 @@ jRadioAbsolute = gui_component('Radio', jPanelDisplay, 'tab', 'Absolute', {Insets(0,0,0,0), jButtonGroup}); jRadioRelative = gui_component('Radio', jPanelDisplay, 'tab', 'Relative', {Insets(0,0,0,0), jButtonGroup}); jRadioAbsolute.setSelected(1); + % Uniform amplitude scales + gui_component('Label', jPanelDisplay, 'br', ['Uniform amplitude scale:' strSpace]); + jCheckUniformAmplitude = gui_component('CheckBox', jPanelDisplay, '', '', Insets(0,0,0,0), [], @UniformTimeSeries_Callback); + jPanelBottom.add('br hfill', jPanelDisplay); jPanelMain.add(jPanelBottom, BorderLayout.SOUTH) @@ -235,6 +239,7 @@ 'jCheckRegionColor', jCheckRegionColor, ... 'jCheckOverlayScouts', jCheckOverlayScouts, ... 'jCheckOverlayConditions', jCheckOverlayConditions, ... + 'jCheckUniformAmplitude', jCheckUniformAmplitude, ... 'jListScouts', jListScouts)); @@ -301,6 +306,14 @@ case uint8('-') EditScoutsSize('Shrink1'); case ev.VK_ESCAPE SetSelectedScouts(0); + case 3 % CTRL+C + if ev.getModifiers == 2 + CopyScouts(); + end + case 22 % CTRL+V + if ev.getModifiers == 2 + PasteScouts() + end end end @@ -338,6 +351,16 @@ function ButtonShow_Callback(hObj, ev) end +%% ===== OPTIONS: UNIFORMIZE SCALES ===== +function UniformTimeSeries_Callback(hObj, ev) + % Get button + jCheck = ev.getSource(); + isSel = jCheck.isSelected(); + figure_timeseries('UniformizeTimeSeriesScales', isSel); + jCheck.setSelected(isSel); +end + + %% ================================================================================= % === EXTERNAL PANEL CALLBACKS =================================================== @@ -468,11 +491,18 @@ function UpdateMenus(sAtlas, sSurf) gui_component('MenuItem', jMenu, [], 'Rename', IconLoader.ICON_EDIT, [], @(h,ev)bst_call(@EditScoutLabel)); gui_component('MenuItem', jMenu, [], 'Set color', IconLoader.ICON_COLOR_SELECTION, [], @(h,ev)bst_call(@EditScoutsColor)); if ~isReadOnly - gui_component('MenuItem', jMenu, [], 'Delete', IconLoader.ICON_DELETE, [], @(h,ev)bst_call(@RemoveScouts)); - gui_component('MenuItem', jMenu, [], 'Merge', IconLoader.ICON_FUSION, [], @(h,ev)bst_call(@JoinScouts)); - gui_component('MenuItem', jMenu, [], 'Difference', IconLoader.ICON_MINUS, [], @(h,ev)bst_call(@DifferenceScouts)); + gui_component('MenuItem', jMenu, [], 'Delete', IconLoader.ICON_DELETE, [], @(h,ev)bst_call(@RemoveScouts)); + gui_component('MenuItem', jMenu, [], 'Merge', IconLoader.ICON_FUSION, [], @(h,ev)bst_call(@JoinScouts)); + gui_component('MenuItem', jMenu, [], 'Duplicate', IconLoader.ICON_COPY, [], @(h,ev)bst_call(@DuplicateScouts)); + gui_component('MenuItem', jMenu, [], 'Difference', IconLoader.ICON_MINUS, [], @(h,ev)bst_call(@DifferenceScouts)); + gui_component('MenuItem', jMenu, [], 'Intersect', IconLoader.ICON_SCROLL_UP, [], @(h,ev)bst_call(@IntersectScouts)); jMenu.addSeparator(); end + gui_component('MenuItem', jMenu, [], 'Copy', IconLoader.ICON_COPY, [], @(h,ev)bst_call(@CopyScouts)); + if ~isReadOnly + gui_component('MenuItem', jMenu, [], 'Paste', IconLoader.ICON_PASTE, [], @(h,ev)bst_call(@PasteScouts)); + end + jMenu.addSeparator(); gui_component('MenuItem', jMenu, [], 'Export to Matlab', IconLoader.ICON_MATLAB_EXPORT, [], @(h,ev)bst_call(@ExportScoutsToMatlab)); if ~isReadOnly gui_component('MenuItem', jMenu, [], 'Import from Matlab', IconLoader.ICON_MATLAB_IMPORT, [], @(h,ev)bst_call(@ImportScoutsFromMatlab)); @@ -569,6 +599,7 @@ function CreateMenuFunction(jMenu) jMenuNorm = gui_component('RadioMenuItem', jMenu, [], 'Mean(norm)', [], [], @(h,ev)bst_call(@SetScoutFunction,'Mean_norm')); jMenuMax = gui_component('RadioMenuItem', jMenu, [], 'Max', [], [], @(h,ev)bst_call(@SetScoutFunction,'Max')); jMenuPow = gui_component('RadioMenuItem', jMenu, [], 'Power', [], [], @(h,ev)bst_call(@SetScoutFunction,'Power')); + jMenuRms = gui_component('RadioMenuItem', jMenu, [], 'RMS', [], [], @(h,ev)bst_call(@SetScoutFunction,'RMS')); jMenuAll = gui_component('RadioMenuItem', jMenu, [], 'All', [], [], @(h,ev)bst_call(@SetScoutFunction,'All')); % Get the selected functions allFun = unique({sScouts.Function}); @@ -583,6 +614,7 @@ function CreateMenuFunction(jMenu) case 'Mean_norm', jMenuNorm.setSelected(1); case 'Max', jMenuMax.setSelected(1); case 'Power', jMenuPow.setSelected(1); + case 'RMS', jMenuRms.setSelected(1); case 'All', jMenuAll.setSelected(1); end end @@ -694,6 +726,58 @@ function CreateMenuInverse(jMenu) end +%% ===== SET LOCATION CONSTRAINT ===== +function SetLocationConstraint(iScout, locationConstraint) + % Seleted scouts + if ~isempty(iScout) + SetSelectedScouts(iScout) + end + sScouts = GetSelectedScouts(); + if isempty(sScouts) + return + end + % Set location constraint + switch lower(locationConstraint) + case 'surface' + regionArgument = '.S.'; + case 'volume' + regionArgument = '.V.'; + case 'deep brain' + regionArgument = '.D.'; + case 'exclude' + regionArgument = '.X.'; + otherwise + return + end + SetScoutRegion(regionArgument); +end + + +%% ===== SET ORIENTATION CONSTRAINT ===== +function SetOrientationConstraint(iScout, orientationConstraint) + % Seleted scouts + if ~isempty(iScout) + SetSelectedScouts(iScout) + end + sScouts = GetSelectedScouts(); + if isempty(sScouts) + return + end + % Set orientation constraint + switch lower(orientationConstraint) + case 'constrained' + regionArgument = '..C.'; + case 'unconstrained' + regionArgument = '..U'; + case 'loose' + regionArgument = '..L'; + otherwise + return + end + SetScoutRegion(regionArgument); +end + + %% ===== UPDATE ATLAS LIST ===== function UpdateAtlasList(sSurf) import org.brainstorm.list.*; @@ -863,27 +947,39 @@ function UpdateScoutProperties() strSize = sprintf(' Vertices: %d', length(allVertices)); % Volume: Compute the volume enclosed in the scout (cm3) if isVolumeAtlas + GridLoc = []; + if bst_get('MatlabVersion') >=840 % R2014b + hFig = bst_figures('GetCurrentFigure', '3D'); + GridLoc = GetFigureGrid(hFig); + end totalVol = 0; for i = 1:length(sScouts) - patchVol = 0; - if (length(sScouts(i).Vertices) > 3) && ~isempty(sScouts(i).Handles) && ~isempty(sScouts(i).Handles(1).hPatch) - % Get the faces and vertices of the patch - Vertices = double(get(sScouts(i).Handles(1).hPatch, 'Vertices')); - Faces = double(get(sScouts(i).Handles(1).hPatch, 'Faces')); - % Compute patch volume - if (size(Faces,1) > 1) - patchVol = stlVolumeNormals(Vertices', Faces') * 1e6; + scoutVol = 0; + if (length(sScouts(i).Vertices) > 3) + % Compute volume using scout vertices (for 3DFig or MRIViewer) + if ~isempty(GridLoc) + [~, scoutVol] = boundary(GridLoc(sScouts(i).Vertices, :)); + scoutVol = scoutVol * 1e6; + % Compute volume from scout path (only for 3DFig) + elseif ~isempty(sScouts(i).Handles) && ~isempty(sScouts(i).Handles(1).hPatch) + % Get the faces and vertices of the patch + Vertices = double(get(sScouts(i).Handles(1).hPatch, 'Vertices')); + Faces = double(get(sScouts(i).Handles(1).hPatch, 'Faces')); + % Compute patch volume + if (size(Faces,1) > 1) + scoutVol = stlVolumeNormals(Vertices', Faces') * 1e6; + end end end - % Use the maximum of 0.03cm3 and the compute volume of the patch - minVol = 0.01 * length(sScouts(i).Vertices); - if (minVol > patchVol) - patchVol = minVol; - end % Sum with the other scouts - totalVol = totalVol + patchVol; + totalVol = totalVol + scoutVol; end - strArea = sprintf('Volume: %1.2f cm3 ', totalVol); + % Prepare volume (cm3) string + strCm3 = 'Use MRI(3D)'; + if totalVol ~= 0 + strCm3 = sprintf('%1.2f cm3 ', totalVol); + end + strArea = ['Volume: ', strCm3]; % Surface: Compute the total area (cm2) else @@ -909,40 +1005,12 @@ function CurrentFigureChanged_Callback(oldFig, hFig) GlobalData.CurrentScoutsSurface = ''; return end - % Get surfaces in new figure - TessInfo = getappdata(hFig, 'Surface'); - iTess = getappdata(hFig, 'iSurface'); - if isempty(iTess) || isempty(TessInfo) - SurfaceFile = []; - else - SurfaceFile = TessInfo(iTess).SurfaceFile; - end + % Get scout surface in new figure + SurfaceFile = GetScoutSurface(hFig); % If the current surface didn't change: nothing to do if file_compare(GlobalData.CurrentScoutsSurface, SurfaceFile) return; end - % If surface file is an MRI or fibers - if ~isempty(iTess) && ismember(lower(TessInfo(iTess).Name), {'anatomy', 'fibers'}) - % By default: no attached surface - SurfaceFile = []; - % If there are some data associated with this file: get the associated scouts - if ~isempty(TessInfo(iTess).DataSource) && ~isempty(TessInfo(iTess).DataSource.FileName) - FileMat.SurfaceFile = []; - if strcmpi(TessInfo(iTess).DataSource.Type, 'Source') - FileMat = in_bst_results(TessInfo(iTess).DataSource.FileName, 0, 'SurfaceFile'); - elseif strcmpi(TessInfo(iTess).DataSource.Type, 'Timefreq') - FileMat = in_bst_timefreq(TessInfo(iTess).DataSource.FileName, 0, 'SurfaceFile', 'DataFile', 'DataType'); - if isempty(FileMat.SurfaceFile) && ~isempty(FileMat.DataFile) && strcmpi(FileMat.DataType, 'results') - FileMat = in_bst_results(FileMat.DataFile, 0, 'SurfaceFile'); - end - elseif strcmpi(TessInfo(iTess).DataSource.Type, 'HeadModel') - FileMat = in_bst_headmodel(TessInfo(iTess).DataSource.FileName, 0, 'SurfaceFile'); - end - if ~isempty(FileMat.SurfaceFile) % && strcmpi(file_gettype(FileMat.SurfaceFile), 'cortex') - SurfaceFile = FileMat.SurfaceFile; - end - end - end % Update current surface SetCurrentSurface(SurfaceFile); @@ -988,11 +1056,11 @@ function CurrentFigureChanged_Callback(oldFig, hFig) function isReadOnly = isAtlasReadOnly(sAtlas, isInteractive) global GlobalData; % Parse inputs - if (nargin < 1) || isempty(isInteractive) + if (nargin < 2) || isempty(isInteractive) isInteractive = 1; end % Get current atlas - if (nargin < 2) || isempty(sAtlas) + if (nargin < 1) || isempty(sAtlas) % Get current surface sSurf = bst_memory('GetSurface', GlobalData.CurrentScoutsSurface); % If there are no surface, or atlases: return @@ -1004,9 +1072,18 @@ function CurrentFigureChanged_Callback(oldFig, hFig) end % If it is an "official" atlas: read-only if ismember(lower(sAtlas.Name), {... - 'brainvisa_tzourio-mazoyer', ... % Old default anatomy - 'freesurfer_destrieux_15000V', 'freesurfer_desikan-killiany_15000V', 'freesurfer_brodmann_15000V', ... % Old default anatomy - 'destrieux', 'desikan-killiany', 'brodmann', 'brodmann-thresh', 'dkt40', 'dkt', 'mindboggle', 'structures'}) % New freesurf + ... % Old default anatomy + 'brainvisa_tzourio-mazoyer', ... + ... % Old default anatomy + 'freesurfer_destrieux_15000V', 'freesurfer_desikan-killiany_15000V', 'freesurfer_brodmann_15000V', ... + ... % New default anatomy (2023b) + ... % https://neuroimage.usc.edu/brainstorm/Tutorials/DefaultAnatomy#FreeSurfer_templates + 'destrieux', 'desikan-killiany', 'brodmann', 'brodmann-thresh', 'dkt40', 'dkt', 'mindboggle', 'vcatlas', 'structures', ... % FreeSurfer + 'brainnetome', 'hcp_mmp1', 'oasis cortical hubs', ... % Brainnetome, HCP-MMP1.0, OASIS + 'pals-b12 brodmann', 'pals-b12 lobes', 'pals-b12 orbito-frontal', 'pals-b12 visuotopic', ... % PALS-B12 + 'schaefer_100_17net', 'schaefer_200_17net', 'schaefer_400_17net', 'schaefer_600_17net',... % Schaefer2018 17 networks + 'schaefer_100_7net', ' schaefer_200_7net', 'schaefer_400_7net', 'schaefer_600_7net',... % Schaefer2018 7 networks + }) if isInteractive java_dialog('warning', [... 'This atlas is a reference and cannot be modified or deleted.' 10 10 ... @@ -1077,6 +1154,41 @@ function SetCurrentAtlas(iAtlas, isForced) end +%% ===== GET SCOUT SURFACE FOR FIGURE ===== +function SurfaceFile = GetScoutSurface(hFig) + % Get surface in new figure + TessInfo = getappdata(hFig, 'Surface'); + iTess = getappdata(hFig, 'iSurface'); + SurfaceFile = []; + if isempty(iTess) || isempty(TessInfo) + return + % If surface file is an MRI or fibers + elseif ismember(lower(TessInfo(iTess).Name), {'anatomy', 'fibers'}) + % By default: no attached surface + SurfaceFile = []; + % If there are some data associated with this file: get the associated scouts + if ~isempty(TessInfo(iTess).DataSource) && ~isempty(TessInfo(iTess).DataSource.FileName) + FileMat.SurfaceFile = []; + if strcmpi(TessInfo(iTess).DataSource.Type, 'Source') + FileMat = in_bst_results(TessInfo(iTess).DataSource.FileName, 0, 'SurfaceFile'); + elseif strcmpi(TessInfo(iTess).DataSource.Type, 'Timefreq') + FileMat = in_bst_timefreq(TessInfo(iTess).DataSource.FileName, 0, 'SurfaceFile', 'DataFile', 'DataType'); + if isempty(FileMat.SurfaceFile) && ~isempty(FileMat.DataFile) && strcmpi(FileMat.DataType, 'results') + FileMat = in_bst_results(FileMat.DataFile, 0, 'SurfaceFile'); + end + elseif strcmpi(TessInfo(iTess).DataSource.Type, 'HeadModel') + FileMat = in_bst_headmodel(TessInfo(iTess).DataSource.FileName, 0, 'SurfaceFile'); + end + if ~isempty(FileMat.SurfaceFile) % && strcmpi(file_gettype(FileMat.SurfaceFile), 'cortex') + SurfaceFile = FileMat.SurfaceFile; + end + end + else + SurfaceFile = TessInfo(iTess).SurfaceFile; + end +end + + %% ===== SET CURRENT SURFACE ===== function SetCurrentSurface(newSurfaceFile) global GlobalData; @@ -1333,6 +1445,7 @@ function SetSelectedScoutLabels(selLabels) 'overlayScouts', 0, ... 'overlayConditions', 0, ... 'displayAbsolute', 1, ... + 'uniformAmplitude', 0, ... 'showSelection', 'all', ... 'patchAlpha', .7, ... 'displayContour', 1, ... @@ -1345,6 +1458,8 @@ function SetSelectedScoutLabels(selLabels) ScoutsOptions.overlayConditions = ctrl.jCheckOverlayConditions.isSelected(); % Absolute values ScoutsOptions.displayAbsolute = ctrl.jRadioAbsolute.isSelected(); + % Uniform amplitude scale + ScoutsOptions.uniformAmplitude = ctrl.jCheckUniformAmplitude.isSelected(); % Show selection if ~ctrl.jRadioShowSel.isSelected() && ~ctrl.jRadioShowAll.isSelected() ScoutsOptions.showSelection = 'none'; @@ -3707,6 +3822,82 @@ function JoinScouts(varargin) end +%% ===== INTERSECT SCOUTS ===== +% Intersection between scouts selected in the JList +function IntersectScouts(varargin) + % Prevent edition of read-only atlas + if isAtlasReadOnly() + return; + end + % Stop scout edition + SetSelectionState(0); + % Get selected scouts + [sScouts, iScouts] = GetSelectedScouts(); + % Need at least TWO scouts + if (length(sScouts) < 2) + java_dialog('warning', 'You need to select at least two scouts.', 'Join selected scouts'); + return; + end + % === Intersect scouts === + % Create new scout + sNewScout = db_template('Scout'); + % Initialize vertices + sNewScout.Vertices = sScouts(1).Vertices; + for i = 2:length(sScouts) + sNewScout.Vertices = intersect(sNewScout.Vertices, sScouts(i).Vertices); + end + if isempty(sNewScout.Vertices) + java_dialog('msgbox', 'Intersection is empty.', 'Intersect selected scouts'); + return; + end + % Copy unmodified fields + sNewScout.Seed = sNewScout.Vertices(1); + % Label : "Label1 ^ Label2 ^ ..." + sNewScout.Label = sScouts(1).Label; + for i = 2:length(sScouts) + sNewScout.Label = [sNewScout.Label ' n ' sScouts(i).Label]; + end + % Save new scout + iNewScout = SetScouts([], 'Add', sNewScout); + % Display new scout + PlotScouts(iNewScout); + % Update "Scouts Manager" panel + UpdateScoutsList(); + % Select last scout in list (new scout) + SetSelectedScouts(iNewScout); +end + +%% ===== DUPLICATE SCOUTS ===== +% Duplicate scouts selected in the JList +function DuplicateScouts(varargin) + % Prevent edition of read-only atlas + if isAtlasReadOnly() + return; + end + % Stop scout edition + SetSelectionState(0); + % Get selected scouts + sScouts = GetSelectedScouts(); + % New scout template + sNewScout = db_template('scout'); + + % === Copy scouts === + sNewScouts = sScouts; + % Update new scouts name and reset handles + for i = 1:length(sNewScouts) + sNewScouts(i).Label = [sScouts(i).Label '_copy']; + sNewScouts(i).Handles = sNewScout.Handles; + end + % Save new scouts + iNewScouts = SetScouts([], 'Add', sNewScouts); + % Display new scouts + PlotScouts(iNewScouts); + % Update "Scouts Manager" panel + UpdateScoutsList(); + % Select new scouts in list + SetSelectedScouts(iNewScouts); +end + %% =============================================================================== % ====== OTHER SCOUTS OPERATIONS ================================================ % =============================================================================== @@ -3896,7 +4087,7 @@ function ExpandWithCorrelation(varargin) end %% ===== NEW SURFACE: FROM SELECTED SCOUTS ===== -function NewSurface(isKeep) +function NewTessFile = NewSurface(isKeep) % === GET VERTICES TO REMOVE === % Get selected scouts [sScouts, iScouts, sSurf] = GetSelectedScouts(); @@ -3946,8 +4137,84 @@ function NewSurface(isKeep) [sSubject, iSubject] = bst_get('SurfaceFile', sSurf.FileName); % Register this file in Brainstorm database db_add_surface(iSubject, NewTessFile, sSurfNew.Comment); - % Re-open one to show the modifications - view_surface(NewTessFile); + if nargout == 0 + % Re-open one to show the modifications + view_surface(NewTessFile); + end +end + +%% ===== COPY SCOUTS ===== +function CopyScouts() + % Get selected scouts + sScouts = GetSelectedScouts(); + % If nothing selected, exit + if isempty(sScouts) + return; + end + % Remove the graphic Handles + if isfield(sScouts, 'Handles') + [sScouts.Handles] = deal([]); + end + % Get current surface + [sAtlas, ~, sSurf] = GetAtlas(); + [isVolumeAtlas, nGrid] = ParseVolumeAtlas(sAtlas.Name); + % Prepare data for copy to clipboard + sClipboardScout.surfFileName = sSurf.FileName; + sClipboardScout.isVolumeAtlas = isVolumeAtlas; + sClipboardScout.nGrid = nGrid; + sClipboardScout.sScouts = sScouts; + clipboard('copy', bst_jsonencode(sClipboardScout)); +end + +%% ===== PASTE SCOUTS ===== +function PasteScouts() + % Get copied scouts + strClipboard = clipboard('paste'); + if isempty(strClipboard) + return + end + sClip = bst_jsondecode(strClipboard); + if isempty(sClip) || ~isstruct(sClip) || ~all(ismember({'surfFileName', 'isVolumeAtlas', 'nGrid', 'sScouts'} ,fieldnames(sClip))) + return + end + % Get current surface + [sAtlas, ~, sSurf] = GetAtlas(); + [isVolumeAtlas, nGrid] = ParseVolumeAtlas(sAtlas.Name); + % Checks to allow copy-paste + if isAtlasReadOnly(sAtlas, 1) + return + end + if ~strcmpi(sSurf.FileName, sClip.surfFileName) + disp('BST> Cannot copy-paste scouts to a different surface.'); + return + end + if sClip.isVolumeAtlas && ~isVolumeAtlas + disp('BST> Cannot copy-paste scouts from a Volume atlas to a Surface atlas.'); + return + elseif ~sClip.isVolumeAtlas && isVolumeAtlas + disp('BST> Cannot copy-paste scouts from a Surface atlas to a Volume atlas.'); + return + elseif sClip.isVolumeAtlas && isVolumeAtlas + if (nGrid ~= sClip.nGrid) + disp('BST> Cannot copy-paste scouts between volume grids of different size.'); + return + end + end + % All 1D vectors are JSON are column vectors, change to row vectors when needed + sScouts = sClip.sScouts'; + sTemplate = db_template('scout'); + for i = 1:length(sScouts) + sScouts(i).Vertices = sScouts(i).Vertices'; + sScouts(i).Handles = sTemplate.Handles; + end + % Add new scouts + iNewScout = SetScouts([], 'Add', sScouts); + % Display new scout + PlotScouts(iNewScout); + % Update "Scouts Manager" panel + UpdateScoutsList(); + % Select last scout in list (new scout) + SetSelectedScouts(iNewScout); end %% ===== EXPORT SCOUTS TO MATLAB ===== @@ -4303,9 +4570,9 @@ function PlotScouts(iScouts, hFigSel) continue; end % Skip the display of the scouts that are on a hidden half of the cortex (for struct atlas) - if isequal(sSurface.Resect, 'left') && ~isempty(sScouts(i).Region) && (sScouts(i).Region(1) == 'R') + if isequal(sSurface.Resect{2}, 'left') && ~isempty(sScouts(i).Region) && (sScouts(i).Region(1) == 'R') continue; - elseif isequal(sSurface.Resect, 'right') && ~isempty(sScouts(i).Region) && (sScouts(i).Region(1) == 'L') + elseif isequal(sSurface.Resect{2}, 'right') && ~isempty(sScouts(i).Region) && (sScouts(i).Region(1) == 'L') continue; end % Get indice of the target figure in the sScouts.Handles array @@ -4645,7 +4912,7 @@ function ReloadScouts(hFig) % Plot all scouts again PlotScouts([], hFig); % Update selected/displayed scouts - UpdateScoutsDisplay(hFig); + UpdateScoutsDisplay('current'); end @@ -4843,13 +5110,9 @@ function UpdateScoutsDisplay(target) % Get target scouts if ~ischar(target) hFigTarget = target; - TessInfo = getappdata(hFigTarget, 'Surface'); - iTess = getappdata(hFigTarget, 'iSurface'); - if isempty(TessInfo) || isempty(iTess) + SurfaceFile = GetScoutSurface(hFigTarget); + if isempty(SurfaceFile) hFigTarget = []; - SurfaceFile = []; - else - SurfaceFile = TessInfo(iTess).SurfaceFile; end elseif strcmpi(target, 'all') SurfaceFile = []; @@ -5202,11 +5465,15 @@ function CenterMriOnScout(varargin) % =============================================================================== %% ===== LOAD SCOUT ===== -% USAGE: LoadScouts(ScoutFiles, isNewAtlas=1) : Files to import -% LoadScouts() : Ask the user for the files to read -function LoadScouts(ScoutFiles, isNewAtlas) +% USAGE: LoadScouts(ScoutFiles, isNewAtlas=1, FileFormat) : Files to import +% LoadScouts() : Ask the user for the files to read +function LoadScouts(ScoutFiles, isNewAtlas, FileFormat) global GlobalData; % Parse inputs + if (nargin < 3) + FileFormat = []; + end + % Parse inputs if (nargin < 2) || isempty(isNewAtlas) isNewAtlas = 1; end @@ -5250,7 +5517,7 @@ function LoadScouts(ScoutFiles, isNewAtlas) end % Load all files selected by user - [sAtlas, Messages] = import_label(sSurf.FileName, ScoutFiles, isNewAtlas, GridLoc); + [sAtlas, Messages] = import_label(sSurf.FileName, ScoutFiles, isNewAtlas, GridLoc, FileFormat); % Display error messages if ~isempty(Messages) java_dialog('error', Messages, 'Load atlas'); @@ -5509,6 +5776,9 @@ function SaveScouts(varargin) GridLoc = []; HeadModelType = []; GridAtlas = []; + if isempty(hFig) + return + end % Get source file displayed in the figure ResultsFile = getappdata(hFig, 'ResultsFile'); if ~isempty(ResultsFile) @@ -5529,4 +5799,20 @@ function SaveScouts(varargin) GridAtlas = HeadModelMat.GridAtlas; end end + % Get timefreq file displayed in the figure + Timefreq = getappdata(hFig, 'Timefreq'); + if ~isempty(Timefreq) + % Get loaded time-freq structure + [iDS, iTimefreq] = bst_memory('LoadTimefreqFile', Timefreq.FileName); + % Get results file + if strcmpi(GlobalData.DataSet(iDS).Timefreq(iTimefreq).DataType, 'results') && ~isempty(GlobalData.DataSet(iDS).Timefreq(iTimefreq).DataFile) + iResult = bst_memory('GetResultInDataSet', iDS, GlobalData.DataSet(iDS).Timefreq(iTimefreq).DataFile); + HeadModelType = GlobalData.DataSet(iDS).Results(iResult).HeadModelType; + % Get volume grids + if ~isempty(GlobalData.DataSet(iDS).Timefreq(iTimefreq).GridLoc) && ~strcmpi(HeadModelType, 'surface') + GridLoc = GlobalData.DataSet(iDS).Timefreq(iTimefreq).GridLoc; + GridAtlas = GlobalData.DataSet(iDS).Timefreq(iTimefreq).GridAtlas; + end + end + end end diff --git a/toolbox/gui/panel_ssp_selection.m b/toolbox/gui/panel_ssp_selection.m index afa347a36..579825453 100644 --- a/toolbox/gui/panel_ssp_selection.m +++ b/toolbox/gui/panel_ssp_selection.m @@ -448,35 +448,42 @@ function PlotComponents(UseSmoothing, isPlotTopo, isPlotTs) % Get sensors for this topography iChannels = good_channel(GlobalData.DataSet(iDS).Channel, GlobalData.DataSet(iDS).Measures.ChannelFlag, allMod{iMod}); % Type of components - isICA = isequal(sCat.SingVal, 'ICA'); - % ICA: Get the topography to display - if isICA - % Field Components stores the mixing matrix W - W = sCat.Components(iChannels,:)'; - Topo = pinv(W); - % Display name - strDisplay = 'IC'; - % SSP: Limit the maximum number of components to display - else - % Field Components stores the spatial components U - U = sCat.Components(iChannels, :); - Topo = U; - % SSP/PCA results - if ~isempty(sCat.SingVal) - Singular = sCat.SingVal ./ sum(sCat.SingVal); - % SSP/Mean results - else - Singular = eye(size(U,2)); - end - % Rebuild mixing matrix - if isPlotTs - % W = pinv(U); - W = diag(sqrt(Singular)) * pinv(U); - end - % Select only the first 20 components - iComp = intersect(iComp, 1:20); - % Display name - strDisplay = 'SSP'; + componentType = sCat.Method(1:3); + switch lower(componentType) + % ICA: Get the topography to display + case 'ica' + % Explained variance + Singular = []; + if ~isempty(sCat.SingVal) && isnumeric(sCat.SingVal) + Singular = sCat.SingVal; + end + % Field Components stores the mixing matrix W + W = sCat.Components(iChannels,:)'; + Topo = pinv(W); + % Display name + strDisplay = 'IC'; + % SSP: Limit the maximum number of components to display + case 'ssp' + % Field Components stores the spatial components U + U = sCat.Components(iChannels, :); + Topo = U; + switch(sCat.Method) + case 'SSP_pca' + % SSP/PCA results + Singular = sCat.SingVal ./ sum(sCat.SingVal); + case 'SSP_mean' + % SSP/Mean results + Singular = eye(size(U,2)); + end + % Rebuild mixing matrix + if isPlotTs + % W = pinv(U); + W = diag(sqrt(Singular)) * pinv(U); + end + % Select only the first 20 components + iComp = intersect(iComp, 1:20); + % Display name + strDisplay = 'SSP'; end % Keep only the requested components Topo = Topo(:,iComp); @@ -531,7 +538,7 @@ function PlotComponents(UseSmoothing, isPlotTopo, isPlotTs) setappdata(hFig, 'TopoInfo', TopoInfo); bst_figures('ReloadFigures', hFig, 1); % Capture image - if isICA + if isempty(Singular) strLegend = sprintf('%s%d', strDisplay, iComp(i)); else strLegend = sprintf('%s%d (%d%%)', strDisplay, iComp(i), round(100*Singular(iComp(i)))); @@ -562,11 +569,7 @@ function PlotComponents(UseSmoothing, isPlotTopo, isPlotTs) end % Create new montage on the fly sMontage = db_template('Montage'); - if isICA - sMontage.Name = 'ICA components[tmp]'; - else - sMontage.Name = 'SSP components[tmp]'; - end + sMontage.Name = sprintf('%s components[tmp]', componentType); sMontage.Type = 'matrix'; sMontage.ChanNames = {GlobalData.DataSet(iDS).Channel(iChannels).Name}; sMontage.DispNames = LinesLabels; @@ -707,24 +710,31 @@ function UpdateComp() [sCat, iCat] = GetSelectedCat(); % If there is something selected: Add components if ~isempty(sCat) - isICA = isequal(sCat.SingVal, 'ICA'); if (length(sCat.CompMask) > 1) - % ICA: Show all components - if isICA - iDispComp = 1:size(sCat.Components,2); - % PCA: Get only the components that grab 95% of the signal - else - Singular = sCat.SingVal ./ sum(sCat.SingVal); - iDispComp = union(1, find(cumsum(Singular)<=.95)); - % Keep only the first components - iDispComp = intersect(iDispComp, 1:20); - % Always show at least the 10 first components - iDispComp = union(iDispComp, 1:min(10,length(Singular))); + componentType = sCat.Method(1:3); + switch lower(componentType) + % ICA: Show all components + case 'ica' + % Explained variance + Singular = []; + if ~isempty(sCat.SingVal) && isnumeric(sCat.SingVal) + Singular = sCat.SingVal; + end + iDispComp = 1:size(sCat.Components,2); + + % PCA: Get only the components that grab 95% of the signal + case 'ssp' + Singular = sCat.SingVal ./ sum(sCat.SingVal); + iDispComp = union(1, find(cumsum(Singular)<=.95)); + % Keep only the first components + iDispComp = intersect(iDispComp, 1:20); + % Always show at least the 10 first components + iDispComp = union(iDispComp, 1:min(10,length(Singular))); end % Add all the components for i = iDispComp strComp = sprintf('Component #%d', i); - if ~isempty(sCat.SingVal) && ~isICA + if ~isempty(Singular) strComp = [strComp, sprintf(' [%d%%]', round(100 * Singular(i)))]; end listModel.addElement(BstListItem('', '', strComp, int32(sCat.CompMask(i)))); @@ -788,7 +798,8 @@ function SaveFigureAsSsp(hFig, UseDirectly) %#ok Components = Components ./ sqrt(sum(Components .^2)); % Build projector structure sProj = db_template('projector'); - sProj.Comment = sprintf( '%s: %s (%0.3fs)', Modality, FileName, GlobalData.UserTimeWindow.CurrentTime); + sProj.Method = 'SSP_pca'; + sProj.Comment = sprintf( 'SSP_pca: %s: %s (%0.3fs)', Modality, FileName, GlobalData.UserTimeWindow.CurrentTime); sProj.Components = Components; sProj.CompMask = 1; sProj.Status = 1; diff --git a/toolbox/gui/panel_surface.m b/toolbox/gui/panel_surface.m index 121740e8e..e7b0bf2c8 100644 --- a/toolbox/gui/panel_surface.m +++ b/toolbox/gui/panel_surface.m @@ -84,6 +84,7 @@ % Alpha slider jSliderSurfAlpha = JSlider(0, 100, 0); jSliderSurfAlpha.setPreferredSize(Dimension(SLIDER_WIDTH, DEFAULT_HEIGHT)); + jSliderSurfAlpha.setToolTipText('Surface transparency'); java_setcb(jSliderSurfAlpha, 'MouseReleasedCallback', @(h,ev)SliderCallback(h, ev, 'SurfAlpha'), ... 'KeyPressedCallback', @(h,ev)SliderCallback(h, ev, 'SurfAlpha')); jPanelSurfaceOptions.add('tab hfill', jSliderSurfAlpha); @@ -106,6 +107,20 @@ % Quick preview java_setcb(jSliderSurfSmoothValue, 'StateChangedCallback', @(h,ev)SliderQuickPreview(jSliderSurfSmoothValue, jLabelSurfSmoothValue, 1)); + % Threshold title + jLabelSurfIsoValueTitle = gui_component('label', jPanelSurfaceOptions, 'br', 'Thresh.:'); + % Min size slider + jSliderSurfIsoValue = JSlider(1, GetIsoValueMaxRange(), 1); + jSliderSurfIsoValue.setPreferredSize(Dimension(SLIDER_WIDTH, DEFAULT_HEIGHT)); + jSliderSurfIsoValue.setToolTipText('isoSurface Threshold'); + java_setcb(jSliderSurfIsoValue, 'MouseReleasedCallback', @(h,ev)SliderCallback(h, ev, 'SurfIsoValue'), ... + 'KeyPressedCallback', @(h,ev)SliderCallback(h, ev, 'SurfIsoValue')); + jPanelSurfaceOptions.add('tab hfill', jSliderSurfIsoValue); + % IsoValue label + jLabelSurfIsoValue = gui_component('label', jPanelSurfaceOptions, [], ' 1', {JLabel.RIGHT, Dimension(LABEL_WIDTH, DEFAULT_HEIGHT)}); + % Quick preview + java_setcb(jSliderSurfIsoValue, 'StateChangedCallback', @(h,ev)SliderQuickPreview(jSliderSurfIsoValue, jLabelSurfIsoValue, 0)); + % Buttons jButtonSurfColor = gui_component('button', jPanelSurfaceOptions, 'br center', 'Color', {Dimension(BUTTON_WIDTH, DEFAULT_HEIGHT), Insets(0,0,0,0)}, 'Set surface color', @ButtonSurfColorCallback); jButtonSurfSulci = gui_component('toggle', jPanelSurfaceOptions, '', 'Sulci', {Dimension(BUTTON_WIDTH, DEFAULT_HEIGHT), Insets(0,0,0,0)}, 'Show/hide sulci map', @ButtonShowSulciCallback); @@ -186,7 +201,7 @@ jToggleResectLeft = gui_component('toggle', jPanelSurfaceResect, 'br center', 'Left', {Insets(0,0,0,0), Dimension(BUTTON_WIDTH-3, DEFAULT_HEIGHT)}, '', @ButtonResectLeftToggle_Callback); jToggleResectRight = gui_component('toggle', jPanelSurfaceResect, '', 'Right', {Insets(0,0,0,0), Dimension(BUTTON_WIDTH-3, DEFAULT_HEIGHT)}, '', @ButtonResectRightToggle_Callback); jToggleResectStruct = gui_component('toggle', jPanelSurfaceResect, '', 'Struct', {Insets(0,0,0,0), Dimension(BUTTON_WIDTH-3, DEFAULT_HEIGHT)}, '', @ButtonResectStruct_Callback); - jButtonResectReset = gui_component('button', jPanelSurfaceResect, '', 'Reset', {Insets(0,0,0,0), Dimension(BUTTON_WIDTH-3, DEFAULT_HEIGHT)}, '', @ButtonResectResetCallback); + jButtonResectReset = gui_component('button', jPanelSurfaceResect, '', 'Reset', {Insets(0,0,0,0), Dimension(BUTTON_WIDTH-3, DEFAULT_HEIGHT)}, '', @ButtonResectResetCallback); jPanelOptions.add(jPanelSurfaceResect); % ===== SURFACE LABELS ===== @@ -213,6 +228,9 @@ 'jButtonSurfColor', jButtonSurfColor, ... 'jLabelSurfSmoothValue', jLabelSurfSmoothValue, ... 'jSliderSurfSmoothValue', jSliderSurfSmoothValue, ... + 'jLabelSurfIsoValueTitle',jLabelSurfIsoValueTitle, ... + 'jLabelSurfIsoValue', jLabelSurfIsoValue, ... + 'jSliderSurfIsoValue', jSliderSurfIsoValue, ... 'jButtonSurfSulci', jButtonSurfSulci, ... 'jButtonSurfEdge', jButtonSurfEdge, ... 'jSliderResectX', jSliderResectX, ... @@ -240,6 +258,8 @@ function SliderQuickPreview(jSlider, jText, isPercent) nVertices = str2num(char(jLabelNbVertices.getText())); sliderSizeVector = GetSliderSizeVector(nVertices); jText.setText(sprintf('%d', sliderSizeVector(double(jSlider.getValue())))); + elseif (jSlider == jSliderSurfIsoValue) + jText.setText(sprintf('%d', double(jSlider.getValue()))); elseif isPercent jText.setText(sprintf('%d%%', double(jSlider.getValue()))); else @@ -265,7 +285,10 @@ function ButtonResectResetCallback(varargin) jSliderResectX.setValue(0); jSliderResectY.setValue(0); jSliderResectZ.setValue(0); - + jToggleResectLeft.setSelected(0); + jToggleResectRight.setSelected(0); + jToggleResectStruct.setSelected(0); + % Get handle to current 3DViz figure hFig = bst_figures('GetCurrentFigure', '3D'); if isempty(hFig) @@ -288,7 +311,10 @@ function ButtonResectResetCallback(varargin) if strcmpi(TessInfo(iSurf).Name, 'FEM') iSurf = find(strcmpi({TessInfo.Name}, 'FEM')); end - [TessInfo(iSurf).Resect] = deal('none'); + for ix = 1 : length(iSurf) + TessInfo(iSurf(ix)).Resect{1} = [0, 0, 0]; + TessInfo(iSurf(ix)).Resect{2} = 'none'; + end setappdata(hFig, 'Surface', TessInfo); SliderCallback([], MouseEvent(jSliderResectX, 0, 0, 0, 0, 0, 1, 0), 'ResectX'); end @@ -388,6 +414,42 @@ function SliderCallback(hObject, event, target) SurfSmoothValue = jSlider.getValue() / 100; SetSurfaceSmooth(hFig, iSurface, SurfSmoothValue, 1); + case 'SurfIsoValue' + % get the handles + hFig = bst_figures('GetFiguresByType', '3DViz'); + SubjectFile = getappdata(hFig, 'SubjectFile'); + if ~isempty(SubjectFile) + sSubject = bst_get('Subject', SubjectFile); + CtFile = []; + MeshFile = []; + for i=1:length(sSubject.Anatomy) + if ~isempty(regexp(sSubject.Anatomy(i).FileName, '_volct', 'match')) + CtFile = sSubject.Anatomy(i).FileName; + end + end + for i=1:length(sSubject.Surface) + if ~isempty(regexp(sSubject.Surface(i).FileName, 'tess_isosurface', 'match')) + MeshFile = sSubject.Surface(i).FileName; + end + end + end + + % ask user if they want to proceed + isProceed = java_dialog('confirm', 'Do you want to proceed generating mesh with new isoValue ?', 'Changing threshold'); + if ~isProceed + [sSubjectTmp, iSubjectTmp, iSurfaceTmp] = bst_get('SurfaceFile', MeshFile); + isoValue = regexp(sSubjectTmp.Surface(iSurfaceTmp).Comment, '\d*', 'match'); + SetIsoValue(str2double(isoValue{1})); + return; + end + + % get the iso value from slider + isoValue = jSlider.getValue(); + + % remove the old isosurface and generate and load the new one + ButtonRemoveSurfaceCallback(); + tess_isosurface(CtFile, isoValue); + case 'DataAlpha' % Update value in Surface array TessInfo(iSurface).DataAlpha = jSlider.getValue() / 100; @@ -469,6 +531,41 @@ function SliderCallback(hObject, event, target) end end +%% ===== GET SLIDER ISOVALUE ===== +function isoValue = GetIsoValueMaxRange() + % get the handles + hFig = bst_figures('GetFiguresByType', '3DViz'); + if ~isempty(hFig) + SubjectFile = getappdata(hFig, 'SubjectFile'); + if ~isempty(SubjectFile) + sSubject = bst_get('Subject', SubjectFile); + CtFile = []; + for i=1:length(sSubject.Anatomy) + if ~isempty(regexp(sSubject.Anatomy(i).FileName, '_volct', 'match')) + CtFile = sSubject.Anatomy(i).FileName; + end + end + end + + if ~isempty(CtFile) + sMri = bst_memory('LoadMri', CtFile); + isoValue = double(sMri.Histogram.intensityMax); + end + else + isoValue = 4500.0; + end +end + +%% ===== SET SLIDER ISOVALUE ===== +function SetIsoValue(isoValue) + % get panel controls + ctrl = bst_get('PanelControls', 'Surface'); + if isempty(ctrl) + return; + end + ctrl.jLabelSurfIsoValue.setText(sprintf('%d', isoValue)); + ctrl.jSliderSurfIsoValue.setValue(isoValue); +end %% ===== SCROLL MRI CUTS ===== function ScrollMriCuts(hFig, direction, value) %#ok @@ -614,13 +711,9 @@ function SelectHemispheres(name) end % Update surface Resect field for i = 1:length(iSurf) - TessInfo(iSurf(i)).Resect = name; + TessInfo(iSurf(i)).Resect{2} = name; end setappdata(hFig, 'Surface', TessInfo); - % Reset all the resect sliders - ctrl.jSliderResectX.setValue(0); - ctrl.jSliderResectY.setValue(0); - ctrl.jSliderResectZ.setValue(0); % Display progress bar bst_progress('start', 'Select hemisphere', 'Selecting hemisphere...'); % Update surface display @@ -648,13 +741,8 @@ function ResectSurface(hFig, iSurf, resectDim, resectValue) end % Update all selected surfaces for i = 1:length(iSurf) - % If previously using "Select hemispheres" - if ischar(TessInfo(iSurf(i)).Resect) - % Reset "Resect" field - TessInfo(iSurf(i)).Resect = [0 0 0]; - end % Update value in Surface array - TessInfo(iSurf(i)).Resect(resectDim) = resectValue; + TessInfo(iSurf(i)).Resect{1}(resectDim) = resectValue; end % Update surface setappdata(hFig, 'Surface', TessInfo); @@ -673,11 +761,6 @@ function ResectSurface(hFig, iSurf, resectDim, resectValue) figure_callback(hFig, 'PlotTensorCut', hFig, resectValue, resectDim, 1); end end - % Deselect both Left and Right buttons - ctrl = bst_get('PanelControls', 'Surface'); - ctrl.jToggleResectLeft.setSelected(0); - ctrl.jToggleResectRight.setSelected(0); - ctrl.jToggleResectStruct.setSelected(0); % Close progress bar if isProgress bst_progress('text', 'Updating figure...'); @@ -1052,6 +1135,11 @@ function UpdateSurfaceProperties() end % If surface is sliced MRI isAnatomy = strcmpi(TessInfo(iSurface).Name, 'Anatomy'); + if ~isempty(regexp(TessInfo(iSurface).SurfaceFile, 'isosurface', 'match')) + isIsoSurface = 1; + else + isIsoSurface = 0; + end % ==== Surface properties ==== % Number of vertices @@ -1067,6 +1155,15 @@ function UpdateSurfaceProperties() % Surface smoothing ALPHA ctrl.jSliderSurfSmoothValue.setValue(100 * TessInfo(iSurface).SurfSmoothValue); ctrl.jLabelSurfSmoothValue.setText(sprintf('%d%%', round(100 * TessInfo(iSurface).SurfSmoothValue))); + % Show/hide isoSurface thresholding + ctrl.jSliderSurfIsoValue.setVisible(isIsoSurface); + ctrl.jLabelSurfIsoValueTitle.setVisible(isIsoSurface); + ctrl.jLabelSurfIsoValue.setVisible(isIsoSurface); + if isIsoSurface + [sSubjectTmp, iSubjectTmp, iSurfaceTmp] = bst_get('SurfaceFile', TessInfo(iSurface).SurfaceFile); + isoValue = regexp(sSubjectTmp.Surface(iSurfaceTmp).Comment, '\d*', 'match'); + SetIsoValue(str2double(isoValue{1})); + end % Show sulci button ctrl.jButtonSurfSulci.setSelected(TessInfo(iSurface).SurfShowSulci); % Show surface edges button @@ -1083,12 +1180,9 @@ function UpdateSurfaceProperties() ResectXYZ = [0,0,0]; end radioSelected = 'none'; - elseif ischar(TessInfo(iSurface).Resect) - ResectXYZ = [0,0,0]; - radioSelected = TessInfo(iSurface).Resect; else - ResectXYZ = 100 * TessInfo(iSurface).Resect; - radioSelected = 'none'; + ResectXYZ = 100 * TessInfo(iSurface).Resect{1}; + radioSelected = TessInfo(iSurface).Resect{2}; end % X, Y, Z ctrl.jSliderResectX.setValue(ResectXYZ(1)); @@ -1184,6 +1278,11 @@ function UpdateSurfaceProperties() TessInfo(iTess).Name = sSurface.Name; TessInfo(iTess).nVertices = size(sSurface.Vertices, 1); TessInfo(iTess).nFaces = size(sSurface.Faces, 1); + if isempty(sSurface.Color) + sSurface.Color = TessInfo(iTess).AnatomyColor(2,:); + else + TessInfo(iTess).AnatomyColor = [.75 .* sSurface.Color; sSurface.Color]; + end % === PLOT SURFACE === switch (FigureId.Type) @@ -1194,7 +1293,7 @@ function UpdateSurfaceProperties() [hFig, TessInfo(iTess).hPatch] = figure_3d('PlotSurface', hFig, ... sSurface.Faces, ... sSurface.Vertices, ... - TessInfo(iTess).AnatomyColor(2,:), ... + sSurface.Color, ... TessInfo(iTess).SurfAlpha); end % Update figure's surfaces list and current surface pointer @@ -1784,6 +1883,14 @@ function UpdateSurfaceProperties() % and the number of vertices of the target surface patch (IGNORE TEST FOR MRI) if strcmpi(TessInfo(iTess).Name, 'Anatomy') % Nothing to check right now + elseif ~isempty(strfind(TessInfo(iTess).DataSource.FileName, '_connect1')) + TessInfo(iTess).DataSource.Atlas = []; + if size(TessInfo(iTess).Data, 1) ~= nVertices + bst_error(sprintf(['Number of connectivity values (%d) is different from number of vertices (%d).\n\n' ... + 'Please compute the connectivity metric.'], size(TessInfo(iTess).Data, 1), nVertices), 'Data mismatch', 0); + isOk = 0; + return; + end elseif ~isempty(TessInfo(iTess).DataSource.Atlas) && ~isempty(TessInfo(iTess).DataSource.Atlas.Scouts) if (size(TessInfo(iTess).Data, 1) ~= length(TessInfo(iTess).DataSource.Atlas.Scouts)) bst_error(sprintf(['Number of sources (%d) is different from number of scouts (%d).\n\n' ... @@ -1949,6 +2056,7 @@ function UpdateSurfaceProperties() %% ===== UPDATE SURFACE COLORMAP ===== function UpdateSurfaceColormap(hFig, iSurfaces) + global GlobalData; % Get surfaces list TessInfo = getappdata(hFig, 'Surface'); if isempty(TessInfo) @@ -2022,8 +2130,15 @@ function UpdateSurfaceColormap(hFig, iSurfaces) if strcmpi(DataType, 'Data') && ~isempty(ColormapInfo.Type) && ismember(ColormapInfo.Type, {'eeg', 'meg', 'nirs'}) DataType = upper(ColormapInfo.Type); % sLORETA: Do not use regular source scaling (pAm) - elseif strcmpi(DataType, 'Source') && ~isempty(strfind(lower(TessInfo(iTess).DataSource.FileName), 'sloreta')) - DataType = 'sLORETA'; + elseif strcmpi(DataType, 'Source') + if ~isempty(strfind(lower(TessInfo(iTess).DataSource.FileName), 'sloreta')) + DataType = 'sLORETA'; + elseif ~strcmpi(file_gettype(lower(TessInfo(iTess).DataSource.FileName)), 'headmodel') + [iDS, iResult] = bst_memory('LoadResultsFile', TessInfo(iTess).DataSource.FileName, 0); + if ~isempty(strfind(lower(GlobalData.DataSet(iDS).Results(iResult).Function), 'sloreta')); + DataType = 'sLORETA'; + end + end end end % === DISPLAY ON MRI === diff --git a/toolbox/gui/view_channels_3d.m b/toolbox/gui/view_channels_3d.m index 60d8bc0f2..b5007bfc1 100644 --- a/toolbox/gui/view_channels_3d.m +++ b/toolbox/gui/view_channels_3d.m @@ -1,5 +1,21 @@ -function [hFig, iDS, iFig] = view_channels_3d(FileNames, Modality, SurfaceType, is3DElectrodes, isDetails) +function [hFig, iDS, iFig] = view_channels_3d(FileNames, Modality, SurfaceType, is3DElectrodes, isDetails, hFig) % VIEW_CHANNELS_3D: Display channel files on top of subject anatomy. +% +% INPUT: +% - FileNames : path to the channel file to display +% - Modality : modality of sensors +% - SurfaceType : surface to display sensors on (scalp) +% - is3DElectrodes +% - isDetails +% - hFig : TargetFigure: +% |- [] : New figure (default) +% |- hFig : Specify the figure in which to display the channels +% +% OUTPUT : +% - hFig : Matlab handle to the 3DViz figure that was created or updated +% - iDS : DataSet index in the GlobalData variable +% - iFig : Indice of returned figure in the GlobalData(iDS).Figure array +% If an error occurs : all the returned variables are set to an empty matrix [] % @============================================================================= % This function is part of the Brainstorm software: @@ -22,7 +38,18 @@ % Authors: Francois Tadel, 2010-2019 global GlobalData; -% Parse inputs + +%% ===== PARSE INPUTS ===== +iDS = []; +iFig = []; +if (nargin < 6) || isempty(hFig) + hFig = 'NewFigure'; +elseif ishandle(hFig) + hFig = bst_figures('GetFigure', hFig); +else + error('Invalid figure handle.'); +end + if (nargin < 5) || isempty(isDetails) isDetails = 0; end @@ -35,9 +62,7 @@ if ischar(FileNames) FileNames = {FileNames}; end -hFig = []; -iDS = []; -iFig = []; + % Coils or channel markers? isShowCoils = ismember(Modality, {'Vectorview306', 'CTF', '4D', 'KIT', 'KRISS', 'BabyMEG', 'NIRS-BRS', 'RICOH'}); @@ -76,7 +101,7 @@ case 'ECOG', SurfAlpha = .2; otherwise, SurfAlpha = opaqueAlpha; end - hFig = view_surface(SurfaceFile, SurfAlpha, [], 'NewFigure'); + hFig = view_surface(SurfaceFile, SurfAlpha, [], hFig); end case 'innerskull' if ~isempty(sSubject.iInnerSkull) && (sSubject.iInnerSkull <= length(sSubject.Surface)) @@ -88,7 +113,7 @@ case 'ECOG', SurfAlpha = .2; otherwise, SurfAlpha = opaqueAlpha; end - hFig = view_surface(SurfaceFile, SurfAlpha, [], 'NewFigure'); + hFig = view_surface(SurfaceFile, SurfAlpha, [], hFig); end case 'scalp' if ~isempty(sSubject.iScalp) && (sSubject.iScalp <= length(sSubject.Surface)) @@ -107,7 +132,7 @@ otherwise SurfAlpha = opaqueAlpha; end - hFig = view_surface(SurfaceFile, SurfAlpha, [], 'NewFigure'); + hFig = view_surface(SurfaceFile, SurfAlpha, [], hFig); end case {'anatomy', 'subjectimage'} if ~isempty(sSubject.iAnatomy) && (sSubject.iAnatomy <= length(sSubject.Anatomy)) @@ -115,12 +140,14 @@ SurfaceFile = sSubject.Anatomy(sSubject.iAnatomy).FileName; end SurfAlpha = .1; - hFig = view_mri_3d(SurfaceFile, [], SurfAlpha, 'NewFigure'); + hFig = view_mri_3d(SurfaceFile, [], SurfAlpha, hFig); end + otherwise end end % Warning if no surface was found -if isempty(hFig) +if isempty(hFig) || strcmp(hFig, 'NewFigure') + hFig = []; disp('BST> Warning: The anatomy of this subject was not imported properly.'); end diff --git a/toolbox/gui/view_connect.m b/toolbox/gui/view_connect.m index cfa5ede32..6f60cc89a 100644 --- a/toolbox/gui/view_connect.m +++ b/toolbox/gui/view_connect.m @@ -273,6 +273,8 @@ %% ===== DRAW FIGURE ===== figure_connect('LoadFigurePlot', hFig); +% Update figure title +bst_figures('UpdateFigureName', hFig); %% ===== UPDATE ENVIRONMENT ===== % Update figure selection diff --git a/toolbox/gui/view_contactsheet.m b/toolbox/gui/view_contactsheet.m index 252b8fe8a..665063cc5 100644 --- a/toolbox/gui/view_contactsheet.m +++ b/toolbox/gui/view_contactsheet.m @@ -35,6 +35,7 @@ % =============================================================================@ % % Authors: Francois Tadel, 2008-2016 +% Raymundo Cassani, 2024 global GlobalData; @@ -70,6 +71,9 @@ isAutoSave = 1; end +% Is 3D figure? +hFig = bst_figures('GetFigure', hFig); +is3D = ~strcmpi(hFig.Tag , 'MriViewer'); %% ===== GET TIME/VOLUME ===== % Get default values for the number of images @@ -77,9 +81,9 @@ % Get dimension switch lower(orientation) case 'fig', dim = 0; - case 'x', dim = 1; - case 'y', dim = 2; - case 'z', dim = 3; + case 'x', dim = 1; % Sagittal + case 'y', dim = 2; % Coronal + case 'z', dim = 3; % Axial end % Time / volume / freq switch lower(inctype) @@ -133,7 +137,7 @@ Samples = TimeVector(bst_closest(linspace(TimeRange(1), TimeRange(2), nImages), TimeVector)); % If reading MRI slices: need to get the surface definition if (dim ~= 0) - [img, TessInfo, iTess] = GetImage(hFig, dim); + [~, TessInfo, iTess] = GetImage(hFig); end % Do not use progress bar isProgress = 0; @@ -157,7 +161,7 @@ initPos = GlobalData.UserFrequencies.iCurrentFreq; % If reading MRI slices: need to get the surface definition if (dim ~= 0) - [img, TessInfo, iTess] = GetImage(hFig, dim); + [~, TessInfo, iTess] = GetImage(hFig); end % Do not use progress bar isProgress = 0; @@ -177,7 +181,7 @@ end % ================================= % Surface information - [img, TessInfo, iTess] = GetImage(hFig, dim); + [~, TessInfo, iTess] = GetImage(hFig); initPos = TessInfo(iTess).CutsPosition; % Get slices positions sMri = bst_memory('GetMri', TessInfo(iTess).SurfaceFile); @@ -203,17 +207,87 @@ if isProgress bst_progress('start', 'Contact sheet: axial slice', 'Getting slices...', 0, nImages); end +% If snapshots requested from MRI viewer, take them from 3D orthogonal slices +if ~is3D + sMri = bst_memory('GetMri', TessInfo(iTess).SurfaceFile); + overlayFile = ''; + isAnatomy = 1; + if isfield(TessInfo(iTess), 'DataSource') && ~isempty(TessInfo(iTess).DataSource) && ~isempty(TessInfo(iTess).DataSource.FileName) + overlayFile = TessInfo(iTess).DataSource.FileName; + [~, ~, isAnatomy] = file_fullpath(overlayFile); + end + if isAnatomy + hFig3d = view_mri_3d(sMri.FileName, overlayFile); + else + hFig3d = view_surface_data(sMri.FileName, overlayFile); + end + % Hide scouts during snapshots + scoutsOptions = panel_scout('GetScoutsOptions'); + panel_scout('SetScoutsOptions', scoutsOptions.overlayScouts, scoutsOptions.overlayConditions, scoutsOptions.displayAbsolute, 'none'); + % Set slides to initial position in MRI + initPos = TessInfo(iTess).CutsPosition; + panel_surface('PlotMri', hFig3d, initPos, 1); + % If OutputFile orignal call was empty or a directory + if ~isAutoSave + OutputFile = bst_fileparts(OutputFile); + end + hContactFig = view_contactsheet(hFig3d, inctype, orientation, OutputFile, nImages, TimeRange, SkipVolume); + panel_scout('SetScoutsOptions', scoutsOptions.overlayScouts, scoutsOptions.overlayConditions, scoutsOptions.displayAbsolute, scoutsOptions.showSelection); + close(hFig3d); + figure(hContactFig); + return +end % Get test image, to build the output volume -testImg = GetImage(hFig, dim); +testImg = GetImage(hFig); % Get extracted image size H = size(testImg, 1); W = size(testImg, 2); % Get number of column and rows of the contact sheet nbRows = floor(sqrt(nImages)); nbCols = ceil(nImages / nbRows); -% Initialize final image -ImgSheet = zeros(nbRows * H, nbCols * W, 3, class(testImg)); -AlphaSheet = zeros(nbRows * H, nbCols * W); +% Initialize array for images +ImgBuffer = zeros(H, W, 3, nImages, class(testImg)); +% Backup current view for 3D figures +if is3D && dim ~= 0 + hAxes = findobj(hFig, '-depth', 1, 'Tag', 'Axes3D'); + % Copy view angle + [az,el] = view(hAxes); + % Copy cam position + pos = campos(hAxes); + % Copy cam target + tar = camtarget(hAxes); + % Copy cam up vector + up = camup(hAxes); + % Copy zoom factor + camva = get(hAxes, 'CameraViewAngle'); + % Set perpendicular view to requested 3D slices + switch dim + case 1 % Sagittal + viewPos = 'right'; + if MriOptions.isRadioOrient + viewPos = 'left'; + end + case 2 % Coronal + viewPos = 'back'; + if MriOptions.isRadioOrient + viewPos = 'front'; + end + case 3 % Axial + viewPos = 'top'; + if MriOptions.isRadioOrient + viewPos = 'bottom'; + end + end + figure_3d('SetStandardView', hFig, {viewPos}); + % Hide colorbar if no data displayed + if strcmpi('volume', inctype) && isempty(TessInfo.Data) + ColormapInfo = getappdata(hFig, 'Colormap'); + sColormap = bst_colormaps('GetColormap', ColormapInfo.Type); + isAnatomyColormap = sColormap.DisplayColorbar; + bst_colormaps('SetDisplayColorbar', ColormapInfo.Type, 0); + end +end + % For each time instant for iSample = 1:nImages % Progress bar @@ -236,35 +310,13 @@ slicesPos(dim) = Samples(iSample); panel_surface('PlotMri', hFig, slicesPos); end - - % Get full figure - if (dim == 0) - % Screen capture - switch lower(inctype) - case 'time', img = out_figure_image(hFig, [], 'time'); - case 'freq', img = out_figure_image(hFig, [], FreqLabels{iSample}); - case 'volume', img = out_figure_image(hFig); - end - alpha = ones(size(img,1), size(img,2), 1); - % Get one slice - else - TessInfo = getappdata(hFig, 'Surface'); - % Get image - img = get(TessInfo(iTess).hPatch(dim), 'CData'); - img = bst_flip(img, 1); - alpha = get(TessInfo(iTess).hPatch(dim), 'AlphaData'); - alpha = bst_flip(alpha, 1); - % Apply radiological orientation - if MriOptions.isRadioOrient - img = bst_flip(img, 2); - alpha = bst_flip(alpha, 2); - end - end - % Find extacted image position in final sheet - i = floor((iSample-1) / nbCols); - j = mod(iSample-1, nbCols); - ImgSheet(i*H+1:(i+1)*H, j*W+1:(j+1)*W, :) = img; - AlphaSheet(i*H+1:(i+1)*H, j*W+1:(j+1)*W) = alpha; + % Get screen capture + switch lower(inctype) + case 'time', img = out_figure_image(hFig, [], 'time'); + case 'freq', img = out_figure_image(hFig, [], FreqLabels{iSample}); + case 'volume', img = out_figure_image(hFig, [], ''); + end + ImgBuffer(:,:,:,iSample) = img; end %% ===== RESTORE INITIAL POSITION ===== @@ -278,70 +330,55 @@ setappdata(hFig, 'Surface', TessInfo); figure_3d('UpdateMriDisplay', hFig, dim, TessInfo, iTess); end +% Backup current view +if is3D && dim ~= 0 + % Copy view angle + view(hAxes, az, el); + % Copy cam position + campos(hAxes, pos); + % Copy cam target + camtarget(hAxes, tar); + % Copy cam up vector + camup(hAxes, up); + % Copy zoom factor + set(hAxes, 'CameraViewAngle', camva); + % Restore colorbar + if strcmpi('volume', inctype) && isempty(TessInfo.Data) && isAnatomyColormap + bst_colormaps('SetDisplayColorbar', ColormapInfo.Type, 1); + end +end %% ===== REMOVE USELESS BACKGROUND ===== % Only in the case of MRI slices if (dim ~= 0) - % Get background points - background = double(AlphaSheet == 0); - % If there are no transparent points on the surface: detect "black" points - if (nnz(background) == 0) - background = double(sqrt(sum(double(ImgSheet).^2,3)) < .05); - end + % Detect "black" points for all images as background + background = all(double(sqrt(sum(double(ImgBuffer).^2,3)) < .05), 4); % Grow background region, to remove all the small parasites - kernel = ones(5,5); + kernel = ones(2,2); background = double(conv2(background, kernel, 'same') > 0); % Grow foreground regions, to cut at least 10 pixels away from each meaningful block of data kernel = ones(11); background = conv2(double(background == 0), kernel, 'same') == 0; - % Detect the empty columns and arrows + % Detect the empty columns and rows iEmptyCol = find(all(background, 1)); iEmptyRow = find(all(background, 2)); % Remove empty lines and columns - ImgSheet(iEmptyRow, :, :) = []; - ImgSheet(:, iEmptyCol, :) = []; + ImgBuffer(iEmptyRow, :, :, :) = []; + ImgBuffer(:, iEmptyCol, :, :) = []; + % Update image size + H = size(ImgBuffer, 1); + W = size(ImgBuffer, 2); end -%% ===== RE-INTERPOLATE IMAGE ===== -% If the MRI image is non-isotropic, re-interpolate it according to the voxel size -if (dim ~= 0) - % Get subject MRI - sMri = bst_memory('GetMri', TessInfo(iTess).SurfaceFile); - % Get image pixel size - pixSize = sMri.Voxsize; - pixSize(dim) = []; - % If image is non-isotropic - if (pixSize(1) ~= pixSize(2)) - % Expand width: Permute dimensions and expand height - isPermute = (pixSize(1) > pixSize(2)); - if isPermute - ImgSheet = permute(ImgSheet, [2 1 3]); - pixSize = fliplr(pixSize); - end - - % === Expand height === - % Get new image size - ratio = pixSize(2) ./ pixSize(1); - initHeight = size(ImgSheet,1); - finalHeight = round(initHeight .* ratio); - X = linspace(1, finalHeight, initHeight); - Xi = 1:finalHeight; - % Build upsampled image - ImgSheet_rsmp = zeros(finalHeight, size(ImgSheet,2), 3); - for j = 1:size(ImgSheet,2) - for k = 1:size(ImgSheet,3) - ImgSheet_rsmp(:,j,k) = interp1(X, ImgSheet(:,j,k), Xi); - end - end - ImgSheet = ImgSheet_rsmp; - - % Re-permute - if isPermute - ImgSheet = permute(ImgSheet, [2 1 3]); - end - end +%% ===== CONCATENATE FINAL IMAGE ===== +ImgSheet = zeros(nbRows * H, nbCols * W, 3, class(testImg)); +for iSample = 1:nImages + % Find extacted image position in final sheet + i = floor((iSample-1) / nbCols); + j = mod(iSample-1, nbCols); + ImgSheet(i*H+1:(i+1)*H, j*W+1:(j+1)*W, :) = ImgBuffer(:,:,:,iSample); end @@ -364,22 +401,15 @@ %% ================================================================================ % ===== GET IMAGE ================================================================ % ================================================================================ -function [img, TessInfo, iTess] = GetImage(hFig, dim) - if (dim == 0) - drawnow; - figure(hFig); - img = out_figure_image(hFig); - else - % Get slice in the right orientation - TessInfo = getappdata(hFig, 'Surface'); - iTess = strcmpi('Anatomy', {TessInfo.Name}); - if isempty(iTess) - hContactFig = []; - return - end - hSlice = TessInfo(iTess).hPatch(dim); - % Get test image, to build the output volume - img = get(hSlice, 'CData'); +function [img, TessInfo, iTess] = GetImage(hFig) + drawnow; + figure(hFig); + img = out_figure_image(hFig); + % Get MRI information from figure + TessInfo = getappdata(hFig, 'Surface'); + iTess = strcmpi('Anatomy', {TessInfo.Name}); + if isempty(iTess) + TessInfo = []; end end diff --git a/toolbox/gui/view_headpoints.m b/toolbox/gui/view_headpoints.m index b5776c1f9..d7dcd0603 100644 --- a/toolbox/gui/view_headpoints.m +++ b/toolbox/gui/view_headpoints.m @@ -30,7 +30,7 @@ % % Authors: Francois Tadel, 2010-2022 -global GlobalData; +global GlobalData Digitize % Default: no color for the distance between the scalp and the points if (nargin < 4) || isempty(isColorDist) @@ -68,8 +68,16 @@ % Load full channel file ChannelMat = in_bst_channel(ChannelFile); -% View scalp surface if available -[hFig, iDS, iFig] = view_surface(ScalpFile, .2); +% Head points for digitizer +if gui_brainstorm('isTabVisible', 'Digitize') && strcmpi(Digitize.Type, '3DScanner') + [hFig, iFig, iDS] = bst_figures('GetCurrentFigure', '3D'); +else + % View on figure with scalp surface if available + [hFig, iFig, iDS] = bst_figures('GetFigureWithSurface', file_short(ScalpFile)); + if isempty(hFig) + [hFig, iDS, iFig] = view_surface(ScalpFile, .2); + end +end figure_3d('SetStandardView', hFig, 'front'); % Extend figure and dataset for this particular channel file diff --git a/toolbox/gui/view_image_reg.m b/toolbox/gui/view_image_reg.m index 9d138427b..ed0c019cb 100644 --- a/toolbox/gui/view_image_reg.m +++ b/toolbox/gui/view_image_reg.m @@ -156,6 +156,8 @@ if ~isempty(FileName) && strcmpi(file_gettype(FileName), 'timefreq') && ~isempty(strfind(FileName, '_connectn')) hAxes = findobj(hFig, '-depth', 1, 'Tag', 'AxesImage'); set(hAxes, 'DataAspectRatio', [1 1 1]); + title(hAxes, DisplayUnits) + DisplayUnits = ''; end diff --git a/toolbox/gui/view_leadfield_sensitivity.m b/toolbox/gui/view_leadfield_sensitivity.m index fcbb0a3db..aba80fa40 100644 --- a/toolbox/gui/view_leadfield_sensitivity.m +++ b/toolbox/gui/view_leadfield_sensitivity.m @@ -161,7 +161,7 @@ case {'isosurface', 'mri3d', 'surface'} view_channels(ChannelFile, Modality, 1, 0, hFig, 1); case 'mriviewer' - figure_mri('LoadElectrodes', hFig, ChannelFile, Modality); + panel_ieeg('LoadElectrodes', hFig, ChannelFile, Modality); gui_brainstorm('ShowToolTab', 'iEEG'); end end diff --git a/toolbox/gui/view_matrix.m b/toolbox/gui/view_matrix.m index 7d6da2318..f4933706f 100644 --- a/toolbox/gui/view_matrix.m +++ b/toolbox/gui/view_matrix.m @@ -65,6 +65,7 @@ % Add tag in the figure appdata StatInfo.StatFile = MatFile; StatInfo.DisplayMode = DisplayMode; + Modality = 'stat'; % Regular matrix file else @@ -77,6 +78,13 @@ Value = bst_memory('FilterLoadedData', Value, sfreq); end StatInfo = []; + Modality = []; +end +% Colormap type +if isfield(sMat, 'ColormapType') && ~isempty(sMat.ColormapType) + ColormapType = sMat.ColormapType; +else + ColormapType = []; end % Display units if isfield(sMat, 'DisplayUnits') && ~isempty(sMat.DisplayUnits) @@ -104,7 +112,7 @@ AxesLabels = sMat.Comment; LinesLabels = sMat.Description; end - [hFig, iDS, iFig] = view_timeseries_matrix(MatFile, Value, [], [], AxesLabels, LinesLabels, [], hFig, Std, DisplayUnits); + [hFig, iDS, iFig] = view_timeseries_matrix(MatFile, Value, [], Modality, AxesLabels, LinesLabels, [], hFig, Std, DisplayUnits); case 'image' % Load file @@ -136,8 +144,7 @@ end % Create the image volume: [N1 x N2 x Ntime x Nfreq] M = reshape(Value, size(Value,1), 1, size(Value,2), 1); - % Show the image - [hFig, iDS, iFig] = view_image_reg(M, Labels, [1,3], DimLabels, MatFile, hFig, [], 1, '$freq'); + [hFig, iDS, iFig] = view_image_reg(M, Labels, [1,3], DimLabels, MatFile, hFig, ColormapType, 1, '$freq', DisplayUnits); % Add stat info in the file if ~isempty(StatInfo) setappdata(hFig, 'StatInfo', StatInfo); diff --git a/toolbox/gui/view_mri_3d.m b/toolbox/gui/view_mri_3d.m index 5d437db3f..6dc6dcd06 100644 --- a/toolbox/gui/view_mri_3d.m +++ b/toolbox/gui/view_mri_3d.m @@ -132,7 +132,7 @@ end % If there is already a volume displayed in this figure, create a new one TessInfo = getappdata(hFig, 'Surface'); -if ~isempty(TessInfo) && ismember('Anatomy', {TessInfo.Name}) +if ~isempty(TessInfo) && ismember('Anatomy', {TessInfo.Name}) && ~ismember(MriFile, {TessInfo.SurfaceFile}) [hFig, iFig, isNewFig] = bst_figures('CreateFigure', iDS, FigureId, 'AlwaysCreate'); end % Set application data diff --git a/toolbox/gui/view_mri_histogram.m b/toolbox/gui/view_mri_histogram.m index b2a13ff58..cd2043c4e 100644 --- a/toolbox/gui/view_mri_histogram.m +++ b/toolbox/gui/view_mri_histogram.m @@ -1,10 +1,12 @@ -function hFig = view_mri_histogram( MriFile ) +function hFig = view_mri_histogram(MriFile, isInteractive) % VIEW_MRI_HISTOGRAM: Compute and view the histogram of a brainstorm MRI. % -% USAGE: hFig = view_mri_histogram( MriFile ); +% USAGE: hFig = view_mri_histogram(MriFile, isInteractive=false); % % INPUT: % - MriFile : Full path to a brainstorm MRI file +% - isInteractive : If true, clicking on the figure will update the background threshold value, +% and offer saving when closing the figure. % OUTPUT: % - hFig : Matlab handle to the figure where the histogram is displayed % @============================================================================= @@ -25,19 +27,30 @@ % For more information type "brainstorm license" at command prompt. % =============================================================================@ % -% Authors: Francois Tadel, 2006-2020 +% Authors: Francois Tadel, 2006-2020, Marc Lalancette 2025 -%% ===== COMPUTE HISTOGRAM ===== +if nargin < 2 || isempty(isInteractive) + isInteractive = false; +end + +%% ===== LOAD OR COMPUTE HISTOGRAM ===== % Display progress bar bst_progress('start', 'View MRI historgram', 'Computing histogram...'); -% Load full MRI -MRI = load(MriFile); -% Compute histogram -Histogram = mri_histogram(MRI.Cube(:,:,:,1)); -% Save histogram -s.Histogram = Histogram; -bst_save(MriFile, s, 'v7', 1); - +if isstruct(MriFile) && isfield(MriFile, 'intensityMax') + Histogram = MriFile; +else + % Load full MRI + MRI = load(MriFile); + % Compute histogram if missing + if ~isfield(MRI, 'Histogram') || isempty(MRI.Histogram) || ~isfield(MRI.Histogram, 'intensityMax') + Histogram = mri_histogram(MRI.Cube(:,:,:,1)); + % Save histogram + s.Histogram = Histogram; + bst_save(MriFile, s, 'v7', 1); % isAppend + else + Histogram = MRI.Histogram; + end +end %% ===== DISPLAY HISTOGRAM ===== % Create figure @@ -81,17 +94,56 @@ yLimits = [0 maxVal*1.3]; ylim(yLimits); % Display background and white matter thresholds -line([Histogram.bgLevel, Histogram.bgLevel], yLimits, 'Color','b'); +% Keep original level to check if it changes. +bgLevel = Histogram.bgLevel; +hBg = line([Histogram.bgLevel, Histogram.bgLevel], yLimits, 'Color','b'); line([Histogram.whiteLevel, Histogram.whiteLevel], yLimits, 'Color','y'); h = legend('MRI hist.','Smoothed hist.','Cumulative hist.','Maxima','Minima',... 'Scalp or grey thresh.','White m thresh.'); set(h, 'FontSize', bst_get('FigFont'), ... 'FontUnits', 'points'); +% Set interactive callbacks +if isInteractive + set(hAxes, 'ButtonDownFcn', @clickCallback); + set(hFig, 'CloseRequestFcn', @(src, event) closeFigureCallback()); +end + % Hide progress bar bst_progress('stop'); +function clickCallback(~, event) + % Extract the x-coordinate of the click + bgLevel = event.IntersectionPoint(1); + % fprintf('Clicked at x = %.4f\n', x); + if bgLevel ~= Histogram.bgLevel + set(hBg, 'xdata', [bgLevel, bgLevel]); + % drawnow % needed? + end +end + +function closeFigureCallback() + if bgLevel ~= Histogram.bgLevel + % Request save confirmation. + [Proceed, isCancel] = java_dialog('confirm', sprintf(... + 'MRI background intensity threshold changed (%d > %d). Save?', round(Histogram.bgLevel), round(bgLevel)), ... + 'MRI background threshold'); + if isCancel + return; + end + if Proceed + % Save histogram + Histogram.bgLevel = bgLevel; + s.Histogram = Histogram; + bst_save(MriFile, s, 'v7', 1); % isAppend + end + end + % Close figure + delete(hFig); +end + +end diff --git a/toolbox/gui/view_scouts.m b/toolbox/gui/view_scouts.m index 43d968283..284c79d63 100644 --- a/toolbox/gui/view_scouts.m +++ b/toolbox/gui/view_scouts.m @@ -81,6 +81,11 @@ % Else: use directly the scout indices in argument else iScouts = ScoutsArg; + % Get current atlas + sAtlas = panel_scout('GetAtlas'); + % Volume scout: Get number of vertices of the atlas + [isVolumeAtlas, nAtlasGrid] = panel_scout('ParseVolumeAtlas', sAtlas.Name); + % Get selected scouts [sScouts, sSurf] = panel_scout('GetScouts', iScouts); end if isempty(sScouts) @@ -174,7 +179,9 @@ bst_progress('stop'); return; end - if ~isempty(strfind(lower(ResultsFiles{iResFile}), 'sloreta')) || ~isempty(strfind(lower(GlobalData.DataSet(iDS).Results(iResult).Comment), 'sloreta')) + if ~isempty(strfind(lower(ResultsFiles{iResFile}), 'sloreta')) || ... + ~isempty(strfind(lower(GlobalData.DataSet(iDS).Results(iResult).Comment), 'sloreta')) || ... + ~isempty(strfind(lower(GlobalData.DataSet(iDS).Results(iResult).Function), 'sloreta')) issloreta = 1; end fileUnits = GlobalData.DataSet(iDS).Results(iResult).DisplayUnits; @@ -355,6 +362,7 @@ isempty(strfind(TestTags, '_norm')) && ... isempty(strfind(TestTags, 'NIRS')) && ... isempty(strfind(TestTags, 'Summed_sensitivities')) && ... + isempty(strfind(TestTags, 'bold')) && ... (isempty(GlobalData.DataSet(iDS).Channel) || isempty(GlobalData.DataSet(iDS).Results(iResult).GoodChannel) || ... ~ismember('NIRS', {GlobalData.DataSet(iDS).Channel(GlobalData.DataSet(iDS).Results(iResult).GoodChannel).Type})); iTrace = k; @@ -660,6 +668,8 @@ else setappdata(hFig, 'ResultsFile', []); end +% === UNIFORM AMPLITUDE SCALE === +figure_timeseries('UniformizeTimeSeriesScales', ScoutsOptions.uniformAmplitude); % Update figure name bst_figures('UpdateFigureName', hFig); % Set the time label visible diff --git a/toolbox/gui/view_surface_data.m b/toolbox/gui/view_surface_data.m index 826636d01..bcfdbfcf1 100644 --- a/toolbox/gui/view_surface_data.m +++ b/toolbox/gui/view_surface_data.m @@ -159,16 +159,17 @@ end OverlayType = 'Source'; case {'timefreq', 'ptimefreq'} - % Force loading associated sources if displaying on the MRI - isLoadResults = strcmpi(SurfaceType, 'Anatomy') || ~isempty(strfind(OverlayFile, '_KERNEL_')); - % Load timefreq file - [iDS, iTimefreq, iResult] = bst_memory('LoadTimefreqFile', OverlayFile, 1, isLoadResults); + % Load timefreq file with associated sources + [iDS, iTimefreq, iResult] = bst_memory('LoadTimefreqFile', OverlayFile, 1, 1); OverlayType = 'Timefreq'; case 'headmodel' OverlayType = 'HeadModel'; iDS = bst_memory('GetDataSetSubject', sSubject.FileName, 1); end +if isempty(iDS) + return; +end %% ===== MODALITY ===== if isempty(Modality) @@ -318,10 +319,16 @@ %% ===== DISPLAY SCOUTS ===== -% If the default atlas is "Source model" or "Structures": Switch it back to "User scouts" +SurfaceFile = panel_scout('GetScoutSurface', hFig); sAtlas = panel_scout('GetAtlas', SurfaceFile); -if ~isempty(sAtlas) && ismember(sAtlas.Name, {'Structures', 'Source model'}) - panel_scout('SetCurrentAtlas', 1); +if ~isempty(sAtlas) + % Check if default atlas matches grid type (surface or volume) + isVolumeAtlas = panel_scout('ParseVolumeAtlas', sAtlas.Name); + isSameGridType = ~xor(isVolumeAtlas, ismember(lower(SurfaceType), {'anatomy', 'fibers'})); + % Switch atlas to "User scouts" when for "Source model" or "Structures" atlases or atlas does not match grid type + if ismember(sAtlas.Name, {'Structures', 'Source model'}) || ~isSameGridType + panel_scout('SetCurrentAtlas', 1); + end end % If there are some loaded scouts available for this figure if ShowScouts && (isResults || isTimefreq) diff --git a/toolbox/gui/view_surface_matrix.m b/toolbox/gui/view_surface_matrix.m index 21dd1df23..c711ca4b3 100644 --- a/toolbox/gui/view_surface_matrix.m +++ b/toolbox/gui/view_surface_matrix.m @@ -40,9 +40,12 @@ % =============================================================================@ % % Authors: Francois Tadel, 2008-2019 +% Chinmay Chinara, 2024 %% ===== PARSE INPUTS ===== -global GlobalData; +global GlobalData + +iDS = []; % If full surface structure is passed if isstruct(Vertices) sSurf = Vertices; @@ -76,14 +79,44 @@ SurfaceFile = []; end +% ===== If surface file is defined ===== +if ~isempty(SurfaceFile) && ~isFem + % Get Subject that holds this surface + sSubject = bst_get('SurfaceFile', SurfaceFile); + % If this surface does not belong to any subject + if isempty(iDS) + if isempty(sSubject) + % Check that the SurfaceFile really exist as an absolute file path + if ~file_exist(SurfaceFile) + bst_error(['File not found : "', SurfaceFile, '"'], 'Display surface'); + return + end + % Create an empty DataSet + SubjectFile = ''; + iDS = bst_memory('GetDataSetEmpty'); + else + % Get GlobalData DataSet associated with subjectfile (create if does not exist) + SubjectFile = sSubject.FileName; + iDS = bst_memory('GetDataSetSubject', SubjectFile, 1); + end + iDS = iDS(1); + else + SubjectFile = sSubject.FileName; + end +end + + % ===== Create new 3DViz figure ===== isProgress = ~bst_progress('isVisible'); if isProgress bst_progress('start', 'View surface', 'Loading surface file...'); end + if isempty(hFig) - % Create a new empty DataSet - iDS = bst_memory('GetDataSetEmpty'); + if isempty(SurfaceFile) + % Create a new empty DataSet + iDS = bst_memory('GetDataSetEmpty'); + end % Prepare FigureId structure FigureId = db_template('FigureId'); FigureId.Type = '3DViz'; @@ -100,6 +133,12 @@ isNewFig = 0; end +if ~isempty(SurfaceFile) && ~isFem + % Set application data + setappdata(hFig, 'SubjectFile', SubjectFile); +end + + % ===== Create a pseudo-surface ===== % Surface type if isFem @@ -117,6 +156,9 @@ sLoadedSurf.Comment = 'User_surface'; sLoadedSurf.Vertices = Vertices; sLoadedSurf.Faces = Faces; +if ~isempty(SurfColor) + sLoadedSurf.Color = SurfColor; +end if ~isempty(sSurf) sLoadedSurf.VertConn = sSurf.VertConn; sLoadedSurf.VertNormals = sSurf.VertNormals; @@ -142,7 +184,7 @@ end % Register in the GUI GlobalData.Surface(end + 1) = sLoadedSurf; - + % ===== Add target surface ===== % Get figure appdata (surfaces configuration) TessInfo = getappdata(hFig, 'Surface'); @@ -209,5 +251,4 @@ gui_brainstorm('SetSelectedTab', 'Surface'); end - end diff --git a/toolbox/gui/view_topography.m b/toolbox/gui/view_topography.m index 5c1a45dc8..a45708207 100644 --- a/toolbox/gui/view_topography.m +++ b/toolbox/gui/view_topography.m @@ -185,8 +185,24 @@ return; end % Additional files: not supported - if ~isempty(MultiDataFiles) + [~, fBase] = bst_fileparts(DataFile); + if ~isempty(MultiDataFiles) && isempty(strfind(fBase, '_psd')) && isempty(strfind(fBase, '_fft')) error('Multiple time-frequency files are not yet supported in 2DLayout.'); + else + % Load additional files + for iFile = 2:length(MultiDataFiles) + iDSmulti = bst_memory('LoadTimefreqFile', MultiDataFiles{iFile}); + if isempty(iDSmulti) + error(['An error occurred loading file: ' MultiDataFiles{iFile}]); + end + % Channel names must be the same for all the files + if ~isequal({GlobalData.DataSet(iDS).Channel.Name}, {GlobalData.DataSet(iDSmulti).Channel.Name}) + bst_error(['All the files must have the same list of channels.', 10, 'Consider using the process "Standardize > Uniform list of channels".'], 'View topography', 0); + return; + end + % Add bad channels to the common list of bad channels (first file) + GlobalData.DataSet(iDS).Measures.ChannelFlag(GlobalData.DataSet(iDSmulti).Measures.ChannelFlag == -1) = -1; + end end % Colormap type if ~isempty(GlobalData.DataSet(iDS).Timefreq(iTimefreq).ColormapType) @@ -360,7 +376,7 @@ TfInfo.RowName = []; TfInfo.RefRowName = RefRowName; TfInfo.Function = process_tf_measure('GetDefaultFunction', GlobalData.DataSet(iDS).Timefreq(iTimefreq)); - if isStaticFreq + if isStaticFreq || (isStatic && strcmpi(TopoType, '2DLayout')) TfInfo.iFreqs = []; elseif ~isempty(GlobalData.UserFrequencies.iCurrentFreq) TfInfo.iFreqs = GlobalData.UserFrequencies.iCurrentFreq; diff --git a/toolbox/inverse/panel_inverse_2018.m b/toolbox/inverse/panel_inverse_2018.m index 6c49ddd80..13976d781 100644 --- a/toolbox/inverse/panel_inverse_2018.m +++ b/toolbox/inverse/panel_inverse_2018.m @@ -448,11 +448,14 @@ function Method_Callback(hObject, event) %% ===== MODALITY CALLBACK ===== function Modality_Callback(hObject, event) + isMEM = ~isempty(ctrl.jRadioMethodMem) && ctrl.jRadioMethodMem.isSelected(); + % If only one checkbox: can't deselect it if (length(Modalities) == 1) event.getSource().setSelected(1); % Warning if both MEG and EEG are selected - elseif isFirstCombinationWarning && ~isempty(jCheckEeg) && jCheckEeg.isSelected() && (... + elseif isFirstCombinationWarning && ~isMEM && ... + ~isempty(jCheckEeg) && jCheckEeg.isSelected() && (... (~isempty(jCheckMeg) && jCheckMeg.isSelected()) || ... (~isempty(jCheckMegGrad) && jCheckMegGrad.isSelected()) || ... (~isempty(jCheckMegMag) && jCheckMegMag.isSelected())) diff --git a/toolbox/io/bst_save_coregistration.m b/toolbox/io/bst_save_coregistration.m new file mode 100644 index 000000000..593576255 --- /dev/null +++ b/toolbox/io/bst_save_coregistration.m @@ -0,0 +1,319 @@ +function [isSuccess, OutFilesMri, OutFilesMeg] = bst_save_coregistration(iSubjects, isBids) + % Save MRI-MEG coregistration info in imported raw BIDS dataset, or MRI fiducials only if not BIDS. + % + % [isSuccess, OutFilesMri, OutFilesMeg] = bst_save_coregistration(iSubjects, isBids=) + % + % Save MRI-MEG coregistration by adding AnatomicalLandmarkCoordinates to the + % _T1w.json MRI metadata, in 0-indexed voxel coordinates, and to the + % _coordsystem.json files for functional data, in native coordinates (e.g. CTF). + % The points used are the anatomical fiducials marked in Brainstorm on the MRI + % that define the Brainstorm subject coordinate system (SCS). + % + % If the raw data is not BIDS, the anatomical fiducials are saved in a + % fiducials.m file next to the raw MRI file, in Brainstorm MRI coordinates. + % + % Discussion about saving MRI-MEG coregistration in BIDS: + % https://groups.google.com/g/bids-discussion/c/BeyUeuNGl7I + + if nargin < 2 || isempty(isBids) + isBids = []; + end + sSubjects = bst_get('ProtocolSubjects'); + if nargin < 1 || isempty(iSubjects) + % Try to get all subjects from currently loaded protocol. + nSub = numel(sSubjects.Subject); + iSubjects = 1:nSub; + else + nSub = numel(iSubjects); + end + + bst_progress('start', 'Save co-registration', ' ', 0, nSub); + + OutFilesMri = cell(nSub, 1); + OutFilesMeg = cell(nSub, 1); + isSuccess = false(nSub, 1); + BidsRoot = ''; + for iOutSub = 1:nSub + iSub = iSubjects(iOutSub); + % Get anatomical file. + if ~contains(sSubjects.Subject(iSub).Anatomy(sSubjects.Subject(iSub).iAnatomy).Comment, 'MRI', 'ignorecase', true) && ... + ~contains(sSubjects.Subject(iSub).Anatomy(sSubjects.Subject(iSub).iAnatomy).Comment, 't1w', 'ignorecase', true) + warning('Selected anatomy is not ''MRI''. Skipping subject %s.', sSubjects.Subject(iSub).Name); + continue; + end + sMri = load(file_fullpath(sSubjects.Subject(iSub).Anatomy(sSubjects.Subject(iSub).iAnatomy).FileName)); + ImportedFile = strrep(sMri.History{1,3}, 'Import from: ', ''); + if ~exist(ImportedFile, 'file') + warning('Imported anatomy file not found. Skipping subject %s.', sSubjects.Subject(iSub).Name); + continue; + end + % Get all linked raw data files. + sStudies = bst_get('StudyWithSubject', sSubjects.Subject(iSub).FileName); + if isempty(isBids) + % Try to find root BIDS folder. + BidsRoot = bst_fileparts(bst_fileparts(ImportedFile)); % go back through "anat" and subject folders at least (session not mandatory). + isBids = true; % changed if not found below + while ~exist(fullfile(BidsRoot, 'dataset_description.json'), 'file') + if isempty(BidsRoot) || ~exist(BidsRoot, 'dir') + isBids = false; + fprintf('BST> bst_save_coregistration detected that raw imported data is NOT structured as BIDS.'); + break; + end + BidsRoot = bst_fileparts(BidsRoot); + end + end + if isBids + if isempty(BidsRoot) + BidsRoot = bst_fileparts(bst_fileparts(ImportedFile)); % go back through "anat" and subject folders at least (session not mandatory). + while ~exist(fullfile(BidsRoot, 'dataset_description.json'), 'file') + if isempty(BidsRoot) || ~exist(BidsRoot, 'dir') + error('Cannot find BIDS root folder and dataset_description.json file; subject %s.', sSubjects.Subject(iSub).Name); + end + BidsRoot = bst_fileparts(BidsRoot); + end + end + + % MRI _t1w.json + % Save anatomical landmarks in Nifti voxel coordinates + [MriPath, MriName, MriExt] = bst_fileparts(ImportedFile); + if strcmpi(MriExt, '.gz') + [~, MriName, MriExt2] = fileparts(MriName); + MriExt = [MriExt2, MriExt]; %#ok + end + if ~strncmpi(MriExt, '.nii', 4) + warning('Imported anatomy not BIDS. Skipping subject %s.', sSubjects.Subject(iSub).Name); + continue; + end + MriJsonFile = fullfile(MriPath, [MriName, '.json']); + if ~exist(MriJsonFile, 'file') + warning('Imported anatomy BIDS json file not found. Skipping subject %s.', sSubjects.Subject(iSub).Name); + continue; + end + sMriJson = bst_jsondecode(MriJsonFile, false); + % We ignore other fiducials after the 3 we use for coregistration. + % These were likely not placed well, only automatically by initial + % linear template alignment. + BstFids = {'NAS', 'LPA', 'RPA'}; %, 'AC', 'PC', 'IH'}; + % We need to go to original Nifti voxel coordinates, but Brainstorm may have + % flipped/permuted dimensions to bring voxels to RAS orientation. If it did, it modified + % all sMRI fields accordingly, including under .Header, and it saved the transformation + % under .InitTransf 'reorient'. + iTransf = find(strcmpi(sMri.InitTransf(:,1), 'reorient')); + if ~isempty(iTransf) + tReorient = sMri.InitTransf{iTransf(1),2}; % Voxel 0-based transformation, from original to Brainstorm + tReorientInv = inv(tReorient); + tReorientInv(4,:) = []; + end + + isLandmarksFound = true; + for iFid = 1:numel(BstFids) + if iFid < 4 + CS = 'SCS'; + else + CS = 'NCS'; + end + Fid = BstFids{iFid}; + % Voxel coordinates (Nifti: 0-indexed, but orientation not standardized, world coords are RAS) + % Bst MRI coordinates are in mm and voxels are 1-indexed, so subtract 1 voxel after going from mm to voxels. + if isfield(sMri, CS) && isfield(sMri.(CS), Fid) && ~isempty(sMri.(CS).(Fid)) && any(sMri.(CS).(Fid)) + % Round to 0.001 voxel. + % Voxsize has 3 elements, ok for non-isotropic voxel size + FidCoord = round(1000 * (sMri.(CS).(Fid)./sMri.Voxsize - 1)) / 1000; + if ~isempty(iTransf) + % Go from Brainstorm RAS-oriented voxels, back to original Nifti voxel orientation. + % Both are 0-indexed in this transform. + FidCoord = [FidCoord, 1] * tReorientInv'; + end + sMriJson.AnatomicalLandmarkCoordinates.(Fid) = FidCoord; + else + isLandmarksFound = false; + break; + end + end + if ~isLandmarksFound + warning('MRI landmark coordinates not found. Skipping subject %s.', sSubjects.Subject(iSub).Name); + continue; + end + WriteJson(MriJsonFile, sMriJson); + OutFilesMri{iOutSub} = MriJsonFile; + + % MEG _coordsystem.json + % Save MRI anatomical landmarks in SCS coordinates and link to MRI. + % This includes coregistration refinement using head points, if used. + + % Convert from mri to scs. + for iFid = 1:3 + Fid = BstFids{iFid}; + % cs_convert mri is in meters + sMriScs.SCS.(Fid) = cs_convert(sMri, 'mri', 'scs', sMri.SCS.(Fid) ./ 1000); + end + sMriNative = sMriScs; % transformed below + + % MEG _coordsystem.json are shared between studies (recordings) within a session. + % Get a list of: (studies >) channel files and (linked original MEG recordings >) json. + % For each unique json, update with first channel file, and check consistency of each + % additional channel file. + MegList = table('VariableNames', {'CoordJson', 'Channel'}, 'VariableTypes', {'char', 'char'}); + for iStudy = 1:numel(sStudies) + % Try to find the first link to raw file in this study. + isLinkToRaw = false; + for iData = 1:numel(sStudies(iStudy).Data) + if strcmpi(sStudies(iStudy).Data(iData).DataType, 'raw') + isLinkToRaw = true; + break; + end + end + % Skip study if no raw file found. + if ~isLinkToRaw + continue; + end + Recording = load(file_fullpath(sStudies(iStudy).Data(iData).FileName)); + Recording = Recording.F.filename; + % Skip if original MEG file not found. + if ~exist(Recording, 'file') + warning('Missing original raw MEG file. Skipping study %s.', Recording); + continue; + end + % Skip empty-room noise recordings + if contains(Recording, 'sub-emptyroom') || contains(Recording, 'task-noise') + % No warning, just skip. + continue; + end + + % Find MEG _coordsystem.json, should be 1 per session. + [MegPath, ~, MegExt] = fileparts(Recording); + if strcmpi(MegExt, '.meg4') + MegPath = fileparts(MegPath); + end + MegCoordJsonFile = dir(fullfile(MegPath, '*_coordsystem.json')); + % Skip if MEG json file not found, or more than one. + if isempty(MegCoordJsonFile) + warning('MEG BIDS _coordsystem.json file not found. Skipping study %s.', Recording); + continue; + elseif numel(MegCoordJsonFile) > 1 + warning('MEG BIDS issue: found multiple _coordsystem.json files. Skipping study %s.', Recording); + continue; + end + % Add to be processed. + MegList(end+1, :) = {fullfile(MegCoordJsonFile.folder, MegCoordJsonFile.name), ... + sStudies(iStudy).Channel.FileName}; %#ok + end + + % Sort and unique table rows + MegList = unique(MegList, 'rows'); + nChan = size(MegList, 1); + ChanNativeTransf = zeros(3, 4); + iOutMeg = 0; + for iChan = 1:nChan + ChannelMat = in_bst_channel(MegList.Channel(iChan)); + % ChannelMat.SCS are *digitized* anatomical landmarks (if present, otherwise might be + % digitized head coils) in Brainstorm/SCS coordinates (defined as CTF but with + % anatomical landmarks). They are NOT updated after refining with head points, so we + % don't rely on them but use those saved in sMri, and update them now with + % UpdateChannelMatScs. + % + % We applied MRI=>SCS (from sMri) to the MRI anat landmarks above, and now need to apply + % SCS=>Native (from ChannelMat). We ignore head motion related adjustments, which are + % dataset specific. We need original raw Native coordinates. UpdateChannelMatScs also + % adds a "Native" copy of .SCS, which represents the digitized anatomical fiducials in + % Native coordinates. Here we just use the transformation, as we want the MRI anat + % fids, not the digitized ones (which can be different if another session). + ChannelMat = process_adjust_coordinates('UpdateChannelMatScs', ChannelMat); + + % If same json as previous, just check consistency and continue. + if iChan > 1 && strcmp(MegList.CoordJson(iChan), MegList.CoordJson(iChan-1)) + % Verify that SCS>Native transformation matches previous channel file for + % this same session. + if any(abs(ChanNativeTransf - [ChannelMat.Native.R, ChannelMat.Native.T]) > 1e-6) + warning('Inconsistent alignment within MEG session, SCS>Native different than previous channel files: %s', MegList.Channel(iChan)) + end + continue; + end + + % New json, store SCS>Native transformation to compare with next channel files in this session. + ChanNativeTransf = [ChannelMat.Native.R, ChannelMat.Native.T]; + + % Convert MRI fids from (possibly adjusted) SCS to Native, and m to cm. + for iFid = 1:3 + Fid = BstFids{iFid}; + sMriNative.SCS.(Fid)(:) = 100 * ChanNativeTransf * [sMriScs.SCS.(Fid)'; 1]; + % Round to um. + sMriNative.SCS.(Fid) = round(sMriNative.SCS.(Fid) * 10000) / 10000; + end + + % Check MRI-digitized anat fids match, and prepare values. + [~, isMriUpdated, isMriMatch, isSessionMatch] = process_adjust_coordinates('CheckPrevAdjustments', ChannelMat, sMri); + if isMriUpdated && isMriMatch % implies session matches + AddFidDescrip = ' The anatomical landmarks saved here match those in the associated T1w image (see IntendedFor field). They correspond to the anatomical landmarks from the digitized head points, averaged if measured more than once. Coregistration with the T1w image was performed with Brainstorm before defacing, initially with an automatic procedure fitting head points to the scalp surface, but often adjusted manually, and validated with pictures of MEG head coils on the participant. As such, these landmarks and the corresponding alignment should be preferred.'; + elseif ~isSessionMatch + % We still use the sMri fids, whether they were updated (different session) or not. + AddFidDescrip = ' The anatomical landmarks saved here match those in the associated T1w image (see IntendedFor field). They do not correspond to the digitized landmarks from this session. Coregistration with the T1w image was performed with Brainstorm before defacing, initially with an automatic procedure fitting head points to the scalp surface, but often adjusted manually, and validated with pictures of MEG head coils on the participant. As such, these landmarks and the corresponding alignment should be preferred.'; + elseif ~isMriMatch && isSessionMatch % practically implies isMriUpdated + % The sMri and digitized anat fids match in terms of shape, but they are not + % aligned. Probably some other alignment was performed after updating the sMri. + % This is unexpected and should be checked. + warning('MRI and digitized anat fids are from the same session, but not aligned. This is unexpected and should be verified. Skipping study %s.', Recording.F.filename); + continue; + end + IntendedForMri = strrep(ImportedFile, [BidsRoot filesep], 'bids::'); + + % Update MEG json file. + sMegJson = bst_jsondecode(MegList.CoordJson(iChan)); + % Here we want to only point to the aligned MRI, even if there are multiple MRIs in + % this BIDS subject and they were all listed. But inform about any change. + if isfield(sMegJson, 'IntendedFor') && ~isempty(sMegJson.IntendedFor) + if iscell(sMegJson.IntendedFor) + if numel(sMegJson.IntendedFor) + fprintf('Replaced "IntendedFor" MEG json field (had multiple). %s\n', MegList.CoordJson(iChan)); + sMegJson.IntendedFor = IntendedForMri; % to simplify following checks, but we do it again below for every case. + else % single cell; we don't expect this case + sMegJson.IntendedFor = sMegJson.IntendedFor{1}; + end + end + % Check if it's different and not just the new BIDS path convention. + if ~strcmpi(sMegJson.IntendedFor, IntendedForMri) && ... + ~contains(sMegJson.IntendedFor, strrep(IntendedForMri, 'bids::', '')) + fprintf('Replaced "IntendedFor" MEG json field: %s > %s in %s\n', sMegJson.IntendedFor, IntendedForMri, MegList.CoordJson(iChan)); + end + end + % Save the single aligned MRI. + sMegJson.IntendedFor = IntendedForMri; + % Save native coordinates (rounded to um above). + for iFid = 1:3 + Fid = BstFids{iFid}; + sMegJson.AnatomicalLandmarkCoordinates.(Fid) = sMriNative.SCS.(Fid); + end + sMegJson.AnatomicalLandmarkCoordinateSystem = 'CTF'; + sMegJson.AnatomicalLandmarkCoordinateUnits = 'cm'; + %sMegJson.AnatomicalLandmarkCoordinateSystemDescription = 'Based on the digitized locations of the head coils. The origin is exactly between the left ear head coil (coilL near LPA) and the right ear head coil (coilR near RPA); the X-axis goes towards the nasion head coil (coilN near NAS); the Y-axis goes approximately towards coilL, orthogonal to X and in the plane spanned by the 3 head coils; the Z-axis goes approximately towards the vertex, orthogonal to X and Y'; + %sMegJson.HeadCoilCoordinateSystemDescription = sMegJson.AnatomicalLandmarkCoordinateSystemDescription; + if ~isfield(sMegJson, 'FiducialsDescription') + sMegJson.FiducialsDescription = ''; + end + if isempty(strfind(sMegJson.FiducialsDescription, AddFidDescrip)) + sMegJson.FiducialsDescription = strtrim([sMegJson.FiducialsDescription, AddFidDescrip]); + end + WriteJson(MegList.CoordJson(iChan), sMegJson); + iOutMeg = iOutMeg + 1; + OutFilesMeg{iOutSub}{iOutMeg} = MegList.CoordJson(iChan); + end + else + % Not BIDS, save in fiducials.m file. + FidsFile = fullfile(bst_fileparts(ImportedFile), 'fiducials.m'); + FidsFile = figure_mri('SaveFiducialsFile', sMri, FidsFile); + if ~exist(FidsFile, 'file') + warning('Fiducials.m file not written for subject %s.', sSubjects.Subject(iSub).Name); + continue; + end + OutFilesMri{iOutSub} = FidsFile; + end + + isSuccess(iOutSub) = true; + bst_progress('inc', 1); + end % subject loop + + bst_progress('stop'); + +end + + diff --git a/toolbox/io/export_channel.m b/toolbox/io/export_channel.m index 03df0002d..dddaf6dce 100644 --- a/toolbox/io/export_channel.m +++ b/toolbox/io/export_channel.m @@ -4,7 +4,7 @@ function export_channel(BstChannelFile, OutputChannelFile, FileFormat, isInterac % USAGE: export_channel(BstChannelFile, OutputChannelFile=[ask], FileFormat=[ask], isInteractive=1) % % INPUTS: -% - BstChannelFile : Full path to input Brainstorm MRI file to be exported +% - BstChannelFile : Full path to input Brainstorm file to be exported % - OutputChannelFile : Full path to target file (extension will determine the format) % - FileFormat : String, format of the exported channel file % - isInteractive : If 1, the function is allowed to ask questions interactively to the user @@ -75,6 +75,11 @@ function export_channel(BstChannelFile, OutputChannelFile, FileFormat, isInterac % Default output filename if (iSubject == 0) || isequal(sSubject.UseDefaultChannel, 2) baseFile = 'channel'; + elseif strcmpi(sSubject.Name, 'Digitize') && strcmpi(DefaultExt, '.pos') + % When digitizing head points, use condition name instead of subject name, which is always "Digitize". + [BstPath, baseFile] = bst_fileparts(BstChannelFile); + baseFile = strrep(baseFile, 'channel_', ''); + baseFile = strrep(baseFile, '_channel', ''); else baseFile = sSubject.Name; end @@ -212,12 +217,16 @@ function export_channel(BstChannelFile, OutputChannelFile, FileFormat, isInterac out_channel_ascii(BstChannelFile, OutputChannelFile, {'indice','-Y','X','Z','name'}, 1, 0, 0, .01); case 'EGI' out_channel_ascii(BstChannelFile, OutputChannelFile, {'name','-Y','X','Z'}, 1, 0, 0, .01); + + % === EEG and NIRS === case {'ASCII_XYZ-EEG', 'ASCII_XYZ_MNI-EEG', 'ASCII_XYZ_WORLD-EEG'} out_channel_ascii(BstChannelFile, OutputChannelFile, {'X','Y','Z'}, 1, 0, 0, .001, Transf); case {'ASCII_NXYZ-EEG', 'ASCII_NXYZ_MNI-EEG', 'ASCII_NXYZ_WORLD-EEG'} out_channel_ascii(BstChannelFile, OutputChannelFile, {'Name','X','Y','Z'}, 1, 0, 0, .001, Transf); case {'ASCII_XYZN-EEG', 'ASCII_XYZN_MNI-EEG', 'ASCII_XYZN_WORLD-EEG'} out_channel_ascii(BstChannelFile, OutputChannelFile, {'X','Y','Z','Name'}, 1, 0, 0, .001, Transf); + case 'BRAINSIGHT-TXT' + out_channel_brainsight(BstChannelFile, OutputChannelFile, .001, Transf); % === NIRS ONLY === case 'BIDS-NIRS-SCANRAS-MM' @@ -229,8 +238,6 @@ function export_channel(BstChannelFile, OutputChannelFile, FileFormat, isInterac case 'BIDS-NIRS-ALS-MM' % No transformation: export unchanged SCS/CTF space out_channel_bids(BstChannelFile, OutputChannelFile, .001, [], 1); - case 'BRAINSIGHT-TXT' - out_channel_nirs_brainsight(BstChannelFile, OutputChannelFile, .001, Transf); otherwise error(['Unsupported file format : "' FileFormat '"']); diff --git a/toolbox/io/export_data.m b/toolbox/io/export_data.m index 6ec76bd9b..b8b8dc473 100644 --- a/toolbox/io/export_data.m +++ b/toolbox/io/export_data.m @@ -224,7 +224,7 @@ if ~isempty(iAnnot) ChannelMatOut.Channel = ChannelMatOut.Channel(iChannelsIn); for iProj = 1:length(ChannelMatOut.Projector) - if isequal(ChannelMatOut.Projector(iProj).SingVal, 'REF') + if strcmpi(ChannelMatOut.Projector(iProj).Method(1:3), 'REF') ChannelMatOut.Projector(iProj).Components = ChannelMatOut.Projector(iProj).Components(iChannelsIn, iChannelsIn); else ChannelMatOut.Projector(iProj).Components = ChannelMatOut.Projector(iProj).Components(iChannelsIn, :); diff --git a/toolbox/io/export_events.m b/toolbox/io/export_events.m index edd2cd5fe..e68ca89fa 100644 --- a/toolbox/io/export_events.m +++ b/toolbox/io/export_events.m @@ -121,6 +121,7 @@ function export_events(sFile, ChannelMat, OutputFile) case '.evl', FileFormat = 'GRAPH_ALT'; case '.txt', FileFormat = 'ARRAY-TIMES'; case '.csv', FileFormat = 'CSV-TIME'; + case '.tsv', FileFormat = 'BIDS'; end end @@ -148,6 +149,8 @@ function export_events(sFile, ChannelMat, OutputFile) out_events_graph(sFile, OutputFile,'alternativeStyle'); case 'CSV-TIME' out_events_csv(sFile, OutputFile); + case 'BIDS' + out_events_bids(sFile, OutputFile); case 'ARRAY-TIMES' if (length(sFile.events) > 1) error('Cannot export more than one event at a time with this format.'); diff --git a/toolbox/io/export_result.m b/toolbox/io/export_result.m index efaec5e94..983294479 100644 --- a/toolbox/io/export_result.m +++ b/toolbox/io/export_result.m @@ -72,7 +72,15 @@ function export_result( BstFile, OutputFile, FileFormat ) end % Build default output filename if ~isempty(BstFile) - [BstPath, BstBase, BstExt] = bst_fileparts(BstFile); + fileType = file_gettype(BstFile); + if strcmp(fileType, 'link') + [kernelFile, dataFile] = file_resolve_link(BstFile); + [~, kernelBase] = bst_fileparts(kernelFile); + [BstPath, BstBase, BstExt] = bst_fileparts(dataFile); + BstBase = [kernelBase, '_' ,BstBase]; + else + [BstPath, BstBase, BstExt] = bst_fileparts(BstFile); + end else BstBase = file_standardize(ResultsMat.Comment); end @@ -101,7 +109,7 @@ function export_result( BstFile, OutputFile, FileFormat ) % Guess file format based on its extension elseif isempty(FileFormat) - [BstPath, BstBase, BstExt] = bst_fileparts(ExportFile); + [BstPath, BstBase, BstExt] = bst_fileparts(OutputFile); switch lower(BstExt) case '.txt', FileFormat = 'ASCII-SPC'; case '.csv', FileFormat = 'ASCII-CSV-HDR'; diff --git a/toolbox/io/file_compare.m b/toolbox/io/file_compare.m index b5c8b771a..7d37a2f74 100644 --- a/toolbox/io/file_compare.m +++ b/toolbox/io/file_compare.m @@ -38,6 +38,11 @@ return end +if iscell(f1) && iscell(f2) && length(f1) ~= length(f2) + res = 0; + return +end + % Check for empty matrices in cell arrays if iscell(f1) f1(cellfun(@isempty, f1)) = {''}; diff --git a/toolbox/io/import_anatomy_cat_2019.m b/toolbox/io/import_anatomy_cat_2019.m index 6e7b42cf6..fc7415b2a 100644 --- a/toolbox/io/import_anatomy_cat_2019.m +++ b/toolbox/io/import_anatomy_cat_2019.m @@ -127,12 +127,12 @@ %% ===== PARSE CAT12 FOLDER ===== isProgress = bst_progress('isVisible'); bst_progress('start', 'Import CAT12 folder', 'Parsing folder...'); -% Find MRI -T1File = file_find(CatDir, '*.nii', 1, 0); +% Find MRI (.nii or .nii.gz) +T1File = file_find(CatDir, '*.nii*', 1, 0); if isempty(T1File) - errorMsg = [errorMsg 'Original MRI file was not found: *.nii in top folder' 10]; + errorMsg = [errorMsg 'Original MRI file was not found: *.nii (or *.nii.gz) in top folder' 10]; elseif (length(T1File) > 1) - errorMsg = [errorMsg 'Multiple .nii found in top folder' 10]; + errorMsg = [errorMsg 'Multiple .nii (or *.nii.gz) found in top folder' 10]; end % Find surfaces TessLhFile = file_find(CatDir, 'lh.central.*.gii', 2); diff --git a/toolbox/io/import_anatomy_cat_2020.m b/toolbox/io/import_anatomy_cat_2020.m index 4aef1bc1a..5f6584a1f 100644 --- a/toolbox/io/import_anatomy_cat_2020.m +++ b/toolbox/io/import_anatomy_cat_2020.m @@ -130,12 +130,12 @@ isProgress = bst_progress('isVisible'); bst_progress('start', 'Import CAT12 folder', 'Parsing folder...'); bst_plugin('SetProgressLogo', 'cat12'); -% Find MRI -T1File = file_find(CatDir, '*.nii', 1, 0); +% Find MRI (.nii or .nii.gz) +T1File = file_find(CatDir, '*.nii*', 1, 0); if isempty(T1File) - errorMsg = [errorMsg 'Original MRI file was not found: *.nii in top folder' 10]; + errorMsg = [errorMsg 'Original MRI file was not found: *.nii (or *.nii.gz) in top folder' 10]; elseif (length(T1File) > 1) - errorMsg = [errorMsg 'Multiple .nii found in top folder' 10]; + errorMsg = [errorMsg 'Multiple .nii (or *.nii.gz) found in top folder' 10]; end % Find central surfaces GiiLcFile = file_find(CatDir, 'lh.central.*.gii', 2); diff --git a/toolbox/io/import_anatomy_fs.m b/toolbox/io/import_anatomy_fs.m index a42681e00..00638c4fb 100644 --- a/toolbox/io/import_anatomy_fs.m +++ b/toolbox/io/import_anatomy_fs.m @@ -1,5 +1,5 @@ function errorMsg = import_anatomy_fs(iSubject, FsDir, nVertices, isInteractive, sFid, isExtraMaps, isVolumeAtlas, isKeepMri) -% IMPORT_ANATOMY_FS: Import a full FreeSurfer folder as the subject's anatomy. +% IMPORT_ANATOMY_FS: Import a full FreeSurfer folder as the subject's anatomy, obtained with either 'recon-all' or 'recon-all-clinical' % % USAGE: errorMsg = import_anatomy_fs(iSubject, FsDir=[ask], nVertices=[ask], isInteractive=1, sFid=[], isExtraMaps=0, isVolumeAtlas=1, isKeepMri=0) % @@ -126,21 +126,31 @@ %% ===== PARSE FREESURFER FOLDER ===== bst_progress('start', 'Import FreeSurfer folder', 'Parsing folder...'); -% Find MRI -T1File = file_find(FsDir, 'T1.mgz', 2); -T2File = file_find(FsDir, 'T2.mgz', 2); -if isempty(T1File) - T1File = file_find(FsDir, '*.nii.gz', 0); - if ~isempty(T1File) - T1Comment = 'MRI'; +% Find MRI files +isReconAllClinical = ~isempty(file_find(FsDir, 'synthSR.mgz', 2)); +isReconAll = ~isempty(file_find(FsDir, 'T1.mgz', 2)) && ~isReconAllClinical; + +mri1File = ''; +if isReconAll + mri1File = file_find(FsDir, 'T1.mgz', 2); + mri2File = file_find(FsDir, 'T2.mgz', 2); + mri1Comment = 'MRI T1'; + mri2Comment = 'MRI T2'; +elseif isReconAllClinical + mri1File = file_find(FsDir, 'synthSR.raw.mgz', 2); + mri2File = file_find(FsDir, 'native.mgz', 2); + mri1Comment = 'MRI (synthSR)'; + mri2Comment = 'MRI (native)'; +end +if isempty(mri1File) + mri1File = file_find(FsDir, '*.nii.gz', 0); + if ~isempty(mri1File) + mri2File = ''; + mri1Comment = 'MRI'; + mri2Comment = ''; else errorMsg = [errorMsg 'MRI file was not found: T1.mgz' 10]; end -elseif ~isempty(T1File) && ~isempty(T2File) - T1Comment = 'MRI T1'; - T2Comment = 'MRI T2'; -else - T1Comment = 'MRI'; end % Find surface: lh.pial (or lh.pial.T1) TessLhFile = file_find(FsDir, 'lh.pial', 2); @@ -232,15 +242,15 @@ end -%% ===== IMPORT T1 ===== +%% ===== IMPORT PRIMARY MRI ===== if isKeepMri && ~isempty(sSubject.Anatomy) - BstT1File = file_fullpath(sSubject.Anatomy(sSubject.iAnatomy).FileName); - in_mri_bst(BstT1File); + BstMri1File = file_fullpath(sSubject.Anatomy(sSubject.iAnatomy).FileName); + in_mri_bst(BstMri1File); else - % Read T1 MRI - BstT1File = import_mri(iSubject, T1File, 'ALL', 0, [], T1Comment); - if isempty(BstT1File) - errorMsg = 'Could not import FreeSurfer folder: MRI was not imported properly'; + % Read primary MRI + BstMri1File = import_mri(iSubject, mri1File, 'ALL', 0, [], mri1Comment); + if isempty(BstMri1File) + errorMsg = ['Could not import FreeSurfer folder: MRI "' mri1File '" was not imported properly']; if isInteractive bst_error(errorMsg, 'Import FreeSurfer folder', 0); end @@ -253,7 +263,7 @@ %% ===== DEFINE FIDUCIALS / MNI NORMALIZATION ===== % Set fiducials and/or compute linear MNI normalization -[isComputeMni, errCall] = process_import_anatomy('SetFiducials', iSubject, FsDir, BstT1File, sFid, isKeepMri, isInteractive); +[isComputeMni, errCall] = process_import_anatomy('SetFiducials', iSubject, FsDir, BstMri1File, sFid, isKeepMri, isInteractive); % Error handling if ~isempty(errCall) errorMsg = [errorMsg, errCall]; @@ -266,12 +276,12 @@ end -%% ===== IMPORT T2 ===== -% Read T2 MRI (optional) -if ~isempty(T2File) - BstT2File = import_mri(iSubject, T2File, 'ALL', 0, [], T2Comment); - if isempty(BstT2File) - disp('BST> Could not import T2.mgz.'); +%% ===== IMPORT SECONDARY MRI ===== +% Read secondary MRI (optional) +if ~isempty(mri2File) + BstMri2File = import_mri(iSubject, mri2File, 'ALL', 0, [], mri2Comment); + if isempty(BstMri2File) + disp(['BST> Could not import "' mri2File '" MRI file.']); end end @@ -533,11 +543,26 @@ %% ===== IMPORT ASEG ATLAS ===== if isVolumeAtlas && ~isempty(AsegFile) + BstMriFiles = {}; % Import atlas as volume - import_mri(iSubject, AsegFile); + BstMriFiles{end+1} = import_mri(iSubject, AsegFile); % Import other ASEG volumes for iFile = 1:length(OtherAsegFiles) - import_mri(iSubject, OtherAsegFiles{iFile}); + BstMriFiles{end+1} = import_mri(iSubject, OtherAsegFiles{iFile}); + end + % Remove padding introduced in every direction by 'mri_synth_surf.py' call in 'recon-all-clinical.sh' + if isReconAllClinical + for iAtlas = 1 : length(BstMriFiles) + sMriAtlas = in_mri_bst(BstMriFiles{iAtlas}); + if iAtlas == 1 + sMri1 = in_mri_bst(BstMri1File); + nPad = unique((size(sMriAtlas.Cube) - size(sMri1.Cube)) / 2); + end + if length(nPad) == 1 && nPad > 0 && round(nPad) == nPad + sMriAtlas.Cube = sMriAtlas.Cube(1+nPad:end-nPad, 1+nPad:end-nPad, 1+nPad:end-nPad); + bst_save(BstMriFiles{iAtlas}, sMriAtlas, 'v7'); + end + end end % Import atlas as surfaces SelLabels = {... diff --git a/toolbox/io/import_channel.m b/toolbox/io/import_channel.m index 6a940d4d9..1ea5b98f2 100644 --- a/toolbox/io/import_channel.m +++ b/toolbox/io/import_channel.m @@ -262,7 +262,7 @@ FileUnits = 'cm'; case 'LOCALITE' - ChannelMat = in_channel_ascii(ChannelFile, {'%d','name','X','Y','Z'}, 1, .001); + ChannelMat = in_channel_ascii(ChannelFile, {'%d','nameWithSpace','X','Y','Z'}, 1, .001); ChannelMat.Comment = 'Localite channels'; FileUnits = 'mm'; @@ -557,7 +557,7 @@ %% ===== DETECT CHANNEL TYPES ===== -% Remove fiducials (expect for BIDS files) +% Remove fiducials (except for BIDS files) isRemoveFid = isempty(strfind(FileFormat, 'BIDS-')); % Detect auxiliary EEG channels + align channel ChannelMat = channel_detect_type(ChannelMat, isAlignScs, isRemoveFid); diff --git a/toolbox/io/import_events.m b/toolbox/io/import_events.m index e828be2ac..b998a016c 100644 --- a/toolbox/io/import_events.m +++ b/toolbox/io/import_events.m @@ -107,7 +107,7 @@ case 'BIDS' newEvents = in_events_bids(sFile, EventFile); case 'BRAINAMP' - newEvents = in_events_brainamp(sFile, EventFile); + newEvents = in_events_brainamp(sFile, ChannelMat, EventFile); case 'BST' FileMat = load(EventFile); % Add missing fields if required @@ -224,6 +224,24 @@ newEvents(iNew).times = [newEvents(iNew).times; newEvents(iNew).times + 0.001]; end end + % Expand 'channels' field for event occurrences if needed + if ~isempty(sFile.events(iEvt).channels) || ~isempty(newEvents(iNew).channels) + if isempty(sFile.events(iEvt).channels) + sFile.events(iEvt).channels = cell(1, size(sFile.events(iEvt).times, 2)); + end + if isempty(newEvents(iNew).channels) + newEvents(iNew).channels = cell(1, size(newEvents(iNew).times, 2)); + end + end + % Expand 'notes' field for event occurrences if needed + if ~isempty(sFile.events(iEvt).notes) || ~isempty(newEvents(iNew).notes) + if isempty(sFile.events(iEvt).notes) + sFile.events(iEvt).notes = cell(1, size(sFile.events(iEvt).times, 2)); + end + if isempty(newEvents(iNew).notes) + newEvents(iNew).notes = cell(1, size(newEvents(iNew).times, 2)); + end + end % Merge events occurrences sFile.events(iEvt).times = [sFile.events(iEvt).times, newEvents(iNew).times]; sFile.events(iEvt).epochs = [sFile.events(iEvt).epochs, newEvents(iNew).epochs]; diff --git a/toolbox/io/import_label.m b/toolbox/io/import_label.m index c5bfd4963..3647f01fe 100644 --- a/toolbox/io/import_label.m +++ b/toolbox/io/import_label.m @@ -44,6 +44,7 @@ LabelFiles = []; end +isInteractiveMriVols = 1; % CALL: import_label(SurfaceFile) if isempty(LabelFiles) % Get last used folder @@ -99,6 +100,9 @@ end otherwise, Messages = 'Unknown file extension.'; return; end + % Import MRI volumes in non-interactive way if Files and FileFormat are input args + elseif length(FileFormat) > 3 && strcmpi(FileFormat(1:3), 'mri') + isInteractiveMriVols = 0; end end @@ -352,9 +356,9 @@ sMriMask = in_mri_bst(LabelFiles{iFile}); sAtlas.Name = [sAtlas.Name, str_remove_parenth(sMriMask.Comment)]; elseif isMni - sMriMask = in_mri(LabelFiles{iFile}, 'ALL-MNI', [], 0); + sMriMask = in_mri(LabelFiles{iFile}, 'ALL-MNI', isInteractiveMriVols, 0); else - sMriMask = in_mri(LabelFiles{iFile}, 'ALL', [], 0); + sMriMask = in_mri(LabelFiles{iFile}, 'ALL', isInteractiveMriVols, 0); end if isempty(sMriMask) return; diff --git a/toolbox/io/import_mri.m b/toolbox/io/import_mri.m index 26515ba0e..5a699422d 100644 --- a/toolbox/io/import_mri.m +++ b/toolbox/io/import_mri.m @@ -1,5 +1,5 @@ function [BstMriFile, sMri, Messages] = import_mri(iSubject, MriFile, FileFormat, isInteractive, isAutoAdjust, Comment, Labels) -% IMPORT_MRI: Import a MRI file in a subject of the Brainstorm database +% IMPORT_MRI: Import a volume file (MRI, Atlas, CT, etc) in a subject of the Brainstorm database % % USAGE: [BstMriFile, sMri, Messages] = import_mri(iSubject, MriFile, FileFormat='ALL', isInteractive=0, isAutoAdjust=1, Comment=[], Labels=[]) % BstMriFiles = import_mri(iSubject, MriFiles, ...) % Import multiple volumes at once @@ -38,6 +38,7 @@ % =============================================================================@ % % Authors: Francois Tadel, 2008-2023 +% Chinmay Chinara, 2023 %% ===== PARSE INPUTS ===== if (nargin < 3) || isempty(FileFormat) @@ -69,6 +70,15 @@ else sSubject = ProtocolSubjects.Subject(iSubject); end +% Volume type +volType = 'MRI'; +if ~isempty(strfind(Comment, 'CT')) + volType = 'CT'; +end +% Get node comment from filename +if ~isempty(strfind(Comment, 'Import')) + Comment = []; +end %% ===== SELECT MRI FILE ===== % If MRI file to load was not defined : open a dialog box to select it @@ -80,9 +90,10 @@ if isempty(DefaultFormats.MriIn) DefaultFormats.MriIn = 'ALL'; end - % Get MRI file + + % Get MRI/CT file [MriFile, FileFormat, FileFilter] = java_getfile( 'open', ... - 'Import MRI...', ... % Window title + ['Import ' volType '...'], ... % Window title LastUsedDirs.ImportAnat, ... % Default directory 'multiple', 'files_and_dirs', ... % Selection mode bst_get('FileFilters', 'mri'), ... @@ -140,11 +151,20 @@ %% ===== LOAD MRI FILE ===== isProgress = bst_progress('isVisible'); if ~isProgress - bst_progress('start', 'Import MRI', 'Loading MRI file...'); + bst_progress('start', ['Import ', volType], ['Loading ', volType, ' file...']); end -% MNI / Atlas? -isMni = ismember(FileFormat, {'ALL-MNI', 'ALL-MNI-ATLAS'}); +% MNI / Atlas / CT ? +isMni = ismember(FileFormat, {'ALL-MNI', 'ALL-MNI-ATLAS'}); isAtlas = ismember(FileFormat, {'ALL-ATLAS', 'ALL-MNI-ATLAS', 'SPM-TPM'}); +isCt = strcmpi(volType, 'CT'); +% Tag for CT volume +if isCt + tagVolType = '_volct'; + isAtlas = 0; +else + tagVolType = ''; +end + % Load MRI isNormalize = 0; sMri = in_mri(MriFile, FileFormat, isInteractive && ~isMni, isNormalize); @@ -168,18 +188,16 @@ %% ===== GET ATLAS LABELS ===== % Try to get associated labels -if isempty(Labels) && ~iscell(MriFile) +if isempty(Labels) && ~iscell(MriFile) && ~isCt Labels = mri_getlabels(MriFile, sMri, isAtlas); end % Save labels in the file structure if ~isempty(Labels) % Labels were found in the input folder sMri.Labels = Labels; - tagAtlas = '_volatlas'; + tagVolType = '_volatlas'; isAtlas = 1; elseif isAtlas % Volume was explicitly imported as an atlas - tagAtlas = '_volatlas'; -else - tagAtlas = ''; + tagVolType = '_volatlas'; end % Get atlas comment if isAtlas && isempty(Comment) && ~iscell(MriFile) @@ -213,9 +231,13 @@ errMsg = ''; % Regular coregistration options between volumes else + % Backup history (import) + tmpHistory.History = sMri.History; + sMri.History = []; % If some transformation where made to the intial volume: apply them to the new one ? if isfield(sMriRef, 'InitTransf') && ~isempty(sMriRef.InitTransf) && any(ismember(sMriRef.InitTransf(:,1), {'permute', 'flipdim'})) - if ~isInteractive || java_dialog('confirm', ['A transformation was applied to the reference MRI.' 10 10 'Do you want to apply the same transformation to this new volume?' 10 10], 'Import MRI') + isApplyTransformation = java_dialog('confirm', ['A transformation was applied to the reference MRI.' 10 10 'Do you want to apply the same transformation to this new volume?' 10 10], ['Import ', volType]); + if ~isInteractive || isApplyTransformation % Apply step by step all the transformations that have been applied to the original MRI for it = 1:size(sMriRef.InitTransf,1) ttype = sMriRef.InitTransf{it,1}; @@ -248,8 +270,13 @@ strOptions = 'How to register the new volume with the reference image?
'; cellOptions = {}; % Register with the SPM - strOptions = [strOptions, '
- SPM:   Coregister the two volumes with SPM (requires SPM toolbox).']; + strOptions = [strOptions, '
- SPM:   Coregister the two volumes with SPM (uses SPM plugin).']; cellOptions{end+1} = 'SPM'; + if isCt + % Register with the ct2mrireg plugin + strOptions = [strOptions, '
- CT2MRI:   Coregister using USC CT2MRI plugin.']; + cellOptions{end+1} = 'CT2MRI'; + end % Register with the MNI transformation strOptions = [strOptions, '
- MNI:   Compute the MNI transformation for both volumes (inaccurate).']; cellOptions{end+1} = 'MNI'; @@ -257,7 +284,8 @@ strOptions = [strOptions, '
- Ignore:   The two volumes are already registered.']; cellOptions{end+1} = 'Ignore'; % Ask user to make a choice - RegMethod = java_dialog('question', [strOptions '

'], 'Import MRI', [], cellOptions, 'Reg+reslice'); + RegMethod = java_dialog('question', [strOptions '

'], ['Import ', volType], [], cellOptions, 'Reg+reslice'); + % In non-interactive mode: ignore if possible, or use the first option available else RegMethod = 'Ignore'; @@ -281,17 +309,47 @@ strSizeWarn = []; end % Ask to reslice - isReslice = java_dialog('confirm', [... + [isReslice, isCancel]= java_dialog('confirm', [... 'Reslice the volume?

' ... - 'This operation rewrites the new MRI to match the alignment,
size and resolution of the original volume.' ... + ['This operation rewrites the new ', volType, ' to match the alignment,
size and resolution of the original volume.'] ... strSizeWarn ... - '

'], 'Import MRI'); + '

'], ['Import ', volType]); + % User aborted the process + if isCancel + bst_progress('stop'); + return; + end % In non-interactive mode: never reslice else isReslice = 0; end + % Check that reference volume has set fiducials for reslicing + if isReslice && (~isfield(sMriRef, 'SCS') || ~isfield(sMriRef.SCS, 'R') || ~isfield(sMriRef.SCS, 'T') || isempty(sMriRef.SCS.R) || isempty(sMriRef.SCS.T)) + errMsg = 'Reslice: No SCS transformation available for the reference volume. Set the fiducials first.'; + RegMethod = ''; % Registration will not be performed + end + + % === ASK SKULL STRIPPING === + if isInteractive && isCt && (strcmpi(RegMethod, 'SPM') || strcmpi(RegMethod, 'CT2MRI')) + % Ask if the user wants to mask out region outside skull in CT + [MaskMethod, isCancel] = java_dialog('question', ['Perform skull stripping on the CT volume?
' ... + 'This removes non-brain tissues (skull, scalp, fat, and other head tissues) from the CT volume.

' ... + 'Which method do you want to proceed with?

' ... + '- SPM:   SPM Tissue Segmentation (uses SPM plugin)
' ... + '- BrainSuite:   Brain Surface Extractor (requires BrainSuite installed)
' ... + '- Skip:   Proceed without skull stripping

'], ... + 'Import CT', [], {'SPM', 'BrainSuite', 'Skip'}, ''); + % User aborted the process + if isCancel + bst_progress('stop'); + return; + end + else + % In non-interactive mode: never do skull stripping + MaskMethod = 'Skip'; + end - % === REGISTRATION === + % === REGISTRATION AND RESLICING === switch (RegMethod) case 'MNI' % Register the new MRI on the existing one using the MNI transformation (+ RESLICE) @@ -299,6 +357,9 @@ case 'SPM' % Register the new MRI on the existing one using SPM + RESLICE [sMri, errMsg, fileTag] = mri_coregister(sMri, sMriRef, 'spm', isReslice, isAtlas); + case 'CT2MRI' + % Register the CT to existing MRI using USC's CT2MRI plugin + RESLICE + [sMri, errMsg, fileTag] = mri_coregister(sMri, sMriRef, 'ct2mri', isReslice, isAtlas); case 'Ignore' if isReslice % Register the new MRI on the existing one using the transformation in the input files (files already registered) @@ -317,18 +378,57 @@ sMri.SCS = sMriRef.SCS; %sMri.NCS = sMriRef.NCS; end + otherwise + % Do nothing end - end - % Stop in case of error - if ~isempty(errMsg) - if isInteractive - bst_error(errMsg, [RegMethod ' MRI'], 0); - sMri = []; - bst_progress('stop'); - return; - else - error(errMsg); + % Stop in case of error + if ~isempty(errMsg) + if isInteractive + bst_error(errMsg, [RegMethod ' MRI'], 0); + sMri = []; + bst_progress('stop'); + return; + else + error(errMsg); + end + end + % === SKULL STRIPPING === + switch lower(MaskMethod) + case 'spm' + [sMri, errMsg, maskFileTag] = mri_skullstrip(sMri, sMriRef, 'spm'); + case 'brainsuite' + [sMri, errMsg, maskFileTag] = mri_skullstrip(sMri, sMriRef, 'brainsuite'); + case 'skip' + % Do nothing + maskFileTag = ''; + end + fileTag = [fileTag, maskFileTag]; + % Stop in case of error + if ~isempty(errMsg) + if isInteractive + bst_error(errMsg, [MaskMethod ' brain mask MRI'], 0); + sMri = []; + bst_progress('stop'); + return; + else + error(errMsg); + end + end + % Add history entry (co-registration) + if ~isempty(RegMethod) && ~strcmpi(RegMethod, 'Ignore') + % Co-registration + sMri = bst_history('add', sMri, 'resample', ['MRI co-registered on default file (' RegMethod '): ' refMriFile]); + end + % Add history entry (reslice) + if isReslice || isMni + sMri = bst_history('add', sMri, 'resample', ['MRI resliced to default file: ' refMriFile]); + end + % Add history entry (skull stripping) + if ~isempty(maskFileTag) + sMri = bst_history('add', sMri, 'resample', ['Skull stripping with "' MaskMethod '" using on default file: ' refMriFile]); end + % Add back history entry (import) + sMri.History = [tmpHistory.History; sMri.History]; end end @@ -376,7 +476,7 @@ % Get subject subdirectory subjectSubDir = bst_fileparts(sSubject.FileName); % Produce a default anatomy filename -BstMriFile = bst_fullfile(ProtocolInfo.SUBJECTS, subjectSubDir, ['subjectimage_' importedBaseName fileTag tagAtlas '.mat']); +BstMriFile = bst_fullfile(ProtocolInfo.SUBJECTS, subjectSubDir, ['subjectimage_' importedBaseName fileTag tagVolType '.mat']); % Make this filename unique BstMriFile = file_unique(BstMriFile); % Save new MRI in Brainstorm format @@ -388,7 +488,7 @@ sSubject.Anatomy(iAnatomy).FileName = file_short(BstMriFile); sSubject.Anatomy(iAnatomy).Comment = sMri.Comment; % Default anatomy: do not change -if isempty(sSubject.iAnatomy) +if isempty(sSubject.iAnatomy) && ~isCt && ~isAtlas sSubject.iAnatomy = iAnatomy; end % Default subject @@ -400,7 +500,7 @@ end bst_set('ProtocolSubjects', ProtocolSubjects); % Save first MRI as permanent default -if (iAnatomy == 1) +if (iAnatomy == 1) && ~isCt && ~isAtlas db_surface_default(iSubject, 'Anatomy', iAnatomy, 0); end diff --git a/toolbox/io/import_sources.m b/toolbox/io/import_sources.m index ac1cd6abe..b618c2a11 100644 --- a/toolbox/io/import_sources.m +++ b/toolbox/io/import_sources.m @@ -92,6 +92,7 @@ {'*.w'}, 'FreeSurfer weight files (*.w)', 'FS-WFILE'; ... {'*'}, 'CIVET maps (*.*)', 'CIVET'; ... {'.gii'}, 'GIfTI texture (*.gii)', 'GII'; ... + {'_sources.mat'}, 'Brainstorm sources (*sources*.mat)', 'BST'; ... {'.mri', '.fif', '.img', '.ima', '.nii', '.mgh', '.mgz', '.mnc', '.mni', '.gz', '_subjectimage'}, 'Volume grids (subject space)', 'ALLMRI'; ... {'.mri', '.fif', '.img', '.ima', '.nii', '.mgh', '.mgz', '.mnc', '.mni', '.gz', '_subjectimage'}, 'Volume grids (MNI space)', 'ALLMRI-MNI'; ... }, DefaultFormats.ResultsIn); @@ -230,6 +231,10 @@ end Comment = strrep(Comment, 'results_', ''); Comment = strrep(Comment, '_results', ''); + if strcmp(FileFormat, 'BST') + Comment = strrep(Comment, 'surface_', ''); + Comment = strrep(Comment, 'volume_', ''); + end % If the two files are imported: remove .lh and .rh if ~isempty(SourceFiles2) Comment = strrep(Comment, 'rh.', ''); @@ -317,12 +322,16 @@ else % New results structure ResultsMat = db_template('resultsmat'); - ResultsMat.ImageGridAmp = [map, map]; + if size(map, 2) > 1 + ResultsMat.ImageGridAmp = map; + else + ResultsMat.ImageGridAmp = [map, map]; + end ResultsMat.ImagingKernel = []; FileType = 'results'; % Time vector if isempty(TimeVector) || (length(TimeVector) ~= size(ResultsMat.ImageGridAmp,2)) - ResultsMat.Time = 0:(size(map,2)-1); + ResultsMat.Time = 0:(size(ResultsMat.ImageGridAmp,2)-1); else ResultsMat.Time = TimeVector; end @@ -434,6 +443,9 @@ end case 'ALLMRI-MNI' error('Not supported yet.'); + case 'BST' + sResultsMat = load(SourceFile); + map = sResultsMat.ImageGridAmp; otherwise error('Unsupported file format.'); end diff --git a/toolbox/io/import_subject.m b/toolbox/io/import_subject.m index 042feb035..f5f318a7e 100644 --- a/toolbox/io/import_subject.m +++ b/toolbox/io/import_subject.m @@ -108,8 +108,8 @@ function import_subject(ZipFile) end % Check the subject configuration SubjectMat = load(SubjectFile); - if (SubjectMat.UseDefaultAnat == 1) || (SubjectMat.UseDefaultChannel == 1) - errMsg = [errMsg, 'Subjects using a default anatomy or channel file cannot be imported in an existing protocol.' 10 ... + if (SubjectMat.UseDefaultAnat == 1) || (SubjectMat.UseDefaultChannel == 2) + errMsg = [errMsg, 'Subjects using "Default anatomy" or "global channel file" cannot be imported in an existing protocol.' 10 ... 'Use the menu: File > Load protocol > Load from zip file.']; continue; end diff --git a/toolbox/io/import_surfaces.m b/toolbox/io/import_surfaces.m index 428e0228d..f36e7b66c 100644 --- a/toolbox/io/import_surfaces.m +++ b/toolbox/io/import_surfaces.m @@ -172,6 +172,9 @@ if isfield(Tess, 'Faces') % Volume meshes do not have Faces field NewTess.Faces = Tess(1).Faces; end + if isfield(Tess, 'Color') % Not all meshes have color + NewTess.Color = Tess(1).Color; + end % Volume FEM mesh else NewTess = Tess; @@ -204,7 +207,11 @@ NewTess = bst_history('add', NewTess, 'import', ['Import from: ' TessFile]); % Produce a default surface filename (surface of volume mesh) if isfield(NewTess, 'Faces') - BstTessFile = bst_fullfile(ProtocolInfo.SUBJECTS, subjectSubDir, ['tess_' importedBaseName '.mat']); + BaseTessFile = ['tess_' importedBaseName '.mat']; + if ~isempty(NewTess.Color) + BaseTessFile = regexprep(BaseTessFile, '^tess_', 'tess_textured_'); + end + BstTessFile = bst_fullfile(ProtocolInfo.SUBJECTS, subjectSubDir, BaseTessFile); else BstTessFile = bst_fullfile(ProtocolInfo.SUBJECTS, subjectSubDir, ['tess_fem_' importedBaseName '.mat']); end diff --git a/toolbox/io/import_video.m b/toolbox/io/import_video.m index 54fb0f7b9..3b4c09304 100644 --- a/toolbox/io/import_video.m +++ b/toolbox/io/import_video.m @@ -1,7 +1,7 @@ -function iNewFiles = import_video(iStudy, VideoFiles) +function [iNewFiles, OutputVideoFiles] = import_video(iStudy, VideoFiles) % IMPORT_VIDEO Link video files to the database. % -% USAGE: iNewFiles = import_dipoles(iStudy, VideoFiles=[ask]) +% USAGE: [iNewFiles, OutputVideoFiles] = import_dipoles(iStudy, VideoFiles=[ask]) % % INPUT: % - iStudy : Index of the study where to import the DipolesFiles @@ -35,6 +35,7 @@ end % Returned variables iNewFiles = []; +OutputVideoFiles = {}; %% ===== SELECT FILES ===== @@ -145,6 +146,7 @@ iImage = length(sStudy.Image) + 1; sStudy.Image(iImage) = sImage; iNewFiles = [iNewFiles, iImage]; + OutputVideoFiles{end+1} = OutputFile; end % Save study diff --git a/toolbox/io/in_bst_channel.m b/toolbox/io/in_bst_channel.m index 78826e4a8..2849daf78 100644 --- a/toolbox/io/in_bst_channel.m +++ b/toolbox/io/in_bst_channel.m @@ -74,6 +74,12 @@ % Old format (I-UUt) => Convert to new format if ~isstruct(sMat.Projector) sMat.Projector = process_ssp2('ConvertOldFormat', sMat.Projector); + elseif ~isfield(sMat.Projector, 'Method') || any(cellfun(@isempty, {sMat.Projector.Method})) + tmpProjector = repmat(db_template('projector'), 1, length(sMat.Projector)); + for ix = 1 : length(sMat.Projector) + tmpProjector(ix) = process_ssp2('ConvertOldFormat', sMat.Projector(ix)); + end + sMat.Projector = tmpProjector; end % Field does not exist else diff --git a/toolbox/io/in_bst_data.m b/toolbox/io/in_bst_data.m index 1d304dc70..8f2b9c09c 100644 --- a/toolbox/io/in_bst_data.m +++ b/toolbox/io/in_bst_data.m @@ -128,6 +128,55 @@ studyPath = bst_fileparts(DataFile); [rawPath, rawBase, rawExt] = bst_fileparts(DataMat.F.filename); newRaw = bst_fullfile(studyPath, [rawBase, rawExt]); + % If not found, try to look for the file in the same file system + if ~file_exist(newRaw) + % Identify the OS for the raw link path + waspc = isempty(regexp(DataMat.F.filename, '/', 'once')); % '/' is forbidden char in Windows paths + % Linux/MacOS -> Linux/MacOS + if ~ispc() + % Get mountpoint + [status, dfResult] = system(['df ' DataFile]); + if ~status % no error + dfLines = str_split(dfResult, 10); + iChar = regexp(dfLines{1}, 'Mounted on'); + mountpoint = dfLines{2}(iChar:end); + % Update raw link path + if ~waspc + % Replace mountpoint + [mountDir, mountLabel] = bst_fileparts(mountpoint); + iCharRelMount = regexp(DataMat.F.filename, mountLabel); + newRaw = bst_fullfile(mountDir, DataMat.F.filename(iCharRelMount:end)); + else + % Replace drive letter with mountpoint + pathTmp = file_win2unix(DataMat.F.filename); + pathTmp = regexprep(pathTmp, '^[A-Z]:/', ''); + newRaw = bst_fullfile(mountpoint, pathTmp); + end + end + else + % Get drive letter + tmp = DataFile; + driverLetter = ''; + while ~strcmp(tmp, driverLetter) + driverLetter = tmp; + tmp = bst_fileparts(tmp); + end + % Update raw link path + if ~waspc + tmp = DataMat.F.filename; + % Remove common mountpoints + tmp2 = regexprep(tmp, '^/Volumes/.*?/', ''); + if strcmp(tmp2, tmp) + tmp2 = regexprep(tmp, '^.*/media/.*?/.*?/', ''); + end + % Add driverletter + newRaw = bst_fullfile(driverLetter, tmp2); + else + % Replace old drive letter + newRaw = regexprep(DataMat.F.filename, '^[A-Z]:/', driverLetter); + end + end + end % If the corrected file exists if file_exist(newRaw) % Update the file in the returned structure @@ -143,6 +192,11 @@ DataMat.Time = DataMat.Time'; end +% ===== FIX TRANSPOSED CHANNEL FLAG ===== +if isfield(DataMat, 'ChannelFlag') && (size(DataMat.ChannelFlag,2) > 1) + DataMat.ChannelFlag = DataMat.ChannelFlag'; +end + % ===== FIX EVENTS STRUCTURES ===== % Imported file if isfield(DataMat, 'Events') && ~isempty(DataMat.Events) diff --git a/toolbox/io/in_bst_results.m b/toolbox/io/in_bst_results.m index 149907b04..8bbc69253 100644 --- a/toolbox/io/in_bst_results.m +++ b/toolbox/io/in_bst_results.m @@ -260,6 +260,14 @@ Results.Leff = DataMat.Leff; end end +% If full results are saved as factor decomposition +elseif isfield(Results,'ImageGridAmp') && iscell(Results.ImageGridAmp) + % ImageGridAmp = ImageGridAmp{1} * ImageGridAmp{2} * ... * ImageGridAmp{N} + tmp = Results.ImageGridAmp{1}; + for iDecomposition = 2 : length(Results.ImageGridAmp) + tmp = tmp * Results.ImageGridAmp{iDecomposition}; + end + Results.ImageGridAmp = full(tmp); end diff --git a/toolbox/io/in_channel_ascii.m b/toolbox/io/in_channel_ascii.m index 36a9411bd..598f3e7f9 100644 --- a/toolbox/io/in_channel_ascii.m +++ b/toolbox/io/in_channel_ascii.m @@ -91,6 +91,9 @@ case 'name' iName = i; strScanFormat = [strScanFormat, '%[^,; \t\b\n\r] ']; + case 'namewithspace' + iName = i; + strScanFormat = [strScanFormat, '%[^,;\t\b\n\r] ']; case 'indice' iIndice = i; strScanFormat = [strScanFormat, '%d ']; diff --git a/toolbox/io/in_channel_curry_pom.m b/toolbox/io/in_channel_curry_pom.m index f4966b817..19dae2e75 100644 --- a/toolbox/io/in_channel_curry_pom.m +++ b/toolbox/io/in_channel_curry_pom.m @@ -1,5 +1,5 @@ function ChannelMat = in_channel_curry_pom(ChannelFile) -% IN_CHANNEL_CURRY_RS3: Read 3D cartesian positions for a set of points from a Curry .pom file. +% IN_CHANNEL_CURRY_POM: Read 3D cartesian positions for a set of points from a Curry .pom file. % % USAGE: ChannelMat = in_channel_curry_pom(ChannelFile) % diff --git a/toolbox/io/in_channel_pos.m b/toolbox/io/in_channel_pos.m index 00441f657..c9fbd7636 100644 --- a/toolbox/io/in_channel_pos.m +++ b/toolbox/io/in_channel_pos.m @@ -1,5 +1,10 @@ function ChannelMat = in_channel_pos(ChannelFile) % IN_CHANNEL_POS: Read 3D positions from a Polhemus .pos CTF-compatible file. +% +% Coordinates are transformed to either "Native" CTF head-coil-based coordinates if digitized HPI +% are present, or to SCS if HPI are not found but anatomical fiducials are present. If neither sets +% of fiducials are found, raw coordinates are kept and Brainstorm assumes they are in "Native" +% coordinates, as was previously done. % @============================================================================= % This function is part of the Brainstorm software: @@ -19,7 +24,7 @@ % For more information type "brainstorm license" at command prompt. % =============================================================================@ % -% Authors: Francois Tadel, Elizabeth Bock, 2012-2013 +% Authors: Francois Tadel, Elizabeth Bock, 2012-2013, Marc Lalancette 2024 % Initialize output structure ChannelMat = db_template('channelmat'); @@ -89,7 +94,7 @@ Loc = ChannelMat.HeadPoints.Loc; iRemove = []; for i = 1:length(iFid) - iRemove = [iRemove, find((abs(Loc(1,iFid(i))-Loc(1,iExtra))<1e-5) & (abs(Loc(2,iFid(i))-Loc(2,iExtra))<1e-5) & (abs(Loc(2,iFid(i))-Loc(2,iExtra)<1e-5)))]; + iRemove = [iRemove, find((abs(Loc(1,iFid(i))-Loc(1,iExtra))<1e-5) & (abs(Loc(2,iFid(i))-Loc(2,iExtra))<1e-5) & (abs(Loc(2,iFid(i))-Loc(2,iExtra))<1e-5))]; end if ~isempty(iRemove) ChannelMat.HeadPoints.Loc(:,iExtra(iRemove)) = []; @@ -98,6 +103,66 @@ end end - - +% Transform coordinates to head-coil-based system if possible, or anatomical-based system. This is +% done during digitization if it's done with Brainstorm, but doing it here will make this compatible +% with other files, and importantly allow simple manual fixes (e.g. swapping mislabeled coils) +% before importing. +% Keep backup in case we get an error, so it can still at least work as before. +ChannelBackup = ChannelMat; +try + if ~isempty(iFid) + % Are the MEG head coils present? + % Get the three fiducials in the head points + iNas = find(strcmpi(ChannelMat.HeadPoints.Label, 'HPI-N')); + iLpa = find(strcmpi(ChannelMat.HeadPoints.Label, 'HPI-L')); + iRpa = find(strcmpi(ChannelMat.HeadPoints.Label, 'HPI-R')); + iCardinal = find(strcmpi(ChannelMat.HeadPoints.Type, 'CARDINAL')); + if ~isempty(iNas) && ~isempty(iLpa) && ~isempty(iRpa) + % Hack: rename anat fids, rename HPI to anat fids to reuse ususal realign functions. Then + % restore names. + RealPointLabels = ChannelMat.HeadPoints.Label; + for iP = iCardinal + ChannelMat.HeadPoints.Label{iP} = ['Tmp-' ChannelMat.HeadPoints.Label(iCardinal)]; + end + for iP = iNas + ChannelMat.HeadPoints.Label{iP} = 'NAS'; + end + for iP = iLpa + ChannelMat.HeadPoints.Label{iP} = 'LPA'; + end + for iP = iRpa + ChannelMat.HeadPoints.Label{iP} = 'RPA'; + end + % Transform to coil-based "Native" CTF coordinates + ChannelMat = channel_detect_type(ChannelMat, 1); + % Restore labels + if numel(RealPointLabels) ~= numel(ChannelMat.HeadPoints.Label) + % channel_detect_type did something unexpected and changed number of points. + error('Unexpected change in number of head points.'); + end + ChannelMat.HeadPoints.Label = RealPointLabels; + % Correct misleading transformation name. + iTrans = find(strcmpi(ChannelMat.TransfMegLabels, 'Native=>Brainstorm/CTF')); + if numel(iTrans) ~= 1 + error('Unexpected transformation(s).') + end + % And for EEG + ChannelMat.TransfMegLabels{iTrans} = 'RawPoints=>Native'; + iTrans = find(strcmpi(ChannelMat.TransfEegLabels, 'Native=>Brainstorm/CTF')); + if numel(iTrans) ~= 1 + error('Unexpected transformation(s).') + end + ChannelMat.TransfEegLabels{iTrans} = 'RawPoints=>Native'; + elseif numel(iCardinal) >= 3 + % Transform to SCS coordinates + ChannelMat = channel_detect_type(ChannelMat, 1); + % Native=>Brainstorm/CTF already added. + end + end +catch ME + disp('BST> Warning: Unable to ensure head points are in "native" coordinates.'); + % rethrow(ME); + disp([' ' ME.message]); + ChannelMat = ChannelBackup; +end diff --git a/toolbox/io/in_channel_tvb.m b/toolbox/io/in_channel_tvb.m index 65e0d08ba..cd2cb847c 100644 --- a/toolbox/io/in_channel_tvb.m +++ b/toolbox/io/in_channel_tvb.m @@ -23,6 +23,14 @@ % % Authors: Francois Tadel, 2020 +% Install/load EasyH5 Toolbox (https://github.com/NeuroJSON/easyh5) as plugin +if ~exist('loadh5', 'file') + [isInstalled, errMsg] = bst_plugin('Install', 'easyh5'); + if ~isInstalled + error(errMsg); + end +end + % Read data from .h5 h5 = loadh5(ChannelFile); % Check data format diff --git a/toolbox/io/in_data_edf_ft.m b/toolbox/io/in_data_edf_ft.m new file mode 100644 index 000000000..97a729eb7 --- /dev/null +++ b/toolbox/io/in_data_edf_ft.m @@ -0,0 +1,44 @@ +function [DataMat, ChannelMat] = in_data_edf_ft(DataFile) +% in_data_edf_ft: Read entire EDF/EDF+ file using FieldTrip import function +% data is upsampled to the highest sampling rate +% +% USAGE: [DataMat, ChannelMat] = in_data_edf_ft(DataFile) + +% @============================================================================= +% This function is part of the Brainstorm software: +% https://neuroimage.usc.edu/brainstorm +% +% Copyright (c) University of Southern California & McGill University +% This software is distributed under the terms of the GNU General Public License +% as published by the Free Software Foundation. Further details on the GPLv3 +% license can be found at http://www.gnu.org/copyleft/gpl.html. +% +% FOR RESEARCH PURPOSES ONLY. THE SOFTWARE IS PROVIDED "AS IS," AND THE +% UNIVERSITY OF SOUTHERN CALIFORNIA AND ITS COLLABORATORS DO NOT MAKE ANY +% WARRANTY, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO WARRANTIES OF +% MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE, NOR DO THEY ASSUME ANY +% LIABILITY OR RESPONSIBILITY FOR THE USE OF THIS SOFTWARE. +% +% For more information type "brainstorm license" at command prompt. +% =============================================================================@ +% +% Authors: Edouard Delaire 2024 +% Raymundo Cassani 2024 + +%% ===== INSTALL PLUGIN FIELDTRIP ===== +if ~exist('edf2fieldtrip', 'file') + [isInstalled, errMsg] = bst_plugin('Install', 'fieldtrip'); + if ~isInstalled + error(errMsg); + end +end + +% Temporary FieldTrip file +[~, filename] = bst_fileparts(DataFile); +tmpfilename = bst_fullfile(bst_get('BrainstormTmpDir', 0, 'fieldtrip'), [filename '.mat']); +% Read EDF using FieldTrip +data = edf2fieldtrip(DataFile); +% Save read data +save(tmpfilename, 'data'); +% Import in Brainstorm +[DataMat, ChannelMat] = in_data_fieldtrip(tmpfilename); diff --git a/toolbox/io/in_data_snirf.m b/toolbox/io/in_data_snirf.m index 4d09fd6fe..a01d8363b 100644 --- a/toolbox/io/in_data_snirf.m +++ b/toolbox/io/in_data_snirf.m @@ -25,7 +25,15 @@ % % Authors: Edouard Delaire, Francois Tadel, 2020 -% Load file header with the JSNIRF Toolbox (https://github.com/fangq/jsnirfy) +% Install/load JSNIRF Toolbox (https://github.com/NeuroJSON/jsnirfy) as plugin +if ~exist('loadsnirf', 'file') + [isInstalled, errMsg] = bst_plugin('Install', 'jsnirfy'); + if ~isInstalled + error(errMsg); + end +end + +% Load file header jnirs = loadsnirf(DataFile); if isempty(jnirs) || ~isfield(jnirs, 'nirs') @@ -85,13 +93,22 @@ % Create channel file structure ChannelMat = db_template('channelmat'); ChannelMat.Comment = 'NIRS-BRS channels'; -ChannelMat.Nirs.Wavelengths = jnirs.nirs.probe.wavelengths; +ChannelMat.Nirs.Wavelengths = round(jnirs.nirs.probe.wavelengths); % NIRS channels for iChan = 1:nChannels % This assume measure are raw; need to change for Hbo,HbR,HbT channel = jnirs.nirs.data.measurementList(iChan); - [ChannelMat.Channel(iChan).Name, ChannelMat.Channel(iChan).Group] = nst_format_channel(channel.sourceIndex, channel.detectorIndex, jnirs.nirs.probe.wavelengths(channel.wavelengthIndex)); + if isempty(jnirs.nirs.probe.sourceLabels) || isempty(jnirs.nirs.probe.detectorLabels) + [ChannelMat.Channel(iChan).Name, ChannelMat.Channel(iChan).Group] = nst_format_channel(channel.sourceIndex, channel.detectorIndex, jnirs.nirs.probe.wavelengths(channel.wavelengthIndex)); + else + + ChannelMat.Channel(iChan).Name = sprintf('%s%sWL%d', jnirs.nirs.probe.sourceLabels(channel.sourceIndex), ... + jnirs.nirs.probe.detectorLabels(channel.detectorIndex), ... + round(jnirs.nirs.probe.wavelengths(channel.wavelengthIndex))); + ChannelMat.Channel(iChan).Group = sprintf('WL%d', round(jnirs.nirs.probe.wavelengths(channel.wavelengthIndex))); + + end ChannelMat.Channel(iChan).Type = 'NIRS'; ChannelMat.Channel(iChan).Weight = 1; if ~isempty(src_pos) && ~isempty(det_pos) @@ -148,11 +165,11 @@ for iLandmark = 1:size(jnirs.nirs.probe.landmarkPos3D, 1) name = strtrim(str_remove_spec_chars(toLine(jnirs.nirs.probe.landmarkLabels{iLandmark}))); - coord = scale .* jnirs.nirs.probe.landmarkPos3D(iLandmark, :); + coord = scale .* jnirs.nirs.probe.landmarkPos3D(iLandmark, 1:3); % Fiducials NAS/LPA/RPA switch lower(name) - case {'nasion','nas'} + case {'nasion','nas','nz'} ChannelMat.SCS.NAS = coord; ltype = 'CARDINAL'; case {'leftear', 'lpa'} @@ -222,24 +239,31 @@ DataMat.Events = repmat(db_template('event'), 1, length(jnirs.nirs.stim)); for iEvt = 1:length(jnirs.nirs.stim) - DataMat.Events(iEvt).label = strtrim(str_remove_spec_chars(toLine(jnirs.nirs.stim(iEvt).name))); - if ~isfield(jnirs.nirs.stim(iEvt), 'data') + if iscell(jnirs.nirs.stim(iEvt)) + DataMat.Events(iEvt).label = strtrim(str_remove_spec_chars(toLine(jnirs.nirs.stim{iEvt}.name))); + else + DataMat.Events(iEvt).label = strtrim(str_remove_spec_chars(toLine(jnirs.nirs.stim(iEvt).name))); + end + if ~isfield(jnirs.nirs.stim(iEvt), 'data') || isempty(jnirs.nirs.stim(iEvt).data) % Events structure warning(sprintf('No data found for event: %s',DataMat.Events(iEvt).label)) continue end % Get timing - - if size(jnirs.nirs.stim(iEvt).data,1) > size(jnirs.nirs.stim(iEvt).data,1) + nStimDataCols = 3; % [starttime duration value] + if isfield(jnirs.nirs.stim(iEvt), 'dataLabels') + nStimDataCols = length(jnirs.nirs.stim(iEvt).dataLabels); + end + % Transpose to match number of columns + if size(jnirs.nirs.stim(iEvt).data, 1) == nStimDataCols && diff(size(jnirs.nirs.stim(iEvt).data)) ~= 0 jnirs.nirs.stim(iEvt).data = jnirs.nirs.stim(iEvt).data'; end - - isExtended = (size(jnirs.nirs.stim(iEvt).data,1) >= 2) && ~all(jnirs.nirs.stim(iEvt).data(2,:) == 0); + isExtended = ~all(jnirs.nirs.stim(iEvt).data(:,2) == 0); if isExtended - evtTime = [jnirs.nirs.stim(iEvt).data(1,:); ... - jnirs.nirs.stim(iEvt).data(1,:) + jnirs.nirs.stim(iEvt).data(2,:)]; + evtTime = [jnirs.nirs.stim(iEvt).data(:,1) , ... + jnirs.nirs.stim(iEvt).data(:,1) + jnirs.nirs.stim(iEvt).data(:,2)]'; else - evtTime = jnirs.nirs.stim(iEvt).data(1,:)'; + evtTime = jnirs.nirs.stim(iEvt).data(:,1)'; end DataMat.Events(iEvt).times = evtTime; diff --git a/toolbox/io/in_data_tvb.m b/toolbox/io/in_data_tvb.m index d76f3e890..1984192be 100644 --- a/toolbox/io/in_data_tvb.m +++ b/toolbox/io/in_data_tvb.m @@ -32,6 +32,14 @@ ChannelFile = []; end +% Install/load EasyH5 Toolbox (https://github.com/NeuroJSON/easyh5) as plugin +if ~exist('loadh5', 'file') + [isInstalled, errMsg] = bst_plugin('Install', 'easyh5'); + if ~isInstalled + error(errMsg); + end +end + % Read data from .h5 h5ts = loadh5(DataFile); % Check data format diff --git a/toolbox/io/in_events_brainamp.m b/toolbox/io/in_events_brainamp.m index 06a9aeeaa..469e2ae75 100644 --- a/toolbox/io/in_events_brainamp.m +++ b/toolbox/io/in_events_brainamp.m @@ -1,4 +1,4 @@ -function events = in_events_brainamp(sFile, EventFile) +function events = in_events_brainamp(sFile, ChannelMat, EventFile) % IN_EVENTS_BRAINAMP: Open a BrainVision BrainAmp .vmrk file. % % OUTPUT: @@ -27,6 +27,7 @@ % =============================================================================@ % % Authors: Francois Tadel, 2012 +% Raymundo Cassani, 2024 % Open and read file @@ -44,7 +45,7 @@ % Lines to skip if isempty(newLine) continue; - elseif ~isempty(strfind(newLine, '[Marker Infos]')) + elseif ~isempty(strfind(lower(newLine), '[marker infos]')) isMarkerSection = 1; elseif ~isMarkerSection || ismember(newLine(1), {'[', ';', char(10), char(13)}) || ~any(newLine == '=') continue; @@ -65,8 +66,14 @@ else mlabel = 'Mk'; end + % Marker channels + if str2num(argLine{5}) == 0 + channels = {[]}; + else + channels = {{ChannelMat.Channel(str2num(argLine{5})).Name}}; + end % Add markers entry: {name, type, start, length} - Markers(end+1,:) = {mlabel, argLine{1}, str2num(argLine{3}), str2num(argLine{4})}; + Markers(end+1,:) = {mlabel, argLine{1}, str2num(argLine{3}), str2num(argLine{4}), channels}; end % Close file fclose(fid); @@ -93,9 +100,40 @@ events(iEvt).times = samples ./ sFile.prop.sfreq; events(iEvt).reactTimes = []; events(iEvt).select = 1; - events(iEvt).channels = []; events(iEvt).notes = []; + if all(cellfun(@isempty, [Markers{iMrk,5}])) + events(iEvt).channels = []; + else + % Handle channel-wise events + events(iEvt).channels = [Markers{iMrk,5}]; + end end +% Merge channel-wise events with same times +for iEvt = 1:length(events) + if ~isempty(events(iEvt).channels) + % Find occurrences with channel + iwChannel = find(~cellfun(@isempty, [events(iEvt).channels])); + iwDelete = []; + for iw = 1 : length(iwChannel) + if ~ismember(iw, iwDelete) + % Find others channel-wise events with the same time, and not to be deleted yet + iwOthers = find(all(bsxfun(@eq, events(iEvt).times(:,iw), events(iEvt).times), 1)); + % Exclude own + iwOthers = setdiff(iwOthers, iw); + % Only consider ones with channel + iwOthers = intersect(iwOthers, iwChannel); + for ix = 1 : length(iwOthers) + % Append the channel names + events(iEvt).channels{iw} = [events(iEvt).channels{iw}, events(iEvt).channels{iwOthers(ix)}]; + iwDelete = [iwDelete, iwOthers(ix)]; + end + end + end + events(iEvt).epochs(iwDelete) = []; + events(iEvt).times(:,iwDelete) = []; + events(iEvt).channels(:,iwDelete) = []; + end +end diff --git a/toolbox/io/in_events_oebin.m b/toolbox/io/in_events_oebin.m index c7e54641d..0764e93da 100644 --- a/toolbox/io/in_events_oebin.m +++ b/toolbox/io/in_events_oebin.m @@ -1,5 +1,5 @@ function events = in_events_oebin(sFile, EventFile) -% IN_EVENTS_OEBIN: Import events from a Open Ephys flat binary event file (timestamps.npy) +% IN_EVENTS_OEBIN: Import events from a Open Ephys flat binary event sample_numbers file % % USAGE: events = in_events_oebin(sFile, EventFile) @@ -22,10 +22,19 @@ % =============================================================================@ % % Authors: Francois Tadel, 2020 +% Raymundo Cassani, 2024 -% Read time stamps -evtTime = reshape(readNPY(EventFile), 1, []); -if isempty(evtTime) +% Install/load npy-matlab Toolbox (https://github.com/kwikteam/npy-matlab) as plugin +if ~exist('readNPY', 'file') + [isInstalled, errMsg] = bst_plugin('Install', 'npy-matlab'); + if ~isInstalled + error(errMsg); + end +end + +% Read event sample number +evtIndices = reshape(readNPY(EventFile), 1, []); +if isempty(evtIndices) events = []; return end @@ -40,7 +49,7 @@ if file_exist(TextFile) disp('EOBIN> ERROR: Text events are not supported yet...'); evtGroupLabel = {'TEXT'}; - evtGroupInd = {1:length(evtTime)}; + evtGroupInd = {1:length(evtIndices)}; evtChan = []; % evtLabels = readNPY(TextFile); @@ -70,7 +79,7 @@ evtChan = []; else evtGroupLabel = {'Unknown'}; - evtGroupInd = {1:length(evtTime)}; + evtGroupInd = {1:length(evtIndices)}; evtChan = []; end @@ -79,7 +88,7 @@ % Get occurrences for each event for iEvt = 1:length(evtGroupLabel) events(iEvt).label = evtGroupLabel{iEvt}; - events(iEvt).times = double(evtTime(evtGroupInd{iEvt})) ./ sFile.prop.sfreq; + events(iEvt).times = double(evtIndices(evtGroupInd{iEvt})) ./ sFile.prop.sfreq; events(iEvt).epochs = ones(1, length(events(iEvt).times)); % Epoch: set as 1 for all the occurrences events(iEvt).reactTimes = []; events(iEvt).select = 1; diff --git a/toolbox/io/in_fopen.m b/toolbox/io/in_fopen.m index 3d87ff10e..d6d8ac1f1 100644 --- a/toolbox/io/in_fopen.m +++ b/toolbox/io/in_fopen.m @@ -193,6 +193,8 @@ DataMat = in_data_ascii(DataFile); case 'EEG-CARTOOL' DataMat = in_data_cartool(DataFile); + case {'EEG-EDF-FT'} + [DataMat, ChannelMat] = in_data_edf_ft(DataFile); case 'EEG-ERPCENTER' DataMat = in_data_erpcenter(DataFile); case 'EEG-ERPLAB' diff --git a/toolbox/io/in_fopen_brainamp.m b/toolbox/io/in_fopen_brainamp.m index f5d733cd3..6c86d0040 100644 --- a/toolbox/io/in_fopen_brainamp.m +++ b/toolbox/io/in_fopen_brainamp.m @@ -224,7 +224,7 @@ %% ===== READ EVENTS ===== if ~isempty(VmrkFile) - sFile = import_events(sFile, [], VmrkFile, 'BRAINAMP', [], 0); + sFile = import_events(sFile, ChannelMat, VmrkFile, 'BRAINAMP', [], 0); end diff --git a/toolbox/io/in_fopen_bst.m b/toolbox/io/in_fopen_bst.m index df3621fde..2b6415947 100644 --- a/toolbox/io/in_fopen_bst.m +++ b/toolbox/io/in_fopen_bst.m @@ -51,7 +51,7 @@ hdr.nchannels = double(fread(fid, [1 1], 'uint32')); % UINT32(1) : Number of channels % ===== CHECK WHETHER VERSION IS SUPPORTED ===== -if (hdr.version > 52) +if (hdr.version > 53) error(['The selected version of the BST format is currently not supported.' ... 10 'Please update Brainstorm.']); end @@ -108,6 +108,12 @@ end end ChannelMat.Projector(i).Status = double(fread(fid, [1 1], 'int8')); % INT8(1) : Status + % September 2024: Added char array for projector method + if hdr.version >= 53 + ChannelMat.Projector(i).Method = str_read(fid, 20); % CHAR(20) : Projector method + end + % Complete projector method if necesary + ChannelMat.Projector(i) = process_ssp2('ConvertOldFormat', ChannelMat.Projector(i)); end % ===== HEAD POINTS ===== diff --git a/toolbox/io/in_fopen_bstmatrix.m b/toolbox/io/in_fopen_bstmatrix.m index 4e773343b..930e6aa97 100644 --- a/toolbox/io/in_fopen_bstmatrix.m +++ b/toolbox/io/in_fopen_bstmatrix.m @@ -45,7 +45,7 @@ DataMat.Comment = MatrixMat.Comment; DataMat.Time = MatrixMat.Time; DataMat.Events = MatrixMat.Events; -DataMat.ChannelFlag = ones(1, nSignals); +DataMat.ChannelFlag = ones(nSignals, 1); % Generate ChannelMat structure ChannelMat = db_template('channelmat'); ChannelMat.Channel = repmat(db_template('channeldesc'), 1, nSignals); diff --git a/toolbox/io/in_fopen_ctf.m b/toolbox/io/in_fopen_ctf.m index da6639678..c7325ed6e 100644 --- a/toolbox/io/in_fopen_ctf.m +++ b/toolbox/io/in_fopen_ctf.m @@ -188,8 +188,19 @@ HeadMat = in_channel_pos(pos_file); % Copy head points ChannelMat.HeadPoints = HeadMat.HeadPoints; + isAlign = true; + % Warn if unsure about coordinate system. Should now be converted to "Native" CTF coil-based + % when HPI points are present when loading the pos file. + if ~isfield(HeadMat, 'TransfMegLabels') || ~iscell(HeadMat.TransfMegLabels) || isempty(HeadMat.TransfMegLabels) + disp('BST> Warning: Unable to confirm coordinate system of head points. Assuming "Native" CTF head-coil-based system.'); + elseif ismember('Native=>Brainstorm/CTF', HeadMat.TransfMegLabels) + disp('BST> Warning: head point coordinates appear to already be in SCS, presumably because of missing digitized head coils.'); + isAlign = false; + elseif ~ismember('RawPoints=>Native', HeadMat.TransfMegLabels) + disp('BST> Warning: Unable to confirm coordinate system of head points. Assuming "Native" CTF head-coil-based system.'); + end % Force re-alignment on the new set of NAS/LPA/RPA (switch from CTF coil-based to SCS anatomical-based coordinate system) - ChannelMat = channel_detect_type(ChannelMat, 1, 0); + ChannelMat = channel_detect_type(ChannelMat, isAlign, 0); end %% ===== READ HC FILE ===== diff --git a/toolbox/io/in_fopen_edf.m b/toolbox/io/in_fopen_edf.m index 9c24ca5a4..09c1896c0 100644 --- a/toolbox/io/in_fopen_edf.m +++ b/toolbox/io/in_fopen_edf.m @@ -120,9 +120,7 @@ hdr.signal(i).physical_max = hdr.signal(i).digital_max; end if (hdr.signal(i).physical_min >= hdr.signal(i).physical_max) - disp(['EDF> Warning: Physical maximum larger than minimum for channel "' hdr.signal(i).label '".']); - hdr.signal(i).physical_min = hdr.signal(i).digital_min; - hdr.signal(i).physical_max = hdr.signal(i).digital_max; + disp(['EDF> Warning: Physical minimum larger than physical maximum for channel "' hdr.signal(i).label '".']); end % Calculate and save channel gain hdr.signal(i).gain = unit_gain ./ (hdr.signal(i).physical_max - hdr.signal(i).physical_min) .* (hdr.signal(i).digital_max - hdr.signal(i).digital_min); @@ -343,6 +341,7 @@ if ~isempty(iChanWrongRate) sFile.channelflag(iChanWrongRate) = -1; disp([sprintf('EDF> WARNING: Excluding channels with sampling rates other than %.3f Hz : ', hdr.signal(iChanFreqRef).sfreq), sprintf('%s ', ChannelMat.Channel(iChanWrongRate).Name)]); + disp( ' To uniform sampling rates import EDF file as "EEG EDF / EDF+ FieldTrip reader".'); end % Consider that the sampling rate of the file is the sampling rate of the first signal diff --git a/toolbox/io/in_fopen_fif.m b/toolbox/io/in_fopen_fif.m index b32ba2aa2..3a1bb031c 100644 --- a/toolbox/io/in_fopen_fif.m +++ b/toolbox/io/in_fopen_fif.m @@ -53,6 +53,13 @@ end %% ===== OPEN FIF FILE ===== +% Use MNE functions in brainstorm3/external/mne/matlab +bst_plugin('Unload', 'fieldtrip'); +bst_plugin('Unload', 'spm12'); +% Reset FIFF +global FIFF; +FIFF = fiff_define_constants(); + % Open file [ fid, tree ] = fiff_open(DataFile); if (fid < 0) diff --git a/toolbox/io/in_fopen_intan.m b/toolbox/io/in_fopen_intan.m index e61272b9a..b04524a33 100644 --- a/toolbox/io/in_fopen_intan.m +++ b/toolbox/io/in_fopen_intan.m @@ -38,7 +38,7 @@ %% ===== GET FILES ===== % Get base dataset folder [hdr.BaseFolder, isItInfo, hdr.FileExt] = bst_fileparts(DataFile); -[filePath, fileComment] = bst_fileparts(hdr.BaseFolder); +[filePath, fileComment] = bst_fileparts(DataFile); % Check the type of file if strcmp(isItInfo,'info') diff --git a/toolbox/io/in_fopen_itab.m b/toolbox/io/in_fopen_itab.m index 091244505..85c8c7d38 100644 --- a/toolbox/io/in_fopen_itab.m +++ b/toolbox/io/in_fopen_itab.m @@ -34,9 +34,9 @@ hdr = read_itab_mhd(HeaderFile); % Get endianness switch (hdr.data_type) - case {0,1,2}, byteorder = 'b'; - case {3,4,5}, byteorder = 'l'; - otherwise, error('Data type not supported.'); + case {0,1,2, 6}, byteorder = 'b'; + case {3,4,5}, byteorder = 'l'; + otherwise, error('Data type not supported.'); end @@ -52,7 +52,7 @@ % Position for iCoil = 1:hdr.ch(iChannels(i)).ncoils ChannelMat.Channel(i).Loc(:,iCoil) = hdr.ch(iChannels(i)).position(iCoil).r_s' ./ 1000; - ChannelMat.Channel(i).Orient(:,iCoil) = hdr.ch(iChannels(i)).position(iCoil).u_s' ./ 1000; + ChannelMat.Channel(i).Orient(:,iCoil) = hdr.ch(iChannels(i)).position(iCoil).u_s'; ChannelMat.Channel(i).Weight(1,iCoil) = hdr.ch(iChannels(i)).wgt(iCoil); end % Type @@ -112,7 +112,7 @@ %% ===== EXTRA HEAD POINTS ===== % Get extra head points -HpLoc = hdr.marker; +HpLoc = hdr.marker(1:3, :); % Keep only marker positions iGood = find(~all(HpLoc == 0, 1)); HpLoc = HpLoc(:,iGood) ./ 1000; nPoints = size(HpLoc,2); diff --git a/toolbox/io/in_fopen_neuralynx.m b/toolbox/io/in_fopen_neuralynx.m index 0fd8f7d14..c9d15b339 100644 --- a/toolbox/io/in_fopen_neuralynx.m +++ b/toolbox/io/in_fopen_neuralynx.m @@ -10,21 +10,31 @@ % http://www.fieldtriptoolbox.org/getting_started/neuralynx % % NCS files structure: -% |- Header ASCII: 16*1044 bytes +% |- Header ASCII: 16*1024 bytes % |- Records: nRecords x 1044 bytes % |- TimeStamp : uint64 % |- ChanNumber : int32 % |- SampFreq : int32 % |- NumValidSamp : int32 -% |- Data : 512 x int16 +% |- Data : 512 samples x int16 +% % NSE files structure: -% |- Header ASCII: 16*1044 bytes +% |- Header ASCII: 16*1024 bytes % |- Records: nRecords x 112 bytes % |- TimeStamp : uint64 % |- ScNumber : int32 % |- CellNumber : int32 % |- Param : 8 x int32 -% |- Data : NumSamples x int16 +% |- Data : 32 samples x int16 +% +% NTT files structure: +% |- Header ASCII: 16*1024 bytes +% |- Records: nRecords x 304 bytes +% |- TimeStamp : uint64 +% |- ScNumber : int32 +% |- CellNumber : int32 +% |- Param : 8 x int32 +% |- Data : 4 channels x 32 samples x int16 % @============================================================================= % This function is part of the Brainstorm software: @@ -45,13 +55,14 @@ % =============================================================================@ % % Authors: Francois Tadel, 2015-2019 +% Raymundo Cassani, 2024 %% ===== GET FILES ===== % Get base dataset folder if isdir(DataFile) hdr.BaseFolder = DataFile; -elseif strcmpi(DataFile(end-3:end), '.ncs') || strcmpi(DataFile(end-3:end), '.nse') || strcmpi(DataFile(end-3:end), '.nev') +elseif ismember(lower(DataFile(end-3:end)), {'.ncs', '.nse', '.ntt', '.nev'}) hdr.BaseFolder = bst_fileparts(DataFile); else error('Invalid Neuralynx folder.'); @@ -62,13 +73,14 @@ disp(['BST> Warning: Events file not found in folder: ' 10 hdr.BaseFolder]); EventFile = []; end -% Recordings (*.ncs; *.nse) +% Recordings (*.ncs; *.nse; *.ntt) NcsFiles = dir(bst_fullfile(hdr.BaseFolder, '*.ncs')); NseFiles = dir(bst_fullfile(hdr.BaseFolder, '*.nse')); +NttFiles = dir(bst_fullfile(hdr.BaseFolder, '*.ntt')); if isempty(NcsFiles) error(['Could not find any .ncs recordings in folder: ' 10 hdr.BaseFolder]); end -ChanFiles = sort({NcsFiles.name, NseFiles.name}); +ChanFiles = sort({NcsFiles.name, NseFiles.name, NttFiles.name}); %% ===== FILE COMMENT ===== @@ -90,7 +102,12 @@ error(['Missing fields in the file header of file: ' ChanFiles{i}]); end % Compute number of records saved in the file - nRecordsFile = round((newHeader.FileSize - newHeader.HeaderSize) / newHeader.RecordSize); + switch newHeader.FileExtension + case {'NCS', 'NSE'} + nRecordsFile = round((newHeader.FileSize - newHeader.HeaderSize) / newHeader.RecordSize); + case {'NTT'} + nRecordsFile = round((newHeader.FileSize - newHeader.HeaderSize) / newHeader.RecordSize); + end % Check if there are missing timestamps in the file if isfield(newHeader, 'LastTimeStamp') && ~isempty(newHeader.LastTimeStamp) nRecordsTime = round(double(newHeader.LastTimeStamp - newHeader.FirstTimeStamp) / 1e6 * newHeader.SamplingFrequency / 512) + 1; @@ -100,11 +117,12 @@ nRecordsFile = nRecordsTime; end end + % Extract information needed for opening the file if (i == 1) hdr.FirstTimeStamp = newHeader.FirstTimeStamp; hdr.LastTimeStamp = newHeader.LastTimeStamp; - hdr.NumSamples = nRecordsFile * 512; + hdr.NumSamples = nRecordsFile * 512; % 512 samples per recording in NCS file hdr.SamplingFrequency = newHeader.SamplingFrequency; if isfield(newHeader, 'HardwareSubSystemType') hdr.HardwareSubSystemType = newHeader.HardwareSubSystemType; @@ -116,14 +134,31 @@ elseif isfield(newHeader, 'LastTimeStamp') && ~isempty(newHeader.LastTimeStamp) && ((hdr.FirstTimeStamp ~= newHeader.FirstTimeStamp) || (hdr.LastTimeStamp ~= newHeader.LastTimeStamp)) disp(['BST> Warning: Timestamps in "' ChanFiles{i} '" do not match "' ChanFiles{1} '". Skipping file...']); continue; - % For .nse files: Compute the spike times - elseif strcmpi(newHeader.FileExtension, 'NSE') + end + + % For spike files: Compute the spike times + if strcmpi(newHeader.FileType, 'Spike') newHeader.SpikeTimes = double(newHeader.SpikeTimeStamps - hdr.FirstTimeStamp) / 1e6; end - - % Save all file names - hdr.chan_headers{end+1} = newHeader; - hdr.chan_files{end+1} = ChanFiles{i}; + % For .ntt files: Repeat the header 4 times, as there are four electrodes per NTT file + if strcmpi(newHeader.FileExtension, 'NTT') + nChannelsNtt = newHeader.NumADChannels; + newHeaders = repmat(newHeader, 1, nChannelsNtt); + for iChannelNtt = 1 : nChannelsNtt + newHeaders(iChannelNtt).NttIndexCh = iChannelNtt; + newHeaders(iChannelNtt).AcqEntName = sprintf('%s_%d', newHeader.AcqEntName, iChannelNtt); + newHeaders(iChannelNtt).ADBitVolts = newHeader.ADBitVolts(iChannelNtt); + newHeaders(iChannelNtt).ADChannel = newHeader.ADChannel(iChannelNtt); + newHeaders(iChannelNtt).InputRange = newHeader.InputRange(iChannelNtt); + newHeaders(iChannelNtt).ThreshVal = newHeader.ThreshVal(iChannelNtt); + end + newHeader = newHeaders; + end + % Save all headers for each channel file + for iHeader = 1 : length(newHeader) + hdr.chan_headers{end+1} = newHeader(iHeader); + hdr.chan_files{end+1} = ChanFiles{i}; + end end hdr.NumChannels = length(hdr.chan_files); @@ -156,8 +191,12 @@ ChannelMat.Channel = repmat(db_template('channeldesc'), [1, hdr.NumChannels]); % For each channel for i = 1:hdr.NumChannels - [fPath,fName,fExt] = bst_fileparts(hdr.chan_files{i}); - ChannelMat.Channel(i).Name = fName; + if isfield(hdr.chan_headers{i}, 'AcqEntName') && ~isempty(hdr.chan_headers{i}.AcqEntName) + channelName = hdr.chan_headers{i}.AcqEntName; + else + [~, channelName] = bst_fileparts(hdr.chan_files{i}); + end + ChannelMat.Channel(i).Name = channelName; ChannelMat.Channel(i).Loc = [0; 0; 0]; ChannelMat.Channel(i).Type = 'EEG'; ChannelMat.Channel(i).Orient = []; diff --git a/toolbox/io/in_fopen_nirs_brs.m b/toolbox/io/in_fopen_nirs_brs.m index 73b1bf43a..dd3f3c481 100644 --- a/toolbox/io/in_fopen_nirs_brs.m +++ b/toolbox/io/in_fopen_nirs_brs.m @@ -252,13 +252,13 @@ measure_type = 'Hb'; ChannelMat.Nirs.Hb = nirs.SD.Lambda; else - measure_type = 'WL'; if( size(nirs.SD.Lambda,1) > 1) % Wavelengths have to be stored as a line vector ChannelMat.Nirs.Wavelengths = nirs.SD.Lambda'; else ChannelMat.Nirs.Wavelengths = nirs.SD.Lambda; end + ChannelMat.Nirs.Wavelengths = round(ChannelMat.Nirs.Wavelengths); end %% Channel information @@ -276,7 +276,7 @@ if strcmp(measure_type, 'Hb') measure_tag = ChannelMat.Nirs.Hb{idx_measure}; else - measure_tag = sprintf('WL%d', round(ChannelMat.Nirs.Wavelengths(idx_measure))); + measure_tag = sprintf('WL%d', ChannelMat.Nirs.Wavelengths(idx_measure)); end Channel(iChan).Name = sprintf('S%dD%d%s', idx_src, idx_det, ... measure_tag); diff --git a/toolbox/io/in_fopen_oebin.m b/toolbox/io/in_fopen_oebin.m index 11405c05f..9b8af339b 100644 --- a/toolbox/io/in_fopen_oebin.m +++ b/toolbox/io/in_fopen_oebin.m @@ -25,8 +25,16 @@ % =============================================================================@ % % Authors: Francois Tadel, 2020 +% Raymundo Cassani, 2024 %% ===== GET FILES ===== +% Install/load npy-matlab Toolbox (https://github.com/kwikteam/npy-matlab) as plugin +if ~exist('readNPY', 'file') + [isInstalled, errMsg] = bst_plugin('Install', 'npy-matlab'); + if ~isInstalled + error(errMsg); + end +end % Build header and markers files names procDir = bst_fileparts(DataFile); [contDir, procName] = bst_fileparts(procDir); @@ -34,23 +42,29 @@ expDir = bst_fileparts(recDir); [parentDir, expName] = bst_fileparts(expDir); [tmp, parentName] = bst_fileparts(parentDir); -% Timestamp file -TimeFile = bst_fullfile(procDir, 'timestamps.npy'); -if ~file_exist(TimeFile) - error(['Could not find timestamp file: ' TimeFile]); -end % OEBIN JSON header OebinFile = bst_fullfile(recDir, 'structure.oebin'); if ~file_exist(OebinFile) error(['Could not find header file: ' OebinFile]); end -% Event files -EvtFiles = file_find(bst_fullfile(recDir, 'events'), 'timestamps.npy', 3, 0); - - -%% ===== READ HEADER ===== % Read JSON file hdr = bst_jsondecode(OebinFile); +% Identify file with sample indices depending on the Open Ephys GUI version +if bst_plugin('CompareVersions', hdr.GUIVersion, '0.6.0') == -1 + SampleIndicesFileName = 'timestamps.npy'; +else + SampleIndicesFileName = 'sample_numbers.npy'; +end +% Sample indices file +SampleIndicesFile = file_find(procDir, SampleIndicesFileName, 1, 1); +if ~file_exist(SampleIndicesFile) + error(['Could not find file with sample indices: ' SampleIndicesFileName]); +end +% Event indices files +EvtIndicesFiles = file_find(bst_fullfile(recDir, 'events'), SampleIndicesFileName, 3, 0); + + +%% ===== PARSE HEADER ===== % If there are multiple processors in the same recording: find the one corresponding to this .dat file if (length(hdr.continuous) > 1) iRec = find(strcmpi({hdr.continuous.folder_name}, [procName, '/'])); @@ -68,9 +82,11 @@ error('Recordings do not contain continuous data.'); end hdr.continuous = hdr.continuous(iRec); -% Read time stamps -TimeStamps = readNPY(TimeFile); - +% Read sample indices +SampleIndices = readNPY(SampleIndicesFile); +% Add first and last sample indices to header structure +hdr.first_samp = double(SampleIndices(1)); +hdr.last_samp = double(SampleIndices(end)); %% ===== CREATE BRAINSTORM SFILE STRUCTURE ===== % Initialize returned file structure @@ -85,7 +101,7 @@ sFile.condition = parentName; % Consider that the sampling rate of the file is the sampling rate of the first signal sFile.prop.sfreq = hdr.continuous.sample_rate; -sFile.prop.times = double([TimeStamps(1), TimeStamps(1) + length(TimeStamps) - 1]) ./ sFile.prop.sfreq; +sFile.prop.times = double([SampleIndices(1), SampleIndices(1) + length(SampleIndices) - 1]) ./ sFile.prop.sfreq; sFile.prop.nAvg = 1; % No info on bad channels sFile.channelflag = ones(hdr.continuous.num_channels, 1); @@ -124,8 +140,8 @@ %% ===== READ EVENTS ===== -for iFile = 1:length(EvtFiles) - sFile = import_events(sFile, [], EvtFiles{iFile}, 'OEBIN', [], 0); +for iFile = 1:length(EvtIndicesFiles) + sFile = import_events(sFile, [], EvtIndicesFiles{iFile}, 'OEBIN', [], 0); end diff --git a/toolbox/io/in_fread_edf.m b/toolbox/io/in_fread_edf.m index 1c54bc8c4..2e40a25a3 100644 --- a/toolbox/io/in_fread_edf.m +++ b/toolbox/io/in_fread_edf.m @@ -219,11 +219,12 @@ F = double(F); % Apply gains F = bst_bsxfun(@rdivide, F, [sFile.header.signal(iChannels).gain]'); -% IN THEORY: THIS OFFSET SECTION IS USEFUL, BUT IN PRACTICE IT LOOKS LIKE THE VALUES IN ALL THE FILES ARE CENTERED ON ZERO -% % Add offset -% if isfield(sFile.header.signal, 'offset') && ~isempty(sFile.header.signal(1).offset) -% % ... -% end + % Add offset + if isfield(sFile.header.signal, 'offset') && ~isempty(sFile.header.signal(1).offset) + offsets = [sFile.header.signal(iChannels).offset]; + offsets(isnan(offsets)) = 0; + F = bst_bsxfun(@plus, F, offsets'); + end end end diff --git a/toolbox/io/in_fread_itab.m b/toolbox/io/in_fread_itab.m index fc295eeb9..280312b3e 100644 --- a/toolbox/io/in_fread_itab.m +++ b/toolbox/io/in_fread_itab.m @@ -36,7 +36,7 @@ % ===== READ DATA ===== % Get data type switch (sFile.header.data_type) - case {0,3} + case {0,3,6} bytesPerVal = 2; dataClass = 'int16'; case {1,4} diff --git a/toolbox/io/in_fread_neuralynx.m b/toolbox/io/in_fread_neuralynx.m index e4b04aa5d..5d5ac5b77 100644 --- a/toolbox/io/in_fread_neuralynx.m +++ b/toolbox/io/in_fread_neuralynx.m @@ -22,6 +22,7 @@ % =============================================================================@ % % Authors: Francois Tadel, 2015-2021 +% Raymundo Cassani, 2024 % Parse inputs if (nargin < 3) || isempty(iChannels) @@ -68,27 +69,38 @@ % Copy values to final matrix F(iChan, :) = Ftmp(iSamples); - % NSE: Spike files, just set the values wherever they are defined - case 'NSE' + % NSE and NTT: Spike files, just set the values wherever they are defined + case {'NSE', 'NTT'} + isNtt = strcmpi(hdr.FileExtension, 'NTT'); + % Number of channels in file + if isNtt + nChannels = 4; + else + nChannels = 1; + end % Compute the spikes samples SpikeSamples = round(hdr.SpikeTimes .* sFile.prop.sfreq); % Get the spikes happening during the selected segment iSpikes = find((SpikeSamples + hdr.NumSamples >= SamplesBounds(1)) & (SpikeSamples <= SamplesBounds(2))); - % Size of one record in the file - sizeRecHdr = 48 + hdr.NumSamples * 2; + % Size the header in each record + sizeRecHdr = hdr.RecordSize - hdr.NumSamples * 2 * nChannels; % Loop on the spikes that were found for i = 1:length(iSpikes) % Seek at the beginning of the spike data offsetStart = hdr.HeaderSize + (iSpikes(i)-1) * hdr.RecordSize + sizeRecHdr; fseek(sfid, offsetStart, 'bof'); % Read the spike data - dat = fread(sfid, hdr.NumSamples, 'int16'); + dat = fread(sfid, [nChannels, hdr.NumSamples], 'int16'); % Find the samples of this spike in the read segment iSmpSpike = 1:hdr.NumSamples; iSmpFile = SpikeSamples(iSpikes(i)) - SamplesBounds(1) + iSmpSpike; iGoodSmp = find((iSmpFile >= 1) & (iSmpFile <= nReadSamples)); % Set the data in the file - F(iChan, iSmpFile(iGoodSmp)) = dat(iSmpSpike(iGoodSmp)); + if isNtt + F(iChan, iSmpFile(iGoodSmp)) = dat(hdr.NttIndexCh, iSmpSpike(iGoodSmp)); + else + F(iChan, iSmpFile(iGoodSmp)) = dat(iSmpSpike(iGoodSmp)); + end end end % Close file diff --git a/toolbox/io/in_fread_oebin.m b/toolbox/io/in_fread_oebin.m index 7c22281d3..75e64c1a8 100644 --- a/toolbox/io/in_fread_oebin.m +++ b/toolbox/io/in_fread_oebin.m @@ -22,14 +22,20 @@ % =============================================================================@ % % Authors: Francois Tadel, 2020 +% Raymundo Cassani, 2024 % Parse inputs if (nargin < 4) || isempty(ChannelsRange) ChannelsRange = [1, sFile.header.continuous.num_channels]; end if (nargin < 3) || isempty(SamplesBounds) - SamplesBounds = round(sFile.prop.times .* sFile.prop.sfreq); + SamplesBounds = [sFile.header.first_samp, sFile.header.last_samp]; end +% Clip sample limits +SamplesBounds(1) = max(SamplesBounds(1), sFile.header.first_samp); +SamplesBounds(2) = min(SamplesBounds(2), sFile.header.last_samp); +% Remove first sample offset +SamplesBounds = SamplesBounds - sFile.header.first_samp; % ===== COMPUTE OFFSETS ===== nChannels = double(sFile.header.continuous.num_channels); diff --git a/toolbox/io/in_mri.m b/toolbox/io/in_mri.m index 5098ba1b1..b4e816e40 100644 --- a/toolbox/io/in_mri.m +++ b/toolbox/io/in_mri.m @@ -133,7 +133,13 @@ if isInteractive [MRI, vox2ras, tReorient] = in_mri_mgh(MriFile, [], []); else - [MRI, vox2ras, tReorient] = in_mri_mgh(MriFile, 1, 0); + mriDir = bst_fileparts(MriFile); + isReconAllClinical = ~isempty(file_find(mriDir, 'synthSR.mgz', 2)); + if isReconAllClinical + [MRI, vox2ras, tReorient] = in_mri_mgh(MriFile, 0, 1); + else + [MRI, vox2ras, tReorient] = in_mri_mgh(MriFile, 1, 0); + end end case 'KIT' error('Not supported yet'); diff --git a/toolbox/io/in_mri_mgh.m b/toolbox/io/in_mri_mgh.m index 1882db0f9..e01fda753 100644 --- a/toolbox/io/in_mri_mgh.m +++ b/toolbox/io/in_mri_mgh.m @@ -7,7 +7,7 @@ % - MriFile : full path to a MRI file, WITH EXTENSION % - isApplyBst : If 1, apply best orientation found to match Brainstorm convention % considering that the volume is aligned as the standard T1.mgz in the -% FreeSurfer output folder. +% FreeSurfer output folder from 'recon-all'. % - isApplyVox2ras : Apply additional transformation to the volume % OUTPUT: % - sMri : Standard brainstorm structure for MRI volumes @@ -142,7 +142,7 @@ if isempty(isApplyBst) isApplyBst = java_dialog('confirm', ['Apply the standard transformation FreeSurfer=>Brainstorm?' 10 10 ... 'Answer "yes" if importing transformed volumes such as T1.mgz in the' 10 ... - 'FreeSurfer output folder, or other volumes in the same folder.' 10 10], 'MRI orientation'); + 'FreeSurfer output folder from ''recon-call'', or other volumes in the same folder.' 10 10], 'MRI orientation'); end % Apply transformation diff --git a/toolbox/io/in_tess.m b/toolbox/io/in_tess.m index 911f68a9c..7dc222b8b 100644 --- a/toolbox/io/in_tess.m +++ b/toolbox/io/in_tess.m @@ -13,7 +13,8 @@ % OUTPUT: % - TessMat: Brainstorm tesselation structure with fields: % |- Vertices : {[3 x nbVertices] double}, in millimeters -% |- Faces : {[nbFaces x 3] double} +% |- Faces : {[nbFaces x 3] double} (optional, volume meshes do not have 'Faces') +% |- Color : {[nColors x 3] double}, normalized between 0-1 (optional, not all surfaces have color info) % |- Comment : {information string} % @============================================================================= @@ -178,6 +179,10 @@ TessMat.Faces = TessMat.Faces(:,[2 1 3]); case 'OFF' TessMat = in_tess_off(TessFile); + % Vertices: convert to meters + TessMat.Vertices = TessMat.Vertices ./ 1000; + % Swap faces + TessMat.Faces = TessMat.Faces(:,[2 1 3]); case 'TRI' TessMat = in_tess_tri(TessFile); case 'DSGL' @@ -213,7 +218,11 @@ T = sMri.Header.info.mat(1:3,4)' - 1; TessMat.Vertices = bst_bsxfun(@minus, TessMat.Vertices, T / 1000); end - + + case 'WFTOBJ' + TessMat = in_tess_wftobj(TessFile); + isConvertScs = 0; + case 'MRI-MASK' [TessMat, Labels] = in_tess_mrimask(TessFile, 0, SelLabels); @@ -268,6 +277,14 @@ %% ===== COMMENT ===== % Add a comment field to the TessMat structure. + +if ~isempty(sMri) + % Get the current subject + sSubject = bst_get('MriFile', sMri.FileName); + % Unique comment + fileBase = file_unique(fileBase, {sSubject.Surface.Comment}); +end + % If various tesselations were loaded from one file if (length(TessMat) > 1) for iTess = 1:length(TessMat) diff --git a/toolbox/io/in_tess_bst.m b/toolbox/io/in_tess_bst.m index 3c8efe1fc..7188ee4eb 100644 --- a/toolbox/io/in_tess_bst.m +++ b/toolbox/io/in_tess_bst.m @@ -52,6 +52,7 @@ % - Remove cells: Old Brainstorm surface files contained more than one tesselation, now one tesselation = file % - Check matrix orientations % - Convert to double +% - Add Color field UpdateFile = 0; if isfield(TessMat, 'Faces') TessMat.Faces = double(TessMat.Faces); @@ -87,6 +88,10 @@ TessMat.Curvature = TessMat.Curvature{1}; UpdateFile = 1; end +if ~isfield(TessMat, 'Color') + TessMat.Color = []; + UpdateFile = 1; +end % ===== ATLASES ===== diff --git a/toolbox/io/in_tess_off.m b/toolbox/io/in_tess_off.m index e3deec5d6..8cbae2579 100644 --- a/toolbox/io/in_tess_off.m +++ b/toolbox/io/in_tess_off.m @@ -59,7 +59,7 @@ Vertices = double(fscanf(fid, '%f', [3 nVertices])); % Go to next line fgetl(fid); -% Read faces +% Read faces, add 1 (convert to 1-based indices) Faces = double(fscanf(fid, '%f',[4 nFaces]) + 1); Faces = Faces(2:4,:); % Close file diff --git a/toolbox/io/in_tess_wftobj.m b/toolbox/io/in_tess_wftobj.m new file mode 100644 index 000000000..f4bdd82ab --- /dev/null +++ b/toolbox/io/in_tess_wftobj.m @@ -0,0 +1,200 @@ +function TessMat = in_tess_wftobj(TessFile) +% IN_TESS_WFTOBJ: Load a WAVEFRONT OBJ mesh file. +% +% USAGE: TessMat = in_tess_wftobj(TessFile, FileType); +% +% INPUT: +% - TessFile : full path to a tesselation file (*.obj) +% +% OUTPUT: +% - TessMat: Brainstorm tesselation structure with fields: +% |- Vertices : {[nVertices x 3] double}, in millimeters +% |- Faces : {[nFaces x 3] double} +% |- Color : {[nColors x 3] double}, normalized between 0-1 +% |- Comment : {information string} +% +% @============================================================================= +% This function is part of the Brainstorm software: +% https://neuroimage.usc.edu/brainstorm +% +% Copyright (c) University of Southern California & McGill University +% This software is distributed under the terms of the GNU General Public License +% as published by the Free Software Foundation. Further details on the GPLv3 +% license can be found at http://www.gnu.org/copyleft/gpl.html. +% +% FOR RESEARCH PURPOSES ONLY. THE SOFTWARE IS PROVIDED "AS IS," AND THE +% UNIVERSITY OF SOUTHERN CALIFORNIA AND ITS COLLABORATORS DO NOT MAKE ANY +% WARRANTY, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO WARRANTIES OF +% MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE, NOR DO THEY ASSUME ANY +% LIABILITY OR RESPONSIBILITY FOR THE USE OF THIS SOFTWARE. +% +% For more information type "brainstorm license" at command prompt. +% =============================================================================@ +% +% Authors: Yash Shashank Vakilna, 2024 +% Chinmay Chinara, 2024 +% Raymundo Cassani, 2024 + +%% ===== PARSE INPUTS ===== +% Check inputs +if (nargin < 1) + bst_error('Invalid call. Please specify the mesh file to be loaded.', 'Importing tesselation', 1); +end + +%% ===== PARSE THE OBJ FILE: SET UP IMPORT OPTIONS AND IMPORT THE DATA ===== +if bst_get('MatlabVersion') < 901 + % MATLAB < R2016b + % Read entire .wobj file + fid = fopen(TessFile, 'r'); + txtStr = fread(fid, '*char')'; + fclose(fid); + % Keep relevant lines from file content + allData = regexp(txtStr, '(\w)+ ([^\n])*\n', 'tokens'); % (\w) ignores comments (#) + allData = cat(1,allData{:}); + % Read data for each element type + elementTags = {'v', 'vt', 'f'}; % Vertices, Texture, Faces + elementData = cell(1, length(elementTags)); + % Parse element data + for iElement = 1: length(elementTags) + iLines = strcmp(elementTags{iElement}, allData(:,1)); + elementTmp = regexp(allData(iLines, 2), '([e|\-|\.|\d])*', 'match')'; + elementTmp = cat(1, elementTmp{:}); + elementSize = size(elementTmp); + elementTmp = sscanf(sprintf(' %s', elementTmp{:}), '%f'); % Faster than str2double + elementData{iElement} = reshape(elementTmp, elementSize); + end + vertices = elementData{1, 1}(:, 1:3); % Use only the first 3 + texture = elementData{2}; + faces = elementData{1, 3}(:, [3,6,9]); + textureIdx = elementData{1, 3}(:, [2,5,8]); +else + % MATLAB R2016b to R2018a had 'DelimitedTextImportOptions' + if(bst_get('MatlabVersion') >= 901) && (bst_get('MatlabVersion') <= 904) + opts = matlab.io.text.DelimitedTextImportOptions(); + else + opts = delimitedTextImportOptions('NumVariables', 10); + end + + opts.Delimiter = {' ', '/'}; + opts.VariableNames = {'type', 'c1', 'c2', 'c3', 'c4', 'c5', 'c6', 'c7', 'c8', 'c9'}; + opts.VariableTypes = {'categorical', 'double', 'double', 'double', 'double', 'double', 'double', 'double', 'double', 'double'}; + + % Specify file level properties + opts.ExtraColumnsRule = 'ignore'; + opts.EmptyLineRule = 'read'; + + % Import the data + objtbl = readtable(TessFile, opts); + + obj = struct; + obj.Vertices = objtbl{objtbl.type=='v', 2:4}; + obj.VertexNormals = objtbl{objtbl.type=='vn', 2:4}; + obj.Faces = objtbl{objtbl.type=='f', [2,5,8]}; + obj.TextCoords = objtbl{objtbl.type=='vt', 2:3}; + obj.TextIndices = objtbl{objtbl.type=='f', [3,6,9]}; + % For some OBJ's exported from 3D softwares like Maya and Blender, when parsed using + % 'readtable', the vertex coordinates start from the 3rd column + if isnan(obj.Vertices(:,1)) + obj.Vertices = objtbl{objtbl.type=='v', 3:5}; + end + vertices = obj.Vertices; + faces = obj.Faces; + texture = obj.TextCoords; + textureIdx = obj.TextIndices; +end + +%% ===== REFINE FACES, MESH AND GENERATE COLOR MATRIX ===== +% Check if there exists a .jpg file of 'TessFile' +[pathstr, name] = fileparts(TessFile); +if exist(fullfile(pathstr, [name, '.jpg']), 'file') + image = fullfile(pathstr, [name, '.jpg']); + hasimage = true; +elseif exist(fullfile(pathstr,[name,'.png']), 'file') + image = fullfile(pathstr,[name,'.png']); + hasimage = true; +else + hasimage = false; +end + +% Check if the texture is defined per vertex, in which case the texture can be refined below +if size(texture, 1)==size(vertices, 1) + texture_per_vert = true; +else + texture_per_vert = false; +end + +% Remove the faces with 0's first +allzeros = sum(faces==0,2)==3; +faces(allzeros, :) = []; +textureIdx(allzeros, :) = []; + +% Check whether all vertices belong to a face. If not, prune the vertices and keep the faces consistent. +ufacesIdx = unique(faces(:)); +remove = setdiff((1:size(vertices, 1))', ufacesIdx); +if ~isempty(remove) + [vertices, faces] = tess_remove_vert(vertices, faces, remove); + if texture_per_vert + % Also remove the removed vertices from the texture + texture(remove, :) = []; + end +end + +color = []; +if hasimage + % If true then there is an image/texture with color information + if texture_per_vert + picture = imread(image); + color = zeros(size(vertices, 1), 3); + for i = 1:size(vertices, 1) + color(i,1:3) = picture(floor((1-texture(i,2))*length(picture)),1+floor(texture(i,1)*length(picture)),1:3); + end + else + % Do the texture to color mapping in a different way, without additional refinement + picture = flip(imread(image),1); + [sy, sx, sz] = size(picture); + picture = reshape(picture, sy*sx, sz); + + % Make image 3D if grayscale + if sz == 1 + picture = repmat(picture, 1, 3); + end + [~, ix] = unique(faces); + textureIdx = textureIdx(ix); + + % Get the indices into the image + x = abs(round(texture(:,1)*(sx-1)))+1; + y = abs(round(texture(:,2)*(sy-1)))+1; + + % Eliminates points out of bounds + if any(x > sx) + texture(x > sx,:) = 1; + x(x > sx) = sx; + end + + if any(find(y > sy)) + texture(y > sy,:) = 1; + y(y > sy) = sy; + end + + xy = sub2ind([sy sx], y, x); + sel = xy(textureIdx); + color = double(picture(sel,:))/255; + end + + % If color is specified as 0-255 rather than 0-1 correct by dividing by 255 + if range(color(:)) > 1 + color = color./255; + end +end + +% Centering vertices +vertices = vertices - repmat(mean(vertices,1), [size(vertices, 1),1]); + +% Convert vertices' unit as locations in Brainstorm are saved in 'meters' +vertices = channel_fixunits(vertices, 'mm', 1, 1); + +%% ===== BRAINSTORM SURFACE STRUCTURE ===== +TessMat = struct('Faces', faces, ... + 'Vertices', vertices, ... + 'Color', color, ... + 'Comment', ''); diff --git a/toolbox/io/out_channel_nirs_brainsight.m b/toolbox/io/out_channel_brainsight.m old mode 100644 new mode 100755 similarity index 86% rename from toolbox/io/out_channel_nirs_brainsight.m rename to toolbox/io/out_channel_brainsight.m index 6eda3f38d..8f141342b --- a/toolbox/io/out_channel_nirs_brainsight.m +++ b/toolbox/io/out_channel_brainsight.m @@ -1,135 +1,140 @@ -function out_channel_nirs_brainsight(BstFile, OutputFile, Factor, Transf) -% OUT_CHANNEL_NIRS_BRAINSIGHT: Export a Brainstorm channel file in -% brainsight coordinate files. -% -% USAGE: out_channel_nirs_brainsight( BstFile, OutputFile, Factor, Transf) -% -% INPUT: -% - BstFile : full path to Brainstorm file to export -% - OutputFile : full path to output file -% - Factor : Factor to convert the positions values in meters. -% - Transf : 4x4 transformation matrix to apply to the 3D positions before saving -% or entire MRI structure. Optodes are -% exported using world coordinates. -% -% @============================================================================= -% This function is part of the Brainstorm software: -% https://neuroimage.usc.edu/brainstorm -% -% Copyright (c) University of Southern California & McGill University -% This software is distributed under the terms of the GNU General Public License -% as published by the Free Software Foundation. Further details on the GPLv3 -% license can be found at http://www.gnu.org/copyleft/gpl.html. -% -% FOR RESEARCH PURPOSES ONLY. THE SOFTWARE IS PROVIDED "AS IS," AND THE -% UNIVERSITY OF SOUTHERN CALIFORNIA AND ITS COLLABORATORS DO NOT MAKE ANY -% WARRANTY, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO WARRANTIES OF -% MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE, NOR DO THEY ASSUME ANY -% LIABILITY OR RESPONSIBILITY FOR THE USE OF THIS SOFTWARE. -% -% For more information type "brainstorm license" at command prompt. -% =============================================================================@ -% -% Authors: Thomas Vincent 2017, Edouard Delaire 2023 - -if (nargin < 3) || isempty(Factor) - Factor = .001; -end -if (nargin < 4) || isempty(Transf) - Transf = []; -end - - -% Load brainstorm channel file -if ischar(BstFile) - ChannelMat = in_bst_channel(BstFile); -else - ChannelMat = BstFile; -end - -if ~isfield(ChannelMat, 'Nirs') - bst_error('Channel file does not correspond to NIRS data.'); - return; -end - -Loc = zeros(3,0); -Label = {}; -Type = {}; - -for i = 1:length(ChannelMat.Channel) - if ~isempty(ChannelMat.Channel(i).Loc) && ~all(ChannelMat.Channel(i).Loc(:) == 0) - CHAN_RE = '^S([0-9]+)D([0-9]+)(WL\d+|HbO|HbR|HbT)$'; - toks = regexp(strrep(ChannelMat.Channel(i).Name, ' ', '_'), CHAN_RE, 'tokens'); - - Loc(:,end+1) = ChannelMat.Channel(i).Loc(:,1); - Label{end+1} = sprintf('S%s',toks{1}{1} ); - Type{end+1} = 'source'; - - Loc(:,end+1) = ChannelMat.Channel(i).Loc(:,2); - Label{end+1} = sprintf('D%s',toks{1}{2} ); - Type{end+1} = 'detector'; - end -end - -% Remove duplicate and sort Sources / Detectors -[Label, I] = unique(Label, 'stable'); -Loc = Loc(:,I); -Type = Type(I); - -[Type, I] = sort(Type); -Label = Label(I); -Loc = Loc(:,I); - - -if isfield(ChannelMat, 'HeadPoints') && ~isempty(ChannelMat.HeadPoints) && ~isempty(ChannelMat.HeadPoints.Loc) - % Find fiducials in the head points - iCardinal = find(strcmpi(ChannelMat.HeadPoints.Type, 'CARDINAL')); - fidu_coords = ChannelMat.HeadPoints.Loc(:,iCardinal); - fidu_labels = ChannelMat.HeadPoints.Label(iCardinal); - - Label = [Label, fidu_labels]; - Loc = [Loc , fidu_coords ]; -end - -% Apply transformation -if ~isempty(Transf) - if isstruct(Transf) - Loc = cs_convert(Transf, 'scs', 'world', Loc')'; - % World coordinates - else - R = Transf(1:3,1:3); - T = Transf(1:3,4); - Loc = R * Loc + T * ones(1, size(Loc,2)); - end -end -% Apply factor -Loc = Loc ./ Factor; - - -% Format header -header = sprintf(['# Version: 5\n# Coordinate system: NIftI-Aligned\n# Created by: Brainstorm (nirstorm plugin)\n' ... - '# units: millimetres, degrees, milliseconds, and microvolts\n# Encoding: UTF-8\n' ... - '# Notes: Each column is delimited by a tab. Each value within a column is delimited by a semicolon.\n' ... - '# Sample Name Index Loc. X Loc. Y Loc. Z Offset\n']); - - -% Open output file -fid = fopen(OutputFile, 'w'); -if (fid < 0) - error('Cannot open file'); -end - -fprintf(fid, '%s', header); -% Write file: one line per location -for i = 1:length(Label) - fprintf(fid,'%s\t%d\t%f\t%f\t%f\t0.0\n',Label{i}, i, Loc(1, i), Loc(2, i), Loc(3, i)); -end - -% Close file -fclose(fid); - -end - - - - +function out_channel_brainsight(BstFile, OutputFile, Factor, Transf) +% OUT_CHANNEL_BRAINSIGHT: Export a Brainstorm channel file in +% brainsight coordinate files. +% +% USAGE: out_channel_brainsight( BstFile, OutputFile, Factor, Transf) +% +% INPUT: +% - BstFile : full path to Brainstorm file to export +% - OutputFile : full path to output file +% - Factor : Factor to convert the positions values in meters. +% - Transf : 4x4 transformation matrix to apply to the 3D positions before saving +% or entire MRI structure. Optodes are +% exported using world coordinates. +% +% @============================================================================= +% This function is part of the Brainstorm software: +% https://neuroimage.usc.edu/brainstorm +% +% Copyright (c) University of Southern California & McGill University +% This software is distributed under the terms of the GNU General Public License +% as published by the Free Software Foundation. Further details on the GPLv3 +% license can be found at http://www.gnu.org/copyleft/gpl.html. +% +% FOR RESEARCH PURPOSES ONLY. THE SOFTWARE IS PROVIDED "AS IS," AND THE +% UNIVERSITY OF SOUTHERN CALIFORNIA AND ITS COLLABORATORS DO NOT MAKE ANY +% WARRANTY, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO WARRANTIES OF +% MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE, NOR DO THEY ASSUME ANY +% LIABILITY OR RESPONSIBILITY FOR THE USE OF THIS SOFTWARE. +% +% For more information type "brainstorm license" at command prompt. +% =============================================================================@ +% +% Authors: Thomas Vincent 2017, Edouard Delaire 2023 + +if (nargin < 3) || isempty(Factor) + Factor = .001; +end +if (nargin < 4) || isempty(Transf) + Transf = []; +end + + +% Load brainstorm channel file +if ischar(BstFile) + ChannelMat = in_bst_channel(BstFile); +else + ChannelMat = BstFile; +end + + +Loc = zeros(3,0); +Label = {}; +Type = {}; + +for i = 1:length(ChannelMat.Channel) + if strcmpi(ChannelMat.Channel(i).Type,'NIRS') + CHAN_RE = '^S([0-9]+)D([0-9]+)(WL\d+|HbO|HbR|HbT)$'; + toks = regexp(strrep(ChannelMat.Channel(i).Name, ' ', '_'), CHAN_RE, 'tokens'); + + Loc(:,end+1) = ChannelMat.Channel(i).Loc(:,1); + Label{end+1} = sprintf('S%s',toks{1}{1} ); + Type{end+1} = 'source'; + + Loc(:,end+1) = ChannelMat.Channel(i).Loc(:,2); + Label{end+1} = sprintf('D%s',toks{1}{2} ); + Type{end+1} = 'detector'; + elseif strcmpi(ChannelMat.Channel(i).Type,'EEG') + Loc(:,end+1) = ChannelMat.Channel(i).Loc; + Label{end+1} = ChannelMat.Channel(i).Name; + Type{end+1} = 'EEG'; + end +end + +if isempty(Label) + bst_error('Channel file does not contain EEG nor NIRS channels.'); + return; +end + +% Remove duplicate and sort Sources / Detectors +[Label, I] = unique(Label, 'stable'); +Loc = Loc(:,I); +Type = Type(I); + +[Type, I] = sort(Type); +Label = Label(I); +Loc = Loc(:,I); + + +if isfield(ChannelMat, 'HeadPoints') && ~isempty(ChannelMat.HeadPoints) && ~isempty(ChannelMat.HeadPoints.Loc) + % Find fiducials in the head points + iCardinal = find(strcmpi(ChannelMat.HeadPoints.Type, 'CARDINAL')); + fidu_coords = ChannelMat.HeadPoints.Loc(:,iCardinal); + fidu_labels = ChannelMat.HeadPoints.Label(iCardinal); + + Label = [Label, fidu_labels]; + Loc = [Loc , fidu_coords ]; +end + +% Apply transformation +if ~isempty(Transf) + if isstruct(Transf) + Loc = cs_convert(Transf, 'scs', 'world', Loc')'; + % World coordinates + else + R = Transf(1:3,1:3); + T = Transf(1:3,4); + Loc = R * Loc + T * ones(1, size(Loc,2)); + end +end +% Apply factor +Loc = Loc ./ Factor; + + +% Format header +header = sprintf(['# Version: 5\n# Coordinate system: NIftI-Aligned\n# Created by: Brainstorm (nirstorm plugin)\n' ... + '# units: millimetres, degrees, milliseconds, and microvolts\n# Encoding: UTF-8\n' ... + '# Notes: Each column is delimited by a tab. Each value within a column is delimited by a semicolon.\n' ... + '# Sample Name Index Loc. X Loc. Y Loc. Z Offset\n']); + + +% Open output file +fid = fopen(OutputFile, 'w'); +if (fid < 0) + error('Cannot open file'); +end + +fprintf(fid, '%s', header); +% Write file: one line per location +for i = 1:length(Label) + fprintf(fid,'%s\t%d\t%f\t%f\t%f\t0.0\n',Label{i}, i, Loc(1, i), Loc(2, i), Loc(3, i)); +end + +% Close file +fclose(fid); + +end + + + + diff --git a/toolbox/io/out_data_snirf.m b/toolbox/io/out_data_snirf.m index f71531ad8..d3bfd8e1b 100644 --- a/toolbox/io/out_data_snirf.m +++ b/toolbox/io/out_data_snirf.m @@ -25,6 +25,14 @@ function out_data_snirf(ExportFile, DataMat, ChannelMatOut) % % Authors: Edouard Delaire, Francois Tadel, 2020 +% Install/load JSNIRF Toolbox (https://github.com/NeuroJSON/jsnirfy) as plugin +if ~exist('jsnirfcreate', 'file') + [isInstalled, errMsg] = bst_plugin('Install', 'jsnirfy'); + if ~isInstalled + error(errMsg); + end +end + % Create an empty snirf data structure snirfdata = jsnirfcreate(); @@ -47,20 +55,9 @@ function out_data_snirf(ExportFile, DataMat, ChannelMatOut) snirfdata.SNIRFData.aux(i_aux).name=ChannelMatOut.Channel(aux_channel(i_aux)).Name; snirfdata.SNIRFData.aux(i_aux).dataTimeSeries=DataMat.F(aux_channel(i_aux),:)'; snirfdata.SNIRFData.aux(i_aux).time=DataMat.Time'; + snirfdata.SNIRFData.aux(i_aux).timeOffset = 0; end -% Set Probe; maybe can be simplified with the export of the measurment list -[isrcs, idets, chan_measures, measure_type] = nst_unformat_channels({ChannelMatOut.Channel(nirs_channels).Name}); - -src_pos= zeros(length(unique(isrcs)),3); -det_pos= zeros(length(unique(idets)),3); - - -% Todo : export detectorLabels and sourceLabels (string array) -snirfdata.SNIRFData.probe.wavelengths=ChannelMatOut.Nirs.Wavelengths; -snirfdata.SNIRFData.probe.sourcePos=src_pos; -snirfdata.SNIRFData.probe.detectorPos=det_pos; - % Set landmark position (eg fiducials) n_landmark=length(ChannelMatOut.HeadPoints.Label); snirfdata.SNIRFData.probe.landmarkPos=zeros(n_landmark,3); @@ -69,39 +66,72 @@ function out_data_snirf(ExportFile, DataMat, ChannelMatOut) snirfdata.SNIRFData.probe.landmarkLabels(i_landmark)=string(ChannelMatOut.HeadPoints.Label{i_landmark}); end +% Set Probe; maybe can be simplified with the export of the measurment list +[isrcs, idets, chan_measures, measure_type] = nst_unformat_channels({ChannelMatOut.Channel(nirs_channels).Name}); + +src_pos = zeros(length(unique(isrcs)),3); +src_label = repmat( "", 1,length(unique(isrcs))); +src_Index = zeros(length(unique(isrcs)),1); +det_pos = zeros(length(unique(idets)),3); +det_label = repmat( "", 1,length(unique(idets))); +det_Index = zeros(length(unique(isrcs)),1); + +% Set Measurment list +nSrc = 1; +nDet = 1; +for ichan=1:n_channel + [isrc, idet, chan_measures, measure_type] = nst_unformat_channels({ChannelMatOut.Channel(ichan).Name}); + + if ~any(cellfun(@(x)strcmp(x, sprintf('S%d',isrc )), src_label)) + src_label(nSrc) = sprintf("S%d",isrc ); + src_Index(nSrc) = isrc; + src_pos(nSrc,:)=ChannelMatOut.Channel(ichan).Loc(:,1)'; + + nSrc = nSrc + 1; + end + + if ~any(cellfun(@(x)strcmp(x, sprintf('D%d',idet )), det_label)) + det_label(nDet) = sprintf("D%d",idet ); + det_Index(nDet) = idet; + det_pos(nDet,:)=ChannelMatOut.Channel(ichan).Loc(:,2)'; + + nDet = nDet + 1; + end + +end + % Set Measurment list for ichan=1:n_channel measurement=struct('sourceIndex',[],'detectorIndex',[],... 'wavelengthIndex',[],'dataType',1,'dataTypeIndex',1); - [isrcs, idets, chan_measures, measure_type] = nst_unformat_channels({ChannelMatOut.Channel(ichan).Name}); + [isrc, idet, chan_measures, measure_type] = nst_unformat_channels({ChannelMatOut.Channel(ichan).Name}); - src_pos(isrcs,:)=ChannelMatOut.Channel(ichan).Loc(:,1)'; - det_pos(idets,:)=ChannelMatOut.Channel(ichan).Loc(:,2)'; - - measurement.sourceIndex=isrcs; - measurement.detectorIndex=idets; - measurement.wavelengthIndex=find(ChannelMatOut.Nirs.Wavelengths==chan_measures); + measurement.sourceIndex = find(src_Index == isrc); + measurement.detectorIndex = find(det_Index == idet); + measurement.wavelengthIndex = find(ChannelMatOut.Nirs.Wavelengths==chan_measures); snirfdata.SNIRFData.data.measurementList(ichan)=measurement; end -% Todo : export detectorLabels and sourceLabels (string array) snirfdata.SNIRFData.probe.wavelengths=ChannelMatOut.Nirs.Wavelengths; -snirfdata.SNIRFData.probe.sourcePos=src_pos; -snirfdata.SNIRFData.probe.sourcePos(:,3)=0; % set z to 0 +snirfdata.SNIRFData.probe.sourcePos2D=src_pos(:,[1,2]); snirfdata.SNIRFData.probe.sourcePos3D=src_pos; +snirfdata.SNIRFData.probe.sourceLabels = src_label; -snirfdata.SNIRFData.probe.detectorPos=det_pos; -snirfdata.SNIRFData.probe.detectorPos(:,3)=0; % set z to 0 +snirfdata.SNIRFData.probe.detectorPos2D=det_pos(:,[1,2]); snirfdata.SNIRFData.probe.detectorPos3D=det_pos; +snirfdata.SNIRFData.probe.detectorLabels=det_label; + % Set Stim nEvt = length(DataMat.Events); +evt_include = true(1,length(DataMat.Events)); for iEvt = 1:nEvt % Skip empty events if isempty(DataMat.Events(iEvt).times) + evt_include(iEvt) = false; continue; end % Event structure @@ -124,7 +154,11 @@ function out_data_snirf(ExportFile, DataMat, ChannelMatOut) stim.data = data; snirfdata.SNIRFData.stim(iEvt) = stim; end - +if any(evt_include) + snirfdata.SNIRFData.stim = snirfdata.SNIRFData.stim(evt_include); +else + snirfdata.SNIRFData = rmfield(snirfdata.SNIRFData,'stim'); +end % Save snirf file. savesnirf(snirfdata, ExportFile); diff --git a/toolbox/io/out_events_bids.m b/toolbox/io/out_events_bids.m new file mode 100644 index 000000000..e87afc1e2 --- /dev/null +++ b/toolbox/io/out_events_bids.m @@ -0,0 +1,72 @@ +function out_events_bids( sFile, EventsFile ) +% OUT_EVENTS_BIDS: export a BIDS _events.tsv file (columns "onset", "duration", "trial_type"). +% +% USAGE: out_events_bids( sFile, EventsFile ) + +% @============================================================================= +% This function is part of the Brainstorm software: +% https://neuroimage.usc.edu/brainstorm +% +% Copyright (c) University of Southern California & McGill University +% This software is distributed under the terms of the GNU General Public License +% as published by the Free Software Foundation. Further details on the GPLv3 +% license can be found at http://www.gnu.org/copyleft/gpl.html. +% +% FOR RESEARCH PURPOSES ONLY. THE SOFTWARE IS PROVIDED "AS IS," AND THE +% UNIVERSITY OF SOUTHERN CALIFORNIA AND ITS COLLABORATORS DO NOT MAKE ANY +% WARRANTY, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO WARRANTIES OF +% MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE, NOR DO THEY ASSUME ANY +% LIABILITY OR RESPONSIBILITY FOR THE USE OF THIS SOFTWARE. +% +% For more information type "brainstorm license" at command prompt. +% =============================================================================@ +% +% Authors: Edouard Delaire, 2024 + +% Concatenate all the events together +allTime = zeros(2,0); +allInd = []; +for i = 1:length(sFile.events) + % Simple events + if (size(sFile.events(i).times, 1) == 1) + allTime = [allTime, [sFile.events(i).times; 0*sFile.events(i).times]]; + % Extented events + elseif (size(sFile.events(i).times, 1) == 2) + allTime = [allTime, sFile.events(i).times]; + end + allInd = [allInd, repmat(i, 1, size(sFile.events(i).times,2))]; +end +% Sort based on time +[tmp, iSort] = sort(allTime(1,:)); +% Apply sorting to both arrays +allTime = allTime(:,iSort); +allInd = allInd(iSort); + +% Save file (ascii) +fout = fopen(EventsFile, 'w'); +if (fout < 0) + warning('Cannot open file.'); + return +end + +% Write header +fprintf(fout,'%s\t%s\t%s\n', 'onset', 'duration', 'trial_type'); +% Write all the events, one by line +for i = 1:length(allInd) + % Get event structure + sEvt = sFile.events(allInd(i)); + % Simple events + if (size(sEvt.times, 1) == 1) + fprintf(fout, '%g\t%g\t%s\n', allTime(1,i), 0, sEvt.label); + % Extended events + elseif (size(sEvt.times, 1) == 2) + fprintf(fout, '%g\t%g\t%s\n', allTime(1,i), allTime(2,i) - allTime(1,i), sEvt.label); + end +end +% Close file +fclose(fout); + + + + + diff --git a/toolbox/io/out_fopen_bst.m b/toolbox/io/out_fopen_bst.m index 330661fde..71696e6cd 100644 --- a/toolbox/io/out_fopen_bst.m +++ b/toolbox/io/out_fopen_bst.m @@ -37,7 +37,8 @@ sFileOut.header.starttime = sFileOut.prop.times(1); sFileOut.header.navg = sFileOut.prop.nAvg; % sFileOut.header.version = 51; % April 2019 -sFileOut.header.version = 52; % March 2023 +% sFileOut.header.version = 52; % March 2023 +sFileOut.header.version = 53; % September 2024 sFileOut.header.nsamples = round((sFileOut.prop.times(2) - sFileOut.prop.times(1)) .* sFileOut.prop.sfreq) + 1; sFileOut.header.epochsize = EpochSize; sFileOut.header.nchannels = length(ChannelMat.Channel); @@ -108,7 +109,8 @@ if ~isempty(ChannelMat.Projector(i).SingVal) fwrite(fid, ChannelMat.Projector(i).SingVal, 'float32'); % FLOAT32(N) : SingVal matrix end - fwrite(fid, ChannelMat.Projector(i).Status, 'int8'); % INT8(1) : Status + fwrite(fid, ChannelMat.Projector(i).Status, 'int8'); % INT8(1) : Status + fwrite(fid, str_zeros(ChannelMat.Projector(i).Method, 20), 'char'); % CHAR(20) : Projector method end % ===== HEAD POINTS ===== diff --git a/toolbox/io/out_fopen_edf.m b/toolbox/io/out_fopen_edf.m index 864e2762a..2d10a1cc9 100644 --- a/toolbox/io/out_fopen_edf.m +++ b/toolbox/io/out_fopen_edf.m @@ -83,6 +83,7 @@ sFileOut.format = 'EEG-EDF'; sFileOut.byteorder = 'l'; sFileOut.comment = fBase; +sFileOut.encoding = 'UTF-8'; date = datetime; % Create a new header structure @@ -187,7 +188,7 @@ if i == header.annotchan header.signal(i).label = 'EDF Annotations'; eventsPerRecord = ceil(numel(header.annotations) / header.nrec); - header.signal(i).nsamples = eventsPerRecord * maxAnnotLength + 15; % For first annotation of each record + header.signal(i).nsamples = eventsPerRecord * maxAnnotLength + 18; % For first annotation of each record % Convert chars (1-byte) to 2-byte integers, the size of a sample header.signal(i).nsamples = int64((header.signal(i).nsamples + 1) / 2); else @@ -227,7 +228,7 @@ end % Open file -fid = fopen(OutputFile, 'w+', sFileOut.byteorder); +fid = fopen(OutputFile, 'w+', sFileOut.byteorder, sFileOut.encoding); if (fid == -1) error('Could not open output file.'); end diff --git a/toolbox/io/out_fwrite.m b/toolbox/io/out_fwrite.m index 2172a9043..f77055f91 100644 --- a/toolbox/io/out_fwrite.m +++ b/toolbox/io/out_fwrite.m @@ -63,8 +63,12 @@ end fclose(sfid); end - % Open file - sfid = fopen(sFile.filename, 'r+', sFile.byteorder); + % Open file (using requested encoding) + if ~isfield(sFile, 'encoding') || isempty(sFile.encoding) + sfid = fopen(sFile.filename, 'r+', sFile.byteorder); + else + sfid = fopen(sFile.filename, 'r+', sFile.byteorder, sFile.encoding); + end if (sfid == -1) error(['Could not open output file: "' sFile.filename '".']); end diff --git a/toolbox/io/out_matrix_ascii.m b/toolbox/io/out_matrix_ascii.m index c12b0e11c..980a8eb24 100644 --- a/toolbox/io/out_matrix_ascii.m +++ b/toolbox/io/out_matrix_ascii.m @@ -182,27 +182,48 @@ function out_matrix_ascii( OutputFile, Data, FileFormat, Label1, Label2, Label3, end % Save headers if ~isempty(Label1) && ~isempty(Label2) - xlswrite(OutputFile, {Title2}, SheetName, 'A1'); - xlswrite(OutputFile, Label1(:), SheetName, 'A2'); - xlswrite(OutputFile, Label2(:)', SheetName, 'B1'); + xls_write(OutputFile, {Title2}, SheetName, 'A1'); + xls_write(OutputFile, Label1(:), SheetName, 'A2'); + xls_write(OutputFile, Label2(:)', SheetName, 'B1'); StartCell = 'B2'; elseif ~isempty(Label1) - xlswrite(OutputFile, Label1(:), SheetName, 'A1'); + xls_write(OutputFile, Label1(:), SheetName, 'A1'); StartCell = 'B1'; elseif ~isempty(Label2) - xlswrite(OutputFile, Label2(:)', SheetName, 'A1'); + xls_write(OutputFile, Label2(:)', SheetName, 'A1'); StartCell = 'A2'; else StartCell = 'A1'; end % Save data as a new sheet - [res,errMsg] = xlswrite(OutputFile, Data(:,:,i3), SheetName, StartCell); + [res,errMsg] = xls_write(OutputFile, Data(:,:,i3), SheetName, StartCell); if ~res error(['Could not export file to Excel: ' 10 errMsg]); end end end +function [res,errMsg] = xls_write(varargin) + % Wrapper to write a EXCEL .xls file for different Matlab versions) + if bst_get('MatlabVersion') < 906 % R2019a + [res,errMsg] = xlswrite(varargin{:}); + else + res = 1; % Success + errMsg = ''; + vars = varargin; + try + if iscell(vars{2}) + writecell(vars{2}, vars{1}, 'Sheet', vars{3}, 'Range', vars{4}); + elseif isnumeric(vars{2}) + writematrix(vars{2}, vars{1}, 'Sheet', vars{3}, 'Range', vars{4}); + end + catch me + res = 0; % Fail + errMsg = me.message; + end + end +end +end \ No newline at end of file diff --git a/toolbox/io/out_mri_bst.m b/toolbox/io/out_mri_bst.m index ca0bc1270..c8f65033a 100644 --- a/toolbox/io/out_mri_bst.m +++ b/toolbox/io/out_mri_bst.m @@ -1,4 +1,4 @@ -function MRI = out_mri_bst( MRI, MriFile ) +function MRI = out_mri_bst( MRI, MriFile, Version) % OUT_MRI_BST: Save a Brainstorm MRI structure. % % USAGE: MRI = out_mri_bst( MRI, MriFile ) @@ -6,8 +6,11 @@ % INPUT: % - MRI : Brainstorm MRI structure % - MriFile : full path to file where to save the MRI in brainstorm format +% - Version : 'v6', fastest option, bigger files, no files >2Gb +% 'v7', slower option, compressed, no files >2Gb (default) +% 'v7.3', much slower, compressed, allows files >2Gb % OUTPUT: -% - MRI : Modificed MRI structure +% - MRI : Modified MRI structure % % NOTES: % - MRI structure: @@ -46,6 +49,10 @@ % % Authors: Francois Tadel, 2008-2012 +if nargin < 3 + Version = 'v7'; +end + % ===== Clean-up MRI structure ===== % Remove (useless or old fieldnames) Fields2BDeleted = {'Origin','sag','ax','cor','hFiducials','header','filename'}; @@ -98,10 +105,10 @@ % SAVE .mat file try - bst_save(MriFile, MRI, 'v7'); + bst_save(MriFile, MRI, Version); catch end - +end diff --git a/toolbox/io/out_mri_nii.m b/toolbox/io/out_mri_nii.m index f01aec313..c4172dd3f 100644 --- a/toolbox/io/out_mri_nii.m +++ b/toolbox/io/out_mri_nii.m @@ -69,9 +69,9 @@ typeMatlab = 'float32'; elseif (MaxVal <= 255) typeMatlab = 'uint8'; - elseif all(MaxVal < 32767) + elseif all(MaxVal <= 32767) typeMatlab = 'int16'; - elseif all(MaxVal < 2147483647) + elseif all(MaxVal <= 2147483647) typeMatlab = 'int32'; else typeMatlab = 'float32'; diff --git a/toolbox/io/out_tess.m b/toolbox/io/out_tess.m index 2d44969eb..c2034495c 100644 --- a/toolbox/io/out_tess.m +++ b/toolbox/io/out_tess.m @@ -86,7 +86,15 @@ function out_tess(BstFile, OutputFile, FileFormat, sMri) end % Export file out_tess_fs(TessMat, OutputFile); + case 'OBJ' + % Swap faces + TessMat.Faces = TessMat.Faces(:,[2 1 3]); + out_tess_obj(TessMat, OutputFile); case 'OFF' + % Vertices: convert to millimeters + TessMat.Vertices = TessMat.Vertices .* 1000; + % Swap faces + TessMat.Faces = TessMat.Faces(:,[2 1 3]); out_tess_off(TessMat, OutputFile); case 'TRI' out_tess_tri(TessMat, OutputFile); diff --git a/toolbox/io/out_tess_obj.m b/toolbox/io/out_tess_obj.m new file mode 100644 index 000000000..dbebfb212 --- /dev/null +++ b/toolbox/io/out_tess_obj.m @@ -0,0 +1,48 @@ +function out_tess_obj( TessMat, OutputFile ) +% OUT_TESS_OBJ: Exports a surface to a .OBJ file. +% +% USAGE: out_tess_obj( TessMat, OutputFile ) +% +% INPUT: +% - TessMat : surface structure +% - OutputFile : full path to output file (with '.obj' extension) + +% @============================================================================= +% This function is part of the Brainstorm software: +% https://neuroimage.usc.edu/brainstorm +% +% Copyright (c) University of Southern California & McGill University +% This software is distributed under the terms of the GNU General Public License +% as published by the Free Software Foundation. Further details on the GPLv3 +% license can be found at http://www.gnu.org/copyleft/gpl.html. +% +% FOR RESEARCH PURPOSES ONLY. THE SOFTWARE IS PROVIDED "AS IS," AND THE +% UNIVERSITY OF SOUTHERN CALIFORNIA AND ITS COLLABORATORS DO NOT MAKE ANY +% WARRANTY, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO WARRANTIES OF +% MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE, NOR DO THEY ASSUME ANY +% LIABILITY OR RESPONSIBILITY FOR THE USE OF THIS SOFTWARE. +% +% For more information type "brainstorm license" at command prompt. +% =============================================================================@ +% +% Authors: Étienne Léger, 2023 + +% ===== PREPARE VALUES ====== +Faces = TessMat.Faces; +% Vertices: convert to millimeters +Vertices = TessMat.Vertices .* 1000; + +% ===== SAVE FILE ===== +% Open file +[fid, message] = fopen(OutputFile, 'wt'); +if (fid < 0) + error(['Could not create file : ' message]); +end +% Write vertices +fprintf(fid, 'v %f\t%f\t%f\n', Vertices'); +% Write faces +fprintf(fid, 'f %d\t%d\t%d\n', Faces'); +% Close file +fclose(fid); + + diff --git a/toolbox/io/out_tess_off.m b/toolbox/io/out_tess_off.m index 6dfbb92e7..933bfb3f9 100644 --- a/toolbox/io/out_tess_off.m +++ b/toolbox/io/out_tess_off.m @@ -30,7 +30,6 @@ function out_tess_off( TessMat, OutputFile ) % ===== PREPARE VALUES ====== % Faces : remove 1 (convert to 0-based indices) Faces = TessMat.Faces - 1; -% Vertices: convert to millimeters Vertices = TessMat.Vertices; % ===== SAVE FILE ===== diff --git a/toolbox/io/private/fif_setup_raw.m b/toolbox/io/private/fif_setup_raw.m index ff8c26df3..907aa6d90 100644 --- a/toolbox/io/private/fif_setup_raw.m +++ b/toolbox/io/private/fif_setup_raw.m @@ -51,7 +51,10 @@ FIFFT_SHORT=2; FIFFT_INT=3; FIFFT_FLOAT=4; +FIFFT_DOUBLE=5; FIFFT_DAU_PACK16=16; +FIFFT_COMPLEX_FLOAT=20; +FIFFT_COMPLEX_DOUBLE=21; me = 'MNE-BST:fif_setup_raw'; % Arguments if (nargin < 3) @@ -137,6 +140,12 @@ nsamp = ent.size/(4*info.nchan); case FIFFT_INT nsamp = ent.size/(4*info.nchan); + case FIFFT_DOUBLE + nsamp = ent.size/(8*info.nchan); + case FIFFT_COMPLEX_FLOAT + nsamp = ent.size/(8*info.nchan); + case FIFFT_COMPLEX_DOUBLE + nsamp = ent.size/(16*info.nchan); otherwise fclose(fid); error(me,'Cannot handle data buffers of type %d',ent.type); diff --git a/toolbox/io/private/neuralynx_getheader.m b/toolbox/io/private/neuralynx_getheader.m index d6055bc48..430169114 100644 --- a/toolbox/io/private/neuralynx_getheader.m +++ b/toolbox/io/private/neuralynx_getheader.m @@ -21,6 +21,7 @@ % % Authors: Robert Oostenveld, 2007, as part of the FieldTrip toolbox % Francois Tadel, 2015, for the Brainstorm integration +% Raymundo Cassani, 2024 % ===== CONSTANTS ===== % Get file extension @@ -55,34 +56,41 @@ % Get file size fseek(fid, 0, 'eof'); hdr.FileSize = ftell(fid); -% NCS: Read first and last timestamps -if strcmpi(fExt, '.ncs') - % Read first time stamp - fseek(fid, hdr.HeaderSize, 'bof'); - hdr.FirstTimeStamp = fread(fid, 1, '*uint64'); - % Read last time stamp - fseek(fid, -hdr.RecordSize, 'eof'); - hdr.LastTimeStamp = fread(fid, 1, '*uint64'); -% NSE: Read all the time samples -elseif strcmpi(fExt, '.nse') - % Compute number of records - hdr.NumSamples = (hdr.RecordSize - 48) / 2; - hdr.NumRecords = floor((hdr.FileSize - hdr.HeaderSize) / hdr.RecordSize); - % Initialize the variables to read from the header - hdr.SpikeTimeStamps = zeros(1, hdr.NumRecords, 'uint64'); - SpikeScNumber = zeros(1, hdr.NumRecords, 'int32'); - SpikeCellNumber = zeros(1, hdr.NumRecords, 'int32'); - hdr.SpikeParam = zeros(8, hdr.NumRecords, 'int32'); - % Loop on the records to get all the timestamps - for iRec = 1:hdr.NumRecords - % Seek at the beginning of the record - fseek(fid, hdr.HeaderSize + (iRec-1) * hdr.RecordSize, 'bof'); - % Read header of the record - hdr.SpikeTimeStamps(iRec) = fread(fid, 1, '*uint64'); - SpikeScNumber(iRec) = fread(fid, 1, 'int32'); % Do not save in the header - SpikeCellNumber(iRec) = fread(fid, 1, 'int32'); % Do not save in the header - hdr.SpikeParam(:,iRec) = fread(fid, 8, 'int32'); - end +switch hdr.FileExtension + % NCS: Read first and last timestamps + case 'NCS' + % Read first time stamp + fseek(fid, hdr.HeaderSize, 'bof'); + hdr.FirstTimeStamp = fread(fid, 1, '*uint64'); + % Read last time stamp + fseek(fid, -hdr.RecordSize, 'eof'); + hdr.LastTimeStamp = fread(fid, 1, '*uint64'); + + % NSE and NTT: Read all the time samples + case {'NSE', 'NTT'} + % Samples are 2 bytes + hdr.NumSamples = (hdr.RecordSize - 48) / 2; + % At each time sample there are four samples (one for each channel) in the NTT file + if strcmpi(hdr.FileExtension, 'NTT') + hdr.NumSamples = hdr.NumSamples / 4; + end + % Compute number of records + hdr.NumRecords = floor((hdr.FileSize - hdr.HeaderSize) / hdr.RecordSize); + % Initialize the variables to read from the header + hdr.SpikeTimeStamps = zeros(1, hdr.NumRecords, 'uint64'); + SpikeScNumber = zeros(1, hdr.NumRecords, 'int32'); + SpikeCellNumber = zeros(1, hdr.NumRecords, 'int32'); + hdr.SpikeParam = zeros(8, hdr.NumRecords, 'int32'); + % Loop on the records to get all the timestamps + for iRec = 1:hdr.NumRecords + % Seek at the beginning of the record + fseek(fid, hdr.HeaderSize + (iRec-1) * hdr.RecordSize, 'bof'); + % Read header of the record + hdr.SpikeTimeStamps(iRec) = fread(fid, 1, '*uint64'); + SpikeScNumber(iRec) = fread(fid, 1, 'int32'); % Do not save in the header + SpikeCellNumber(iRec) = fread(fid, 1, 'int32'); % Do not save in the header + hdr.SpikeParam(:,iRec) = fread(fid, 8, 'int32'); + end end % Close file fclose(fid); @@ -105,12 +113,8 @@ elseif (hdrlines{i}(1) == '#') continue; end - % Strip the '-' sign - while (hdrlines{i}(1) == '-') - hdrlines{i} = hdrlines{i}(2:end); - end - % Cut into pieces - item = textscan(hdrlines{i}, '%s'); + % Get item ('-Key Value') + [~, item] = regexp(hdrlines{i}, '-(\w+) (.*$)', 'match', 'tokens'); % Ignore line if there are less or more than two items if (length(item) ~= 1) || (length(item{1}) ~= 2) continue; @@ -118,13 +122,11 @@ % Item1=key, Item2=value key = item{1}{1}; val = item{1}{2}; - if any(val(1) == '-01234567989') - % Try to convert to number - val = str2num(val); - % Revert to the original text - if isempty(val) - val = item{1}{2}; - end + % Try to convert to number + val = str2num(val); + % Revert to the original text + if isempty(val) + val = item{1}{2}; end % Remove unuseable characters from the variable name (key) key = key(key ~= ':'); diff --git a/toolbox/io/private/read_fieldtrip_chaninfo.m b/toolbox/io/private/read_fieldtrip_chaninfo.m index 2cbf5f0a7..c07c6bdf4 100644 --- a/toolbox/io/private/read_fieldtrip_chaninfo.m +++ b/toolbox/io/private/read_fieldtrip_chaninfo.m @@ -190,7 +190,7 @@ end % Apply units if isfield(grad, 'unit') && ~isempty(grad.unit) - ChannelMat.Channel(i).Loc(:,1) = bst_units_ui(grad.unit, ChannelMat.Channel(i).Loc(:,1)); + ChannelMat.Channel(i).Loc = bst_units_ui(grad.unit, ChannelMat.Channel(i).Loc); end % Get type if isempty(ChannelMat.Channel(i).Type) diff --git a/toolbox/math/bst_epoching.m b/toolbox/math/bst_epoching.m index 0a002245b..c74159ee9 100644 --- a/toolbox/math/bst_epoching.m +++ b/toolbox/math/bst_epoching.m @@ -38,7 +38,7 @@ % This function is part of the Brainstorm software: % https://neuroimage.usc.edu/brainstorm % -% Copyright (c)2000-2020 University of Southern California & McGill University +% Copyright (c) University of Southern California & McGill University % This software is distributed under the terms of the GNU General Public License % as published by the Free Software Foundation. Further details on the GPLv3 % license can be found at http://www.gnu.org/copyleft/gpl.html. diff --git a/toolbox/math/bst_meanvar.mexmaca64 b/toolbox/math/bst_meanvar.mexmaca64 new file mode 100755 index 000000000..2d66bdec4 Binary files /dev/null and b/toolbox/math/bst_meanvar.mexmaca64 differ diff --git a/toolbox/math/bst_meshfit.m b/toolbox/math/bst_meshfit.m index 5ad40a256..5a1eaf364 100644 --- a/toolbox/math/bst_meshfit.m +++ b/toolbox/math/bst_meshfit.m @@ -1,18 +1,19 @@ -function [R, T, newP, distFinal] = bst_meshfit(Vertices, Faces, P) +function [R, T, newP, distFinal] = bst_meshfit(Vertices, Faces, P, Outliers) % BST_MESHFIT: Find the best possible rotation-translation to fit a point cloud on a mesh. % % USAGE: [R, T, newP, distFinal] = bst_meshfit(Vertices, Faces, P) % -% DESCRIPTION: +% DESCRIPTION: % A Gauss-Newton method is used for the optimization of the distance points/mesh. -% The Gauss-Newton algorithm used here was initially implemented by +% The Gauss-Newton algorithm used here was initially implemented by % Qianqian Fang (fangq at nmr.mgh.harvard.edu) and distributed under a GPL license -% as part of the Metch toolbox (http://iso2mesh.sf.net, regpt2surf.m). +% as part of the Metch toolbox (http://iso2mesh.sf.net, regpt2m). % % INPUTS: % - Vertices : [Mx3] double matrix % - Faces : [Nx3] double matrix % - P : [Qx3] double matrix, points to fit on the mesh defined by Vertices/Faces +% - Outliers : proportion of outlier points to ignore (between 0 and 1) % % OUTPUTS: % - R : [3x3] rotation matrix from the original P to the fitted positions. @@ -23,12 +24,12 @@ % @============================================================================= % This function is part of the Brainstorm software: % https://neuroimage.usc.edu/brainstorm -% +% % Copyright (c) University of Southern California & McGill University % This software is distributed under the terms of the GNU General Public License % as published by the Free Software Foundation. Further details on the GPLv3 % license can be found at http://www.gnu.org/copyleft/gpl.html. -% +% % FOR RESEARCH PURPOSES ONLY. THE SOFTWARE IS PROVIDED "AS IS," AND THE % UNIVERSITY OF SOUTHERN CALIFORNIA AND ITS COLLABORATORS DO NOT MAKE ANY % WARRANTY, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO WARRANTIES OF @@ -40,20 +41,64 @@ % % Authors: Qianqian Fang, 2008 % Francois Tadel, 2013-2021 +% Marc Lalancette, 2022 + +% Coordinates are in m. +PenalizeInside = true; +if nargin < 4 || isempty(Outliers) + Outliers = 0; +end + +% nV = size(Vertices, 1); +nF = size(Faces, 1); +nP = size(P, 1); +Outliers = ceil(Outliers * nP); + +% Edges as indices +Edges = unique(sort([Faces(:,[1,2]); Faces(:,[2,3]); Faces(:,[3,1])], 2), 'rows'); +% Edge direction "doubly normalized" so that later projection should be between 0 and 1. +EdgeDir = Vertices(Edges(:,2),:) - Vertices(Edges(:,1),:); +EdgeL = sqrt(sum(EdgeDir.^2, 2)); +EdgeDir = bsxfun(@rdivide, EdgeDir, EdgeL); +% Edges as vectors +EdgesV = zeros(nF, 3, 3); +EdgesV(:,:,1) = Vertices(Faces(:,2),:) - Vertices(Faces(:,1),:); +EdgesV(:,:,2) = Vertices(Faces(:,3),:) - Vertices(Faces(:,2),:); +EdgesV(:,:,3) = Vertices(Faces(:,1),:) - Vertices(Faces(:,3),:); +% First edge to second edge: counter clockwise = up +FaceNormals = cross(EdgesV(:,:,1), EdgesV(:,:,2)); +FaceNormals = bsxfun(@rdivide, FaceNormals, sqrt(sum(FaceNormals.^2, 2))); % normr +%FaceArea = sqrt(sum(FaceNormals.^2, 2)); +% Perpendicular vectors to edges, pointing inside triangular face. +for e = 3:-1:1 + EdgeTriNormals(:,:,e) = cross(FaceNormals, EdgesV(:,:,e)); +end +FaceVertices = zeros(nF, 3, 3); +FaceVertices(:,:,1) = Vertices(Faces(:,1),:); +FaceVertices(:,:,2) = Vertices(Faces(:,2),:); +FaceVertices(:,:,3) = Vertices(Faces(:,3),:); -% Calculate norms -VertNorm = tess_normals(Vertices, Faces); % Calculate the initial error -[distInit, dt] = get_distance(Vertices, VertNorm, P, []); -errInit = sum(abs(distInit)); +InitParams = zeros(6,1); +errInit = CostFunction(InitParams); % Fit points -[R,T,newP] = fit_points(Vertices, VertNorm, P, dt); +% [R,T,newP] = fit_points(Vertices, VertNorm, P, dt); +% Do optimization +% Stop at 0.1 mm total distance, or 0.02 mm displacement. +OptimOptions = optimoptions(@fminunc, 'MaxFunctionEvaluations', 1000, 'MaxIterations', 200, ... + 'FiniteDifferenceStepSize', 1e-3, ... + 'FunctionTolerance', 1e-4, 'StepTolerance', 2e-2, 'Display', 'none'); % 'OptimalityTolerance', 1e-15, 'final-detailed' + +BestParams = fminunc(@CostFunction, InitParams, OptimOptions); +[R,T,newP] = Transform(BestParams, P); +T = T'; +distFinal = PointSurfDistance(newP); % Calculate the final error -distFinal = get_distance(Vertices, VertNorm, newP, dt); -errFinal = sum(abs(distFinal)); +errFinal = CostFunction(BestParams); % If the error is larger than at the beginning: cancel the modifications +% Should no longer occur. if (errFinal > errInit) disp('BST> The optimization failed finding a better fit'); R = []; @@ -61,88 +106,103 @@ newP = P; end -end - +% Better cost function for points fitting: higher cost for points inside the +% head > 1mm, (better distance calculation). + function [Cost, Dist] = CostFunction(Params) + [~,~,Points] = Transform(Params, P); + Dist = PointSurfDistance(Points); + if PenalizeInside + isInside = inpolyhedron(Faces, Vertices, Points, 'FaceNormals', FaceNormals, 'FlipNormals', true); + %patch('Faces',Faces,'Vertices',Vertices); hold on; light; axis equal; + %quiver3(FaceVertices(:,1,1), FaceVertices(:,2,1), FaceVertices(:,3,1), FaceNormals(:,1,1), FaceNormals(:,2,1), FaceNormals(:,3,1)); + %scatter3(Points(1,1),Points(1,2),Points(1,3)); + iSquare = isInside & Dist > 0.001; + Dist(iSquare) = Dist(iSquare).^2 *1e3; % factor for "squaring mm" + iOutside = find(~isInside); + end + Cost = sum(Dist); + for iP = 1:Outliers + if PenalizeInside + % Only remove outside points. + [MaxD, iMaxD] = max(Dist(~isInside)); + Dist(iOutside(iMaxD)) = 0; + else + [MaxD, iMaxD] = max(Dist); + Dist(iMaxD) = 0; + end + Cost = Cost - MaxD; + end + end +%% TODO Slow, look for alternatives. +% This seems similar: https://www.mathworks.com/matlabcentral/fileexchange/52882-point2trimesh-distance-between-point-and-triangulated-surface % ===== COMPUTE POINTS/MESH DISTANCE ===== -% Approximates the distance to the mesh by the projection on the norm vector of the nearest neighbor -function [dist,dt] = get_distance(Vertices, VertNorm, P, dt) - % Find the nearest neighbor - [iNearest, dist_pt, dt] = bst_nearest(Vertices, P, 1, 0, dt); - % Distance = projection of the distance between the point and its nearest - % neighbor in the surface on the vertex normal - % As the head surface is supposed to be very smooth, it should be a good approximation - % of the distance from the point to the surface. - dist = abs(sum(VertNorm(iNearest,:) .* (P - Vertices(iNearest,:)),2)); -end - -% ===== COMPUTE TRANSFORMATION ===== -% Gauss-Newton optimization algorithm -% Based on work from Qianqian Fang, 2008 -function [R,T,newP] = fit_points(Vertices, VertNorm, P, dt) - % Initial parameters: no rotation, no translation - C = zeros(6,1); - newP = P; - % Sensitivity - delta = 1e-4; - % Maximum number of iterations - maxiter = 20; - % Initialize error at the previous iteration - errPrev = Inf; - % Start Gauss-Newton iterations - for iter = 1:maxiter - % Calculate the current residual: the sum of distances to the surface - dist0 = get_distance(Vertices, VertNorm, newP, dt); - err = sum(abs(dist0)); - % If global error is going up: stop - if (err > errPrev) - break; +% For exact distance computation, we need to check all 3: vertices, edges and faces. + function Dist = PointSurfDistance(Points) + Epsilon = 1e-9; % nanometer + % Check distance to vertices + if license('test','statistics_toolbox') + DistVert = pdist2(Vertices, Points, 'euclidean', 'Smallest', 1)'; + else + DistVert = zeros(nP, 1); + for iP = 1:nP + % Find closest surface vertex. + DistVert(iP) = sqrt(min(sum(bsxfun(@minus, Points(iP, :), Vertices).^2, 2))); + end end - errPrev = err; - % fprintf('iter=%d error=%f\n', iter, err); - % Build the Jacobian (sensitivity) matrix - J = zeros(length(dist0),length(C)); - for i = 1:length(C) - dC = C; - if (C(i)) - dC(i) = C(i) * (1+delta); - else - dC(i) = C(i) + delta; + % Check distance to faces + DistFace = inf(nP, 1); + for iP = 1:nP + % Considered Möller and Trumbore 1997, Ray-Triangle Intersection (https://stackoverflow.com/questions/42740765/intersection-between-line-and-triangle-in-3d), but this is simpler still. + % Vectors from triangle vertices to point. + Pyramid = bsxfun(@minus, Points(iP, :), FaceVertices); + % Does the point project inside each face? + InFace = all(sum(Pyramid .* EdgeTriNormals, 2) > -Epsilon, 3); + if any(InFace) + DistFace(iP) = min(abs(sum(Pyramid(InFace,:,1) .* FaceNormals(InFace,:), 2))); end - % Apply this new transformation to the points - [tmpR,tmpT,tmpP] = get_transform(dC, P); - % Calculate the distance for this new transformation - dist = get_distance(Vertices, VertNorm, tmpP, dt); - % J=dL/dC - J(:,i) = (dist-dist0) / (dC(i)-C(i)); end - % Weight the matrix (normalization) - wj = sqrt(sum(J.*J)); - J = J ./ repmat(wj,length(dist0),1); - % Calculate the update: J*dC=dL - dC = (J\dist0) ./ wj'; - C = C - 0.5*dC; - % Get the updated positions with the calculated A and b - [R,T,newP] = get_transform(C, P); + % Check distance to edges + DistEdge = inf(nP, 1); + for iP = 1:nP + % Vector from first edge vertex to point. + Pyramid = bsxfun(@minus, Points(iP, :), Vertices(Edges(:, 1), :)); + Projection = sum(Pyramid .* EdgeDir, 2); + InEdge = Projection > -Epsilon & Projection < (EdgeL + Epsilon); + if any(InEdge) + DistEdge(iP) = sqrt(min(sum((Pyramid(InEdge,:) - bsxfun(@times, Projection(InEdge), EdgeDir(InEdge,:))).^2, 2))); + end + end + + Dist = min([DistVert, DistEdge, DistFace], [], 2); end -end + + +% % Approximates the distance to the mesh by the projection on the norm vector of the nearest neighbor +% function [dist,dt] = get_distance(Vertices, VertNorm, P, dt) +% % Find the nearest neighbor +% [iNearest, dist_pt, dt] = bst_nearest(Vertices, P, 1, 0, dt); +% % Distance = projection of the distance between the point and its nearest +% % neighbor in the surface on the vertex normal +% % As the head surface is supposed to be very smooth, it should be a good approximation +% % of the distance from the point to the surface. +% dist = abs(sum(VertNorm(iNearest,:) .* (P - Vertices(iNearest,:)),2)); +% end % ===== GET TRANSFORMATION ===== -function [R,T,P] = get_transform(params, P) - % Get values - mx = params(1); my = params(2); mz = params(3); % Translation parameters - x = params(4); y = params(5); z = params(6); % Rotation parameters - % Rotation - Rx = [1 0 0 ; 0 cos(x) sin(x) ; 0 -sin(x) cos(x)]; % Rotation over x - Ry = [cos(y) 0 -sin(y); 0 1 0; sin(y) 0 cos(y)]; % Rotation over y - Rz = [cos(z) sin(z) 0; -sin(z) cos(z) 0; 0 0 1]; % Rotation over z - R = Rx*Ry*Rz; - % Translation - T = [mx; my; mz]; - % Apply to points - if (nargin >= 2) - P = (R * P' + T * ones(1,size(P,1)))'; + function [R,T,Points] = Transform(Params, Points) + % Translation in mm (to use default TypicalX of 1) + T = Params(1:3)'/1e3; + % Rotation in degrees (again for expected order of magnitude of 1) + x = Params(4)*pi/180; y = Params(5)*pi/180; z = Params(6)*pi/180; % Rotation parameters + Rx = [1 0 0 ; 0 cos(x) sin(x) ; 0 -sin(x) cos(x)]; % Rotation over x + Ry = [cos(y) 0 -sin(y); 0 1 0; sin(y) 0 cos(y)]; % Rotation over y + Rz = [cos(z) sin(z) 0; -sin(z) cos(z) 0; 0 0 1]; % Rotation over z + R = Rx*Ry*Rz; + % Apply to points + Points = bsxfun(@plus, Points * R', T); end + end diff --git a/toolbox/math/bst_pca.m b/toolbox/math/bst_pca.m index ed230cd9b..c8abfc419 100644 --- a/toolbox/math/bst_pca.m +++ b/toolbox/math/bst_pca.m @@ -54,12 +54,12 @@ % @============================================================================= % This function is part of the Brainstorm software: % https://neuroimage.usc.edu/brainstorm -% +% % Copyright (c) University of Southern California & McGill University % This software is distributed under the terms of the GNU General Public License % as published by the Free Software Foundation. Further details on the GPLv3 % license can be found at http://www.gnu.org/copyleft/gpl.html. -% +% % FOR RESEARCH PURPOSES ONLY. THE SOFTWARE IS PROVIDED "AS IS," AND THE % UNIVERSITY OF SOUTHERN CALIFORNIA AND ITS COLLABORATORS DO NOT MAKE ANY % WARRANTY, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO WARRANTIES OF diff --git a/toolbox/math/bst_permtest.m b/toolbox/math/bst_permtest.m index cd3224113..147ab804f 100644 --- a/toolbox/math/bst_permtest.m +++ b/toolbox/math/bst_permtest.m @@ -215,6 +215,9 @@ S = zeros(sizeData); % Save statistics for all the permutations if (nargout >= 5) + % Args to index PS + ixP = cell(size(sizeData)); + ixP(:) = {':'}; PS = zeros([nPerm, sizeData, 1],'single'); end % Count all good and bad channels for each set @@ -236,7 +239,7 @@ end % Save statistics for all the permutations if (nargout >= 5) - PS(i,:) = Z; + PS(i,ixP{:}) = Z; end end end diff --git a/toolbox/math/bst_project_channel.m b/toolbox/math/bst_project_channel.m index 138b6e39e..fe373e3aa 100644 --- a/toolbox/math/bst_project_channel.m +++ b/toolbox/math/bst_project_channel.m @@ -152,6 +152,13 @@ end % Copy clusters ChannelMatDest.Clusters = ChannelMatSrc.Clusters; +% Copy NIRS information +if isfield(ChannelMatSrc,'Nirs') + ChannelMatDest.Nirs = ChannelMatSrc.Nirs; +end +% Copy history +ChannelMatDest.History = ChannelMatSrc.History; +ChannelMatDest = bst_history('add', ChannelMatDest, 'project', ['Project channel file: ' sSubjectSrc.Name ' => ' sSubjectDest.Name]); % ===== SAVE NEW FILE ===== bst_progress('text', 'Saving results...'); diff --git a/toolbox/math/bst_project_sources.m b/toolbox/math/bst_project_sources.m index 43a076ee1..8f7584a06 100644 --- a/toolbox/math/bst_project_sources.m +++ b/toolbox/math/bst_project_sources.m @@ -189,8 +189,8 @@ ResultsFile = ResultsGroups{iGroup}{iFile}; if isInteractive bst_progress('inc', 1); + bst_progress('text', sprintf('Processing file #%d/%d: %s', iFile, nFile, ResultsFile)); end - bst_progress('text', sprintf('Processing file #%d/%d: %s', iFile, nFile, ResultsFile)); % ===== OUTPUT STUDY ===== % Get source study @@ -254,8 +254,8 @@ end % Remove link with original file ResultsMat.DataFile = []; - % Check if the file was reprojected on an atlas - if isfield(ResultsMat, 'Atlas') && ~isempty(ResultsMat.Atlas) + % Check if the file was reprojected on an atlas (only for results files) + if isfield(ResultsMat, 'Atlas') && ~isempty(ResultsMat.Atlas) && ~isTimefreq wrnMsg = ['Cannot process atlas-based source files: Skipping file "' ResultsFile '"...']; if isInteractive disp(wrnMsg); diff --git a/toolbox/math/bst_scout_channels.m b/toolbox/math/bst_scout_channels.m new file mode 100644 index 000000000..91eb214d6 --- /dev/null +++ b/toolbox/math/bst_scout_channels.m @@ -0,0 +1,225 @@ +function OutputFile = bst_scout_channels(ChannelFile, SurfaceFile, Modality, Radius) +% BST_SCOUT_CHANNELS: Create a scout with vertices within a radius around sensors on a surface. +% +% USAGE: OutputFile = bst_scout_channels(ChannelFile, SurfaceFile, Modality, Radius) +% OutputFile = bst_scout_channels(ChannelFiles, ...) +% +% INPUT: +% - ChannelFile : Path to channel file with sensors +% - SurfaceFile : Path to surface file to create the scouts, or +% Type of surface, it will use the default surface for tha type +% - Modality : Modality to indicate the sensors to be used in scout creation +% - Radius : Radius around sensor to create scout on surface (in mm) +% If Radius == 0, the closest vertex is selected + +% @============================================================================= +% This function is part of the Brainstorm software: +% https://neuroimage.usc.edu/brainstorm +% +% Copyright (c) University of Southern California & McGill University +% This software is distributed under the terms of the GNU General Public License +% as published by the Free Software Foundation. Further details on the GPLv3 +% license can be found at http://www.gnu.org/copyleft/gpl.html. +% +% FOR RESEARCH PURPOSES ONLY. THE SOFTWARE IS PROVIDED "AS IS," AND THE +% UNIVERSITY OF SOUTHERN CALIFORNIA AND ITS COLLABORATORS DO NOT MAKE ANY +% WARRANTY, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO WARRANTIES OF +% MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE, NOR DO THEY ASSUME ANY +% LIABILITY OR RESPONSIBILITY FOR THE USE OF THIS SOFTWARE. +% +% For more information type "brainstorm license" at command prompt. +% =============================================================================@ +% +% Authors: Edouard Delaire, 2024 +% Raymundo Cassani, 2024 + +% ===== PARSE INPUTS ====== +if (nargin < 4) + Radius = []; + if (nargin < 3) + Modality = []; + if (nargin < 2) + SurfaceFile = []; + end + end +end +errMsg = []; +OutputFile = SurfaceFile; + +% Get Subject info +sStudy = bst_get('ChannelFile', file_short(ChannelFile)); +[sSubject, iSubject] = bst_get('Subject', sStudy.BrainStormSubject); +% Subjects must be either the default anatomy, or must have individual anatomy +if (sSubject.UseDefaultChannel && (iSubject ~= 0)) + errMsg = 'Subject is using the default anatomy.'; +end + +% Modality options (options with Location) +[~, modalityOptions] = bst_get('ChannelModalities', ChannelFile); +% Surface options +surfaceOptions = {}; +if ~isempty(sSubject.iScalp) + surfaceOptions{end+1} = 'Scalp'; +end +if ~isempty(sSubject.iOuterSkull) + surfaceOptions{end+1} = 'OuterSkull'; +end +if ~isempty(sSubject.iInnerSkull) + surfaceOptions{end+1} = 'InnerSkull'; +end +if ~isempty(sSubject.iCortex) + surfaceOptions{end+1} = 'Cortex'; +end + +% Get and validate Modality, SurfaceFile and Radius +surfaceTarget = []; +modalityTarget = []; +radiusTarget = []; + +% Modality +if isempty(errMsg) && ~isempty(Modality) + if ~iscell(Modality) + Modality = {Modality}; + end + if any(ismember(Modality, modalityOptions)) + modalityTarget = Modality; + else + modalityMiss = setdiff(Modality, modalityOptions); + errMsg = ['No channel for modality: "', strjoin(modalityMiss, ', '), '" was found in Channel file.']; + end +elseif isempty(errMsg) && isempty(Modality) + [modalityTarget, isCancel] = java_dialog('checkbox', 'Which sensor modality or modalities will be used to create surface scouts?', ... + 'Scouts from sensors', [], modalityOptions); + if isempty(modalityTarget) || isCancel + return + end +end + +% Surface +if isempty(errMsg) && ~isempty(SurfaceFile) + % Surface is a filename + if ~strcmpi(file_gettype(SurfaceFile), 'unknown') && file_exist(file_fullpath(SurfaceFile)) + [~, iSubjectSurf] = bst_get('SurfaceFile', SurfaceFile); + if iSubject == iSubjectSurf + surfaceTarget = SurfaceFile; + surfaceType = file_gettype(SurfaceFile); + else + errMsg = 'Subjects for channel File and for Surface files are not the same.'; + end + % Surface is Type + else + if ismember(SurfaceFile, surfaceOptions) + surfaceType = SurfaceFile; + surfaceTarget = sSubject.Surface(sSubject.(['i' surfaceType])).FileName; + else + errMsg = ['Subject does not have default surface of type ' SurfaceFile '.']; + end + end +elseif isempty(errMsg) && isempty(SurfaceFile) + [surfaceType, isCancel] = java_dialog('question', sprintf('Surface to create scouts from [%s] sensors:', strjoin(modalityTarget, ', ')), ... + 'Scouts from sensors', [], surfaceOptions); + if isempty(surfaceType) || isCancel + return + end + surfaceTarget = sSubject.Surface(sSubject.(['i' surfaceType])).FileName; +end + +% Radius +if isempty(errMsg) && ~isempty(Radius) + radiusTarget = Radius; +elseif isempty(errMsg) && isempty(Radius) + [res, isCancel] = java_dialog('input', sprintf('Radius (in mm) for scouts on [%s] surface for sensors [%s]:', ... + surfaceType, strjoin(modalityTarget, ', ')), ... + 'Scouts from sensors', [], '5'); + if isempty(res) || isCancel + return + end + radiusTarget = str2double(res); +end +if isempty(errMsg) && (isnan(radiusTarget) || radiusTarget < 0) + errMsg = 'Radius must be a number larger than 0 mm.'; +end + +% Error handling +if ~isempty(errMsg) + bst_error(errMsg); + return; +end + +% ===== GET INPUT DATA ===== +% Progress bar +isProgress = bst_progress('isVisible'); +bst_progress('start', 'Scouts from sensors', 'Loading surface file...'); + +% Load surface +sSurf = in_tess_bst(surfaceTarget); +surfVertices = sSurf.Vertices; + +% ===== SCOUTS FROM CHANNEL FILE ===== +bst_progress('start', 'Scouts from sensors', 'Loading surface file...'); +ChannelMat = in_bst_channel(ChannelFile); +iChannels = channel_find(ChannelMat.Channel, modalityTarget); + +ChanLocOrg = [ChannelMat.Channel(iChannels).Loc]'; +ChanLocPrj = channel_project_scalp(surfVertices, ChanLocOrg); +if any(sqrt(sum( (ChanLocPrj - ChanLocOrg).^2, 2)) > 0.001) + bst_error(['One or more sensors are not placed on the surface.' 10 ... + 'Project the sensors to the target surface before generating the scouts.']); + return +end + +% Project sensors +scoutVertices = []; +for ix = 1 : length(iChannels) + if isempty(ChannelMat.Channel(iChannels(ix)).Loc) + continue + end + for iLoc = 1 : size(ChannelMat.Channel(iChannels(ix)).Loc, 2) + distances = sqrt(sum((surfVertices - ChannelMat.Channel(iChannels(ix)).Loc(:, iLoc)').^2, 2)); + if radiusTarget == 0 + [~, iMinDist] = min(distances); + scoutVertices = [scoutVertices, iMinDist]; + else + scoutVertices = [scoutVertices, find(distances < radiusTarget./1000)']; + end + end +end + +if isempty(scoutVertices) + bst_error(['No vertex found on the surface. '... + 'Check that the sensors are projected on the target surface']); + return; +end +% Create scout +scout_channel = db_template('Scout'); +scout_channel.Label = sprintf('%s | %s (%d mm)', sStudy.Condition{1}, strjoin(modalityTarget, ' '), radiusTarget); +scout_channel.Vertices = unique(scoutVertices); +scout_channel.Seed = scoutVertices(1); +scout_channel.Handles = []; +scout_channel.Color = [1 0 0]; + +% ===== SAVE SCOUT ===== +bst_progress('text', 'Saving scouts...'); +atlasName = 'Scout from sensors'; +s.Atlas = sSurf.Atlas; +if ~isempty(s.Atlas) && ismember(atlasName, {s.Atlas.Name}) + [~, iAtlas] = ismember(atlasName, {s.Atlas.Name}); +else + s.Atlas(end+1).Name = 'Scout from sensors'; + iAtlas = length(s.Atlas); +end +if ~isempty(s.Atlas(iAtlas).Scouts) && ismember(scout_channel.Label, {s.Atlas(iAtlas).Scouts.Label}) + [~, iScout] = ismember(scout_channel.Label, {s.Atlas(iAtlas).Scouts.Label}); +else + s.Atlas(iAtlas).Scouts(end+1) = scout_channel; + iScout = length(s.Atlas(iAtlas).Scouts); +end +s.Atlas(iAtlas).Scouts(iScout) = scout_channel; +bst_save(file_fullpath(surfaceTarget), s, [], 1); +OutputFile = surfaceTarget; +% Close progress bar +if ~isProgress + bst_progress('stop'); +end + +end diff --git a/toolbox/math/bst_scout_value.m b/toolbox/math/bst_scout_value.m index 522eea5f8..4cf28604d 100644 --- a/toolbox/math/bst_scout_value.m +++ b/toolbox/math/bst_scout_value.m @@ -189,14 +189,21 @@ % STD : Standard deviation of the patch activity at each time instant case 'std' Fs = std(F,[],1); + % STDERR : Standard error case 'stderr' %% This formula was incorrect for standard error (fixed 2023-04). Is it used anywhere? Fs = std(F,[],1) ./ sqrt(nRow); - % RMS + + % RMS : Root mean square, square root of the average of the square of the all the signals case 'rms' - Fs = sqrt(sum(F.^2,1)); - + if (nComponents == 1) + Fs = mean(F.^2, 1); + else + Fs = mean(sum(F.^2, 3), 1); + end + Fs = sqrt(Fs); + % MEAN_NORM : Average of the norms of all the vertices each time instant % If only one components: computes mean(abs(F)) => Compatibility with older versions case 'mean_norm' diff --git a/toolbox/math/bst_shepards.m b/toolbox/math/bst_shepards.m index 1c2d8a439..18fc1b9f7 100644 --- a/toolbox/math/bst_shepards.m +++ b/toolbox/math/bst_shepards.m @@ -1,7 +1,7 @@ -function Wmat = bst_shepards(destLoc, srcLoc, nbNeighbors, excludeParam, expDistance) +function Wmat = bst_shepards(destLoc, srcLoc, nbNeighbors, excludeParam, expDistance, isInteractive) % BST_SHEPARDS: 3D nearest-neighbor interpolation using Shepard's weighting. % -% USAGE: Wmat = bst_shepards(destLoc, srcLoc, nbNeighbors=8, excludeParam=0, expDistance=2) +% USAGE: Wmat = bst_shepards(destLoc, srcLoc, nbNeighbors=8, excludeParam=0, expDistance=2, isInteractive=1) % % INPUT: % - srcLoc : Nx3 array of original locations, or tesselation structure (Faces,Vertices,VertConn) @@ -12,6 +12,7 @@ % where minDist represents the minimal distance between each source point and the destination surface % If < 0, exclude the vertices that are further from the absolute distance excludeParam (in millimeters) % - expDistance : Distance exponent (if higher, influence of a value decreases faster) +% - isInteractive: If 1, show a progress bar % % OUTPUT: % - Wmat : Interpolation matrix @@ -57,6 +58,11 @@ if (nargin < 5) || isempty(expDistance) expDistance = 2; end +% Argument: isInteractive +if (nargin < 6) || isempty(isInteractive) + isInteractive = 1; +end + %% ===== SHEPARDS INTERPOLATION ===== % Allocate interpolation matrix @@ -68,7 +74,7 @@ end % Find nearest neighbors -[I,dist] = bst_nearest(srcLoc, destLoc, nbNeighbors, 1); +[I,dist] = bst_nearest(srcLoc, destLoc, nbNeighbors, isInteractive); % Square the distance matrix dist = dist .^ 2; % Eliminate zeros in distance matrix for stability diff --git a/toolbox/math/bst_tess_distance.m b/toolbox/math/bst_tess_distance.m new file mode 100644 index 000000000..0993496a5 --- /dev/null +++ b/toolbox/math/bst_tess_distance.m @@ -0,0 +1,60 @@ +function Dist = bst_tess_distance(SurfaceMat, VerticesA, VerticesB, metric) +% bst_tess_distance: Distance computation between two set of vertices (A and B) +% +% USAGE: W = bst_tess_distance(SurfaceMat, VerticesA, VerticesB, metric) +% +% INPUT: +% - SurfaceMat : Cortical surface matrix +% - VerticesA : Vertices from region A +% - VerticesB : Vertices from region B +% - Method : Metric used to compute the distance {'euclidean', 'geodesic_edge', 'geodesic_dist'} +% OUPUT: +% - Dist: distance matrix. D(i,j) is the distance between vertex VerticesA(i), and +% VerticesB(j). + +% +% @============================================================================= +% This function is part of the Brainstorm software: +% https://neuroimage.usc.edu/brainstorm +% +% Copyright (c) University of Southern California & McGill University +% This software is distributed under the terms of the GNU General Public License +% as published by the Free Software Foundation. Further details on the GPLv3 +% license can be found at http://www.gnu.org/copyleft/gpl.html. +% +% FOR RESEARCH PURPOSES ONLY. THE SOFTWARE IS PROVIDED "AS IS," AND THE +% UNIVERSITY OF SOUTHERN CALIFORNIA AND ITS COLLABORATORS DO NOT MAKE ANY +% WARRANTY, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO WARRANTIES OF +% MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE, NOR DO THEY ASSUME ANY +% LIABILITY OR RESPONSIBILITY FOR THE USE OF THIS SOFTWARE. +% +% For more information type "brainstorm license" at command prompt. +% =============================================================================@ +% +% Authors: Edouard Delaire 2023 + + Vertices = SurfaceMat.Vertices; + + if strcmp(metric,'euclidean') + Dist = zeros(length(VerticesA),length(VerticesB)); + x = Vertices(VerticesA,:)'; + for i = 1:length(VerticesB) + y = Vertices(VerticesB(i),:)'; + Dist(:,i) = sum((x-y).^2).^0.5; % m + end + + elseif ~isempty(strfind(metric,'geodesic')) + if strcmp(metric,'geodesic_dist') + [vi,vj] = find(SurfaceMat.VertConn); + nv = size(Vertices,1); + x = Vertices(vi,:)'; + y = Vertices(vj,:)'; + D = sparse(vi, vj, sum((x-y).^2).^0.5, nv, nv); % m + else + D = SurfaceMat.VertConn; % edges + end + + G = graph(D); + Dist = distances(G, VerticesA, VerticesB); + end +end diff --git a/toolbox/misc/struct_copy_fields.m b/toolbox/misc/struct_copy_fields.m index 6ae5a4cbc..96a70b6d5 100644 --- a/toolbox/misc/struct_copy_fields.m +++ b/toolbox/misc/struct_copy_fields.m @@ -1,6 +1,7 @@ -function sDest = struct_copy_fields(sDest, sSrc, override) +function sDest = struct_copy_fields(sDest, sSrc, override, deepest) % STRUCT_COPY_FIELDS: Copy the fields from sSrc structure to sDest structure - +% If override, override the field sDest by the field of sSrc +% If deepest, try to apply the copy at the deepest level % @============================================================================= % This function is part of the Brainstorm software: % https://neuroimage.usc.edu/brainstorm @@ -21,10 +22,15 @@ % % Authors: Sylvain Baillet, March 2002 +if (nargin < 4) || isempty(deepest) + deepest = 0; +end + if (nargin < 3) || isempty(override) override = 1; end + % No fields to add if isempty(sSrc) return @@ -35,6 +41,12 @@ else namesSrc = fieldnames(sSrc); for i = 1:length(namesSrc) + % if subfield are both structure, we do a "deep copy" + if deepest && isfield(sDest, namesSrc{i}) && isstruct(sDest.(namesSrc{i})) && isfield(sSrc, namesSrc{i}) && isstruct(sSrc.(namesSrc{i})) + sDest.(namesSrc{i}) = struct_copy_fields(sDest.(namesSrc{i}), sSrc.(namesSrc{i}), override); + continue; + end + if override || ~isfield(sDest, namesSrc{i}) sDest.(namesSrc{i}) = sSrc.(namesSrc{i}); end diff --git a/toolbox/process/bst_process.m b/toolbox/process/bst_process.m index 143324ec4..406f4d867 100644 --- a/toolbox/process/bst_process.m +++ b/toolbox/process/bst_process.m @@ -401,7 +401,7 @@ sInput.Measure = []; end % Do not allow Time Bands - if isfield(sMat, 'TimeBands') && ~isempty(sMat.TimeBands) && ismember(func2str(sProcess.Function), {'process_average_time', 'process_baseline_norm', 'process_extract_time'}) + if isfield(sMat, 'TimeBands') && ~isempty(sMat.TimeBands) && ~strcmpi(sMat.Method, 'mtmconvol') && ismember(func2str(sProcess.Function), {'process_average_time', 'process_baseline_norm', 'process_extract_time'}) bst_report('Error', sProcess, sInput, 'Cannot process values averaged by time bands.'); return; end @@ -608,14 +608,14 @@ else [rawPathIn, rawBaseIn] = bst_fileparts(sFileIn.filename); end - % Make sure that there are not weird characters in the folder names - rawBaseIn = file_standardize(rawBaseIn); % New folder name if isfield(sFileIn, 'condition') && ~isempty(sFileIn.condition) newCondition = ['@raw', sFileIn.condition, fileTag]; else newCondition = ['@raw', rawBaseIn, fileTag]; end + % Make sure that there are not weird characters in the folder names + newCondition = file_standardize(newCondition); % Get new condition name newStudyPath = file_unique(bst_fullfile(ProtocolInfo.STUDIES, sInput.SubjectName, newCondition)); % Output file name derives from the condition name @@ -963,7 +963,17 @@ end % Output time vector if isfield(sMat, 'TimeBands') && ~isempty(sMat.TimeBands) - % Time bands: Do not update time vector + if isTimeChange + % Find time bands related to new time vector (obtained from time bands) + timeVectorBands = mean(process_tf_bands('GetBounds', sMat.TimeBands), 2); + ixTimeBandKeep = (timeVectorBands >= OutTime(1)) & (timeVectorBands <= OutTime(end)); + sMat.TimeBands = sMat.TimeBands(ixTimeBandKeep, :); + % Update original time vector to match new time vector range + ixTimeDel = (sMat.Time < OutTime(1)) | (sMat.Time > OutTime(end)); + sMat.Time(ixTimeDel) = []; + else + % Time bands: Do not update time vector + end else sMat.Time = OutTime; end @@ -997,7 +1007,7 @@ if isfield(sProcess.options, 'Comment') && isfield(sProcess.options.Comment, 'Value') && ~isempty(sProcess.options.Comment.Value) sMat.Comment = sProcess.options.Comment.Value; % Modify comment based on modifications in function Run - elseif ~isRaw && isfield(sInput, 'Comment') && ~isempty(sInput.Comment) && ~isequal(sMat.Comment, sInput.Comment) + elseif ~isRaw && isfield(sInput, 'Comment') && ~isempty(sInput.Comment) && ~isequal(sMat.Comment, sInput.Comment) && ~isAbsolute sMat.Comment = sInput.Comment; % Add file tag (defined in process Run function) elseif isfield(sInput, 'CommentTag') && ~isempty(sInput.CommentTag) @@ -1145,7 +1155,7 @@ sInputB.Measure = []; end % Do not allow TimeBands - if ((isfield(sMatA, 'TimeBands') && ~isempty(sMatA.TimeBands)) || (isfield(sMatB, 'TimeBands') && ~isempty(sMatB.TimeBands))) ... + if ((isfield(sMatA, 'TimeBands') && ~isempty(sMatA.TimeBands)) && ~strcmpi(sMatA.Method, 'mtmconvol') || (isfield(sMatB, 'TimeBands') && ~isempty(sMatB.TimeBands))) && ~strcmpi(sMatB.Method, 'mtmconvol') ... && ismember(func2str(sProcess.Function), {'process_baseline_ab', 'process_zscore_ab', 'process_baseline_norm2'}) bst_report('Error', sProcess, [sInputA, sInputB], 'Cannot process values averaged by time bands.'); OutputFile = []; @@ -1904,7 +1914,9 @@ isflip = ismember(sInput.DataType, {'link','results'}) && ... isempty(strfind(FileName, '_norm')) && ... isempty(strfind(FileName, 'NIRS')) && ... - isempty(strfind(FileName, 'Summed_sensitivities')); + isempty(strfind(FileName, 'Summed_sensitivities')) && ... + isempty(strfind(FileName, 'bold')); + % Call process sMat = CallProcess('process_extract_scout', FileName, [], ... 'timewindow', TimeWindow, ... diff --git a/toolbox/process/bst_report.m b/toolbox/process/bst_report.m index de2232cdb..82eb2cbf5 100644 --- a/toolbox/process/bst_report.m +++ b/toolbox/process/bst_report.m @@ -12,6 +12,7 @@ % bst_report('Save', sInputs, ReportFile=[]) % bst_report('Save', FileNames, ReportFile=[]) % bst_report('Open', ReportFile=[ask], isFullReport=1) +% bst_report('Reset') % bst_report('Export', ReportFile, HtmlFile=[ask]) % bst_report('Export', ReportFile, HtmlDir) % bst_report('Email', ReportFile, username, to, subject, isFullReport=1) @@ -66,13 +67,12 @@ %% ===== START ===== function Start(sInputs) - global GlobalData; % If there were no inputs if (nargin < 1) || isempty(sInputs) sInputs = []; end % Reset current report - GlobalData.ProcessReports.Reports = {}; + Reset(); % Get current protocol description ProtocolInfo = bst_get('ProtocolInfo'); % Add start entry @@ -89,7 +89,7 @@ function Add(strType, sProcess, sInputs, strMsg) return; end if isempty(GlobalData.ProcessReports.Reports) || ~iscell(GlobalData.ProcessReports.Reports) || (size(GlobalData.ProcessReports.Reports,2) ~= 5) - GlobalData.ProcessReports.Reports = {}; + Reset(); end % No input if isempty(strType) @@ -189,8 +189,6 @@ function Info(sProcess, sInputs, strMsg) end return; end - % Use short file name - FileName = file_short(FileName); % Get current window layout curLayout = bst_get('Layout', 'WindowManager'); if ~isempty(curLayout) @@ -226,6 +224,10 @@ function Info(sProcess, sInputs, strMsg) if ~isempty(ScoutsOptions) && ~strcmpi(ScoutsOptions.showSelection, 'none') panel_scout('SetScoutShowSelection', 'none'); end + % Use short file name + if ~isempty(FileName) + FileName = file_short(FileName); + end % Show figures try @@ -680,7 +682,7 @@ function Info(sProcess, sInputs, strMsg) strErr = bst_error(); disp(['BST_REPORT> ERROR:' 10 strErr]); % Log error message - Error('process_snapshot', FileName, strErr); + Error('process_snapshot', ['"' SnapType '" "' FileName '"'], strErr); hFig = []; end % Output images @@ -775,7 +777,7 @@ function Info(sProcess, sInputs, strMsg) ReportMat.Reports = GlobalData.ProcessReports.Reports; bst_save(ReportFile, ReportMat, 'v7'); % Reset - GlobalData.ProcessReports.Reports = {}; + Reset(); end @@ -931,21 +933,10 @@ function Info(sProcess, sInputs, strMsg) 'Brainstorm process report' 10]; % Elapsed time if ~isempty(iStart) && ~isempty(iStop) - % Get time elapsed between start and stop - eTime = datevec(datenum(Reports{iStop,5}) - datenum(Reports{iStart,5})); - % Format elapsed time - strElapsed = []; - if (eTime(3) > 0) - strElapsed = [strElapsed num2str(eTime(3)) 'd ']; - end - if (eTime(4) > 0) - strElapsed = [strElapsed num2str(eTime(4)) 'h ']; - end - if (eTime(5) > 0) - strElapsed = [strElapsed num2str(eTime(5)) 'm ']; - end + % Get elapsed time string 'Xd Xh Xm Xs' + strElapsed = GetElapsedStr(Reports, iStart, iStop); % Time line - strElapsed = [strElapsed num2str(eTime(6)) 's' 10]; + strElapsed = [strElapsed '' 10]; html = [html 'Start: ' Reports{iStart,5} '        Elapsed: ' strElapsed]; end @@ -1344,6 +1335,9 @@ function ClearHistory(isUserConfirm) end % Get all the available reports ProtocolInfo = bst_get('ProtocolInfo'); + if isempty(ProtocolInfo) + return + end ProtocolName = file_standardize(ProtocolInfo.Comment); reportsDir = bst_get('UserReportsDir'); % If directory exists @@ -1631,9 +1625,21 @@ function Recall(target) % Compact report: prepare and send if ~isFullReport html = ''; + iStart = find(strcmpi(Reports(:,1), 'start'), 1); + iStop = find(strcmpi(Reports(:,1), 'stop'), 1); + iErrors = find(strcmpi(Reports(:,1), 'error')); + iWarnings = find(strcmpi(Reports(:,1), 'warning')); + % Elapsed time + if ~isempty(iStart) && ~isempty(iStop) + % Get elapsed time string 'Xd Xh Xm Xs' + strElapsed = GetElapsedStr(Reports, iStart, iStop); + html = [html 'Start: ' Reports{iStart,5} ' Elapsed: ' strElapsed 10 10]; + end + % Errors and warnings + html = [html sprintf('%d errors and %d warnings', length(iErrors), length(iWarnings)) 10 10]; for iEntry = 1:size(Reports,1) if ~isempty(Reports{iEntry,1}) && ~isempty(Reports{iEntry,5}) - html = [html, Reports{iEntry,5}, ' : ', Reports{iEntry,1}]; + html = [html, Reports{iEntry,5}, ' : ', Reports{iEntry,1}, repmat(' ', 1, 7-length(Reports{iEntry,1}))]; if ~isempty(Reports{iEntry,2}) if isstruct(Reports{iEntry,2}) html = [html, 9, ' - ' func2str(Reports{iEntry,2}.Function)]; @@ -1655,4 +1661,33 @@ function Recall(target) isOk = isequal(resp, 'ok'); end +%% == GET ELAPSED TIME === +function strElapsed = GetElapsedStr(Reports, iStart, iStop) + strElapsed = ''; + if nargin < 3 || isempty(Reports) || isempty(iStart) || isempty(iStop) + return + end + % Get time elapsed between start and stop + eTime = datevec(datenum(Reports{iStop,5}) - datenum(Reports{iStart,5})); + % Format elapsed time + strElapsed = []; + if (eTime(3) > 0) + strElapsed = [strElapsed num2str(eTime(3)) 'd ']; + end + if (eTime(4) > 0) + strElapsed = [strElapsed num2str(eTime(4)) 'h ']; + end + if (eTime(5) > 0) + strElapsed = [strElapsed num2str(eTime(5)) 'm ']; + end + % Time line + strElapsed = [strElapsed num2str(eTime(6)) 's']; +end + +%% ===== RESET CURRENT REPORT ===== +% USAGE: bst_report('Reset') +function Reset() + global GlobalData; + GlobalData.ProcessReports.Reports = {}; +end diff --git a/toolbox/process/functions/process_corr1n_time.m b/toolbox/process/deprecated/process_corr1n_time.m similarity index 100% rename from toolbox/process/functions/process_corr1n_time.m rename to toolbox/process/deprecated/process_corr1n_time.m diff --git a/toolbox/process/functions/process_add_tag.m b/toolbox/process/functions/process_add_tag.m index 8cfaf5710..60ee92865 100644 --- a/toolbox/process/functions/process_add_tag.m +++ b/toolbox/process/functions/process_add_tag.m @@ -186,6 +186,8 @@ OldFileName = file_fullpath(sInputs(i).FileName); [fPath, fBase, fExt] = bst_fileparts(OldFileName); NewFileName = bst_fullfile(fPath, [fBase, fileTag, fExt]); + % Ensure uniqueness + NewFileName = file_unique(NewFileName); OutputFiles{i} = file_short(NewFileName); end % Rename file diff --git a/toolbox/process/functions/process_adjust_coordinates.m b/toolbox/process/functions/process_adjust_coordinates.m index bf861a247..ec135d658 100644 --- a/toolbox/process/functions/process_adjust_coordinates.m +++ b/toolbox/process/functions/process_adjust_coordinates.m @@ -1,9 +1,10 @@ function varargout = process_adjust_coordinates(varargin) -% PROCESS_ADJUST_COORDINATES: Adjust, recompute, or remove various coordinate transformations. +% PROCESS_ADJUST_COORDINATES: Adjust, recompute, or remove various coordinate transformations, primarily for CTF MEG datasets. % -% Native coordinates are based on system fiducials (e.g. MEG head coils), -% whereas Brainstorm's SCS coordinates are based on the anatomical fiducial -% points from the .pos file. +% Native coordinates are based on system fiducials (e.g. MEG head coils), whereas Brainstorm's SCS +% coordinates are based on the anatomical fiducial points. After alignment between MRI and +% headpoints, the anatomical fiducials on the MRI side define the SCS and the ones in the channel +% files (ChannelMat.SCS) are ignored. % @============================================================================= % This function is part of the Brainstorm software: @@ -23,14 +24,13 @@ % For more information type "brainstorm license" at command prompt. % =============================================================================@ % -% Authors: Marc Lalancette, 2018-2020 +% Authors: Marc Lalancette, 2018-2022 eval(macro_method); end - -function sProcess = GetDescription() %#ok +function sProcess = GetDescription() % Description of the process sProcess.Comment = 'Adjust coordinate system'; sProcess.Description = 'https://neuroimage.usc.edu/brainstorm/Tutorials/HeadMotion#Adjust_the_reference_head_position'; @@ -42,7 +42,7 @@ sProcess.OutputTypes = {'raw', 'data'}; sProcess.nInputs = 1; sProcess.nMinFiles = 1; - % Option [to do: ignore bad segments] + % Option sProcess.options.reset.Type = 'checkbox'; sProcess.options.reset.Comment = 'Reset coordinates using original channel file (removes all adjustments: head, points, manual).'; sProcess.options.reset.Value = 0; @@ -60,10 +60,18 @@ sProcess.options.bad.Type = 'checkbox'; sProcess.options.bad.Comment = 'For adjust option, exclude bad segments.'; sProcess.options.bad.Value = 1; - sProcess.options.bad.Class = 'Adjust'; + sProcess.options.bad.Class = 'Adjust'; sProcess.options.points.Type = 'checkbox'; sProcess.options.points.Comment = 'Refine MRI coregistration using digitized head points.'; sProcess.options.points.Value = 0; + sProcess.options.points.Controller = 'Refine'; + sProcess.options.tolerance.Comment = 'Tolerance (outlier points to ignore):'; + sProcess.options.tolerance.Type = 'value'; + sProcess.options.tolerance.Value = {0, '%', 0}; + sProcess.options.tolerance.Class = 'Refine'; + sProcess.options.scs.Type = 'checkbox'; + sProcess.options.scs.Comment = 'Replace MRI nasion and ear points with digitized landmarks (cannot undo).'; + sProcess.options.scs.Value = 0; sProcess.options.remove.Type = 'checkbox'; sProcess.options.remove.Comment = 'Remove selected adjustments (if present) instead of adding them.'; sProcess.options.remove.Value = 0; @@ -82,7 +90,7 @@ function OutputFiles = Run(sProcess, sInputs) - + OutputFiles = {}; isDisplay = sProcess.options.display.Value; nInFiles = length(sInputs); @@ -93,25 +101,58 @@ bst_memory('UnloadAll', 'Forced'); % Close all the existing figures. end - [UniqueChan, iUniqFiles, iUniqInputs] = unique({sInputs.ChannelFile}); + [~, iUniqFiles, iUniqInputs] = unique({sInputs.ChannelFile}); nFiles = numel(iUniqFiles); - if ~sProcess.options.remove.Value && sProcess.options.head.Value && ... - nFiles < nInFiles + % Special cases when replacing MRI fids: don't allow resetting or refining with head points, + % probably a user mistake. Still possible if done in two separate calls. Adjusting head position + % ignored with a warning only (would be lost anyway). + % Also only a single channel file per subject is allowed. + if ~sProcess.options.remove.Value && sProcess.options.scs.Value + % TODO: how to go back to process panel, like for missing TF options in some processes? + if sProcess.options.reset.Value + bst_report('Error', sProcess, sInputs, ... + ['Incompatible options: "Reset coordinates" would be applied first and remove coregistration adjustments.' 10, ... + '"Replace MRI landmarks" automatically resets all channel files for selected subject(s) after MRI update.']); + return; + end + if sProcess.options.points.Value + bst_report('Error', sProcess, sInputs, ... + ['Incompatible options: "Refine with head points" not currently allowed in same call as' 10, ... + '"Replace MRI landmarks" to avoid user error and possibly loosing manual coregistration adjustments.' 10, ... + 'If you really want to do this, run this process separately for each operation.']); + return; + end + if sProcess.options.head.Value + bst_report('Warning', sProcess, sInputs, ... + ['Incompatible options: "Adjust head position" ignored since it would be lost after MRI update.' 10, ... + '"Replace MRI landmarks" automatically resets all channel files for selected subject(s) after MRI update.']); + sProcess.options.head.Value = 0; + end + + % Check for multiple channel files per subject. + UniqueSubs = unique({sInputs(iUniqFiles).SubjectFile}); + if numel(UniqueSubs) < nFiles + bst_report('Error', sProcess, sInputs, ... + '"Replace MRI landmarks" can only be run with a single channel file per subject.'); + return; + end + end + + if ~sProcess.options.remove.Value && sProcess.options.head.Value && nFiles < nInFiles bst_report('Info', sProcess, sInputs, ... 'Multiple inputs were found for a single channel file. They will be concatenated for adjusting the head position.'); end - bst_progress('start', 'Adjust coordinate system', ... - ' ', 0, nFiles); - % If resetting, in case the original data moved, and because the same - % channel file may appear in many places for processed data, keep track - % of user file selections. + + bst_progress('start', 'Adjust coordinate system', ' ', 0, nFiles); + % If resetting, in case the original data moved, and because the same channel file may appear in + % many places for processed data, keep track of user file selections. NewChannelFiles = cell(0, 2); for iFile = iUniqFiles(:)' % no need to repeat on same channel file. ChannelMat = in_bst_channel(sInputs(iFile).ChannelFile); % Get the leading modality - [tmp, DispMod] = channel_get_modalities(ChannelMat.Channel); + [~, DispMod] = channel_get_modalities(ChannelMat.Channel); % 'MEG' is added when 'MEG GRAD' or 'MEG MAG'. ModPriority = {'MEG', 'EEG', 'SEEG', 'ECOG', 'NIRS'}; iMod = find(ismember(ModPriority, DispMod), 1, 'first'); @@ -132,52 +173,69 @@ % ---------------------------------------------------------------- if sProcess.options.reset.Value - % The main goal of this option is to fix a bug in a previous - % version: when importing a channel file, when going to SCS - % coordinates based on digitized coils and anatomical - % fiducials, the channel orientation was wrong. We wish to fix - % this but keep as much pre-processing that was previously - % done. Thus we will re-import the channel file, and copy the - % projectors (and history) from the old one. + % The original goal of this option was to fix data affected by a previous bug while + % keeping as much pre-processing that was previously done. We re-import the channel + % file, and copy the projectors (and history) from the old one. - [ChannelMat, NewChannelFiles, Failed] = ... - ResetChannelFile(ChannelMat, NewChannelFiles, sInputs(iFile), sProcess); - if Failed + [ChannelMat, NewChannelFiles, isError] = ResetChannelFile(ChannelMat, ... + NewChannelFiles, sInputs(iFile), sProcess); + if isError continue; end % ---------------------------------------------------------------- elseif sProcess.options.remove.Value - % Because channel_align_manual does not consistently apply the - % manual transformation to all sensors or save it in both - % TransfMeg and TransfEeg, it could lead to confusion and - % errors when playing with transforms. Therefore, if we detect - % a difference between the MEG and EEG transforms when trying - % to remove one that applies to both (currently only refine - % with head points), we don't proceed and recommend resetting - % with the original channel file instead. + % Because channel_align_manual does not consistently apply the manual transformation to + % all sensors or save it in both TransfMeg and TransfEeg, it could lead to confusion and + % errors when playing with transforms. Therefore, if we detect a difference between the + % MEG and EEG transforms when trying to remove one that applies to both (currently only + % refine with head points), we don't proceed and recommend resetting with the original + % channel file instead. Which = {}; if sProcess.options.head.Value - Which{end+1} = 'AdjustedNative'; + Which{end+1} = 'AdjustedNative'; %#ok end if sProcess.options.points.Value - Which{end+1} = 'refine registration: head points'; + Which{end+1} = 'refine registration: head points'; %#ok end for TransfLabel = Which - TransfLabel = TransfLabel{1}; + TransfLabel = TransfLabel{1}; %#ok ChannelMat = RemoveTransformation(ChannelMat, TransfLabel, sInputs(iFile), sProcess); end % TransfLabel loop + + % We cannot change back the MRI fiducials, but in order to be able to update it again + % from digitized fids without warnings, edit the MRI history. + if sProcess.options.scs.Value + % Get subject in database, with subject directory + sSubject = bst_get('Subject', sInputs(iFile).SubjectFile); + MriFile = sSubject.Anatomy(sSubject.iAnatomy).FileName; + sMri = in_mri_bst(sSubject.Anatomy(sSubject.iAnatomy).FileName); + % Slightly change the string we use to verify if it was done: append " (hidden)". + iHist = find(strcmpi(sMri.History(:,3), 'Applied digitized anatomical fiducials')); + if ~isempty(iHist) + for iH = 1:numel(iHist) + sMri.History{iHist(iH),3} = [sMri.History{iHist(iH),3}, ' (hidden)']; + end + try + bst_save(file_fullpath(MriFile), sMri, 'v7'); + catch + bst_report('Error', sProcess, sInputs(iFile), ... + sprintf('Unable to save MRI file %s.', MriFile)); + continue; + end + end + end end % reset channel file or remove transformations % ---------------------------------------------------------------- if ~sProcess.options.remove.Value && sProcess.options.head.Value % Complex indexing to get all inputs for this same channel file. - [ChannelMat, Failed] = AdjustHeadPosition(ChannelMat, ... + [ChannelMat, isError] = AdjustHeadPosition(ChannelMat, ... sInputs(iUniqInputs == iUniqInputs(iFile)), sProcess); - if Failed + if isError continue; end end % adjust head position @@ -187,23 +245,57 @@ % Redundant, but makes sense to have it here also. bst_progress('text', 'Fitting head surface to points...'); - [ChannelMat, R, T, isSkip] = ... - channel_align_auto(sInputs(iFile).ChannelFile, ChannelMat, 0, 0); % No warning or confirmation - % ChannelFile needed to find subject and scalp surface, but not - % used otherwise when ChannelMat is provided. - if isSkip - bst_report('Error', sProcess, sInputs(iFile), ... - 'Error trying to refine registration using head points.'); + % If called externally without a tolerance value, set isWarning true so it asks. + if isempty(sProcess.options.tolerance.Value) + isWarning = true; + Tolerance = 0; + else + isWarning = false; + Tolerance = sProcess.options.tolerance.Value{1} / 100; + end + [ChannelMat, ~, ~, isSkip, isUserCancel, strReport] = channel_align_auto(sInputs(iFile).ChannelFile, ... + ChannelMat, isWarning, 0, Tolerance); % No confirmation + % ChannelFile needed to find subject and scalp surface, but not used otherwise when + % ChannelMat is provided. + if ~isempty(strReport) + bst_report('Info', sProcess, sInputs(iFile), strReport); + elseif isSkip + bst_report('Warning', sProcess, sInputs(iFile), ... + 'Refine registration using head points, failed finding a better fit.'); continue; + elseif isUserCancel + bst_report('Info', sProcess, sInputs(iFile), 'User cancelled registration with head points.'); + continue end end % refine registration with head points % ---------------------------------------------------------------- - % Save channel file. + % Save channel file. + % Before potiential MRI update since that function takes ChannelFile, not ChannelMat. bst_save(file_fullpath(sInputs(iFile).ChannelFile), ChannelMat, 'v7'); - isFileOk(iFile) = true; + % ---------------------------------------------------------------- + if ~sProcess.options.remove.Value && sProcess.options.scs.Value + % This is now a subfunction in this process + [~, isCancel, isError] = channel_align_scs(sInputs(iFile).ChannelFile, eye(4), ... + true, false, sInputs(iFile), sProcess); % interactive warnings but no confirmation + % Head surface was unloaded from memory, in case we want to display "after" figure. + % TODO Maybe modify like channel_align_auto to take in and return ChannelMat. + if isError + return; + elseif isCancel + continue; + end + % If something happened and channel files were not reset, there was a pop-up error and + % we'll just let the user deal with it for now (i.e. try again to reset the channel file). + + % TODO Verify + end + + % ---------------------------------------------------------------- + isFileOk(iFile) = true; + if isDisplay && ~isempty(Modality) % Display "after" results, besides the "before" figure. hFigAfter = channel_align_manual(sInputs(iFile).ChannelFile, Modality, 0); @@ -212,10 +304,9 @@ end % file loop bst_progress('stop'); - % Return the input files that were processed properly. Include those - % that were removed due to sharing a channel file, where appropriate. - % The complicated indexing picks the first input of those with the same - % channel file, i.e. the one that was marked ok. + % Return the input files that were processed properly. Include those that were removed due to + % sharing a channel file, where appropriate. The complicated indexing picks the first input of + % those with the same channel file, i.e. the one that was marked ok. OutputFiles = {sInputs(isFileOk(iUniqInputs(iUniqFiles))).FileName}; end @@ -266,21 +357,32 @@ % end -function [ChannelMat, NewChannelFiles, Failed] = ... - ResetChannelFile(ChannelMat, NewChannelFiles, sInput, sProcess) - if nargin < 4 +function [ChannelMat, NewChannelFiles, isError] = ResetChannelFile(ChannelMat, NewChannelFiles, sInput, sProcess) + % Reload a channel file, but keep projectors and history. First look for original file from + % history, and if it's no longer there, user will be prompted. User selections are noted as + % pairs {old, new} in NewChannelFiles for potential reuse (e.g. same original data at multiple + % pre-processing steps). + % This function does not save the file, but only returns the updated structure. + if nargin < 4 || isempty(sProcess) sProcess = []; + isReport = false; + else + isReport = true; + end + if nargin < 3 + sInput = []; end - Failed = false; + if nargin < 2 || isempty(NewChannelFiles) + NewChannelFiles = cell(0,2); + end + isError = false; bst_progress('text', 'Importing channel file...'); % Extract original data file from channel file history. - if any(size(ChannelMat.History) < [1, 3]) || ... - ~strcmp(ChannelMat.History{1, 2}, 'import') + if any(size(ChannelMat.History) < [1, 3]) || ~strcmp(ChannelMat.History{1, 2}, 'import') NotFound = true; ChannelFile = ''; else - ChannelFile = regexp(ChannelMat.History{1, 3}, ... - '(?<=: )(.*)(?= \()', 'match'); + ChannelFile = regexp(ChannelMat.History{1, 3}, '(?<=: )(.*)(?= \()', 'match'); if isempty(ChannelFile) NotFound = true; else @@ -298,19 +400,26 @@ if NewFound ChannelFile = NewChannelFiles{iNew, 2}; NotFound = false; - bst_report('Info', sProcess, sInput, ... - sprintf('Using channel file in new location: %s.', ChannelFile)); + if isReport + bst_report('Info', sProcess, sInput, ... + sprintf('Using channel file in new location: %s.', ChannelFile)); + end end end - FileFormatsChan = bst_get('FileFilters', 'channel'); - FileFormat = FileFormatsChan{sProcess.options.format.Value{1}, 3}; + if isfield(sProcess, 'options') && isfield(sProcess.options, 'format') + FileFormatsChan = bst_get('FileFilters', 'channel'); + FileFormat = FileFormatsChan{sProcess.options.format.Value{1}, 3}; + else + FileFormat = []; + end if NotFound - bst_report('Info', sProcess, sInput, ... - sprintf('Could not find original channel file: %s.', ChannelFile)); - % import_channel will prompt the user, but they will not - % know which file to pick! And prompt is modal for Matlab, - % so likely can't look at command window (e.g. if - % Brainstorm is in front). + if isReport + bst_report('Info', sProcess, sInput, ... + sprintf('Could not find original channel file: %s.', ChannelFile)); + end + % import_channel will prompt the user, but they would not know which file to pick! And + % prompt is modal for Matlab, so likely can't look at command window (e.g. if Brainstorm is + % in front). So add another pop-up with the needed info. [ChanPath, ChanName, ChanExt] = fileparts(ChannelFile); MsgFig = msgbox(sprintf('Select the new location of channel file %s %s to reset %s.', ... ChanPath, [ChanName, ChanExt], sInput.ChannelFile), ... @@ -318,17 +427,17 @@ movegui(MsgFig, 'north'); figure(MsgFig); % bring it to front. % Adjust default format to the one selected. - DefaultFormats = bst_get('DefaultFormats'); - DefaultFormats.ChannelIn = FileFormat; - bst_set('DefaultFormats', DefaultFormats); + if ~isempty(FileFormat) + DefaultFormats = bst_get('DefaultFormats'); + DefaultFormats.ChannelIn = FileFormat; + bst_set('DefaultFormats', DefaultFormats); + end - [NewChannelMat, NewChannelFile] = import_channel(... - sInput.iStudy, '', FileFormat, 0, 0, 0, [], []); + [NewChannelMat, NewChannelFile] = import_channel(sInput.iStudy, '', FileFormat, 0, 0, 0, [], []); else % Import from original file. - [NewChannelMat, NewChannelFile] = import_channel(... - sInput.iStudy, ChannelFile, FileFormat, 0, 0, 0, [], []); + [NewChannelMat, NewChannelFile] = import_channel(sInput.iStudy, ChannelFile, FileFormat, 0, 0, 0, [], []); % iStudies, ChannelFile, FileFormat, ChannelReplace, ChannelAlign, isSave, isFixUnits, isApplyVox2ras) % iStudy index is needed to avoid error for noise recordings with missing SCS transform. % ChannelReplace is for replacing the file, only if isSave. @@ -337,31 +446,42 @@ % See if it worked. if isempty(NewChannelFile) - bst_report('Error', sProcess, sInput, ... - 'No file channel file selected.'); - Failed = true; + if isReport + bst_report('Error', sProcess, sInput, 'No channel file selected.'); + else + bst_error('No channel file selected.'); + end + isError = true; return; elseif isempty(NewChannelMat) - bst_report('Error', sProcess, sInput, ... - sprintf('Unable to import channel file: %s', NewChannelFile)); - Failed = true; + if isReport + bst_report('Error', sProcess, sInput, sprintf('Unable to import channel file: %s', NewChannelFile)); + else + bst_error(sprintf('Unable to import channel file: %s', NewChannelFile)); + end + isError = true; return; elseif numel(NewChannelMat.Channel) ~= numel(ChannelMat.Channel) - bst_report('Error', sProcess, sInput, ... - 'Original channel file has different channels than current one, aborting.'); - Failed = true; + if isReport + bst_report('Error', sProcess, sInput, ... + 'Original channel file has different channels than current one, aborting.'); + else + bst_error('Original channel file has different channels than current one, aborting.'); + end + isError = true; return; elseif NotFound && ~isempty(ChannelFile) % Save the selected new location. NewChannelFiles(end+1, :) = {ChannelFile, NewChannelFile}; end - % Copy the new old projectors and history to the new structure. + % Copy the old projectors and history to the new structure. NewChannelMat.Projector = ChannelMat.Projector; NewChannelMat.History = ChannelMat.History; ChannelMat = NewChannelMat; - % clear NewChannelMat + % Add number of channels to comment, like in db_set_channel. ChannelMat.Comment = [ChannelMat.Comment, sprintf(' (%d)', length(ChannelMat.Channel))]; + % Add history ChannelMat = bst_history('add', ChannelMat, 'import', ... ['Reset from: ' NewChannelFile ' (Format: ' FileFormat ')']); end % ResetChannelFile @@ -380,9 +500,8 @@ % Need to check for empty, otherwise applies to all channels! else iChan = []; % All channels. - % Note: NIRS doesn't have a separate set of - % transformations, but "refine" and "SCS" are applied - % to NIRS as well. + % Note: NIRS doesn't have a separate set of transformations, but "refine" and "SCS" are + % applied to NIRS as well. end while ~isempty(iUndoMeg) if isMegOnly && isempty(iChan) @@ -441,8 +560,8 @@ end % RemoveTransformation -function [ChannelMat, Failed] = AdjustHeadPosition(ChannelMat, sInputs, sProcess) - Failed = false; +function [ChannelMat, isError] = AdjustHeadPosition(ChannelMat, sInputs, sProcess) + isError = false; % Check the input is CTF. isRaw = (length(sInputs(1).FileName) > 9) && ~isempty(strfind(sInputs(1).FileName, 'data_0raw')); if isRaw @@ -453,13 +572,12 @@ if ~strcmp(DataMat.Device, 'CTF') bst_report('Error', sProcess, sInputs, ... 'Adjust head position is currently only available for CTF data.'); - Failed = true; + isError = true; return; end - % The data could be changed such that the head position could be - % readjusted (e.g. by deleting segments). This is allowed and the - % previous adjustment will be replaced. + % The data could be changed such that the head position could be readjusted (e.g. by deleting + % segments). This is allowed and the previous adjustment will be replaced. if isfield(ChannelMat, 'TransfMegLabels') && iscell(ChannelMat.TransfMegLabels) && ... ismember('AdjustedNative', ChannelMat.TransfMegLabels) bst_report('Info', sProcess, sInputs, ... @@ -477,7 +595,7 @@ [Locs, HeadSamplePeriod] = process_evt_head_motion('LoadHLU', sInputs(iIn), [], false); if isempty(Locs) % No HLU channels. Error already reported. Skip this file. - Failed = true; + isError = true; return; end % Exclude all bad segments. @@ -513,7 +631,7 @@ Locs(:, ismember(iHeadSamples, iBad)) = []; end end - Locations = [Locations, Locs]; + Locations = [Locations, Locs]; %#ok end % If a collection was aborted, the channels will be filled with zeros. Remove these. @@ -523,55 +641,46 @@ MedianLoc = MedianLocation(Locations); % disp(MedianLoc); - % Also get the initial reference position. We only use it to see - % how much the adjustment moves. + % Also get the initial reference position. We only use it to estimate how much the adjustment moves. InitRefLoc = ReferenceHeadLocation(ChannelMat, sInputs); if isempty(InitRefLoc) % There was an error, already reported. Skip this file. - Failed = true; + isError = true; return; end - % Extract transformations that are applied before and after the - % head position adjustment. Any previous adjustment will be - % ignored here and replaced later. + % Extract transformations that are applied before and after the head position adjustment. Any + % previous adjustment will be ignored here and replaced later. [TransfBefore, TransfAdjust, TransfAfter, iAdjust, iDewToNat] = ... GetTransforms(ChannelMat, sInputs); if isempty(TransfBefore) % There was an error, already reported. Skip this file. - Failed = true; + isError = true; return; end % Compute transformation corresponding to coil position. [TransfMat, TransfAdjust] = LocationTransform(MedianLoc, ... TransfBefore, TransfAdjust, TransfAfter); - % This TransfMat would automatically give an identity - % transformation if the process is run multiple times, and - % TransfAdjust would not change. - - % Apply this transformation to the current head position. - % This is a correction to the 'Dewar=>Native' - % transformation so it applies to MEG channels only and not - % to EEG or head points, which start in Native. + % This TransfMat would automatically give an identity transformation if the process is run + % multiple times, and TransfAdjust would not change. + + % Apply this transformation to the current head position. This is a correction to the + % 'Dewar=>Native' transformation so it applies to MEG channels only and not to EEG or head + % points, which start in Native. iMeg = sort([good_channel(ChannelMat.Channel, [], 'MEG'), ... good_channel(ChannelMat.Channel, [], 'MEG REF')]); ChannelMat = channel_apply_transf(ChannelMat, TransfMat, iMeg, false); % Don't apply to head points. ChannelMat = ChannelMat{1}; - % After much thought, it was decided to save this - % adjustment transformation separately and at its logical - % place: between 'Dewar=>Native' and - % 'Native=>Brainstorm/CTF'. In particular, this allows us - % to use it directly when displaying head motion distance. - % This however means we must correctly move the - % transformation from the end where it was just applied to - % its logical place. This "moved" transformation is also - % computed in LocationTransform above. + % After much thought, it was decided to save this adjustment transformation separately and at + % its logical place: between 'Dewar=>Native' and 'Native=>Brainstorm/CTF'. In particular, this + % allows us to use it directly when displaying head motion distance. This however means we must + % correctly move the transformation from the end where it was just applied to its logical place. + % This "moved" transformation is also computed in LocationTransform above. if isempty(iAdjust) iAdjust = iDewToNat + 1; - % Shift transformations to make room for the new - % adjustment, and reject the last one, that we just - % applied. + % Shift transformations to make room for the new adjustment, and reject the last one, that + % we just applied. ChannelMat.TransfMegLabels(iDewToNat+2:end) = ... ChannelMat.TransfMegLabels(iDewToNat+1:end-1); % reject last one ChannelMat.TransfMeg(iDewToNat+2:end) = ChannelMat.TransfMeg(iDewToNat+1:end-1); @@ -591,24 +700,22 @@ AfterRefLoc = ReferenceHeadLocation(ChannelMat, sInputs); if isempty(AfterRefLoc) % There was an error, already reported. Skip this file. - Failed = true; + isError = true; return; end DistanceAdjusted = process_evt_head_motion('RigidDistances', AfterRefLoc, InitRefLoc); fprintf('Head position adjusted by %1.1f mm.\n', DistanceAdjusted * 1e3); bst_report('Info', sProcess, sInputs, ... - sprintf('Head position adjusted by %1.1f mm.\n', DistanceAdjusted * 1e3)); + sprintf('Head position adjusted by %1.1f mm.', DistanceAdjusted * 1e3)); end % AdjustHeadPosition - function [InitLoc, Message] = ReferenceHeadLocation(ChannelMat, sInput) % Compute initial head location in Dewar coordinates. - % Here we want to recreate the correct triangle shape from the relative - % head coil locations and in the position saved as the reference - % (initial) head position according to Brainstorm coordinate - % transformation matrices. + % Here we want to recreate the correct triangle shape from the relative head coil locations and + % in the position saved as the reference (initial) head position according to Brainstorm + % coordinate transformation matrices. if nargin < 2 sInput = []; @@ -616,23 +723,24 @@ sInput = sInput(1); end Message = ''; - - % These aren't exactly the coil positions in the .hc file, which are not saved - % anywhere in Brainstorm, but was verified to give the same transformation. - % The SCS coil coordinates are from the digitized coil positions. - if isfield(ChannelMat, 'SCS') && all(isfield(ChannelMat.SCS, {'NAS','LPA','RPA'})) && ... - (length(ChannelMat.SCS.NAS) == 3) && (length(ChannelMat.SCS.LPA) == 3) && (length(ChannelMat.SCS.RPA) == 3) - % % Use the SCS distances from origin, with left and right PA points symmetrical. - % LeftRightDist = sqrt(sum((ChannelMat.SCS.LPA - ChannelMat.SCS.RPA).^2)); - % NasDist = ChannelMat.SCS.NAS(1); - InitLoc = [ChannelMat.SCS.NAS(:), ChannelMat.SCS.LPA(:), ChannelMat.SCS.RPA(:); ones(1, 3)]; - elseif ~isempty(sInput) && isfield(sInput, 'header') && isfield(sInput.header, 'hc') && isfield(sInput.header.hc, 'SCS') && ... + + % From recent investigations, digitized locations are probably not as robust/accurate as those + % measured by the MEG. So use the .hc positions if available. + if ~isempty(sInput) && isfield(sInput, 'header') && isfield(sInput.header, 'hc') && isfield(sInput.header.hc, 'SCS') && ... all(isfield(sInput.header.hc.SCS, {'NAS','LPA','RPA'})) && length(sInput.header.hc.SCS.NAS) == 3 % Initial head coil locations from the CTF .hc file, but in dewar coordinates, NOT in SCS coordinates! InitLoc = [sInput.header.hc.SCS.NAS(:), sInput.header.hc.SCS.LPA(:), sInput.header.hc.SCS.RPA(:)]; % 3x3 by columns InitLoc = InitLoc(:); return; - %InitLoc = TransfAdjust * TransfBefore * [InitLoc; ones(1, 3)]; + % ChannelMat.SCS are not the coil positions in the .hc file, which are not saved in Brainstorm, + % but the digitized coil positions, if present. However, both are saved in "Native" coordinates + % and thus give the same transformation. + elseif isfield(ChannelMat, 'SCS') && all(isfield(ChannelMat.SCS, {'NAS','LPA','RPA'})) && ... + (length(ChannelMat.SCS.NAS) == 3) && (length(ChannelMat.SCS.LPA) == 3) && (length(ChannelMat.SCS.RPA) == 3) + % % Use the SCS distances from origin, with left and right PA points symmetrical. + % LeftRightDist = sqrt(sum((ChannelMat.SCS.LPA - ChannelMat.SCS.RPA).^2)); + % NasDist = ChannelMat.SCS.NAS(1); + InitLoc = [ChannelMat.SCS.NAS(:), ChannelMat.SCS.LPA(:), ChannelMat.SCS.RPA(:); ones(1, 3)]; else % Just use some reasonable distances, with a warning. Message = 'Exact reference head coil locations not available. Using reasonable (adult) locations according to head position.'; @@ -643,11 +751,9 @@ % InitLoc above is in Native coordiates (if pre head loc didn't fail). % Bring it back to Dewar coordinates to compare with HLU channels. % - % Take into account if the initial/reference head position was - % "adjusted", i.e. replaced by the median position throughout the - % recording. If so, use all transformations from 'Dewar=>Native' to - % this adjustment transformation. (In practice there shouldn't be any - % between them.) + % Take into account if the initial/reference head position was "adjusted", i.e. replaced by the + % median position throughout the recording. If so, use all transformations from 'Dewar=>Native' + % to this adjustment transformation. (In practice there shouldn't be any between them.) [TransfBefore, TransfAdjust] = GetTransforms(ChannelMat, sInput); InitLoc = TransfBefore \ (TransfAdjust \ InitLoc); InitLoc(4, :) = []; @@ -655,25 +761,25 @@ end % ReferenceHeadLocation -function [TransfBefore, TransfAdjust, TransfAfter, iAdjust, iDewToNat] = ... - GetTransforms(ChannelMat, sInputs) - % Extract transformations that are applied before and after the head - % position adjustment we are creating now. We keep the 'Dewar=>Native' - % transformation intact and separate from the adjustment for no deep - % reason, but it is the only remaining trace of the initial head coil +function [TransfBefore, TransfAdjust, TransfAfter, iAdjust, iDewToNat] = GetTransforms(ChannelMat, sInputs) + % Extract transformations that are applied before and after the head position adjustment we are + % creating now. We keep the 'Dewar=>Native' transformation intact and separate from the + % adjustment for no deep reason, but it is the only remaining trace of the initial head coil % positions in Brainstorm. - % The reason this function was split from LocationTransform is that it - % can be called only once outside the head sample loop in process_sss, - % whereas LocationTransform is called many times within the loop. + % The reason this function was split from LocationTransform is that it can be called only once + % outside the head sample loop in process_sss, whereas LocationTransform is called many times + % within the loop. - % When this is called from process_sss, we are possibly working on a - % second head adjustment, this time based on the instantaneous head - % position, so we need to keep the global adjustment based on the entire - % recording if it is there. + % When this is called from process_sss, we are possibly working on a second head adjustment, + % this time based on the instantaneous head position, so we need to keep the global adjustment + % based on the entire recording if it is there. - % Check order of transformations. These situations should not happen - % unless there was some manual editing. + if nargin < 2 + sInputs = []; + end + % Check order of transformations. These situations should not happen unless there was some + % manual editing. iDewToNat = find(strcmpi(ChannelMat.TransfMegLabels, 'Dewar=>Native')); iAdjust = find(strcmpi(ChannelMat.TransfMegLabels, 'AdjustedNative')); TransfBefore = []; @@ -724,11 +830,9 @@ end % GetTransforms -function [TransfMat, TransfAdjust] = LocationTransform(Loc, ... - TransfBefore, TransfAdjust, TransfAfter) - % Compute transformation corresponding to head coil positions. - % We want this to be as efficient as possible, since used many times by - % process_sss. +function [TransfMat, TransfAdjust] = LocationTransform(Loc, TransfBefore, TransfAdjust, TransfAfter) + % Compute transformation corresponding to head coil positions. We want this to be as efficient + % as possible, since used many times by process_sss. % Check for previous version. if nargin < 4 @@ -736,10 +840,10 @@ end % Transformation matrices are in m, as are HLU channels. - % The HLU channels (here Loc) are in dewar coordinates. Bring them to - % the current system by applying all saved transformations, starting with - % 'Dewar=>Native'. This will save us from having to use inverse - % transformations later. + % + % The HLU channels (here Loc) are in dewar coordinates. Bring them to the current system by + % applying all saved transformations, starting with 'Dewar=>Native'. This will save us from + % having to use inverse transformations later. Loc = TransfAfter(1:3, :) * TransfAdjust * TransfBefore * [reshape(Loc, 3, 3); 1, 1, 1]; % [[Loc(1:3), Loc(4:6), Loc(5:9)]; 1, 1, 1]; % test if efficiency difference. @@ -759,14 +863,12 @@ TransfMat(1:3,1:3) = [X, Y, Z]'; TransfMat(1:3,4) = - [X, Y, Z]' * Origin; - % TransfMat at this stage is a transformation from the current system - % back to the now adjusted Native system. We thus need to reapply the - % following tranformations. + % TransfMat at this stage is a transformation from the current system back to the now adjusted + % Native system. We thus need to reapply the following tranformations. if nargout > 1 - % Transform from non-adjusted native coordinates to newly adjusted native - % coordinates. To be saved in channel file between "Dewar=>Native" and - % "Native=>Brainstorm/CTF". + % Transform from non-adjusted native coordinates to newly adjusted native coordinates. To + % be saved in channel file between "Dewar=>Native" and "Native=>Brainstorm/CTF". TransfAdjust = TransfMat * TransfAfter * TransfAdjust; end @@ -796,34 +898,19 @@ % % M = GeoMedian(X, Precision) % - % Calculate the geometric median: the point that minimizes sum of - % Euclidean distances to all points. size(X) = [n, d, ...], where n is - % the number of data points, d is the number of components for each point - % and any additional array dimension is treated as independent sets of - % data and a median is calculated for each element along those dimensions - % sequentially; size(M) = [1, d, ...]. This is an approximate iterative - % procedure that stops once the desired precision is achieved. If - % Precision is not provided, 1e-4 of the max distance from the centroid - % is used. - % - % Weiszfeld's algorithm is used, which is a subgradient algorithm; with - % (Verdi & Zhang 2001)'s modification to avoid non-optimal fixed points - % (if at any iteration the approximation of M equals a data point). - % - % - % (c) Copyright 2018 Marc Lalancette - % The Hospital for Sick Children, Toronto, Canada + % Calculate the geometric median: the point that minimizes sum of Euclidean distances to all + % points. size(X) = [n, d, ...], where n is the number of data points, d is the number of + % components for each point and any additional array dimension is treated as independent sets of + % data and a median is calculated for each element along those dimensions sequentially; size(M) + % = [1, d, ...]. This is an approximate iterative procedure that stops once the desired + % precision is achieved. If Precision is not provided, 1e-4 of the max distance from the + % centroid is used. % - % This file is part of a free repository of Matlab tools for MEG - % data processing and analysis . - % You can redistribute it and/or modify it under the terms of the GNU - % General Public License as published by the Free Software Foundation, - % either version 3 of the License, or (at your option) a later version. + % Weiszfeld's algorithm is used, which is a subgradient algorithm; with (Verdi & Zhang 2001)'s + % modification to avoid non-optimal fixed points (if at any iteration the approximation of M + % equals a data point). % - % This program is distributed WITHOUT ANY WARRANTY. - % See the LICENSE file, or for details. - % - % 2012-05 + % Marc Lalancette 2012-05 nDims = ndims(X); XSize = size(X); @@ -850,17 +937,15 @@ Precision = bsxfun(@rdivide, Precision, Scale); % Precision ./ Scale; % [1, 1, nSets] end - % Initial estimate: median in each dimension separately. Though this - % gives a chance of picking one of the data points, which requires - % special treatment. + % Initial estimate: median in each dimension separately. Though this gives a chance of picking + % one of the data points, which requires special treatment. M2 = median(X, 1); - % It might be better to calculate separately each independent set, - % otherwise, they are all iterated until the worst case converges. + % It might be better to calculate separately each independent set, otherwise, they are all + % iterated until the worst case converges. for s = 1:nSets - % For convenience, pick another point far enough so the loop will always - % start. + % For convenience, pick another point far enough so the loop will always start. M = bsxfun(@plus, M2(:, :, s), Precision(:, :, s)); % Iterate. while sum((M - M2(:, :, s)).^2 , 2) > Precision(s)^2 % any()scalar @@ -868,8 +953,7 @@ % Distances from M. % R = sqrt(sum( (M(ones(n, 1), :) - X(:, :, s)).^2 , 2 )); % [n, 1] R = sqrt(sum( bsxfun(@minus, M, X(:, :, s)).^2 , 2 )); % [n, 1] - % Find data points not equal to M, that we use in the computation - % below. + % Find data points not equal to M, that we use in the computation below. Good = logical(R); nG = sum(Good); if nG % > 0 @@ -883,10 +967,10 @@ end % New estimate. - % Note the possibility of D = 0 and (n - nG) = 0, in which case 0/0 - % should be 0, but here gives NaN, which the max function ignores, - % returning 0 instead of 1. This is fine however since this - % multiplies D (=0 in that case). + % + % Note the possibility of D = 0 and (n - nG) = 0, in which case 0/0 should be 0, but + % here gives NaN, which the max function ignores, returning 0 instead of 1. This is fine + % however since this multiplies D (=0 in that case). M2(:, :, s) = M - max(0, 1 - (n - nG)/sqrt(sum( D.^2 , 2 ))) * ... D / sum(1 ./ R, 1); end @@ -903,3 +987,516 @@ end % GeoMedian +function [AlignType, isMriUpdated, isMriMatch, isSessionMatch, ChannelMat] = CheckPrevAdjustments(ChannelMat, sMri) + % Flag if auto or manual registration performed, and if MRI fids updated. Print to command + % window for now, if no output arguments. + AlignType = []; + isMriUpdated = []; + isMriMatch = []; + isSessionMatch = []; + isPrint = nargout == 0; + if any(~isfield(ChannelMat, {'History', 'HeadPoints'})) + % Nothing to check. + return; + end + if nargin < 2 || isempty(sMri) || ~isfield(sMri, 'History') || isempty(sMri.History) + iMriHist = []; + else + % History string is set in figure_mri SaveMri. + iMriHist = find(strcmpi(sMri.History(:,3), 'Applied digitized anatomical fiducials'), 1, 'last'); + end + % Can also be reset, so check for 'import' action and ignore previous alignments. + iImport = find(strcmpi(ChannelMat.History(:,2), 'import')); + iAlign = find(strcmpi(ChannelMat.History(:,2), 'align')); + iAlign(iAlign < iImport(end)) = []; + if numel(iImport) > 1 + AlignType = 'none/reset'; + else + AlignType = 'none'; + end + while ~isempty(iAlign) + % Check which adjustment was done last. + switch lower(ChannelMat.History{iAlign(end),3}(1:5)) + case 'remov' % ['Removed transform: ' TransfLabel] + % Removed a previous step. Ignore corresponding adjustment and look again. + iAlign(end) = []; + if strncmpi(ChannelMat.History{iAlign(end),3}(20:24), 'AdjustedNative', 5) + iAlignRemoved = find(cellfun(@(c)strcmpi(c(1:5), 'added'), ChannelMat.History(iAlign,3)), 1, 'last'); + elseif strncmpi(ChannelMat.History{iAlign(end),3}(20:24), 'refine registration: head points', 5) + iAlignRemoved = find(cellfun(@(c)strcmpi(c(1:5), 'refin'), ChannelMat.History(iAlign,3)), 1, 'last'); + elseif strncmpi(ChannelMat.History{iAlign(end),3}(20:24), 'manual correction', 5) + iAlignRemoved = find(cellfun(@(c)strcmpi(c(1:5), 'align'), ChannelMat.History(iAlign,3)), 1, 'last'); + else + bst_error('Unrecognized removed transformation in history.'); + end + if isempty(iAlignRemoved) + bst_error('Missing removed transformation in history.'); + else + iAlign(iAlignRemoved) = []; + end + case 'added' % 'Added adjustment to Native coordinates based on median head position' + % This alignment is between points and functional dataset, ignore here. + iAlign(end) = []; + case 'refin' % 'Refining the registration using the head points:' + % Automatic MRI-points alignment + AlignType = 'auto'; + break; + case 'align' % 'Align channels manually:' + % Manual MRI-points alignment + AlignType = 'manual'; + break; + case 'non-l' % 'Non-linear transformation' + AlignType = 'non-linear'; + break; + otherwise + AlignType = 'unrecognized'; + break; + end + end + if isPrint + disp(['BST> Previous registration adjustment: ' AlignType]); + end + if ~isempty(iMriHist) + isMriUpdated = true; + % Compare digitized fids to MRI fids (in MRI coordinates, mm). ChannelMat.SCS fids are NOT + % kept up to date when adjusting registration (manual or auto), so get them from head points + % again. + % Get the three fiducials in the head points + ChannelMat = UpdateChannelMatScs(ChannelMat); + % Check if coordinates differ by more than 1 um. + if any(abs(sMri.SCS.NAS - cs_convert(sMri, 'scs', 'mri', ChannelMat.SCS.NAS) .* 1000) > 1e-3) || ... + any(abs(sMri.SCS.LPA - cs_convert(sMri, 'scs', 'mri', ChannelMat.SCS.LPA) .* 1000) > 1e-3) || ... + any(abs(sMri.SCS.RPA - cs_convert(sMri, 'scs', 'mri', ChannelMat.SCS.RPA) .* 1000) > 1e-3) + isMriMatch = false; + % Check if just different alignment, or if different set of fiducials (different + % session), using inter-fid distances. + DiffMri = [sMri.SCS.NAS - sMri.SCS.LPA, sMri.SCS.LPA - sMri.SCS.RPA, sMri.SCS.RPA - sMri.SCS.NAS]; + DiffChannel = [ChannelMat.SCS.NAS - ChannelMat.SCS.LPA, ChannelMat.SCS.LPA - ChannelMat.SCS.RPA, ChannelMat.SCS.RPA - ChannelMat.SCS.NAS]; + if any(abs(DiffMri - DiffChannel) > 1e-3) + isSessionMatch = false; + if isPrint + disp('BST> MRI fiducials previously updated, but different session than current digitized fiducials.'); + end + else + isSessionMatch = true; + if isPrint + disp('BST> MRI fiducials previously updated, same session but not aligned with current digitized fiducials.'); + end + end + else + isMriMatch = true; + isSessionMatch = true; + if isPrint + disp('BST> MRI fiducials previously updated, and match current digitized fiducials.'); + end + end + else + isMriUpdated = false; + isMriMatch = false; + isSessionMatch = false; + end + +end + + +function [DistHead, DistSens, Message] = CheckCurrentAdjustments(ChannelMat, ChannelMatRef) + % Display max displacement from registration adjustments, in command window. + % If second ChannelMat is provided as reference, get displacement between the two. + isPrint = nargout == 0; + if nargin < 2 || isempty(ChannelMatRef) + ChannelMatRef = []; + end + + % Update SCS from head points if present. + ChannelMat = UpdateChannelMatScs(ChannelMat); + + if ~isempty(ChannelMatRef) + ChannelMatRef = UpdateChannelMatScs(ChannelMatRef); + % For head displacement, we use the "rigid distance" from the head motion code, basically + % the max distance of any point on a simplified spherical head. + DistHead = process_evt_head_motion('RigidDistances', ... + [ChannelMat.SCS.NAS(:); ChannelMat.SCS.LPA(:); ChannelMat.SCS.RPA(:)], ... + [ChannelMatRef.SCS.NAS(:); ChannelMatRef.SCS.LPA(:); ChannelMatRef.SCS.RPA(:)]); + DistSens = max(sqrt(sum(([ChannelMat.Channel.Loc] - [ChannelMatRef.Channel.Loc]).^2))); + else + % Implicitly using actual (MRI) SCS as reference, this includes all adjustments. + DistHead = process_evt_head_motion('RigidDistances', ... + [ChannelMat.SCS.NAS(:); ChannelMat.SCS.LPA(:); ChannelMat.SCS.RPA(:)]); + % Get equivalent transform for all adjustments to "undo" on sensors for comparison. The + % adjustments we want come after 'Native=>Brainstorm/CTF' + iNatToScs = find(strcmpi(ChannelMat.TransfMegLabels, 'Native=>Brainstorm/CTF')); + if iNatToScs < numel(ChannelMat.TransfMeg) + Transf = eye(4); + for t = iNatToScs+1:numel(ChannelMat.TransfMeg) + Transf = ChannelMat.TransfMeg{t} * Transf; + end + Loc = [ChannelMat.Channel.Loc]; + % Inverse transf: subtract translation first, then rotate the "other way" (transpose). + LocRef = Transf(1:3,1:3)' * bsxfun(@minus, Loc, Transf(1:3,4)); + DistSens = max(sqrt(sum((Loc - LocRef).^2))); + else + DistSens = 0; + end + end + + Message = sprintf('BST> Max displacement for registration adjustment:\n head: %1.1f mm\n sensors: %1.1f cm\n', ... + DistHead*1000, DistSens*100); + if isPrint + fprintf(Message); + end + +end + + +function ChannelMat = UpdateChannelMatScs(ChannelMat) + if ~isfield(ChannelMat, 'HeadPoints') + return; + end + % Get the three anatomical fiducials in the head points + iNas = find(strcmpi(ChannelMat.HeadPoints.Label, 'Nasion') | strcmpi(ChannelMat.HeadPoints.Label, 'NAS')); + iLpa = find(strcmpi(ChannelMat.HeadPoints.Label, 'Left') | strcmpi(ChannelMat.HeadPoints.Label, 'LPA')); + iRpa = find(strcmpi(ChannelMat.HeadPoints.Label, 'Right') | strcmpi(ChannelMat.HeadPoints.Label, 'RPA')); + if ~isempty(iNas) && ~isempty(iLpa) && ~isempty(iRpa) + ChannelMat.SCS.NAS = mean(ChannelMat.HeadPoints.Loc(:,iNas)', 1); %#ok<*UDIM> + ChannelMat.SCS.LPA = mean(ChannelMat.HeadPoints.Loc(:,iLpa)', 1); + ChannelMat.SCS.RPA = mean(ChannelMat.HeadPoints.Loc(:,iRpa)', 1); + end + % Do the same with head coils, used when exporting coregistration to BIDS + iHpiN = find(strcmpi(ChannelMat.HeadPoints.Label, 'HPI-N')); + iHpiL = find(strcmpi(ChannelMat.HeadPoints.Label, 'HPI-L')); + iHpiR = find(strcmpi(ChannelMat.HeadPoints.Label, 'HPI-R')); + if ~isempty(iHpiN) && ~isempty(iHpiL) && ~isempty(iHpiR) + % Temporarily put the head coils there to calculate transform. + ChannelMat.Native.NAS = mean(ChannelMat.HeadPoints.Loc(:,iHpiN)', 1); + ChannelMat.Native.LPA = mean(ChannelMat.HeadPoints.Loc(:,iHpiL)', 1); + ChannelMat.Native.RPA = mean(ChannelMat.HeadPoints.Loc(:,iHpiR)', 1); + % Get "current" SCS to Native transformation. + TmpChanMat = ChannelMat; + TmpChanMat.SCS = ChannelMat.Native; + % cs_compute doesn't change coordinates, only adds the R,T,Origin fields + [~, TmpChanMat] = cs_compute(TmpChanMat, 'scs'); + ChannelMat.Native = TmpChanMat.SCS; + % Now apply the transform to the digitized anat fiducials. These are not used anywhere yet, + % only the transform, but might as well be consistent and save the same points as in .SCS. + % But still in meters, not cm. + ChannelMat.Native.NAS(:) = [ChannelMat.Native.R, ChannelMat.Native.T] * [ChannelMat.SCS.NAS'; 1]; + ChannelMat.Native.LPA(:) = [ChannelMat.Native.R, ChannelMat.Native.T] * [ChannelMat.SCS.LPA'; 1]; + ChannelMat.Native.RPA(:) = [ChannelMat.Native.R, ChannelMat.Native.T] * [ChannelMat.SCS.RPA'; 1]; + else + % Missing digitized MEG head coils, probably the anatomical points are actually coils. + disp('BST> Missing digitized MEG head coils, NAS/LPA/RPA are likely head coils.'); + ChannelMat.Native = ChannelMat.SCS; + end +end + + +% Decided to bring this back as subfunction of this process, as it is the only place to run it from for now. +function [Transform, isCancel, isError] = channel_align_scs(ChannelFile, Transform, isInteractive, isConfirm, sInput, sProcess) +% CHANNEL_ALIGN_SCS: Saves new MRI anatomical points after manual or auto registration adjustment. +% +% USAGE: Transform = channel_align_scs(ChannelFile, Transform=eye(4), isInteractive=1, isConfirm=1) +% +% DESCRIPTION: +% After modifying registration between digitized head points and MRI (with "refine with head +% points" or manually), this function allows saving the change in the MRI fiducials so that +% they exactly match the digitized anatomical points (nasion and ears). This would replace +% having to save a registration adjustment transformation for each functional dataset sharing +% this set of digitized points. This affects all files registered to the MRI and should +% therefore be done as one of the first steps after importing, and with only one set of +% digitized points (one session). Surfaces are adjusted to maintain alignment with the MRI. +% Additional sessions for the same Brainstorm subject, with separate digitized points, will +% still need the usual "per dataset" registration adjustment to align with the same MRI. +% +% This function will not modify an MRI that it changed previously without user confirmation +% (if both isInteractive and isConfirm are false). In that case, Transform is returned unaltered. +% +% INPUTS: +% - ChannelFile : Channel file to align with its anatomy +% - Transform : Transformation matrix from digitized SCS coordinates to MRI SCS coordinates, +% after some alignment is made (auto or manual) and the two no longer match. +% This transform should not already be saved in the ChannelFile, though the +% file may already contain similar adjustments, in which case Transform would be +% an additional adjustment to add. (This will typically be empty or identity, it +% was intended for calling from manual alignment panel, but now done after.) +% - isInteractive : If true, display dialog in case of errors, or if this was already done +% previously for this MRI. +% - isConfirm : If true, ask the user for confirmation before proceeding. +% +% OUTPUTS: +% - Transform : If the MRI fiducial points and coordinate system are updated, the transform +% becomes the identity. If the MRI was not updated, the input Transform is +% returned. The idea is that the returned Transform applied to the *reset* +% channels would maintain the registration. If channel files were not reset +% (error or cancellation in this function), this will no longer be true and the +% user should verify the registration of all affected studies. +% - isCancel : If true, nothing was changed nor saved. +% - isError : An error occurred that can affect registration of MRI and functional studies, +% e.g. the MRI was updated, but some channel files of that subject were not +% reset. + +% The Transform output is currently unused. It was changed (below is the previous behavior) since it +% depended on CTF MEG specific transform labels. +% - Transform : If the MRI fiducial points and coordinate system are updated, and the channel +% file is reset, the transform becomes the identity. If the channel file is not +% reset, Transform will be the inverse of all previous manual or automatic +% adjustments. If the MRI was not updated, the input Transform is returned. The +% idea is that the returned Transform applied to the channels would maintain the +% registration. + +% Authors: Marc Lalancette 2022-2025 + +if nargin < 6 || isempty(sProcess) + isReport = false; +else + isReport = true; +end +if nargin < 4 || isempty(isConfirm) + isConfirm = true; +end +if nargin < 3 || isempty(isInteractive) + isInteractive = true; +end +if nargin < 2 || isempty(Transform) + Transform = eye(4); +end +if nargin < 1 || isempty(ChannelFile) + bst_error('ChannelFile argument required.'); +end + +isError = false; +isCancel = false; +% Get study +sStudy = bst_get('ChannelFile', ChannelFile); +% Get subject +sSubject = bst_get('Subject', sStudy.BrainStormSubject); +% Check if default anatomy. +if sSubject.UseDefaultAnat + Message = 'Digitized nasion and ear points cannot be applied to default anatomy.'; + if isReport + bst_report('Error', sProcess, sInput, Message); + elseif isInteractive + bst_error(Message, 'Apply digitized anatomical fiducials to MRI', 0); + else + disp(['BST> ' Message]); + end + isCancel = true; + return; +end +% Get Channels +ChannelMat = in_bst_channel(ChannelFile); + +% Check if digitized anat points present, saved in ChannelMat.SCS. +% Note that these coordinates are NOT currently updated when doing refine with head points (below). +% They are in "initial SCS" coordinates, updated in channel_detect_type. +if ~all(isfield(ChannelMat.SCS, {'NAS','LPA','RPA'})) || ~(length(ChannelMat.SCS.NAS) == 3) || ~(length(ChannelMat.SCS.LPA) == 3) || ~(length(ChannelMat.SCS.RPA) == 3) + Message = 'Digitized nasion and ear points not found.'; + if isReport + bst_report('Error', sProcess, sInput, Message); + elseif isInteractive + bst_error(Message, 'Apply digitized anatomical fiducials to MRI', 0); + else + disp(['BST> ' Message]); + end + isCancel = true; + return; +end + +% Check if already adjusted +sMriOld = in_mri_bst(sSubject.Anatomy(sSubject.iAnatomy).FileName); +% This Check function also updates ChannelMat.SCS with the saved (possibly previously adjusted) head +% points. (We don't consider isMriMatch here because we still have to apply the provided +% Transformation.) +[~, isMriUpdated, ~, ~, ChannelMat] = CheckPrevAdjustments(ChannelMat, sMriOld); +% Get user confirmation +if isMriUpdated + % Already done previously. + if isInteractive || isConfirm + % Request confirmation. + [Proceed, isCancel] = java_dialog('confirm', ['The MRI fiducial points NAS/LPA/RPA were previously updated from a set of' 10 ... + 'aligned digitized points. Updating them again will break any previous alignment' 10 ... + 'with other sets of digitized points and associated functional datasets.' 10 10 ... + 'Proceed and overwrite previous alignment?' 10], 'Head points/anatomy registration'); + if ~Proceed || isCancel + isCancel = true; + return; + end + else + % Do not proceed. + Message = 'Digitized nasion and ear points previously applied to this MRI. Not applying again.'; + if isReport + bst_report('Warning', sProcess, sInput, Message); + else + disp(['BST> ' Message]); + end + isCancel = true; + return; + end +elseif isConfirm + % Request confirmation. + [Proceed, isCancel] = java_dialog('confirm', ['Updating the MRI fiducial points NAS/LPA/RPA to match a set of' 10 ... + 'aligned digitized points is mainly used for exporting registration to a BIDS dataset.' 10 ... + 'It will break any previous alignment of this subject with all other functional datasets!' 10 10 ... + 'Proceed and update MRI now?' 10], 'Head points/anatomy registration'); + if ~Proceed || isCancel + isCancel = true; + return; + end +end +% If EEG, warn that only linear transformation would be saved this way. +if ~isempty([good_channel(ChannelMat.Channel, [], 'EEG'), good_channel(ChannelMat.Channel, [], 'SEEG'), good_channel(ChannelMat.Channel, [], 'ECOG')]) + [Proceed, isCancel] = java_dialog('confirm', ['Updating the MRI fiducial points NAS/LPA/RPA will only save' 10 ... + 'global rotations and translations. Any other changes to EEG channels will be lost.' 10 10 ... + 'Proceed and update MRI now?' 10], 'Head points/anatomy registration'); + if ~Proceed || isCancel + isCancel = true; + return; + end +end + +% Convert digitized fids to MRI SCS coordinates. +% Here, ChannelMat.SCS already may contain some auto/manual adjustment, and we're adding a new one (possibly identity). +% Apply the transformation provided. +sMri = sMriOld; +% Intermediate step, these are not valid coordinates for sMri. +sMri.SCS.NAS = (Transform(1:3,:) * [ChannelMat.SCS.NAS'; 1])'; +sMri.SCS.LPA = (Transform(1:3,:) * [ChannelMat.SCS.LPA'; 1])'; +sMri.SCS.RPA = (Transform(1:3,:) * [ChannelMat.SCS.RPA'; 1])'; +% Then convert to MRI coordinates (mm), this is how sMri.SCS is saved. +% cs_convert mri is in meters +sMri.SCS.NAS = cs_convert(sMriOld, 'scs', 'mri', sMri.SCS.NAS) .* 1000; +sMri.SCS.LPA = cs_convert(sMriOld, 'scs', 'mri', sMri.SCS.LPA) .* 1000; +sMri.SCS.RPA = cs_convert(sMriOld, 'scs', 'mri', sMri.SCS.RPA) .* 1000; +% Re-compute transformation in this struct, which goes from MRI to SCS (but the fids stay in MRI coords in this struct). +[~, sMri] = cs_compute(sMri, 'scs'); + +% Compare with existing MRI fids, replace if changed (> 1um), and update surfaces. +sMri.FileName = sSubject.Anatomy(sSubject.iAnatomy).FileName; +figure_mri('SaveMri', sMri); +% At minimum, we must unload surfaces that have been modified, but we want to avoid closing figures +% for when we show "before" and "after" figures. +bst_memory('UnloadAll'); % not 'forced' so won't close figures, but won't unload what we want most likely. +bst_memory('UnloadSurface'); % this unloads the head surface as we want. +% Now that MRI is saved, update Transform to identity. +Transform = eye(4); + +% MRI SCS now matches digitized-points-defined SCS (defined from same points), but registration is +% now broken with all channel files that were adjusted! Reset channel file, and all others for this +% anatomy. +isError = ResetChannelFiles(ChannelMat, sSubject, isConfirm, sInput, sProcess); + +% Removed this output Transform for now as GetTransform only works on CTF MEG data and the rest of +% this function can work more generally. +% if isError +% % Get the equivalent overall registration adjustment transformation previously saved. +% [~, ~, TransfAfter] = GetTransforms(ChannelMat); +% % Return its inverse as it's now part of the MRI and should be removed from the channel file. +% Transform = inverse(TransfAfter); +% else +% Transform = eye(4); +% end + +end % main function + +% (This function was based on channel_align_manual CopyToOtherFolders). +function [isError, Message] = ResetChannelFiles(ChannelMatSrc, sSubject, isConfirm, sInput, sProcess) + if nargin < 5 || isempty(sProcess) + sProcess = []; + isReport = false; + else + isReport = true; + end + % Confirmation: ask the first time + if nargin < 3 || isempty(isConfirm) + isConfirm = true; + end + + NewChannelFiles = cell(0,2); + % First, always reset the "source" channel file. + [ChannelMatSrc, NewChannelFiles, isError] = ResetChannelFile(ChannelMatSrc, NewChannelFiles, sInput, sProcess); + if isError + Message = sprintf(['Unable to reset channel file for subject: %s\n' ... + 'MRI registration for all their functional studies should be verified!'], sSubject.Name); + if isReport + bst_report('Error', sProcess, sInput, Message); + end + % This is very important so always show it interactively. + java_dialog('msgbox', Message); + return; + end + bst_save(file_fullpath(sInput.ChannelFile), ChannelMatSrc, 'v7'); + + % If the subject is configured to share its channel files, nothing to do + if (sSubject.UseDefaultChannel >= 1) + return; + end + % Get all the dependent studies + sStudies = bst_get('StudyWithSubject', sSubject.FileName); + % List of channel files to update + ChannelFiles = {}; + % Loop on the other folders + for i = 1:length(sStudies) + % Skip studies without channel files + if isempty(sStudies(i).Channel) || isempty(sStudies(i).Channel(1).FileName) + continue; + end + % Add channel file to list of files to process + ChannelFiles{end+1} = sStudies(i).Channel(1).FileName; %#ok + end + % Unique files and skip "source". + ChannelFiles = setdiff(unique(ChannelFiles), sInput.ChannelFile); + if ~isempty(ChannelFiles) + % Ask confirmation to the user + if isConfirm + Proceed = java_dialog('confirm', ... + sprintf('Reset all %d other channel files for this subject (typically recommended)?', numel(ChannelFiles)), 'Reset channel files'); + if ~Proceed + Message = sprintf(['User cancelled resetting %d other channel files for subject: %s\n' ... + 'MRI registration for all functional studies should be verified!'], numel(ChannelFiles), sSubject.Name); + if isReport + bst_report('Warning', sProcess, sInput, Message); + else + java_dialog('msgbox', Message); + end + return; + end + end + % Progress bar + bst_progress('start', 'Reset channel files', 'Updating other studies...'); + strMsg = ''; + strErr = ''; + for iChan = 1:numel(ChannelFiles) + ChannelFile = ChannelFiles{iChan}; + % Load channel file + ChannelMat = in_bst_channel(ChannelFile); + % Reset & save + [ChannelMat, NewChannelFiles, isError] = ResetChannelFile(ChannelMat, NewChannelFiles, sInput, sProcess); + if isError + strErr = [strErr, ChannelFile, 10]; %#ok + else + strMsg = [strMsg, ChannelFile, 10]; %#ok + bst_save(file_fullpath(ChannelFile), ChannelMat, 'v7'); + end + end + bst_progress('stop'); + % Give report to the user + if ~isempty(strErr) + Message = sprintf(['Unable to reset channel file(s) for subject %s:\n%s\n' ... + 'MRI registration should be verified for these studies!'], sSubject.Name, strErr); + if isReport + bst_report('Error', sProcess, sInput, Message); + end + % This is very important so always show it interactively. + java_dialog('msgbox', Message); + return; + end + Message = sprintf('%d channel files reset for subject %s:\n%s', numel(ChannelFiles)+1, sSubject.Name, strMsg); + if isReport + bst_report('Info', sProcess, sInput, Message); + elseif isConfirm + java_dialog('msgbox', Message); + else + disp(Message); + end + end +end + diff --git a/toolbox/process/functions/process_channel_addloc.m b/toolbox/process/functions/process_channel_addloc.m index 288622abd..6cda64695 100644 --- a/toolbox/process/functions/process_channel_addloc.m +++ b/toolbox/process/functions/process_channel_addloc.m @@ -202,8 +202,8 @@ bst_report('Error', sProcess, [], 'No channel file selected.'); return end - % Load channel file - ChannelMat = in_bst_channel(ChannelFile); + % Channel file to be loaded in channel_add_loc() + ChannelMat = ChannelFile; else isMni = 0; end diff --git a/toolbox/process/functions/process_channel_biosemi.m b/toolbox/process/functions/process_channel_biosemi.m index 3907b9963..6bbf3e16f 100644 --- a/toolbox/process/functions/process_channel_biosemi.m +++ b/toolbox/process/functions/process_channel_biosemi.m @@ -161,7 +161,7 @@ function ComputeInteractive(ChannelFile) 'C4', 'C6', 'T8', 'TP7', 'CP5', 'CP3', 'CP1', 'CPz', ... 'CP2', 'CP4', 'CP6', 'TP8', 'P9', 'P7', 'P5', 'P3', ... 'P1', 'Pz', 'P2', 'P4', 'P6', 'P8', 'P10', 'PO7', ... - 'PO3', 'POZ', 'PO4', 'PO8', 'O1', 'OZ', 'O2', 'Iz'}; + 'PO3', 'POz', 'PO4', 'PO8', 'O1', 'Oz', 'O2', 'Iz'}; otherwise error('BioSemi cap size %d is not supported.', capSize); diff --git a/toolbox/process/functions/process_cohere1.m b/toolbox/process/functions/process_cohere1.m index 31075db97..06c41dd66 100644 --- a/toolbox/process/functions/process_cohere1.m +++ b/toolbox/process/functions/process_cohere1.m @@ -52,9 +52,9 @@ sProcess.options.label1.Comment = 'Connectivity Metric:'; sProcess.options.label1.Type = 'label'; sProcess.options.cohmeasure.Comment = {... - 'Magnitude-squared coherence: |C|^2 = |Cxy|^2/(Cxx*Cyy)', ... - 'Imaginary coherence: IC = |imag(C)|', ... - 'Lagged coherence / Corrected imaginary coherence: LC = |imag(C)|/sqrt(1-real(C)^2)'; ... + 'Magnitude-squared coherence', ... + 'Imaginary coherence', ... + 'Lagged coherence / Corrected imaginary coherence'; ... 'mscohere', 'icohere2019','lcohere2019'}; % , 'icohere' % ' Squared Lagged Coherence ("imaginary coherence" before 2019)' ... sProcess.options.cohmeasure.Type = 'radio_label'; @@ -98,16 +98,7 @@ %% ===== FORMAT COMMENT ===== function Comment = FormatComment(sProcess) - if ~isempty(sProcess.options.cohmeasure.Value) - iMethod = find(strcmpi(sProcess.options.cohmeasure.Comment(2,:), sProcess.options.cohmeasure.Value)); - if ~isempty(iMethod) - Comment = str_striptag(sProcess.options.cohmeasure.Comment{1,iMethod}); - else - Comment = sProcess.Comment; - end - else - Comment = sProcess.Comment; - end + Comment = sProcess.Comment; end diff --git a/toolbox/process/functions/process_cohere1n.m b/toolbox/process/functions/process_cohere1n.m index f3faa491f..294c18820 100644 --- a/toolbox/process/functions/process_cohere1n.m +++ b/toolbox/process/functions/process_cohere1n.m @@ -53,9 +53,9 @@ sProcess.options.label1.Comment = 'Connectivity Metric:'; sProcess.options.label1.Type = 'label'; sProcess.options.cohmeasure.Comment = {... - 'Magnitude-squared coherence: |C|^2 = |Cxy|^2/(Cxx*Cyy)', ... - 'Imaginary coherence: IC = |imag(C)|', ... - 'Lagged coherence / Corrected imaginary coherence: LC = |imag(C)|/sqrt(1-real(C)^2)'; ... + 'Magnitude-squared coherence', ... + 'Imaginary coherence', ... + 'Lagged coherence / Corrected imaginary coherence'; ... 'mscohere', 'icohere2019','lcohere2019'}; % , 'icohere' % ' Squared Lagged Coherence ("imaginary coherence" before 2019)' ... sProcess.options.cohmeasure.Type = 'radio_label'; @@ -99,16 +99,7 @@ %% ===== FORMAT COMMENT ===== function Comment = FormatComment(sProcess) - if ~isempty(sProcess.options.cohmeasure.Value) - iMethod = find(strcmpi(sProcess.options.cohmeasure.Comment(2,:), sProcess.options.cohmeasure.Value)); - if ~isempty(iMethod) - Comment = str_striptag(sProcess.options.cohmeasure.Comment{1,iMethod}); - else - Comment = sProcess.Comment; - end - else - Comment = sProcess.Comment; - end + Comment = sProcess.Comment; end diff --git a/toolbox/process/functions/process_cohere2.m b/toolbox/process/functions/process_cohere2.m index c3383b7c7..6ab1df5e6 100644 --- a/toolbox/process/functions/process_cohere2.m +++ b/toolbox/process/functions/process_cohere2.m @@ -99,16 +99,7 @@ %% ===== FORMAT COMMENT ===== function Comment = FormatComment(sProcess) - if ~isempty(sProcess.options.cohmeasure.Value) - iMethod = find(strcmpi(sProcess.options.cohmeasure.Comment(2,:), sProcess.options.cohmeasure.Value)); - if ~isempty(iMethod) - Comment = str_striptag(sProcess.options.cohmeasure.Comment{1,iMethod}); - else - Comment = sProcess.Comment; - end - else - Comment = sProcess.Comment; - end + Comment = sProcess.Comment; end diff --git a/toolbox/process/functions/process_combine_recordings.m b/toolbox/process/functions/process_combine_recordings.m new file mode 100644 index 000000000..d47e4cd9a --- /dev/null +++ b/toolbox/process/functions/process_combine_recordings.m @@ -0,0 +1,298 @@ +function varargout = process_combine_recordings(varargin) +% process_combine_recordings: Combine multiple synchronized signals into +% one recording (resampling the signals to the highest sampling frequency) + +% @============================================================================= +% This function is part of the Brainstorm software: +% https://neuroimage.usc.edu/brainstorm +% +% Copyright (c) University of Southern California & McGill University +% This software is distributed under the terms of the GNU General Public License +% as published by the Free Software Foundation. Further details on the GPLv3 +% license can be found at http://www.gnu.org/copyleft/gpl.html. +% +% FOR RESEARCH PURPOSES ONLY. THE SOFTWARE IS PROVIDED "AS IS," AND THE +% UNIVERSITY OF SOUTHERN CALIFORNIA AND ITS COLLABORATORS DO NOT MAKE ANY +% WARRANTY, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO WARRANTIES OF +% MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE, NOR DO THEY ASSUME ANY +% LIABILITY OR RESPONSIBILITY FOR THE USE OF THIS SOFTWARE. +% +% For more information type "brainstorm license" at command prompt. +% =============================================================================@ +% +% Authors: Edouard Delaire, 2024 +% Raymundo Cassani, 2024 + +eval(macro_method); +end + + +%% ===== GET DESCRIPTION ===== +function sProcess = GetDescription() + % Description the process + sProcess.Comment = 'Combine files'; + sProcess.Category = 'Custom'; + sProcess.SubGroup = 'Synchronize'; + sProcess.Index = 682; + % Definition of the input accepted by this process + sProcess.InputTypes = {'raw'}; + sProcess.OutputTypes = {'raw'}; + sProcess.nInputs = 1; + sProcess.nMinFiles = 2; + + % Option: Condition + sProcess.options.condition.Comment = 'Condition name:'; + sProcess.options.condition.Type = 'text'; + sProcess.options.condition.Value = 'Combined'; +end + + +%% ===== FORMAT COMMENT ===== +function Comment = FormatComment(sProcess) %#ok + Comment = sProcess.Comment; +end + + +%% ===== RUN ===== +function OutputFiles = Run(sProcess, sInputs) + OutputFiles = {}; + + nInputs = length(sInputs); + sIdxChNew = cell(1, nInputs); % Channel indices for each input in new channel file + sProjNew = cell(1, nInputs); % Projectors for each input in new channel file + + % Check for same Subject + if length(unique({sInputs.SubjectFile})) > 1 + bst_report('Error', sProcess, sInputs, 'All raw recordings must belong to the same Subject.'); + return + end + % Get unique comment for new condition + NewComment = sProcess.options.condition.Value; + if isempty(NewComment) + bst_report('Error', sProcess, sInputs, 'Condition name was not defined.'); + return + end + NewCondition = ['@raw', file_standardize(NewComment)]; + sStudies = bst_get('StudyWithSubject', sInputs(1).SubjectFile); + NewCondition = file_unique(NewCondition, [sStudies.Condition]); + + + % ===== GET METADATA FOR RECORDINGS ===== + bst_progress('start', 'Combining recordings', 'Loading metadata...', 0, 3 * nInputs); % 3 steps per input file + % Get Time and F structure + for iInput = 1 : nInputs + sMetaData(iInput) = in_bst_data(sInputs(iInput).FileName, 'Time', 'F'); + bst_progress('inc', 1); + end + % Check that the limits are close enough, same duration, and same start + all_times = [arrayfun(@(x) x.F.prop.times(1), sMetaData)', arrayfun(@(x) x.F.prop.times(2), sMetaData)']; + max_diffs = max(all_times,[], 1) - min(all_times,[], 1); + if any(max_diffs > 1) % Tolerance of 1 second for maximum differences + bst_report('Error', sProcess, sInputs, 'Recordings to merge must have the same start and end time.'); + return + end + + + % ===== COMBINE METADATA ===== + bst_progress('text', 'Combining metadata...'); + % Recordings file with higher sampling frequency is used as seed for time + [~, iRefRec] = max(arrayfun(@(x) x.F.prop.sfreq, sMetaData)); + % New sampling frequency + NewFs = sMetaData(iRefRec).F.prop.sfreq; + % Study for combined recordings + iNewStudy = db_add_condition(sInputs(iRefRec).SubjectName, NewCondition); + sNewStudy = bst_get('Study', iNewStudy); + % New time vector + NewTime = sMetaData(iRefRec).Time; + % New channel definition + NewChannelsN = 0; + NewChannelMat = db_template('ChannelMat'); + NewChannelMat.Channel = repmat(db_template('channeldesc'), NewChannelsN); + % New channel flag + NewChannelFlag = []; + % New events + NewEvents = repmat(db_template('event'), 0); + + % Events to be merged in combined raw file + poolEvents = NewEvents; % Empty sEvents + poolEventsIx = []; % Index of file associated with the event + poolEventsFx = []; % Fixing channel-wise data is needed + for iInput = 1 : nInputs + poolEvents = [poolEvents, sMetaData(iInput).F.events]; + poolEventsIx = [poolEventsIx, repmat(iInput, 1, length(sMetaData(iInput).F.events))]; + poolEventsFx = [poolEventsFx, ones(1, length(sMetaData(iInput).F.events))]; + end + % Handle duplicated names and identify events do not need their channel field updated + [uniqueLabels, ~, iUnique] = unique({poolEvents.label}); + iDel = []; + for iu = 1 : length(uniqueLabels) + % Instances of unique events + ixUnique = find(iUnique == iu)'; + % Do nothing if event is not duplicated + if length(ixUnique) == 1 + continue + % Keep only one copy, set to not be channel-wise fixed + elseif all(cellfun(@isempty, {poolEvents(ixUnique).channels})) && isequal(poolEvents(ixUnique).times) + poolEventsFx(ixUnique) = 0; + iDel = [iDel, ixUnique(2:end)]; + % Create unique names + else + baseLabel = poolEvents(ixUnique(1)).label; + for id = 1 : length(ixUnique) + % Update their names to make unique + poolEvents(ixUnique(id)).label = sprintf([baseLabel '_%02d'], id); + end + end + end + poolEvents(iDel) = []; + poolEventsIx(iDel) = []; + poolEventsFx(iDel) = []; + + for iInput = 1 : nInputs + % Get channel file + tmpChannelMat = in_bst_channel(sInputs(iInput).ChannelFile); + tmpChannelNames = {tmpChannelMat.Channel.Name}; + % Concatenate channels + % Ensure unique names for channels + ixChannelDup = find(ismember({tmpChannelMat.Channel.Name}, {NewChannelMat.Channel.Name})); + for iDup = 1 : length(ixChannelDup) + tmpChannelMat.Channel(ixChannelDup(iDup)).Name = file_unique(tmpChannelMat.Channel(ixChannelDup(iDup)).Name, {NewChannelMat.Channel.Name}); + end + sIdxChNew{iInput} = NewChannelsN + [1 : length(tmpChannelMat.Channel)]; + NewChannelMat.Channel = [NewChannelMat.Channel, tmpChannelMat.Channel]; + + % Concatenate channel flag + NewChannelFlag = [NewChannelFlag; sMetaData(iInput).ChannelFlag]; + + % Store projectors to concatenate later + sProjNew{iInput} = tmpChannelMat.Projector; + + % Add channel information if needed + tmpEvents = poolEvents(poolEventsIx == iInput); + for iEvent = 1 : length(tmpEvents) + tmpEvent = tmpEvents(iEvent); + if poolEventsFx(iEvent) + % Add channel info + addedChannelNames = {NewChannelMat.Channel(sIdxChNew{iInput}).Name}; + nOccurences = size(tmpEvent.times, 2); + % Make a channel-wise event with all channels in Input file + if isempty(tmpEvent.channels) + tmpEvent.channels = repmat({addedChannelNames}, 1, nOccurences); + else + for iOccurence = 1 : nOccurences + % Make a channel-wise event with all channels in Input file + if isempty(tmpEvent.channels{iOccurence}) + tmpEvent.channels{iOccurence} = addedChannelNames; + % Update channel names to names that were added in combined file + else + [~, iLoc] = ismember(tmpEvent.channels{iOccurence}, tmpChannelNames); + tmpEvent.channels{iOccurence} = addedChannelNames(iLoc); + end + end + end + end + NewEvents = [NewEvents, tmpEvent]; + end + + % Copy videos + tmpStudy = bst_get('Study', sInputs(iInput).iStudy); + if isfield(tmpStudy,'Image') && ~isempty(tmpStudy.Image) + for iTmpVideo = 1 : length(tmpStudy.Image) + sTmpVideo = load(file_fullpath(tmpStudy.Image(iTmpVideo).FileName)); + if isempty(sTmpVideo.VideoStart) + sTmpVideo.VideoStart = 0; + end + [~, outVideoFile] = import_video(iNewStudy, sTmpVideo.LinkTo); + figure_video('SetVideoStart', outVideoFile{1}, sprintf('%.3f', sTmpVideo.VideoStart)); + end + end + + % Concat NIRS wavelengths + if isfield(tmpChannelMat, 'Nirs') + if ~isfield(NewChannelMat, 'Nirs') + NewChannelMat.Nirs = tmpChannelMat.Nirs; + else + NewChannelMat.Nirs = sort(union(tmpChannelMat.Nirs, NewChannelMat.Nirs)); + end + end + + % New channel count + NewChannelsN = length(NewChannelMat.Channel); + % Progress + bst_progress('inc', 1); + end + + % Concatenate Projectors + for iInput = 1 : nInputs + for iProj = 1 : length(sProjNew{iInput}) + % Adjust Nchan in Projector + sizeComp = size(sProjNew{iInput}(iProj).Components); + newSizeComp = sizeComp; + ixs = cell(1, length(newSizeComp)); + for iDim = 1 : length(sizeComp) + if sizeComp(iDim) == length(sIdxChNew{iInput}) + newSizeComp(iDim) = NewChannelsN; + ixs{iDim} = sIdxChNew{iInput}; + else + newSizeComp(iDim) = sizeComp(iDim); + ixs{iDim} = 1:sizeComp(iDim); + end + end + newComponents = zeros(newSizeComp); + newComponents(ixs{1}, ixs{2}) = sProjNew{iInput}(iProj).Components; + sProjNew{iInput}(iProj).Components = newComponents; + end + end + NewChannelMat.Projector = [sProjNew{:}]; + + % Save channel file + db_set_channel(iNewStudy, NewChannelMat, 0, 0); + + + % ===== COMBINE DATA ===== + bst_progress('text', 'Combining data...'); + % Link to combined raw file + OutputFile = bst_process('GetNewFilename', bst_fileparts(sNewStudy.FileName), 'data_0raw_combined'); + % Combined raw file + [rawDirOut, rawBaseOut] = bst_fileparts(OutputFile); + rawBaseOut = regexprep(rawBaseOut, '^data_0raw_', ''); + RawFileOut = bst_fullfile(rawDirOut, [rawBaseOut, '.bst']); + + % Create a header structure for combined recordings + sFileIn = db_template('sfile'); + sFileIn.header.nsamples = length(NewTime); + sFileIn.prop.times = [NewTime(1), NewTime(end)]; + sFileIn.prop.sfreq = NewFs; + sFileIn.events = NewEvents; + sFileIn.channelflag = NewChannelFlag; + sFileOut = out_fopen(RawFileOut, 'BST-BIN', sFileIn, NewChannelMat); + + % Build output structure for combined recordings + sOutMat = db_template('DataMat'); + sOutMat.Comment = 'Link to raw file | Combined'; + sOutMat.F = sFileOut; + sOutMat.format = 'BST-BIN'; + sOutMat.DataType = 'raw'; + sOutMat.ChannelFlag = NewChannelFlag; + sOutMat.Time = sFileIn.prop.times; + sOutMat.Device = 'Brainstorm'; + bst_save(OutputFile, sOutMat, 'v6'); + + % Save all data to combined file + for iInput = 1 : nInputs + % Load raw data + sDataToCombine = in_bst(sInputs(iInput).FileName, [], 1, 1, 'no', 0); + % Update raw data to new time vector + if iInput ~= iRefRec + sDataToCombine.F = interp1(sDataToCombine.Time, sDataToCombine.F', NewTime)'; + end + % Write these channels + out_fwrite(sFileOut, NewChannelMat, 1, [], sIdxChNew{iInput}, sDataToCombine.F); + bst_progress('inc', 1); + end + + % Register in BST database + db_add_data(iNewStudy, OutputFile, sOutMat); + OutputFiles{iInput} = OutputFile; + bst_progress('stop'); +end diff --git a/toolbox/process/functions/process_convert_raw_to_lfp.m b/toolbox/process/functions/process_convert_raw_to_lfp.m index 20ed8351e..8a63bce81 100644 --- a/toolbox/process/functions/process_convert_raw_to_lfp.m +++ b/toolbox/process/functions/process_convert_raw_to_lfp.m @@ -168,7 +168,7 @@ RawFileFormat = 'BST-BIN'; % Number of time points in output file - newTimeVector = downsample(DataMat.Time, round(Fs/LFP_fs)); + newTimeVector = process_resample('Compute', DataMat.Time, linspace(0, length(DataMat.Time)/Fs, length(DataMat.Time)), LFP_fs); nTimeOut = length(newTimeVector); % Template structure for the creation of the output raw file sFileTemplate = sFileIn; diff --git a/toolbox/process/functions/process_corr1.m b/toolbox/process/functions/process_corr1.m index 6eec2891f..0b0a9c093 100644 --- a/toolbox/process/functions/process_corr1.m +++ b/toolbox/process/functions/process_corr1.m @@ -22,6 +22,8 @@ % =============================================================================@ % % Authors: Francois Tadel, 2012-2020 +% Raymundo Cassani, 2023 + eval(macro_method); end @@ -43,6 +45,22 @@ % === CONNECT INPUT sProcess = process_corr1n('DefineConnectOptions', sProcess, 0); + % === TIME RESOLUTION + sProcess.options.timeres.Comment = {'Windowed', 'None', 'Time resolution:'; ... + 'windowed', 'none', ''}; + sProcess.options.timeres.Type = 'radio_linelabel'; + sProcess.options.timeres.Value = 'none'; + sProcess.options.timeres.Controller = struct('windowed', 'windowed', 'none', 'nowindowed'); + % === WINDOW LENGTH + sProcess.options.avgwinlength.Comment = '   Time window length:'; + sProcess.options.avgwinlength.Type = 'value'; + sProcess.options.avgwinlength.Value = {1, 's', []}; + sProcess.options.avgwinlength.Class = 'windowed'; + % === WINDOW OVERLAP + sProcess.options.avgwinoverlap.Comment = '   Time window overlap:'; + sProcess.options.avgwinoverlap.Type = 'value'; + sProcess.options.avgwinoverlap.Value = {50, '%', []}; + sProcess.options.avgwinoverlap.Class = 'windowed'; % === SCALAR PRODUCT sProcess.options.scalarprod.Comment = 'Compute scalar product instead of correlation
(do not remove average of the signal)'; sProcess.options.scalarprod.Type = 'checkbox'; @@ -75,6 +93,12 @@ OPTIONS.Method = 'corr'; OPTIONS.pThresh = 0.05; OPTIONS.RemoveMean = ~sProcess.options.scalarprod.Value; + if strcmpi(sProcess.options.timeres.Value, 'windowed') + OPTIONS.WinLen = sProcess.options.avgwinlength.Value{1}; + OPTIONS.WinOverlap = sProcess.options.avgwinoverlap.Value{1}/100; + end + % Time-resolved; now option, no longer separate process + OPTIONS.TimeRes = sProcess.options.timeres.Value; % Compute metric OutputFiles = bst_connectivity(sInputA, sInputA, OPTIONS); diff --git a/toolbox/process/functions/process_corr1n.m b/toolbox/process/functions/process_corr1n.m index b7f0c103f..4070d9d2c 100644 --- a/toolbox/process/functions/process_corr1n.m +++ b/toolbox/process/functions/process_corr1n.m @@ -22,6 +22,7 @@ % =============================================================================@ % % Authors: Francois Tadel, 2012-2020 +% Raymundo Cassani, 2023 eval(macro_method); end @@ -40,9 +41,26 @@ sProcess.OutputTypes = {'timefreq', 'timefreq', 'timefreq'}; sProcess.nInputs = 1; sProcess.nMinFiles = 1; + sProcess.isSeparator = 1; % === CONNECT INPUT sProcess = process_corr1n('DefineConnectOptions', sProcess, 1); + % === TIME RESOLUTION + sProcess.options.timeres.Comment = {'Windowed', 'None', 'Time resolution:'; ... + 'windowed', 'none', ''}; + sProcess.options.timeres.Type = 'radio_linelabel'; + sProcess.options.timeres.Value = 'none'; + sProcess.options.timeres.Controller = struct('windowed', 'windowed', 'none', 'nowindowed'); + % === WINDOW LENGTH + sProcess.options.avgwinlength.Comment = '   Time window length:'; + sProcess.options.avgwinlength.Type = 'value'; + sProcess.options.avgwinlength.Value = {1, 's', []}; + sProcess.options.avgwinlength.Class = 'windowed'; + % === WINDOW OVERLAP + sProcess.options.avgwinoverlap.Comment = '   Time window overlap:'; + sProcess.options.avgwinoverlap.Type = 'value'; + sProcess.options.avgwinoverlap.Value = {50, '%', []}; + sProcess.options.avgwinoverlap.Class = 'windowed'; % === SCALAR PRODUCT sProcess.options.scalarprod.Comment = 'Compute scalar product instead of correlation
(do not remove average of the signal)'; sProcess.options.scalarprod.Type = 'checkbox'; @@ -74,6 +92,12 @@ OPTIONS.Method = 'corr'; OPTIONS.pThresh = 0.05; OPTIONS.RemoveMean = ~sProcess.options.scalarprod.Value; + if strcmpi(sProcess.options.timeres.Value, 'windowed') + OPTIONS.WinLen = sProcess.options.avgwinlength.Value{1}; + OPTIONS.WinOverlap = sProcess.options.avgwinoverlap.Value{1}/100; + end + % Time-resolved; now option, no longer separate process + OPTIONS.TimeRes = sProcess.options.timeres.Value; % Compute metric OutputFiles = bst_connectivity(sInputA, [], OPTIONS); diff --git a/toolbox/process/functions/process_corr2.m b/toolbox/process/functions/process_corr2.m index 89057673d..2d62957f4 100644 --- a/toolbox/process/functions/process_corr2.m +++ b/toolbox/process/functions/process_corr2.m @@ -22,6 +22,7 @@ % =============================================================================@ % % Authors: Francois Tadel, 2012-2020 +% Raymundo Cassani, 2023 eval(macro_method); end @@ -44,6 +45,22 @@ % === CONNECT INPUT sProcess = process_corr2('DefineConnectOptions', sProcess); + % === TIME RESOLUTION + sProcess.options.timeres.Comment = {'Windowed', 'None', 'Time resolution:'; ... + 'windowed', 'none', ''}; + sProcess.options.timeres.Type = 'radio_linelabel'; + sProcess.options.timeres.Value = 'none'; + sProcess.options.timeres.Controller = struct('windowed', 'windowed', 'none', 'nowindowed'); + % === WINDOW LENGTH + sProcess.options.avgwinlength.Comment = '   Time window length:'; + sProcess.options.avgwinlength.Type = 'value'; + sProcess.options.avgwinlength.Value = {1, 's', []}; + sProcess.options.avgwinlength.Class = 'windowed'; + % === WINDOW OVERLAP + sProcess.options.avgwinoverlap.Comment = '   Time window overlap:'; + sProcess.options.avgwinoverlap.Type = 'value'; + sProcess.options.avgwinoverlap.Value = {50, '%', []}; + sProcess.options.avgwinoverlap.Class = 'windowed'; % === SCALAR PRODUCT sProcess.options.scalarprod.Comment = 'Compute scalar product instead of correlation
(do not remove average of the signal)'; sProcess.options.scalarprod.Type = 'checkbox'; @@ -76,6 +93,12 @@ OPTIONS.Method = 'corr'; OPTIONS.pThresh = 0.05; OPTIONS.RemoveMean = ~sProcess.options.scalarprod.Value; + if strcmpi(sProcess.options.timeres.Value, 'windowed') + OPTIONS.WinLen = sProcess.options.avgwinlength.Value{1}; + OPTIONS.WinOverlap = sProcess.options.avgwinoverlap.Value{1}/100; + end + % Time-resolved; now option, no longer separate process + OPTIONS.TimeRes = sProcess.options.timeres.Value; % Compute metric OutputFiles = bst_connectivity(sInputA, sInputB, OPTIONS); diff --git a/toolbox/process/functions/process_decoding_maxcorr.m b/toolbox/process/functions/process_decoding_maxcorr.m index d2c892321..472d18162 100644 --- a/toolbox/process/functions/process_decoding_maxcorr.m +++ b/toolbox/process/functions/process_decoding_maxcorr.m @@ -30,7 +30,7 @@ sProcess.Comment = 'Max-correlation decoding'; sProcess.Category = 'Custom'; sProcess.SubGroup = 'Decoding'; - sProcess.Index = 702; + sProcess.Index = 712; sProcess.Description = 'https://neuroimage.usc.edu/brainstorm/Tutorials/Decoding'; % Definition of the input accepted by this process sProcess.InputTypes = {'data'}; diff --git a/toolbox/process/functions/process_decoding_svm.m b/toolbox/process/functions/process_decoding_svm.m index ead868c61..ac6cc4a28 100644 --- a/toolbox/process/functions/process_decoding_svm.m +++ b/toolbox/process/functions/process_decoding_svm.m @@ -30,7 +30,7 @@ sProcess.Comment = 'SVM decoding'; sProcess.Category = 'Custom'; sProcess.SubGroup = 'Decoding'; - sProcess.Index = 702; + sProcess.Index = 712; sProcess.Description = 'https://neuroimage.usc.edu/brainstorm/Tutorials/Decoding'; % Definition of the input accepted by this process sProcess.InputTypes = {'data'}; @@ -46,6 +46,11 @@ sProcess.options.sensortypes.Comment = 'Sensor types or names (empty=all): '; sProcess.options.sensortypes.Type = 'text'; sProcess.options.sensortypes.Value = 'MEG'; + % === ignore bad channels + sProcess.options.ignorebad.Comment = 'Ignore bad channels'; + sProcess.options.ignorebad.Type = 'checkbox'; + sProcess.options.ignorebad.Value = 0; + sProcess.options.ignorebad.InputTypes = {'data'}; % === lowpass filtering sProcess.options.lowpass.Comment = 'Low-pass cutoff frequency (0=disabled): '; sProcess.options.lowpass.Type = 'value'; @@ -94,6 +99,11 @@ model = sProcess.options.model.Value; methods = {'pairwise', 'temporalgen', 'multiclass'}; method = methods{sProcess.options.method.Value}; + if isfield(sProcess.options, 'ignorebad') && isfield(sProcess.options.ignorebad, 'Value') && ~isempty(sProcess.options.ignorebad.Value) + IgnoreBad = sProcess.options.ignorebad.Value; + else + IgnoreBad = 0; + end % Ensure we are including the LibSVM folder in the Matlab path if strcmpi(model, 'svm') @@ -141,6 +151,20 @@ bst_report('Error', sProcess, [], ['Decoding using the ' method ' method is not yet supported.']); return; end + % Channel files for all inputs must have the same list of channels + uniqueChannelFiles = unique({sInputs.ChannelFile}); + if length(uniqueChannelFiles) > 1 + channelMatRef = in_bst_channel(uniqueChannelFiles{1}); + channelNamesRef = {channelMatRef.Channel.Name}; + for iChannelFile = 2 : length(uniqueChannelFiles) + channelMatTest = in_bst_channel(uniqueChannelFiles{iChannelFile}); + channelNamesCond = {channelMatTest.Channel.Name}; + if ~isequal(channelNamesRef, channelNamesCond) + bst_report('Error', sProcess, [], 'Input files have different lists of channels.'); + return + end + end + end % Summarize trials and conditions to process fprintf('BST> Found %d different conditions across %d trials:%c', numConditions, length(sInputs), char(10)); @@ -150,7 +174,15 @@ end % Load trials - [trial,Time] = load_trials_bs(sInputs, LowPass, SensorTypes); + [trial, Time, iChannels, errMsg] = load_trials_bs(sInputs, LowPass, SensorTypes, IgnoreBad); + if ~isempty(errMsg) + bst_report('Error', sProcess, [], errMsg); + return + end + varNames = {channelMatRef.Channel(iChannels).Name}; + strVars = cellfun(@(x) ['"', x, '", '], varNames, 'UniformOutput', false); + strVars = [strVars{:}]; + strVars = regexprep(strVars, ', $', ''); % Run SVM decoding if strcmpi(model, 'maxcorr') % Run max-correlation decoding @@ -185,6 +217,7 @@ FileMat.Description = Description; FileMat.Time = Time; FileMat.CondLabels = uniqueConditions; + FileMat = bst_history('add', FileMat, 'SVM', ['Channels: ' strVars]); % ===== OUTPUT CONDITION ===== % Default condition name @@ -215,8 +248,10 @@ % - sInputs : Trial files to load % - SensorTypes : List of channel types or names separated with commas % - LowPass : Low pass frequency for data filtering +% - IgnoreBad : 1 = bad channels are ignored, 0 = use all channels % Authors: Dimitrios Pantazis, Seyed-Mahdi Khaligh-Razavi, Martin Cousineau -function [trial, Time] = load_trials_bs(sInputs, LowPass, SensorTypes) +function [trial, Time, iChannels, errMsg] = load_trials_bs(sInputs, LowPass, SensorTypes, IgnoreBad) + errMsg = []; % Load channel file ChannelMat = in_bst_channel(sInputs(1).ChannelFile); % Parse inputs @@ -232,16 +267,25 @@ % Make sure channels are unique and sorted iChannels = unique(iChannels); end + if nargin < 4 || isempty(IgnoreBad) + % Use all channels + IgnoreBad = 0; + end % Initialize output matrix (numChannels x numSamples x numObservations) nInputs = length(sInputs); DataMat = in_bst_data(sInputs(1).FileName); + if IgnoreBad + channelFlagRef = (DataMat.ChannelFlag == 1); + iChannels = iChannels(channelFlagRef); + end trial = zeros(length(iChannels), length(DataMat.Time), nInputs); + Time = DataMat.Time; % Low-pass filtering if LowPass > 0 % Design low pass filter - order = max(100,round(size(DataMat.F(iChannels,:),2)/10)); %keep one 10th of the timepoints as model order + order = max(100,round(size(DataMat.F,2)/10)); %keep one 10th of the timepoints as model order Fs = 1 ./ (DataMat.Time(2) - DataMat.Time(1)); h = filter_design('lowpass', LowPass, order, Fs, 0); end @@ -251,6 +295,14 @@ for f = 1:nInputs bst_progress('inc',1); DataMat = in_bst_data(sInputs(f).FileName); + % Check for same channel number + if IgnoreBad && (f > 1) + % Check channel flag for this data + if ~isequal(channelFlagRef, (DataMat.ChannelFlag == 1)) + errMsg = 'Input files have different lists of bad channels.'; + return + end + end if LowPass > 0 % do low-pass filtering trial(:,:,f) = filter_apply(DataMat.F(iChannels,:),h); %smooth over time @@ -259,7 +311,6 @@ end end bst_progress('stop'); - Time = DataMat.Time; end diff --git a/toolbox/process/functions/process_detectbad.m b/toolbox/process/functions/process_detectbad.m index e23a60c55..9076e790f 100644 --- a/toolbox/process/functions/process_detectbad.m +++ b/toolbox/process/functions/process_detectbad.m @@ -20,6 +20,7 @@ % =============================================================================@ % % Authors: Francois Tadel, 2010-2021 +% Raymundo Cassani, 2024 eval(macro_method); end @@ -32,10 +33,10 @@ sProcess.Category = 'Custom'; sProcess.SubGroup = 'Artifacts'; sProcess.Index = 115; - sProcess.Description = 'https://neuroimage.usc.edu/brainstorm/Tutorials/MedianNerveCtf#Review_the_individual_trials'; + sProcess.Description = 'https://neuroimage.usc.edu/brainstorm/Tutorials/ArtifactsDetect#Other_detection_processes'; % Definition of the input accepted by this process - sProcess.InputTypes = {'data'}; - sProcess.OutputTypes = {'data'}; + sProcess.InputTypes = {'raw', 'data'}; + sProcess.OutputTypes = {'raw', 'data'}; sProcess.nInputs = 1; sProcess.nMinFiles = 1; @@ -79,9 +80,15 @@ sProcess.options.comment1.Comment = ['
- First column: Signal detection threshold (peak-to-peak)
' 10 ... ' - Second column: Signal rejection threshold (peak-to-peak)']; sProcess.options.comment1.Type = 'label'; - % Reject entire trial + % Separator sProcess.options.sep2.Type = 'separator'; - sProcess.options.rejectmode.Comment = {'Reject only the bad channels', 'Reject the entire trial'}; + % Option: Window length + sProcess.options.win_length.Comment = 'Length of analysis window: '; + sProcess.options.win_length.Type = 'value'; + sProcess.options.win_length.Value = {1, 's', []}; + sProcess.options.win_length.InputTypes = {'raw'}; + % Reject entire trial + sProcess.options.rejectmode.Comment = {'Reject only the bad channels', 'Reject the entire segments/trials (all channels)'}; sProcess.options.rejectmode.Type = 'radio'; sProcess.options.rejectmode.Value = 2; end @@ -102,7 +109,7 @@ if (sProcess.options.rejectmode.Value == 1) Comment = 'Detect bad channels: Peak-to-peak '; else - Comment = 'Detect bad trials: Peak-to-peak '; + Comment = 'Detect bad segments/trials: Peak-to-peak '; end % What are the criteria for critName = {'meggrad', 'megmag', 'eeg', 'eog', 'ecg'} @@ -142,36 +149,39 @@ OutputFiles = []; return; end + % Check: Same FileType for all files + is_raw = strcmp({sInputs.FileType},'raw'); + if ~all(is_raw) && ~all(~is_raw) + bst_error('Please do not mix continous (raw) and imported data', 'Detect bad segments', 0); + return; + end % Reject entire trial isRejectTrial = (sProcess.options.rejectmode.Value == 2); - + % If raw file, get window length + if is_raw(1) && isfield(sProcess.options, 'win_length') && ~isempty(sProcess.options.win_length) && ~isempty(sProcess.options.win_length.Value) && iscell(sProcess.options.win_length.Value) + winLength = sProcess.options.win_length.Value{1}; + end + % Initializations - iBadTrials = []; + iBadTrials = []; % Bad trials for imported files progressPos = bst_progress('get'); prevChannelFile = ''; % ===== LOOP ON FILES ===== for iFile = 1:length(sInputs) - % === LOAD ALL DATA === + % === LOAD FILE === % Progress bar bst_progress('set', progressPos + round(iFile / length(sInputs) * 100)); - % Get file in database - [sStudy, iStudy, iData] = bst_get('DataFile', sInputs(iFile).FileName); % Load channel file (if not already loaded ChannelFile = sInputs(iFile).ChannelFile; if isempty(prevChannelFile) || ~strcmpi(ChannelFile, prevChannelFile) prevChannelFile = ChannelFile; ChannelMat = in_bst_channel(ChannelFile); end - % Get modalities - Modalities = unique({ChannelMat.Channel.Type}); % Load file DataMat = in_bst_data(sInputs(iFile).FileName, 'F', 'ChannelFlag', 'History', 'Time'); - % Scale gradiometers / magnetometers: - % - Neuromag: Apply axial factor to MEG GRAD sensors, to convert in fT/cm - % - CTF: Apply factor to MEG REF gradiometers - DataMat.F = bst_scale_gradmag(DataMat.F, ChannelMat.Channel); - % Get time window + nChannels = length(DataMat.ChannelFlag); + % Get sample bounds for time window if ~isempty(TimeWindow) iTime = bst_closest(sProcess.options.timewindow.Value{1}, DataMat.Time); if (iTime(1) == iTime(2)) && any(iTime(1) == DataMat.Time) @@ -183,74 +193,141 @@ else iTime = 1:length(DataMat.Time); end - % List of bad channels for this file - iBadChan = []; - % === LOOP ON MODALITIES === - for iMod = 1:length(Modalities) - % === GET REJECTION CRITERIA === - % Get threshold according to the modality - if ismember(Modalities{iMod}, {'MEG', 'MEG GRAD'}) - Threshold = Criteria.meggrad; - elseif strcmpi(Modalities{iMod}, 'MEG MAG') - Threshold = Criteria.megmag; - elseif strcmpi(Modalities{iMod}, 'EEG') - Threshold = Criteria.eeg; - elseif ismember(Modalities{iMod}, {'SEEG', 'EOCG'}) - Threshold = Criteria.ieeg; - elseif ~isempty(strfind(lower(Modalities{iMod}), 'eog')) - Threshold = Criteria.eog; - elseif ~isempty(strfind(lower(Modalities{iMod}), 'ecg')) || ~isempty(strfind(lower(Modalities{iMod}), 'ekg')) - Threshold = Criteria.ecg; - else - continue; - end - % If threshold is [0 0]: nothing to do - if isequal(Threshold, [0 0]) - continue; + % ===== DETECT BAD SEGMENTS ===== + % Process raw (continuous) data file in blocks + if strcmp(sInputs(iFile).FileType, 'raw') + % Get maximum size of a data block + ProcessOptions = bst_get('ProcessOptions'); + blockLengthSamples = max(floor(ProcessOptions.MaxBlockSize / nChannels), 1); + % Sampling frequency + fs = 1 ./ (DataMat.Time(2) - DataMat.Time(1)); + % Length of the analysis window in samples + winLengthSamples = round(fs*winLength); + % Block length as multiple of the length of the analysis window + blockLengthSamples = winLengthSamples * floor(blockLengthSamples / winLengthSamples); + % List of bad events for this file + sBadEvents = repmat(db_template('event'), 0); + % Indices for each block + [~, iTimesBlocks, R] = bst_epoching(iTime, blockLengthSamples); + if ~isempty(R) + if ~isempty(iTimesBlocks) + lastTime = iTimesBlocks(end, 2); + else + lastTime = 0; + end + % Add the times for the remaining block + iTimesBlocks = [iTimesBlocks; lastTime+1, lastTime+size(R,2)]; end - - % === DETECT BAD CHANNELS === - % Get channels for this modality - iChan = good_channel(ChannelMat.Channel, DataMat.ChannelFlag, Modalities{iMod}); - % Get data to test - DataToTest = DataMat.F(iChan, iTime); - % Compute peak-to-peak values for all the sensors - p2p = (max(DataToTest,[],2) - min(DataToTest,[],2)); - % Get bad channels - iBadChanMod = find((p2p < Threshold(1)) | (p2p > Threshold(2))); - % If some bad channels were detected - if ~isempty(iBadChanMod) - % Convert indices back into the intial Channels structure - iBadChan = [iBadChan, iChan(iBadChanMod)]; + for iBlock = 1 : size(iTimesBlocks, 1) + blockTimeBounds = DataMat.Time(iTimesBlocks(iBlock, :)); + % Load data from link to raw data + RawDataMat = in_bst(sInputs(iFile).FileName, blockTimeBounds, 1, 0, 'no'); + % Scale gradiometers / magnetometers: + % - Neuromag: Apply axial factor to MEG GRAD sensors, to convert in fT/cm + % - CTF: Apply factor to MEG REF gradiometers + RawDataMat.F = bst_scale_gradmag(RawDataMat.F, ChannelMat.Channel); + [~, iTimesSegments, R] = bst_epoching(RawDataMat.F, winLengthSamples); + if ~isempty(R) + if ~isempty(iTimesSegments) + lastTime = iTimesSegments(end, 2); + else + lastTime = 0; + end + % Add the times for the remaining + iTimesSegments = [iTimesSegments; lastTime+1, lastTime+size(R,2)]; + end + % Detect bad segments + for iSegment = 1 : size(iTimesSegments, 1) + iTimesSegment = iTimesSegments(iSegment, :); + [iBadChannels, criteriaModalities] = Thresholding(RawDataMat.F(:,iTimesSegment(1):iTimesSegment(2)), RawDataMat.ChannelFlag, ChannelMat, Criteria); + % Create one bad event for each channel-segment + for ix = 1 : length(iBadChannels) + % Create bad event + sBadEvent = db_template('event'); + sBadEvent.label = sprintf('BAD_detectbad_%s_block_%04d_segment_%04d_channel_%04d', criteriaModalities{ix}, iBlock, iSegment, iBadChannels(ix)); + sBadEvent.times = RawDataMat.Time(iTimesSegment)'; + sBadEvent.epochs = 1; + sBadEvent.channels = {{ChannelMat.Channel(iBadChannels(ix)).Name}}; + sBadEvent.notes = []; + % Add to events structure + sBadEvents(end+1) = sBadEvent; + end + end end - end - - % === TAG FILE === - if ~isempty(iBadChan) - % Reject entire trial + % If reject trial, ignore 'channels' field if isRejectTrial - % Mark trial as bad - iBadTrials(end+1) = iFile; - % Report - bst_report('Info', sProcess, sInputs(iFile), 'Marked as bad trial.'); - % Update study - sStudy.Data(iData).BadTrial = 1; - bst_set('Study', iStudy, sStudy); - % Reject channels only - else - % Add detected channels to list of file bad channels - s.ChannelFlag = DataMat.ChannelFlag; - s.ChannelFlag(iBadChan) = -1; - % History - DataMat = bst_history('add', DataMat, 'detect', [FormatComment(sProcess) ' => Detected bad channels:' sprintf(' %d', iBadChan)]); - s.History = DataMat.History; - bst_report('Info', sProcess, sInputs(iFile), ['Bad channels: ' sprintf(' %d', iBadChan)]); - % Save file - bst_save(file_fullpath(sInputs(iFile).FileName), s, 'v6', 1); + % Remove channel information + [sBadEvents.channels] = deal([]); + end + % Merge all bad events by modality + badEventModNames = cellfun(@(x) regexp(x, '^BAD_detectbad_[^\W_]+', 'match'), {sBadEvents.label}); + [badEventModNamesUnique, ~, ic] = unique(badEventModNames); + for iMod = 1 : length(badEventModNamesUnique) + % If only one event for modality + if sum(ic == iMod) == 1 + sBadEvents = [sBadEvents, sBadEvents(ic == iMod)]; + sBadEvents(end).label = badEventModNamesUnique{iMod}; + else + sBadEvents = process_evt_merge('Compute', '', sBadEvents, {sBadEvents(ic == iMod).label}, badEventModNamesUnique{iMod}, 0); + end + end + % Remove bad events that were merged + sBadEvents(1:length(ic)) = []; + % Combine bad channels for same bad segment + if ~isRejectTrial + for iBadEvent = 1 : length(sBadEvents) + % Combine channels for each unique window + [~, ics, ias] = unique(bst_round(sBadEvents(iBadEvent).times', 9), 'rows', 'stable'); + for iw = 1 : length(ics) + % Combine channels + sBadEvents(iBadEvent).channels{ics(iw)} = [sBadEvents(iBadEvent).channels{ias == iw}]; + end + % Delete all but unique windows + sBadEvents(iBadEvent).times = sBadEvents(iBadEvent).times(:, ics); + sBadEvents(iBadEvent).epochs = sBadEvents(iBadEvent).epochs(:, ics); + sBadEvents(iBadEvent).channels = sBadEvents(iBadEvent).channels(:, ics); + end + end + % Append bad events to original events in file + DataMat.F.events = [DataMat.F.events, sBadEvents]; + % Save bad events + bst_save(file_fullpath(sInputs(iFile).FileName), DataMat, 'v6', 1); + + % Process imported data file + else + % File is already loaded + % Scale gradiometers / magnetometers: + % - Neuromag: Apply axial factor to MEG GRAD sensors, to convert in fT/cm + % - CTF: Apply factor to MEG REF gradiometers + DataMat.F = bst_scale_gradmag(DataMat.F, ChannelMat.Channel); + % List of bad channels for this file + iBadChan = Thresholding(DataMat.F(:,iTime), DataMat.ChannelFlag, ChannelMat, Criteria); + + % === TAG FILE === + if ~isempty(iBadChan) + % Reject entire trial + if isRejectTrial + % Mark trial as bad + iBadTrials(end+1) = iFile; + % Report + bst_report('Info', sProcess, sInputs(iFile), 'Marked as bad trial.'); + % Reject channels only + else + % Add detected channels to list of file bad channels + s.ChannelFlag = DataMat.ChannelFlag; + s.ChannelFlag(iBadChan) = -1; + % History + DataMat = bst_history('add', DataMat, 'detect', [FormatComment(sProcess) ' => Detected bad channels:' sprintf(' %d', iBadChan)]); + s.History = DataMat.History; + bst_report('Info', sProcess, sInputs(iFile), ['Bad channels: ' sprintf(' %d', iBadChan)]); + % Save file + bst_save(file_fullpath(sInputs(iFile).FileName), s, 'v6', 1); + end end end end + % Record bad trials in study if ~isempty(iBadTrials) SetTrialStatus({sInputs(iBadTrials).FileName}, 1); @@ -262,6 +339,69 @@ end +%% ===== THRESHOLDING ===== +% USAGE: iBadChannel = Thresholding(Data, ChannelFile, Criteria) + +function [iBadChannel, criteriaModalities] = Thresholding(F, ChannelFlag, ChannelFile, Criteria) + % CALL: Thresholding(FileName, ChannelFile, ...) + if ischar(ChannelFile) + ChannelMat = in_bst_channel(ChannelFile); + % CALL: Thresholding(FileName, ChannelMat, ...) + else + ChannelMat = ChannelFile; + end + % Get modalities + Modalities = unique({ChannelMat.Channel.Type}); + + % List of bad channels + iBadChannel = []; + % List of modality criteria used for each bad channel + criteriaModalities = {}; + + % === LOOP ON MODALITIES === + for iMod = 1:length(Modalities) + % === GET REJECTION CRITERIA === + % Get threshold according to the modality + if ismember(Modalities{iMod}, {'MEG', 'MEG GRAD'}) + criteriaField = 'meggrad'; + elseif strcmpi(Modalities{iMod}, 'MEG MAG') + criteriaField = 'megmag'; + elseif strcmpi(Modalities{iMod}, 'EEG') + criteriaField = 'eeg'; + elseif ismember(Modalities{iMod}, {'SEEG', 'EOCG'}) + criteriaField = 'ieeg'; + elseif ~isempty(strfind(lower(Modalities{iMod}), 'eog')) + criteriaField = 'eog'; + elseif ~isempty(strfind(lower(Modalities{iMod}), 'ecg')) || ~isempty(strfind(lower(Modalities{iMod}), 'ekg')) + criteriaField = 'ecg'; + else + return; + end + Threshold = Criteria.(criteriaField); + % If threshold is [0 0]: nothing to do + if isequal(Threshold, [0 0]) + return; + end + + % === DETECT BAD CHANNELS === + % Get channels for this modality + iChan = good_channel(ChannelMat.Channel, ChannelFlag, Modalities{iMod}); + % Get data to test + DataToTest = F(iChan, :); + % Compute peak-to-peak values for all the sensors + p2p = (max(DataToTest,[],2) - min(DataToTest,[],2)); + % Get bad channels + iBadChanMod = find((p2p < Threshold(1)) | (p2p > Threshold(2))); + % If some bad channels were detected + if ~isempty(iBadChanMod) + % Convert indices back into the intial Channels structure + iBadChannel = [iBadChannel, iChan(iBadChanMod)]; + criteriaModalities = [criteriaModalities, repmat({criteriaField}, 1, length(iBadChanMod))]; + end + end +end + + %% ===== SET STUDY BAD TRIALS ===== % USAGE: SetTrialStatus(FileNames, isBad) % SetTrialStatus(FileName, isBad) diff --git a/toolbox/process/functions/process_detectbad_mad.m b/toolbox/process/functions/process_detectbad_mad.m new file mode 100644 index 000000000..556871e22 --- /dev/null +++ b/toolbox/process/functions/process_detectbad_mad.m @@ -0,0 +1,419 @@ +function varargout = process_detectbad_mad( varargin ) +% PROCESS_DETECTBAD_MAD: Detection based on +-n median absolute deviation from amplitude and gradient medians + +% @============================================================================= +% This function is part of the Brainstorm software: +% https://neuroimage.usc.edu/brainstorm +% +% Copyright (c) University of Southern California & McGill University +% This software is distributed under the terms of the GNU General Public License +% as published by the Free Software Foundation. Further details on the GPLv3 +% license can be found at http://www.gnu.org/copyleft/gpl.html. +% +% FOR RESEARCH PURPOSES ONLY. THE SOFTWARE IS PROVIDED "AS IS," AND THE +% UNIVERSITY OF SOUTHERN CALIFORNIA AND ITS COLLABORATORS DO NOT MAKE ANY +% WARRANTY, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO WARRANTIES OF +% MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE, NOR DO THEY ASSUME ANY +% LIABILITY OR RESPONSIBILITY FOR THE USE OF THIS SOFTWARE. +% +% For more information type "brainstorm license" at command prompt. +% =============================================================================@ +% +% Authors: Raymundo Cassani, 2024 +% Alex Wiesman, 2024 +% +% Process based on the AUTO and MANUAL methods from Trial Exclusion in ArtifactScanTool +% https://github.com/nichrishayes/ArtifactScanTool + +eval(macro_method); +end + + +%% ===== GET DESCRIPTION ===== +function sProcess = GetDescription() %#ok + % Description the process + sProcess.Comment = 'Detect bad: amplitude and gradient thresholds'; + sProcess.Category = 'Custom'; + sProcess.SubGroup = 'Artifacts'; + sProcess.Index = 116; + sProcess.Description = 'https://neuroimage.usc.edu/brainstorm/Tutorials/ArtifactsDetect#Other_detection_processes'; + % Definition of the input accepted by this process + sProcess.InputTypes = {'raw', 'data'}; + sProcess.OutputTypes = {'raw', 'data'}; + sProcess.nInputs = 1; + sProcess.nMinFiles = 1; + + % Extra info + sProcess.options.info.Comment = ['Reject bad segments/trials on MEG recordings, based on:
'... + '1. peak-to-peak amplitude, and/or
' ... + '2. numerical gradient values
' ... + 'outside of specified thresholds.
' ... + '
', ... + '
']; + sProcess.options.info.Type = 'label'; + % Time window + sProcess.options.timewindow.Comment = 'Time window:'; + sProcess.options.timewindow.Type = 'timewindow'; + sProcess.options.timewindow.Value = []; + % Option: Window length + sProcess.options.win_length.Comment = 'Length of analysis window: '; + sProcess.options.win_length.Type = 'value'; + sProcess.options.win_length.Value = {1, 's', []}; + sProcess.options.win_length.InputTypes = {'raw'}; + % Warning on complete windows + sProcess.options.warning_raw.Comment = 'Only will complete windows be analyzed'; + sProcess.options.warning_raw.Type = 'label'; + sProcess.options.warning_raw.InputTypes = {'raw'}; + % Separator + sProcess.options.sep1.Type = 'separator'; + % Threshold method: Auto or Manual + sProcess.options.threshold_method.Comment = {'auto', 'manual', 'Threshold method: '; ... + 'auto', 'manual', ''}; + sProcess.options.threshold_method.Type = 'radio_linelabel'; + sProcess.options.threshold_method.Value = 'auto'; + sProcess.options.threshold_method.Controller = struct('auto', 'auto', 'manual', 'manual'); + % AUTO + % Option: Number of mad for amplitude and grandient + sProcess.options.n_mad.Comment = 'Number of median absolute deviations for thresholds:'; + sProcess.options.n_mad.Type = 'value'; + sProcess.options.n_mad.Value = {3, 'mad', []}; + sProcess.options.n_mad.Class = 'auto'; + % MANUAL + % Option: Threshold p2p amplitude + sProcess.options.threshold_p2p.Comment = 'Threshold peak-to-peak amplitude: '; + sProcess.options.threshold_p2p.Type = 'value'; + sProcess.options.threshold_p2p.Value = {0, 'fT', 2}; + sProcess.options.threshold_p2p.Class = 'manual'; + % Option: Threshold gradiente + sProcess.options.threshold_grad.Comment = 'Threshold gradient: '; + sProcess.options.threshold_grad.Type = 'value'; + sProcess.options.threshold_grad.Value = {0, 'fT/s', 2}; + sProcess.options.threshold_grad.Class = 'manual'; + % Separator + sProcess.options.sep2.Type = 'separator'; + % Option: Ignore sign for gradient + sProcess.options.abs_gradient.Comment = 'Use absolute gradient: '; + sProcess.options.abs_gradient.Type = 'checkbox'; + sProcess.options.abs_gradient.Value = 1; +end + + +%% ===== FORMAT COMMENT ===== +function Comment = FormatComment(sProcess) + Comment = sProcess.Comment; +end + + +%% ===== RUN ===== +function OutputFiles = Run(sProcess, sInputsAll) %#ok + % ===== GET OPTIONS ===== + % Get time window + if isfield(sProcess.options, 'timewindow') && isfield(sProcess.options.timewindow, 'Value') && iscell(sProcess.options.timewindow.Value) && ~isempty(sProcess.options.timewindow.Value) + TimeWindow = sProcess.options.timewindow.Value{1}; + else + TimeWindow = []; + end + % Check: Same FileType for all files + is_raw = strcmp({sInputsAll.FileType},'raw'); + if ~all(is_raw) && ~all(~is_raw) + bst_error('Please do not mix continous (raw) and imported data', 'Detect bad segments', 0); + return; + end + % If raw file, get window length + if is_raw(1) && isfield(sProcess.options, 'win_length') && ~isempty(sProcess.options.win_length) && ~isempty(sProcess.options.win_length.Value) && iscell(sProcess.options.win_length.Value) + winLength = sProcess.options.win_length.Value{1}; + end + % Number of mad + nMad = sProcess.options.n_mad.Value{1}; + if nMad <= 0 + bst_error('Number of MAD must be greater than 0', 'Detect bad segments', 0); + return; + end + abs_gradient = sProcess.options.abs_gradient.Value; + + % Threshold method + thMethod = sProcess.options.threshold_method.Value; + switch thMethod + case 'auto' + isThAuto = 1; + + case 'manual' + isThAuto = 0; + threshold_p2p = sProcess.options.threshold_p2p.Value{1} * 1e-15; + threshold_gradient = sProcess.options.threshold_grad.Value{1} * 1e-15; + if threshold_p2p <= 0 || threshold_gradient <=0 + bst_error('Thresholds cannot be smaller than zero', 'Detect bad segments', 0); + return; + end + + otherwise + bst_error(sprintf('Threshold method "%s" is not supported', thMethod), 'Detect bad segments', 0); + return; + end + + % Group files by Study + [iGroups, ~, StudyPaths] = process_average('SortFiles', sInputsAll, 3); + + % Get current progressbar position + progressPos = bst_progress('get'); + + OutputFiles = {}; + % ===== LOOP ON STUDIES ===== + for ix = 1 : length(StudyPaths) + bst_progress('set', progressPos + round(ix / length(StudyPaths) * 100)); + % Find Study in database + [sStudy, iStudy] = bst_get('StudyWithCondition', StudyPaths{ix}); + % Find Subject with Study + sSubject = bst_get('Subject', sStudy.BrainStormSubject); + sInputs = sInputsAll(iGroups{ix}); + % Load channel file + sChannel = bst_get('ChannelForStudy', iStudy); + ChannelMat = in_bst_channel(sChannel.FileName); + % Get MEG channels + Modalities = channel_get_modalities(ChannelMat.Channel); + if ~any(ismember(Modalities, {'MEG', 'MEG GRAD', 'MEG MAG'})) + bst_error(sprintf('Channel files "%s" does not contain MEG sensors', sChannel.FileName), 'Detect bad segments', 0); + return; + end + + % === PROCESS RAW DATA === + if is_raw + if length(sInputs) > 1 + bst_error(sprintf('Study "%s" has more than one continous (raw) data', sStudy.Condition), 'Detect bad segments', 0); + return; + end + % === LOAD FILE === + % Load data file + DataMat = in_bst_data(sInputs(1).FileName, 'F', 'ChannelFlag', 'History', 'Time'); + % Get sample bounds for time window + if ~isempty(TimeWindow) + iTime = bst_closest(sProcess.options.timewindow.Value{1}, DataMat.Time); + if (iTime(1) == iTime(2)) && any(iTime(1) == DataMat.Time) + bst_report('Error', sProcess, sInputs(1), 'Invalid time definition.'); + OutputFiles = []; + return; + end + iTime = iTime(1):iTime(2); + else + iTime = 1:length(DataMat.Time); + end + % Sampling frequency + fs = 1 ./ (DataMat.Time(2) - DataMat.Time(1)); + % MEG channels (includes 'MEG GRAD' and 'MEG MAG') + iMegChannels = good_channel(ChannelMat.Channel, DataMat.ChannelFlag, 'MEG'); + nChannels = length(iMegChannels); + % Get maximum size of a data block + ProcessOptions = bst_get('ProcessOptions'); + blockLengthSamples = max(floor(ProcessOptions.MaxBlockSize / nChannels), 1); + % Length of window of analysis in samples + winLengthSamples = round(fs*winLength); + % Block length as multiple of the length of the analysis window + blockLengthSamples = winLengthSamples * floor(blockLengthSamples / winLengthSamples); + if blockLengthSamples == 0 + return + end + % List of bad events for this file + sBadEvents = repmat(db_template('event'), 0); + % Indices for each block + [~, iTimesBlocks, R] = bst_epoching(iTime, blockLengthSamples); + nWindows = size(iTimesBlocks, 1) * floor(blockLengthSamples / winLengthSamples); + if ~isempty(R) + if ~isempty(iTimesBlocks) + lastTime = iTimesBlocks(end, 2); + else + lastTime = 0; + end + % Add the times for the remaining block + iTimesBlocks = [iTimesBlocks; lastTime+1, lastTime+size(R,2)]; + nWindows = nWindows + floor(size(R,2) / winLengthSamples); + end + % Maximum Peak-2-peak per window + max_p2p = zeros(nWindows, 1); + % Maximum Gradient per window + max_gradient = zeros(nWindows, 1); + iWindow = 1; + iTimesWindows = []; + for iBlock = 1 : size(iTimesBlocks, 1) + blockTimeBounds = DataMat.Time(iTimesBlocks(iBlock, :)); + % Load data from link to raw data + RawDataMat = in_bst(sInputs(1).FileName, blockTimeBounds, 1, 0, 'no'); + % Scale gradiometers / magnetometers: + % - Neuromag: Apply axial factor to MEG GRAD sensors, to convert in fT/cm + % - CTF: Apply factor to MEG REF gradiometers + RawDataMat.F = bst_scale_gradmag(RawDataMat.F, ChannelMat.Channel); + [~, iTimesSegments] = bst_epoching(RawDataMat.F, winLengthSamples); + iTimesWindows = [iTimesWindows; iTimesSegments + (iBlock-1)*blockLengthSamples]; + % Process each segment + for iSegment = 1 : size(iTimesSegments, 1) + iTimesSegment = iTimesSegments(iSegment, :); + % Compute metrics + max_p2p(iWindow) = max(max(RawDataMat.F(iMegChannels, iTimesSegment(1):iTimesSegment(2)), [], 2) - ... + min(RawDataMat.F(iMegChannels, iTimesSegment(1):iTimesSegment(2)), [], 2), [], 1); + if abs_gradient + max_gradient(iWindow) = max(max(abs(gradient(RawDataMat.F(iMegChannels, iTimesSegment(1):iTimesSegment(2)), 1./fs)), [], 2), [], 1); + else + max_gradient(iWindow) = max(max(gradient(RawDataMat.F(iMegChannels, iTimesSegment(1):iTimesSegment(2)), 1./fs), [], 2), [], 1); + end + iWindow = iWindow + 1; + end + end + if isThAuto + % Compute thresholds + threshold_p2p = median(max_p2p) + (nMad * mad(max_p2p,1)); + threshold_gradient = median(max_gradient) + (nMad * mad(max_gradient,1)); + end + % Create one bad event for each window over any threshold + iBadWindows = []; + for iWindow = 1 : nWindows + % Criteria + if max_p2p(iWindow) > threshold_p2p && max_gradient(iWindow) > threshold_gradient + criteria_str = 'p2p_gradient'; + elseif max_p2p(iWindow) > threshold_p2p + criteria_str = 'p2p'; + elseif max_gradient(iWindow) > threshold_gradient + criteria_str = 'gradient'; + else + continue + end + iBadWindows(end+1) = iWindow; + % Create bad event + sBadEvent = db_template('event'); + sBadEvent.label = sprintf('BAD_detectbad_mad_%s_window_%d', criteria_str, iWindow); + sBadEvent.times = DataMat.Time(iTimesWindows(iWindow,:))'; + sBadEvent.epochs = 1; + sBadEvent.channels = []; + sBadEvent.notes = []; + % Add to events structure + sBadEvents(end+1) = sBadEvent; + end + % Merge all bad events by criteria + badEventCriteriaNames = cellfun(@(x) regexp(x, '^BAD_detectbad_mad_.*(?=_window)', 'match'), {sBadEvents.label}); + [badEventCriteriaNamesUnique, ~, ic] = unique(badEventCriteriaNames); + for iCriteria = 1 : length(badEventCriteriaNamesUnique) + % If only one event for criteria + if sum(ic == iCriteria) == 1 + sBadEvents = [sBadEvents, sBadEvents(ic == iCriteria)]; + sBadEvents(end).label = badEventCriteriaNamesUnique{iCriteria}; + else + sBadEvents = process_evt_merge('Compute', '', sBadEvents, {sBadEvents(ic == iCriteria).label}, badEventCriteriaNamesUnique{iCriteria}, 0); + end + end + % Remove bad events that were merged + sBadEvents(1:length(ic)) = []; + % Append bad events to original events in file + DataMat.F.events = [DataMat.F.events, sBadEvents]; + % Save bad events + bst_save(file_fullpath(sInputs(1).FileName), DataMat, 'v6', 1); + % Report by Study + GenerateReportEntry(sProcess, sInputs(1), sSubject, sStudy, is_raw, ... + {max_p2p, max_gradient}, {threshold_p2p, threshold_gradient}, ... + nWindows, nWindows-length(iBadWindows)); + % Return input file + OutputFiles = [OutputFiles, sInputs(1).FileName]; + + + % === PROCESS IMPORTED DATA FILES === + else + nFiles = length(sInputs); + % Maximum Peak-2-peak per trial + max_p2p = zeros(nFiles, 1); + % Maximum Gradient per trial + max_gradient = zeros(nFiles, 1); + for iFile = 1:length(sInputs) + % === LOAD FILE === + % Load data file + DataMat = in_bst_data(sInputs(iFile).FileName, 'F', 'ChannelFlag', 'History', 'Time'); + % Get sample bounds for time window + if ~isempty(TimeWindow) + iTime = bst_closest(sProcess.options.timewindow.Value{1}, DataMat.Time); + if (iTime(1) == iTime(2)) && any(iTime(1) == DataMat.Time) + bst_report('Error', sProcess, sInputs(iFile), 'Invalid time definition.'); + OutputFiles = []; + return; + end + iTime = iTime(1):iTime(2); + else + iTime = 1:length(DataMat.Time); + end + % Sampling frequency + fs = 1 ./ (DataMat.Time(2) - DataMat.Time(1)); + % MEG channels (includes 'MEG GRAD' and 'MEG MAG') + iMegChannels = good_channel(ChannelMat.Channel, DataMat.ChannelFlag, 'MEG'); + % Scale gradiometers / magnetometers: + % - Neuromag: Apply axial factor to MEG GRAD sensors, to convert in fT/cm + % - CTF: Apply factor to MEG REF gradiometers + DataMat.F = bst_scale_gradmag(DataMat.F, ChannelMat.Channel); + % Compute metrics + max_p2p(iFile) = max(max(DataMat.F(iMegChannels, iTime), [], 2) - ... + min(DataMat.F(iMegChannels, iTime), [], 2), [], 1); + if abs_gradient + max_gradient(iFile) = max(max(abs(gradient(DataMat.F(iMegChannels, iTime), 1./fs)), [], 2), [], 1); + else + max_gradient(iFile) = max(max(gradient(DataMat.F(iMegChannels, iTime), 1./fs), [], 2), [], 1); + end + end + if isThAuto + % Compute thresholds + threshold_p2p = median(max_p2p) + (nMad * mad(max_p2p,1)); + threshold_gradient = median(max_gradient) + (nMad * mad(max_gradient,1)); + end + % Set as bad trials over any threshold + iBadTrials = []; + % === TAG FILE === + for iFile = 1 : nFiles + % Criteria + if max_p2p(iFile) > threshold_p2p && max_gradient(iFile) > threshold_gradient + criteria_str = 'p2p_gradient'; + elseif max_p2p(iFile) > threshold_p2p + criteria_str = 'p2p'; + elseif max_gradient(iFile) > threshold_gradient + criteria_str = 'gradient'; + else + continue + end + iBadTrials(end+1) = iFile; + % Add rejection criteria to history of file + DataMat = bst_history('add', DataMat, 'detect', [FormatComment(sProcess) ' => Detected bad with MAP due to: ' criteria_str]); + s.History = DataMat.History; + bst_save(file_fullpath(sInputs(iFile).FileName), s, 'v6', 1); + % Set bad in Study + process_detectbad('SetTrialStatus', {sInputs(iFile).FileName}, 1); + end + % Report by study + GenerateReportEntry(sProcess, sInputs(1), sSubject, sStudy, is_raw, ... + {max_p2p, max_gradient}, {threshold_p2p, threshold_gradient}, ... + nFiles, nFiles-length(iBadTrials)); + % Return only good files + iGoodTrials = setdiff(1:length(sInputs), iBadTrials); + OutputFiles = [OutputFiles, sInputs(iGoodTrials).FileName]; + end + end +end + + +function GenerateReportEntry(sProcess, sInput, sSubject, sStudy, is_raw, maxValues, thresholdValues, nTotal, nAccepted) + histLegends = {'Max P2P range', 'Max Gradient range'}; + histColors = {'b', 'r'}; + histUnits = {'fT', 'fT/s'}; + itemLabel = 'windows'; + if ~is_raw + itemLabel = 'files'; + end + % Report thresholds and number of windows/files + bst_report('Info', sProcess, sInput, sprintf('Subject = %s, Study = %s, P2P threshold = %E %s, Gradient threshold %E %s, Total %s = %d, Acepted %s = %d', ... + sSubject.Name, sStudy.Name, ... + thresholdValues{1}*1e15, histUnits{1}, thresholdValues{2}*1e15, histUnits{2}, ... + itemLabel, nTotal, itemLabel, nAccepted)); + % Report histograms + hFig = figure(); + for iHist = 1 : length(maxValues) + ax = subplot(2,1,iHist); + histogram(ax, maxValues{iHist}*1e15,'BinWidth',(max(maxValues{iHist}) - min(maxValues{iHist}))*1e15/10, 'FaceColor', histColors{iHist}); + line(ax, [thresholdValues{iHist}, thresholdValues{iHist}]*1e15, ylim, 'Color','black','LineStyle','--'); + legend(ax, histLegends{iHist}); + xlabel(ax, histUnits{iHist}); + ylabel(ax, [itemLabel, ' count']) + end + bst_report('Snapshot', hFig, sInput, [sProcess.Comment, ':: Distributions P2P and Gradient']); + close(hFig); +end diff --git a/toolbox/process/functions/process_dwi2dti.m b/toolbox/process/functions/process_dwi2dti.m index 419f64e40..7ff6bfecf 100644 --- a/toolbox/process/functions/process_dwi2dti.m +++ b/toolbox/process/functions/process_dwi2dti.m @@ -147,16 +147,47 @@ OutputFiles = {'import'}; end - -%% ===== COMPUTE DTI ===== -function [DtiFile, errMsg] = Compute(iSubject, T1BstFile, DwiFile, BvalFile, BvecFile) - DtiFile = []; - errMsg = ''; +%% ===== CHECK FOR BRAINSUITE INSTALLATION ===== +function [bdp_exe, errMsg] = CheckBrainSuiteInstall() + errMsg = []; if ~ispc bdp_exe = 'bdp.sh'; else bdp_exe = 'bdp'; end + + % ===== INSTALL BRAINSUITE ===== + bst_progress('text', 'Testing BrainSuite installation...'); + % Check BrainSuite installation + status = system([bdp_exe ' --version']); + if (status ~= 0) + % Get BrainSuite path from Brainstorm preferences + BsDir = bst_get('BrainSuiteDir'); + BsBinDir = bst_fullfile(BsDir, 'bin'); + BsBdpDir = bst_fullfile(BsDir, 'bdp'); + % Add BrainSuite path to system path + if ~isempty(BsDir) && file_exist(BsBinDir) && file_exist(BsBdpDir) + disp(['BST> Adding to system path: ' BsBinDir]); + disp(['BST> Adding to system path: ' BsBdpDir]); + setenv('PATH', [getenv('PATH'), pathsep, BsBinDir, pathsep, BsBdpDir]); + % Check again + status = system([bdp_exe ' --version']); + end + % Brainsuite is not installed + if (status ~= 0) + errMsg = ['BrainSuite is not installed on your computer.' 10 ... + 'Download it from http://brainsuite.org and install it.' 10 ... + 'Then set its installation folder in the Brainstorm options (File > Edit preferences)']; + return + end + end +end + +%% ===== COMPUTE DTI ===== +function [DtiFile, errMsg] = Compute(iSubject, T1BstFile, DwiFile, BvalFile, BvecFile) + DtiFile = []; + errMsg = ''; + % ===== INPUTS ===== % Try to find the bval/bvec files in the same folder [fPath, fBase, fExt] = bst_fileparts(DwiFile); @@ -195,31 +226,8 @@ T1BstFile = sSubject.Anatomy(sSubject.iAnatomy).FileName; end - % ===== INSTALL BRAINSUITE ===== - bst_progress('text', 'Testing BrainSuite installation...'); - % Check BrainSuite installation - status = system([bdp_exe ' --version']); - if (status ~= 0) - % Get BrainSuite path from Brainstorm preferences - BsDir = bst_get('BrainSuiteDir'); - BsBinDir = bst_fullfile(BsDir, 'bin'); - BsBdpDir = bst_fullfile(BsDir, 'bdp'); - % Add BrainSuite path to system path - if ~isempty(BsDir) && file_exist(BsBinDir) && file_exist(BsBdpDir) - disp(['BST> Adding to system path: ' BsBinDir]); - disp(['BST> Adding to system path: ' BsBdpDir]); - setenv('PATH', [getenv('PATH'), pathsep, BsBinDir, pathsep, BsBdpDir]); - % Check again - status = system([bdp_exe ' --version']); - end - % Brainsuite is not installed - if (status ~= 0) - errMsg = ['BrainSuite is not installed on your computer.' 10 ... - 'Download it from http://brainsuite.org and install it.' 10 ... - 'Then set its installation folder in the Brainstorm options (File > Edit preferences)']; - return - end - end + % ===== CHECK FOR BRAINSUITE INSTALLATION ===== + [bdp_exe, errMsg] = CheckBrainSuiteInstall(); % ===== TEMPORARY FOLDER ===== bst_progress('text', 'Preparing temporary folder...'); diff --git a/toolbox/process/functions/process_eegref.m b/toolbox/process/functions/process_eegref.m index 6bf7c733d..6b1d3e55c 100644 --- a/toolbox/process/functions/process_eegref.m +++ b/toolbox/process/functions/process_eegref.m @@ -153,11 +153,12 @@ proj.Components = W; proj.CompMask = []; proj.Status = 1; - proj.SingVal = 'REF'; + proj.SingVal = []; + proj.Method = 'REF'; % === SAVE PROJECTOR === % Check for existing re-referencing projector - if ~isempty(ChannelMat.Projector) && any(cellfun(@(c)isequal(c,'REF'), {ChannelMat.Projector.SingVal})) + if ~isempty(ChannelMat.Projector) && any(cellfun(@(c)isequal(c,'REF'), {ChannelMat.Projector.Method})) %bst_report('Warning', sProcess, [], 'There was already a re-referencing projector.'); disp('BST> EEGREF: There was already a re-referencing projector.'); end diff --git a/toolbox/process/functions/process_evt_extended.m b/toolbox/process/functions/process_evt_extended.m index 9118d5efe..e39dbc0f5 100644 --- a/toolbox/process/functions/process_evt_extended.m +++ b/toolbox/process/functions/process_evt_extended.m @@ -80,10 +80,12 @@ DataMat = in_bst_data(sInput.FileName, 'F'); sEvents = DataMat.F.events; sFreq = DataMat.F.prop.sfreq; + FullTimeWindow = DataMat.F.prop.times; else DataMat = in_bst_data(sInput.FileName, 'Events', 'Time'); sEvents = DataMat.Events; sFreq = 1 ./ (DataMat.Time(2) - DataMat.Time(1)); + FullTimeWindow = [DataMat.Time(1), DataMat.Time(end)]; end % If no markers are present in this file if isempty(sEvents) @@ -111,11 +113,9 @@ end % ===== PROCESS EVENTS ===== - for i = 1:length(iEvtList) - sEvents(iEvtList(i)).times = round([... - sEvents(iEvtList(i)).times + TimeWindow(1); - sEvents(iEvtList(i)).times + TimeWindow(2)] .* sFreq) ./ sFreq; - end + sEventsMod = sEvents(iEvtList); + sEventsMod = Compute(sEventsMod, TimeWindow, FullTimeWindow, sFreq); + sEvents(iEvtList) = sEventsMod; % ===== SAVE RESULT ===== % Report results @@ -129,5 +129,11 @@ end - - +%% ===== COMPUTE ===== +function sEvents = Compute(sEvents, EventTimeWindow, FullTimeWindow, sFreq) + for i = 1:length(sEvents) + sEvents(i).times = round([... + max(FullTimeWindow(1), sEvents(i).times(1,:) + EventTimeWindow(1)); + min(FullTimeWindow(2), sEvents(i).times(1,:) + EventTimeWindow(2))] .* sFreq) ./ sFreq; + end +end \ No newline at end of file diff --git a/toolbox/process/functions/process_evt_head_motion.m b/toolbox/process/functions/process_evt_head_motion.m index 43a7d659c..137f7db44 100644 --- a/toolbox/process/functions/process_evt_head_motion.m +++ b/toolbox/process/functions/process_evt_head_motion.m @@ -323,9 +323,9 @@ nSamples = SamplesBounds(2) - SamplesBounds(1) + 1; - iHLU = find(strcmp({ChannelMat.Channel.Type}, 'HLU')); + iHLU = find(strcmpi({ChannelMat.Channel.Type}, 'HLU')); [Unused, iSortHlu] = sort({ChannelMat.Channel(iHLU).Name}); - iFitErr = find(strcmp({ChannelMat.Channel.Type}, 'FitErr')); + iFitErr = find(strcmpi({ChannelMat.Channel.Type}, 'FitErr')); [Unused, iSortFitErr] = sort({ChannelMat.Channel(iFitErr).Name}); nChannels = numel(iHLU); if nChannels == 0 @@ -467,8 +467,7 @@ if nargin < 3 || isempty(StopThreshold) StopThreshold = false; end - - if size(Locations, 1) ~= 9 || size(Reference, 1) ~= 9 + if size(Locations, 1) ~= 9 %|| size(Reference, 1) ~= 9 bst_error('Expecting 9 HLU channels in first dimension.'); end nS = size(Locations, 2); @@ -476,15 +475,24 @@ % Calculate distances. - Reference = reshape(Reference, [3, 3]); - % Reference "head origin" and inverse "orientation matrix". - [YO, YR] = RigidCoordinates(Reference); - % Sphere radius. - r = max( sqrt(sum((Reference - YO(:, [1, 1, 1])).^2, 1)) ); - if any(YR(:)) % any ignores NaN and returns false for empty. - YI = inv(YR); % Faster to calculate inverse once here than "/" in loop. + if nargin < 2 || isempty(Reference) + % Assume reference defines coordinate system. + YO = zeros(3, 1); + YI = eye(3); + % Use first location to estimate sphere radius. + r = max(sqrt(sum(bsxfun(@minus, reshape(Locations(1:9), [3,3]), ... + (Locations(4:6) + Locations(7:9))/2).^2, 1))); else - YI = YR; + Reference = reshape(Reference, [3, 3]); + % Reference "head origin" and inverse "orientation matrix". + [YO, YR] = RigidCoordinates(Reference); + % Sphere radius, estimate from reference. + r = max( sqrt(sum((Reference - YO(:, [1, 1, 1])).^2, 1)) ); + if any(YR(:)) % any ignores NaN and returns false for empty. + YI = inv(YR); % Faster to calculate inverse once here than "/" in loop. + else + YI = YR; + end end % SinHalf = zeros([nS, 1, nT]); @@ -499,21 +507,8 @@ % it is a rotation around an axis through the real origin). R = XR * YI; % %#ok - % Sine of half the rotation angle. - % SinHalf = sqrt(3 - trace(R)) / 2; - % For very small angles, this formula is not accurate compared to - % w, since diagonal elements are around 1, and eps(1) = 2.2e-16. - % This will be the order of magnitude of non-diag. elements due to - % errors. So we should get SinHalf from w. - % Rotation axis with amplitude = SinHalf (like in rotation quaternions). - w = [R(3, 2) - R(2, 3); R(1, 3) - R(3, 1); R(2, 1) - R(1, 2)] / ... - (2 * sqrt(1 + R(1, 1) + R(2, 2) + R(3, 3))); - SinHalf = sqrt(sum(w.^2)); - TNormSq = sum(T.^2); - % Maximum sphere distance for translation + rotation, as described - % above. - D(s, t) = sqrt( TNormSq + (2 * r * SinHalf)^2 + ... - 4 * r * sqrt(TNormSq * SinHalf^2 - (T' * w)^2) ); + % Maximum sphere distance for translation + rotation, as described above. + D(s, t) = RigidDistTransform(R, T, r); % CHECK should be comparable AND >= to max coil movement. % Option to interrupt when past a distance threshold. @@ -527,6 +522,28 @@ +function D = RigidDistTransform(R, T, rad) + % Maximum sphere distance for translation + rotation, as described above. + if isempty(T) && size(R,1) == 4 + T = R(1:3, 4); + R = R(1:3, 1:3); + end + % Sine of half the rotation angle. + % SinHalf = sqrt(3 - trace(R)) / 2; + % For very small angles, this formula is not accurate compared to w, since diagonal + % elements are around 1, and eps(1) = 2.2e-16. This will be the order of magnitude of + % non-diag. elements due to errors. So we should get SinHalf from w. + % Rotation axis with amplitude = SinHalf (like in rotation quaternions). + w = [R(3, 2) - R(2, 3); R(1, 3) - R(3, 1); R(2, 1) - R(1, 2)] / ... + (2 * sqrt(1 + R(1, 1) + R(2, 2) + R(3, 3))); + SinHalf = sqrt(sum(w.^2)); + TNormSq = sum(T.^2); + + D = sqrt( TNormSq + (2 * rad * SinHalf)^2 + 4 * rad * sqrt(TNormSq * SinHalf^2 - (T' * w)^2) ); +end % RigidDistTransform + + + function [O, R] = RigidCoordinates(FidsColumns) % Convert head coil locations to origin position and rotation matrix. % Works with 9x1 or 3x3 (columns) input. diff --git a/toolbox/process/functions/process_evt_merge.m b/toolbox/process/functions/process_evt_merge.m index 084cfe51f..9036377f7 100644 --- a/toolbox/process/functions/process_evt_merge.m +++ b/toolbox/process/functions/process_evt_merge.m @@ -124,6 +124,9 @@ %% ===== MERGE EVENTS ===== function [events, isModified] = Compute(sInput, events, EvtNames, NewName, isDelete) + if isempty(sInput) + sInput = ''; + end % No modification isModified = 0; @@ -149,6 +152,13 @@ bst_report('Error', 'process_evt_merge', sInput, 'You must enter at least one event name to copy.'); return; end + % Make sure selected events are all of same type + try + [events(iEvents).times]; + catch + bst_report('Error', 'process_evt_merge', sInput, 'You cannot merge simple and extended events together.'); + return; + end % Inialize new event group newEvent = events(iEvents(1)); @@ -156,23 +166,58 @@ newEvent.times = [events(iEvents).times]; newEvent.epochs = [events(iEvents).epochs]; % Reaction time, channels, notes: only if all the events have them - if all(~cellfun(@isempty, {events(iEvents).channels})) - newEvent.channels = [events(iEvents).channels]; - else + if all(cellfun(@isempty, {events(iEvents).channels})) newEvent.channels = []; - end - if all(~cellfun(@isempty, {events(iEvents).notes})) - newEvent.notes = [events(iEvents).notes]; else - newEvent.notes = []; + % Expand empty channels if needed + for ie = 1 : length(iEvents) + if isempty(events(iEvents(ie)).channels) + events(iEvents(ie)).channels = cell(1, size(events(iEvents(ie)).times, 2)); + end + end + newEvent.channels = [events(iEvents).channels]; end - if all(~cellfun(@isempty, {events(iEvents).reactTimes})) - newEvent.reactTimes = [events(iEvents).reactTimes]; + if all(cellfun(@isempty, {events(iEvents).notes})) + newEvent.notes = []; else + % Expand empty notes if needed + for ie = 1 : length(iEvents) + if isempty(events(iEvents(ie)).notes) + events(iEvents(ie)).notes = cell(1, size(events(iEvents(ie)).notes, 2)); + end + end + newEvent.notes = [events(iEvents).notes]; + end + if all(cellfun(@isempty, {events(iEvents).reactTimes})) newEvent.reactTimes = []; + else + % Expand empty reactTimes if needed + for ie = 1 : length(iEvents) + if isempty(events(iEvents(ie)).reactTimes) + events(iEvents(ie)).reactTimes = zeros(1, size(events(iEvents(ie)).reactTimes, 2)); + end + end + newEvent.reactTimes = [events(iEvents).reactTimes]; + end + % Find duplicated events + iRemoveDuplicate = []; + [~, ics, ias] = unique(bst_round(newEvent.times', 9), 'rows', 'stable'); + % Check if duplicated times are really duplicated events + for ix = 1 : length(ics) + ids = find(ias == ix); + for iy = 2 : length(ids) + id = ids(iy); + if (isempty(newEvent.channels) || isequal(newEvent.channels{ids(1)}, newEvent.channels{id})) && ... + (isempty(newEvent.notes) || isequal(newEvent.notes{ids(1)}, newEvent.notes{id})) && ... + (isempty(newEvent.reactTimes) || isequal(newEvent.reactTimes(ids(1)), newEvent(id).reactTimes)) + iRemoveDuplicate = [iRemoveDuplicate, id]; + end + end end - % Sort by samples indices, and remove redundant values - [tmp__, iSort] = unique(bst_round(newEvent.times(1,:), 9)); + % Sort by samples indices + [~, iSort] = sort(bst_round(newEvent.times(1,:), 9)); + % Remove indices of duplicated events + iSort = iSort(~ismember(iSort, iRemoveDuplicate)); newEvent.times = newEvent.times(:,iSort); newEvent.epochs = newEvent.epochs(iSort); if ~isempty(newEvent.channels) diff --git a/toolbox/process/functions/process_evt_read.m b/toolbox/process/functions/process_evt_read.m index dfbde5517..745002994 100644 --- a/toolbox/process/functions/process_evt_read.m +++ b/toolbox/process/functions/process_evt_read.m @@ -48,9 +48,22 @@ sProcess.options.trackmode.Comment = {'Value: detect the changes of channel value', ... 'Bit: detect the changes for each bit independently', ... 'TTL: detect peaks of 5V/12V on an analog channel (baseline=0V)', ... - 'RTTL: detect peaks of 0V on an analog channel (baseline!=0V)'}; - sProcess.options.trackmode.Type = 'radio'; - sProcess.options.trackmode.Value = 1; + 'RTTL: detect peaks of 0V on an analog channel (baseline!=0V)'; ... + 'value', 'bit', 'ttl', 'rttl'}; + sProcess.options.trackmode.Type = 'radio_label'; + sProcess.options.trackmode.Value = 'value'; + sProcess.options.trackmode.Controller = struct('value', 'mask_check'); + % Option: Mask boolean + sProcess.options.maskcheck.Comment = 'Apply digital mask to events (only for "Value" option)'; + sProcess.options.maskcheck.Type = 'checkbox'; + sProcess.options.maskcheck.Value = 0; + sProcess.options.maskcheck.Class = 'mask_check'; + sProcess.options.maskcheck.Controller = 'mask_value'; + % Option: Mask + sProcess.options.mask.Comment = 'Mask value (integer or 0xHEX): '; + sProcess.options.mask.Type = 'text'; + sProcess.options.mask.Value = '0'; + sProcess.options.mask.Class = 'mask_value'; % Option: Accept zeros sProcess.options.zero.Comment = 'Accept zeros as trigger values'; sProcess.options.zero.Type = 'checkbox'; @@ -82,15 +95,31 @@ end % Event type switch (sProcess.options.trackmode.Value) - case 1, EventsTrackMode = 'value'; - case 2, EventsTrackMode = 'bit'; - case 3, EventsTrackMode = 'ttl'; - case 4, EventsTrackMode = 'rttl'; + case {1, 'value'}, EventsTrackMode = 'value'; + case {2, 'bit'}, EventsTrackMode = 'bit'; + case {3, 'ttl'}, EventsTrackMode = 'ttl'; + case {4, 'rttl'}, EventsTrackMode = 'rttl'; end + sProcess.options.trackmode.Value = EventsTrackMode; % Other options isAcceptZero = sProcess.options.zero.Value; MinDuration = sProcess.options.min_duration.Value{1}; - + % Get mask + MaskValue = 0; + if strcmpi(EventsTrackMode, 'value') && sProcess.options.maskcheck.Value + strMask = regexprep(sProcess.options.mask.Value, '^0[x|X]', ''); + % Hex value was provided + if ~strcmpi(strMask, sProcess.options.mask.Value) + MaskValue = hex2dec(strMask); + % Integer string (only 0-9 characters) + elseif isempty(regexp(strMask, '[^0-9]', 'once')) + MaskValue = str2double(strMask); + % Invalid string + else + bst_report('Error', sProcess, sInput, ['String "' strMask '" is not a valid mask.']); + return + end + end % ===== GET FILE DESCRIPTOR ===== % Load the raw file descriptor isRaw = strcmpi(sInput.FileType, 'raw'); @@ -113,8 +142,8 @@ % CTF: Read separately upper and lower bytes if ismember(sFile.format, {'CTF', 'CTF-CONTINUOUS'}) % Detect separately events on the upper and lower bytes of the STIM channel - eventsU = Compute(sFile, ChannelMat, [StimChan '__U'], EventsTrackMode, isAcceptZero, MinDuration); - eventsL = Compute(sFile, ChannelMat, [StimChan '__L'], EventsTrackMode, isAcceptZero, MinDuration); + eventsU = Compute(sFile, ChannelMat, [StimChan '__U'], EventsTrackMode, isAcceptZero, MinDuration, MaskValue); + eventsL = Compute(sFile, ChannelMat, [StimChan '__L'], EventsTrackMode, isAcceptZero, MinDuration, MaskValue); % If there are events on both: add marker U/L if ~isempty(eventsU) && ~isempty(eventsL) for iEvt = 1:length(eventsU) @@ -126,7 +155,7 @@ end events = [eventsL, eventsU]; else - events = Compute(sFile, ChannelMat, StimChan, EventsTrackMode, isAcceptZero, MinDuration); + events = Compute(sFile, ChannelMat, StimChan, EventsTrackMode, isAcceptZero, MinDuration, MaskValue); end % ===== SAVE RESULT ===== @@ -152,8 +181,11 @@ %% ===== COMPUTE ===== -function [events, EventsTrackMode, StimChan] = Compute(sFile, ChannelMat, StimChan, EventsTrackMode, isAcceptZero, MinDuration) +function [events, EventsTrackMode, StimChan] = Compute(sFile, ChannelMat, StimChan, EventsTrackMode, isAcceptZero, MinDuration, MaskValue) % Parse inputs + if (nargin < 7) + MaskValue = 0; + end if (nargin < 6) MinDuration = 0; end @@ -366,6 +398,10 @@ % === PROCESS EACH TRACK SEPARATELY === nTooShort = 0; for iTrack = 1:length(tracks_name) + % Apply mask + if strcmpi(EventsTrackMode, 'value') && MaskValue > 0 + tracks_vals{iTrack} = bitand(tracks_vals{iTrack}, MaskValue, 'uint32'); + end % Get the indices where something happens if strcmpi(EventsTrackMode, 'rttl') ixs = find(tracks_vals{iTrack} == 0); diff --git a/toolbox/process/functions/process_evt_simple.m b/toolbox/process/functions/process_evt_simple.m index bcf85ce32..c285352ba 100644 --- a/toolbox/process/functions/process_evt_simple.m +++ b/toolbox/process/functions/process_evt_simple.m @@ -44,10 +44,12 @@ sProcess.options.eventname.Comment = 'Event names: '; sProcess.options.eventname.Type = 'text'; sProcess.options.eventname.Value = ''; - % Time offset - sProcess.options.method.Comment = {'Keep the start of the events', 'Keep the middle of the events', 'Keep the end of the events'}; - sProcess.options.method.Type = 'radio'; - sProcess.options.method.Value = 1; + % Method from extended to simple + sProcess.options.method.Comment = {'Keep the start of the events', 'Keep the middle of the events', 'Keep the end of the events', 'Keep all samples of the events'; ... + 'start', 'middle', 'end', 'all'}; + sProcess.options.method.Type = 'radio_label'; + sProcess.options.method.Value = 'start'; + end @@ -65,9 +67,10 @@ % ===== GET OPTIONS ===== % Time offset switch (sProcess.options.method.Value) - case 1, Method = 'start'; - case 2, Method = 'middle'; - case 3, Method = 'end'; + case {1, 'start'}, Method = 'start'; + case {2, 'middle'}, Method = 'middle'; + case {3, 'end'}, Method = 'end'; + case {4, 'all'}, Method = 'every_sample'; end % Event names EvtNames = strtrim(str_split(sProcess.options.eventname.Value, ',;')); @@ -115,18 +118,10 @@ end % ===== PROCESS EVENTS ===== - for i = 1:length(iEvtList) - switch Method - case 'start' - sEvents(iEvtList(i)).times = sEvents(iEvtList(i)).times(1,:); - case 'middle' - sEvents(iEvtList(i)).times = mean(sEvents(iEvtList(i)).times,1); - case 'end' - sEvents(iEvtList(i)).times = sEvents(iEvtList(i)).times(2,:); - end - sEvents(iEvtList(i)).times = round(sEvents(iEvtList(i)).times .* sFreq) / sFreq; - end - + sEventsMod = sEvents(iEvtList); + sEventsMod = Compute(sEventsMod, Method, sFreq); + sEvents(iEvtList) = sEventsMod; + % ===== SAVE RESULT ===== % Report results if isRaw @@ -139,5 +134,38 @@ end - - +%% ===== COMPUTE ===== +function sEvents = Compute(sEvents, modification, sFreq) + % Apply modificiation to each event type + for i = 1:length(sEvents) + switch (modification) + case 'start' + sEvents(i).times = sEvents(i).times(1,:); + case 'middle' + sEvents(i).times = mean(sEvents(i).times, 1); + case 'end' + sEvents(i).times = sEvents(i).times(2,:); + case 'every_sample' + % Create an event instance for every sample in limits of the extendend event + sEventAllOcc = repmat(db_template('Event'), 0); + for iOccurExt = 1 : size(sEvents(i).times,2) + nSamples = round(diff(sEvents(i).times(:, iOccurExt)) * sFreq) + 1; + sEventOcc = sEvents(i); + sEventOcc.epochs = repmat(sEvents(i).epochs(iOccurExt), 1, nSamples); + sEventOcc.times = ([0:nSamples-1] / sFreq) + sEvents(i).times(1,iOccurExt); + if ~isempty(sEvents(i).channels) + sEventOcc.channels = repmat(sEvents(i).channels(iOccurExt), 1, nSamples); + end + if ~isempty(sEvents(i).notes) + sEventOcc.notes = repmat(sEvents(i).notes(iOccurExt), 1, nSamples); + end + % Events for one occurence + sEventOcc.label = sprintf('%s_%05d', sEvents(i).label, iOccurExt); + sEventAllOcc(end+1) = sEventOcc; + end + % Merge events from all ocurrences + sEvents(i) = process_evt_merge('Compute', '', sEventAllOcc, {sEventAllOcc.label}, sEvents(i).label, 1); + end + end + sEvents(i).times = round(sEvents(i).times .* sFreq) / sFreq; +end diff --git a/toolbox/process/functions/process_export_file.m b/toolbox/process/functions/process_export_file.m new file mode 100644 index 000000000..bc45dd514 --- /dev/null +++ b/toolbox/process/functions/process_export_file.m @@ -0,0 +1,205 @@ +function varargout = process_export_file( varargin ) +% PROCESS_FILE_EXPORT: Exports RawData, Data, Results, TimeFreq or Matrix files +% +% USAGE: sProcess = process_export_file('GetDescription') +% process_export_file('Run', sProcess, sInputs) + +% @============================================================================= +% This function is part of the Brainstorm software: +% https://neuroimage.usc.edu/brainstorm +% +% Copyright (c) University of Southern California & McGill University +% This software is distributed under the terms of the GNU General Public License +% as published by the Free Software Foundation. Further details on the GPLv3 +% license can be found at http://www.gnu.org/copyleft/gpl.html. +% +% FOR RESEARCH PURPOSES ONLY. THE SOFTWARE IS PROVIDED "AS IS," AND THE +% UNIVERSITY OF SOUTHERN CALIFORNIA AND ITS COLLABORATORS DO NOT MAKE ANY +% WARRANTY, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO WARRANTIES OF +% MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE, NOR DO THEY ASSUME ANY +% LIABILITY OR RESPONSIBILITY FOR THE USE OF THIS SOFTWARE. +% +% For more information type "brainstorm license" at command prompt. +% =============================================================================@ +% +% Authors: Raymundo Cassani, 2023 + +eval(macro_method); +end + + +%% ===== GET DESCRIPTION ===== +function sProcess = GetDescription() %#ok + % Description the process + sProcess.Comment = 'Export to file'; + sProcess.Category = 'Custom'; + sProcess.SubGroup = 'File'; + sProcess.Index = 982; + sProcess.Description = 'https://neuroimage.usc.edu/brainstorm/Tutorials/Scripting'; + % Definition of the input accepted by this process + sProcess.InputTypes = {'raw', 'data', 'results', 'timefreq', 'matrix'}; + sProcess.OutputTypes = {'raw', 'data', 'results', 'timefreq', 'matrix'}; + sProcess.nInputs = 1; + sProcess.nMinFiles = 1; + % Instructions label + sProcess.options.label1.Comment = ['One file: Provide filename and format

' ... + 'Multiple files: Provide directory and format
' ... + '(filenames are obtained from database)

']; + sProcess.options.label1.Type = 'label'; + % File selection options + SelectOptions = {... + '', ... % Filename + '', ... % FileFormat + 'save', ... % Dialog type: {open,save} + '', ... % Window title + '', ... % DefaultFile + 'single', ... % Selection mode: {single,multiple} + 'files', ... % Selection mode: {files,dirs,files_and_dirs} + '', ... % Available file formats + ''}; % DefaultFormats: {ChannelIn,DataIn,DipolesIn,EventsIn,AnatIn,MriIn,NoiseCovIn,ResultsIn,SspIn,SurfaceIn,TimefreqIn} + exportComment = 'Output file:'; + windowTitle = 'Export: %s...'; + % === TARGET RAW DATA FILENAME + SelectOptions{4} = sprintf(windowTitle, 'raw data'); + SelectOptions{8} = bst_get('FileFilters', 'rawout'); + SelectOptions{9} = 'DataOut'; + sProcess.options.exportraw.Comment = exportComment; + sProcess.options.exportraw.Type = 'filename'; + sProcess.options.exportraw.Value = SelectOptions; + sProcess.options.exportraw.InputTypes = {'raw'}; + + % === TARGET DATA FILENAME + SelectOptions{4} = sprintf(windowTitle, 'data'); + SelectOptions{8} = bst_get('FileFilters', 'dataout'); + SelectOptions{9} = 'DataOut'; + sProcess.options.exportdata.Comment = exportComment; + sProcess.options.exportdata.Type = 'filename'; + sProcess.options.exportdata.Value = SelectOptions; + sProcess.options.exportdata.InputTypes = {'data'}; + + % === TARGET RESULTS FILENAME + SelectOptions{4} = sprintf(windowTitle, 'sources'); + SelectOptions{8} = bst_get('FileFilters', 'resultsout'); + SelectOptions{9} = 'ResultsOut'; + sProcess.options.exportresults.Comment = exportComment; + sProcess.options.exportresults.Type = 'filename'; + sProcess.options.exportresults.Value = SelectOptions; + sProcess.options.exportresults.InputTypes = {'results'}; + + % === TARGET TIMEFREQ FILENAME + SelectOptions{4} = sprintf(windowTitle, 'time-freq'); + SelectOptions{8} = bst_get('FileFilters', 'timefreqout'); + SelectOptions{9} = 'TimefreqOut'; + sProcess.options.exporttimefreq.Comment = exportComment; + sProcess.options.exporttimefreq.Type = 'filename'; + sProcess.options.exporttimefreq.Value = SelectOptions; + sProcess.options.exporttimefreq.InputTypes = {'timefreq'}; + + % === TARGET MATRIX FILENAME + SelectOptions{4} = sprintf(windowTitle, 'matrix'); + SelectOptions{8} = bst_get('FileFilters', 'matrixout'); + SelectOptions{9} = 'MatrixOut'; + sProcess.options.exportmatrix.Comment = exportComment; + sProcess.options.exportmatrix.Type = 'filename'; + sProcess.options.exportmatrix.Value = SelectOptions; + sProcess.options.exportmatrix.InputTypes = {'matrix'}; +end + +%% ===== FORMAT COMMENT ===== +function Comment = FormatComment(sProcess) %#ok + inputType = InputTypeFromFields(sProcess); + if ~isempty(inputType) + inputType(1) = upper(inputType(1)); + end + Comment = ['Export to file: ' inputType]; +end + +%% ===== RUN ===== +function OutputFiles = Run(sProcess, sInputs) %#ok + % Returned files: same as input + OutputFiles = {sInputs.FileName}; + % Get options + inputType = InputTypeFromFields(sProcess); + outFileOptions = sProcess.options.(['export' inputType]).Value; + isExportDir = isdir(outFileOptions{1}); + % If dir (export multiple files), find extension for filter + if isExportDir + % Get dir path + fPath = outFileOptions{1}; + % Get extension for filter + Filters = bst_get('FileFilters', [inputType, 'out']); + iFilter = find(ismember(Filters(:,3), outFileOptions{2}), 1, 'first'); + if isempty(iFilter) + bst_error(sprintf('Format "%s" is not valid for files of type "%s"', outFileOptions{2}, inputType), 'Export to file'); + return + end + fExt = Filters{iFilter, 1}{1}; + end + % Export files + for iInput = 1 : length(sInputs) + % Name for one file is already clean of tags + if isExportDir + % Base name from Brainstorm database + fileName = sInputs(iInput).FileName; + fileType = file_gettype(fileName); + if strcmp(fileType, 'link') + [kernelFile, dataFile] = file_resolve_link(sInputs(iInput).FileName); + [~, kBase] = bst_fileparts(kernelFile); + [~, fBase] = bst_fileparts(dataFile); + fBase = [kBase, '_' ,fBase]; + else + [~, fBase] = bst_fileparts(fileName); + end + % Remove fit type tagsn from fBase + switch(fileType) + case 'data' % includes 'raw' files + tagsToRemove = {'_data', 'data_', '0raw_'}; + case {'results', 'link'} + tagsToRemove = {'_results', 'results_'}; + case 'timefreq' + tagsToRemove = {'_timefreq', 'timefreq_'}; + case 'matrix' + tagsToRemove = {'_matrix', 'matrix_'}; + end + for iTag = 1 : length(tagsToRemove) + fBase = strrep(fBase, tagsToRemove{iTag}, ''); + end + outFileOptions{1} = bst_fullfile(fPath, [fBase, fExt]); + % Verify that extension for BST format ends in '.ext' + if strcmp(outFileOptions{2}, 'BST') && isempty(regexp(outFileOptions{1}, '\.\w+$', 'once')) + % Add prefix for Brainstorm files + prefix = ''; + if strcmp(fExt(1), '_') + prefix = [fExt(2:end), '_']; + end + outFileOptions{1} = bst_fullfile(fPath, [prefix, fBase, '.mat']); + end + end + % Export files according their input type + switch inputType + case {'data', 'raw'} + export_data(sInputs(iInput).FileName, [], outFileOptions{1}, outFileOptions{2}); + case 'results' + export_result(sInputs(iInput).FileName, outFileOptions{1}, outFileOptions{2}); + case 'timefreq' + export_timefreq(sInputs(iInput).FileName, outFileOptions{1}, outFileOptions{2}); + case 'matrix' + export_matrix(sInputs(iInput).FileName, outFileOptions{1}, outFileOptions{2}); + end + % Info of where the file was saved (console and report) + bst_report('Info', sProcess, sInputs(iInput), sprintf('File exported as %s', outFileOptions{1})); + fprintf(['BST: File "%s" exported as "%s"' 10], sInputs(iInput).FileName, outFileOptions{1}); + end +end + +function inputType = InputTypeFromFields(sProcess) + % Find InputType from first option field named 'exportINPUTTYPE' + % FileTypes: 'raw', 'data', 'results', 'timefreq' or 'matrix' + optFields = fieldnames(sProcess.options); + iField = find(~cellfun(@isempty, regexp(optFields, '^export')), 1, 'first'); + if ~isempty(iField) + inputType = regexprep(optFields{iField}, '^export', ''); + else + inputType = ''; + end +end diff --git a/toolbox/process/functions/process_export_spmvol.m b/toolbox/process/functions/process_export_spmvol.m index 3237576f9..4f8f580a4 100644 --- a/toolbox/process/functions/process_export_spmvol.m +++ b/toolbox/process/functions/process_export_spmvol.m @@ -194,6 +194,15 @@ bst_progress('inc',1); % ===== LOAD RESULTS ===== + % Check the data type: timefreq must be source/surface based + if strcmpi(file_gettype(sInputs(iFile).FileName), 'timefreq') + ResMat = in_bst_timefreq(sInputs(iFile).FileName, 0, 'DataType'); + if ~strcmpi(ResMat.DataType, 'results') + errMsg = 'Only surface or volume maps can be exported.'; + bst_report('Error', func2str(sProcess.Function), sInputs(iFile).FileName, errMsg); + continue; + end + end % Load results sInput = bst_process('LoadInputFile', sInputs(iFile).FileName, [], TimeWindow, LoadOptions); if isempty(sInput.Data) @@ -250,8 +259,8 @@ end % Unconstrained sources: combine orientations (norm of all dimensions) sInput.Data = bst_source_orient([], sInput.nComponents, ResultsMat.GridAtlas, sInput.Data, 'rms'); - % If an atlas exists - if strcmpi(ResultsMat.HeadModelType, 'surface') && isfield(ResultsMat, 'Atlas') && ~isempty(ResultsMat.Atlas) && ~isempty(ResultsMat.Atlas.Scouts) + % If an atlas exists and it is not from a 1xN connectivity file (1 Scout x N Sources) + if strcmpi(ResultsMat.HeadModelType, 'surface') && isfield(ResultsMat, 'Atlas') && ~isempty(ResultsMat.Atlas) && ~isempty(ResultsMat.Atlas.Scouts) && isempty(strfind(sInputs(iFile).FileName, '_connect1')) Atlas = ResultsMat.Atlas; else Atlas = []; @@ -292,13 +301,40 @@ % Else: Compute interpolation matrix grid points => MRI voxels else sMri.FileName = MriFile; - GridSmooth = isempty(ResultsMat.DisplayUnits) || ~ismember(ResultsMat.DisplayUnits, {'s','ms','t'}); - MriInterp = grid_interp_mri(ResultsMat.GridLoc, sMri, ResultsMat.SurfaceFile, 0, [], [], GridSmooth); + GridSmooth = isempty(ResultsMat.DisplayUnits) || ~ismember(ResudltsMat.DisplayUnits, {'s','ms','t'}); + switch (ResultsMat.HeadModelType) + case 'volume' + % Compute interpolation + MriInterp = grid_interp_mri(ResultsMat.GridLoc, sMri, ResultsMat.SurfaceFile, 0, [], [], GridSmooth); + + case 'mixed' + % Compute the surface interpolation + tess2mri_interp = tess_interp_mri(ResultsMat.SurfaceFile, sMri); + % Initialize returned interpolation matrix + MriInterp = sparse(numel(sMri.Cube(:,:,:,1)), size(ResultsMat.GridAtlas.Grid2Source, 1)); + % Process each region separately + ind = 1; + sScouts = ResultsMat.GridAtlas.Scouts; + for i = 1:length(sScouts) + % Indices in the interpolation matrix + iGrid = ind + (0:length(sScouts(i).GridRows) - 1); + ind = ind + length(sScouts(i).GridRows); + % Interpolation depends on the type of region (volume or surface) + switch (sScouts(i).Region(2)) + case 'V' + GridLoc = ResultsMat.GridLoc(sScouts(i).GridRows,:); + MriInterp(:,iGrid) = grid_interp_mri(GridLoc, sMri, ResultsMat.SurfaceFile, 1, [], [], GridSmooth); + case 'S' + MriInterp(:,iGrid) = tess2mri_interp(:, sScouts(i).Vertices); + end + end + end % Save values for next iteration prevInterp = MriInterp; prevGridLoc = ResultsMat.GridLoc; end end + % Export surface-based files else MriInterp = []; diff --git a/toolbox/process/functions/process_extract_headdist.m b/toolbox/process/functions/process_extract_headdist.m index 73e2c6a82..d1ded4a68 100644 --- a/toolbox/process/functions/process_extract_headdist.m +++ b/toolbox/process/functions/process_extract_headdist.m @@ -68,7 +68,7 @@ DataMat = in_bst_data(sInputs(iInput).FileName); % Check for CTF. if ~strcmp(DataMat.Device, 'CTF') - bst_report('Error', sProcess, sInputs(iFile), 'Extract head distance is currently only available for CTF data.'); + bst_report('Error', sProcess, sInputs(iInput), 'Extract head distance is currently only available for CTF data.'); end % Channel file for Study ChannelMat = in_bst_channel(sInputs.ChannelFile); diff --git a/toolbox/process/functions/process_extract_scout.m b/toolbox/process/functions/process_extract_scout.m index f7cfe962f..db72fded1 100644 --- a/toolbox/process/functions/process_extract_scout.m +++ b/toolbox/process/functions/process_extract_scout.m @@ -6,12 +6,12 @@ % @============================================================================= % This function is part of the Brainstorm software: % https://neuroimage.usc.edu/brainstorm -% +% % Copyright (c) University of Southern California & McGill University % This software is distributed under the terms of the GNU General Public License % as published by the Free Software Foundation. Further details on the GPLv3 % license can be found at http://www.gnu.org/copyleft/gpl.html. -% +% % FOR RESEARCH PURPOSES ONLY. THE SOFTWARE IS PROVIDED "AS IS," AND THE % UNIVERSITY OF SOUTHERN CALIFORNIA AND ITS COLLABORATORS DO NOT MAKE ANY % WARRANTY, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO WARRANTIES OF @@ -55,11 +55,11 @@ sProcess.options.flatten.Value = 1; sProcess.options.flatten.InputTypes = {'results'}; % === SCOUT FUNCTION === - sProcess.options.scoutfunc.Comment = {'Mean', 'Power', 'Max', 'PCA', 'Std', 'All', 'Scout function:'; ... - 'mean', 'power', 'max', 'pca', 'std', 'all', ''}; + sProcess.options.scoutfunc.Comment = {'Mean', 'Power', 'Max', 'PCA', 'Std', 'RMS', 'All', 'Scout function:'; ... + 'mean', 'power', 'max', 'pca', 'std', 'rms', 'all', ''}; sProcess.options.scoutfunc.Type = 'radio_linelabel'; sProcess.options.scoutfunc.Value = 'pca'; - sProcess.options.scoutfunc.Controller = struct('pca', 'pca', 'mean', 'notpca', 'power', 'notpca', 'max', 'notpca', 'std', 'notpca', 'all', 'notpca'); + sProcess.options.scoutfunc.Controller = struct('pca', 'pca', 'mean', 'notpca', 'power', 'notpca', 'max', 'notpca', 'std', 'notpca', 'rms', 'notpca', 'all', 'notpca'); % === PCA Options sProcess.options.pcaedit.Comment = {'panel_pca', ' PCA options: '}; sProcess.options.pcaedit.Type = 'editpref'; @@ -151,6 +151,7 @@ case {4, 'std'}, ScoutFunc = 'std'; case {5, 'all'}, ScoutFunc = 'all'; case {6, 'power'}, ScoutFunc = 'power'; + case {7, 'rms'}, ScoutFunc = 'rms'; otherwise, bst_report('Error', sProcess, [], 'Invalid scout function.'); return; end else @@ -185,18 +186,15 @@ end % Unconstrained orientations - % Only allow norm when called without new pca flattening option. - if isfield(sProcess.options, 'flatten') && isfield(sProcess.options.flatten, 'Value') - if isequal(sProcess.options.flatten.Value, 1) - UnconstrFunc = 'pca'; - else - UnconstrFunc = 'none'; - end - elseif isfield(sProcess.options, 'isnorm') && isfield(sProcess.options.isnorm, 'Value') && isequal(sProcess.options.isnorm.Value, 1) + UnconstrFunc = 'none'; + % Perform norm if requested ('isnorm' may be set to '1' by calling process) + if isfield(sProcess.options, 'isnorm') && isfield(sProcess.options.isnorm, 'Value') && isequal(sProcess.options.isnorm.Value, 1) UnconstrFunc = 'norm'; - else - UnconstrFunc = 'none'; + % If norm=0, then check if PCA fattening was requested + elseif isfield(sProcess.options, 'flatten') && isfield(sProcess.options.flatten, 'Value') && isequal(sProcess.options.flatten.Value, 1) + UnconstrFunc = 'pca'; end + % Check if there actually are sources with unconstrained orientations if ~strcmpi(UnconstrFunc, 'none') % Check if there are unconstrained sources. The function only checks the first file. Other files @@ -300,7 +298,10 @@ bst_progress('set', round(100*(iInput-1)/length(sInputs))); end end - isAbs = ~isempty(strfind(sInputs(iInput).FileName, '_abs')); + % Get meaningful tags in the results file name (without folders) + TestResFile = file_resolve_link(sInputs(iInput).FileName); + [~, TestTags] = bst_fileparts(TestResFile); + isAbs = ~isempty(strfind(TestTags, '_abs')); % === READ FILES === [sResults, matSourceValues, matDataValues, fileComment] = LoadFile(sProcess, sInputs(iInput), TimeWindow); @@ -357,6 +358,10 @@ OutputFiles = {}; CleanExit; return; % Error already reported. end + % If norm, orientations will be aggregated, keep only one name per scout + if strcmpi(UnconstrFunc, 'norm') + RowNames = {ScoutName}; + end if AddFileComment && ~isempty(fileComment) RowNames = cellfun(@(c) [c ' @ ' fileComment], RowNames, 'UniformOutput', false); end @@ -463,15 +468,17 @@ scoutStd = cat(1, scoutStd, tmpScoutStd); end % Add frequency to row descriptions. - if (nFreq > 1) && AddFileComment + if isfield(sResults, 'Freqs') && ~isempty(sResults.Freqs) && ((iscell(sResults.Freqs) && size(sResults.Freqs,1) == nFreq) || (~iscell(sResults.Freqs) && length(sResults.Freqs) == nFreq)) if iscell(sResults.Freqs) freqComment = [' ' sResults.Freqs{iFreq,1}]; else freqComment = [' ' num2str(sResults.Freqs(iFreq)), 'Hz']; end - RowNames = cellfun(@(c) [c freqComment], RowNames, 'UniformOutput', false); + RowNamesFreq = cellfun(@(c) [c freqComment], RowNames, 'UniformOutput', false); + else + RowNamesFreq = RowNames; end - Description = cat(1, Description, RowNames); + Description = cat(1, Description, RowNamesFreq); end end % If nothing was found diff --git a/toolbox/process/functions/process_extract_time.m b/toolbox/process/functions/process_extract_time.m index 57ae02e81..7226a05e0 100644 --- a/toolbox/process/functions/process_extract_time.m +++ b/toolbox/process/functions/process_extract_time.m @@ -35,8 +35,8 @@ sProcess.Index = 353; sProcess.Description = 'https://neuroimage.usc.edu/brainstorm/Tutorials/ExploreRecordings?highlight=%28Extract+time%29#Time_selection'; % Definition of the input accepted by this process - sProcess.InputTypes = {'data', 'results', 'timefreq', 'matrix'}; - sProcess.OutputTypes = {'data', 'results', 'timefreq', 'matrix'}; + sProcess.InputTypes = {'raw', 'data', 'results', 'timefreq', 'matrix'}; + sProcess.OutputTypes = {'raw', 'data', 'results', 'timefreq', 'matrix'}; sProcess.nInputs = 1; sProcess.nMinFiles = 1; sProcess.isSeparator = 1; diff --git a/toolbox/process/functions/process_extract_values.m b/toolbox/process/functions/process_extract_values.m index 8cbc5163b..9cb7ca95e 100644 --- a/toolbox/process/functions/process_extract_values.m +++ b/toolbox/process/functions/process_extract_values.m @@ -91,7 +91,8 @@ sProcess.options.scoutfunc.InputTypes = {'results'}; % === NORM XYZ - sProcess.options.isnorm.Comment = 'Compute absolute values (or norm for unconstrained sources)'; + sProcess.options.isnorm.Comment = ['Compute absolute values (or norm for unconstrained sources).
' ... + 'Applied after scout function.']; sProcess.options.isnorm.Type = 'checkbox'; sProcess.options.isnorm.Value = 0; sProcess.options.isnorm.InputTypes = {'results'}; @@ -395,14 +396,6 @@ sLoaded.Data = sLoaded.Data(:,1,:,:); sLoaded.Time = OPTIONS.TimeWindow(1); end - % Add components labels - if (sLoaded.nComponents == 3) - sLoaded.RowNames = sLoaded.RowNames(:)'; - sLoaded.RowNames = [cellfun(@(c)cat(2,c,'.1'), sLoaded.RowNames, 'UniformOutput', 0); ... - cellfun(@(c)cat(2,c,'.2'), sLoaded.RowNames, 'UniformOutput', 0); ... - cellfun(@(c)cat(2,c,'.3'), sLoaded.RowNames, 'UniformOutput', 0)]; - sLoaded.RowNames = sLoaded.RowNames(:); - end % Convert to matrix structure FileMat = db_template('matrixmat'); FileMat.Value = sLoaded.Data; @@ -681,8 +674,18 @@ tstart = FileMat.Time(1); end elseif isfield(FileMat, 'TimeBands') && ~isempty(FileMat.TimeBands) - sfreq = 1; - tstart = 1; + timeBounds = process_tf_bands('GetBounds', FileMat.TimeBands); + durationBands = diff(timeBounds, 1, 2); + stepBands = diff(timeBounds(:,1)); % Empty if only one timeband + % Generate time vector if time bands are periodic + if (~isempty(stepBands)) && (std(stepBands) / mean(stepBands) < 0.01) && (std(durationBands) / mean(durationBands) < 0.01) + tmpTime = mean(timeBounds, 2); + sfreq = 1/(tmpTime(2) - tmpTime(1)); + tstart = tmpTime(1); + else + sfreq = 1; + tstart = 1 ; + end FileMat.TimeBands = []; else sfreq = 1/(FileMat.Time(2) - FileMat.Time(1)); diff --git a/toolbox/process/functions/process_fem_mesh.m b/toolbox/process/functions/process_fem_mesh.m index f4f7bde35..d38b76e4d 100644 --- a/toolbox/process/functions/process_fem_mesh.m +++ b/toolbox/process/functions/process_fem_mesh.m @@ -56,8 +56,9 @@ 'Brain2mesh:
Segment the T1 (and T2) MRI with SPM12, mesh with Brain2mesh
', ... 'SimNIBS 3.x:
Call SimNIBS/headreco to segment and mesh the T1 (and T2) MRI.', ... 'SimNIBS 4.x:
Call SimNIBS/charm to segment and mesh the T1 (and T2) MRI.', ... - 'FieldTrip:
Call FieldTrip to create hexahedral mesh of the T1 MRI.'; ... - 'iso2mesh-2021', 'iso2mesh', 'brain2mesh', 'simnibs3', 'simnibs4', 'fieldtrip'}; + 'FieldTrip:
Call FieldTrip to create hexahedral mesh of the T1 MRI.', ... + 'Zeffiro:
Call Zeffiro to create a tetrahedral mesh from the BEM surfaces
'; ... + 'iso2mesh-2021', 'iso2mesh', 'brain2mesh', 'simnibs3', 'simnibs4', 'fieldtrip', 'zeffiro'}; sProcess.options.method.Type = 'radio_label'; sProcess.options.method.Value = 'iso2mesh'; % Iso2mesh options: @@ -108,6 +109,22 @@ sProcess.options.zneck.Comment = 'Cut neck below MNI Z coordinate (0=disable): '; sProcess.options.zneck.Type = 'value'; sProcess.options.zneck.Value = {OPTIONS.Zneck, '', 0}; + % Zeffiro options: + sProcess.options.opt4.Comment = '
Zeffiro options: '; + sProcess.options.opt4.Type = 'label'; + % Zeffiro options: Mesh resolution [Add more Zef option TODO by Zef team] + sProcess.options.zefMeshResolution.Comment = 'Mesh resolution (edge mm): '; + sProcess.options.zefMeshResolution.Type = 'value'; + sProcess.options.zefMeshResolution.Value = {OPTIONS.ZefMeshResolution, '', 3}; + % Zeffiro options:Use GPU + sProcess.options.zefUseGPU.Comment = 'Use GPU for Zeffiro mesh generation'; + sProcess.options.zefUseGPU.Type = 'checkbox'; + sProcess.options.zefUseGPU.Value = OPTIONS.ZefUseGPU; + % Zeffiro options:Use GPU + sProcess.options.zefAdvancedInterface.Comment = 'Use Zeffiro advanced interface'; + sProcess.options.zefAdvancedInterface.Type = 'checkbox'; + sProcess.options.zefAdvancedInterface.Value = OPTIONS.ZefAdvancedInterface; + end @@ -141,7 +158,7 @@ end % Method OPTIONS.Method = sProcess.options.method.Value; - if isempty(OPTIONS.Method) || ~ischar(OPTIONS.Method) || ~ismember(OPTIONS.Method, {'iso2mesh-2021','iso2mesh','brain2mesh','simnibs3','simnibs4','fieldtrip'}) + if isempty(OPTIONS.Method) || ~ischar(OPTIONS.Method) || ~ismember(OPTIONS.Method, {'iso2mesh-2021','iso2mesh','brain2mesh','simnibs3','simnibs4','fieldtrip','zeffiro'}) bst_report('Error', sProcess, [], 'Invalid method.'); return end @@ -190,6 +207,16 @@ bst_report('Error', sProcess, [], 'Invalid downsampling factor.'); return end + % Zeffiro: Mesh resolution -Edge Length- + OPTIONS.ZefMeshResolution = sProcess.options.zefMeshResolution.Value{1}; + if isempty(OPTIONS.ZefMeshResolution) || (OPTIONS.ZefMeshResolution < 1) || (OPTIONS.ZefMeshResolution > 4.5) + bst_report('Error', sProcess, [], 'Invalid Mesh resolution value, please use value [1 - 4.5] mm.'); + return + end + % Zeffiro: Use the GPU -Edge Length- + OPTIONS.ZefUseGPU = sProcess.options.zefUseGPU.Value; + % Zeffiro: Use the zef Advanced Interface + OPTIONS.ZefAdvancedInterface = sProcess.options.zefAdvancedInterface.Value; % Call processing function [isOk, errMsg] = Compute(iSubject, [], 0, OPTIONS); @@ -207,18 +234,21 @@ %% ===== DEFAULT OPTIONS ===== function OPTIONS = GetDefaultOptions() OPTIONS = struct(... - 'Method', 'iso2mesh-2021', ... % {'iso2mesh-2021', 'iso2mesh', 'brain2mesh', 'simnibs3', 'simnibs4', 'roast', 'fieldtrip'} - 'MeshType', 'tetrahedral', ... % iso2mesh: 'tetrahedral'; simnibs: 'tetrahedral'; roast:'hexahedral'/'tetrahedral'; fieldtrip:'hexahedral'/'tetrahedral' - 'MaxVol', 0.1, ... % iso2mesh: Max tetrahedral volume (10=coarse, 0.0001=fine) - 'KeepRatio', 100, ... % iso2mesh: Percentage of elements kept (1-100%) - 'BemFiles', [], ... % iso2mesh: List of layers to use for meshing (if not specified, use the files selected in the database - 'MergeMethod', 'mergemesh', ... % iso2mesh: {'mergemesh', 'mergesurf'} Function used to merge the meshes - 'VertexDensity', 0.5, ... % SimNIBS: [0.1 - X] setting the vertex density (nodes per mm2) of the surface meshes - 'NbVertices', 15000, ... % SimNIBS: Number of vertices for the cortex surface - 'isEegCaps', 0, ... % SimNIBS: If 1, import the default EEG caps generated by SimNIBS - 'NodeShift', 0.3, ... % FieldTrip: [0 - 0.49] Improves the geometrical properties of the mesh - 'Downsample', 3, ... % FieldTrip: Integer, Downsampling factor to apply to the volumes before meshing - 'Zneck', -115); % Input T1/T2: Cut volumes below neck (MNI Z-coordinate) + 'Method', 'iso2mesh-2021', ... % {'iso2mesh-2021', 'iso2mesh', 'brain2mesh', 'simnibs3', 'simnibs4', 'roast', 'fieldtrip'} + 'MeshType', 'tetrahedral', ... % iso2mesh: 'tetrahedral'; simnibs: 'tetrahedral'; roast:'hexahedral'/'tetrahedral'; fieldtrip:'hexahedral'/'tetrahedral' + 'MaxVol', 0.1, ... % iso2mesh: Max tetrahedral volume (10=coarse, 0.0001=fine) + 'KeepRatio', 100, ... % iso2mesh: Percentage of elements kept (1-100%) + 'BemFiles', [], ... % iso2mesh: List of layers to use for meshing (if not specified, use the files selected in the database + 'MergeMethod', 'mergemesh', ... % iso2mesh: {'mergemesh', 'mergesurf'} Function used to merge the meshes + 'VertexDensity', 0.5, ... % SimNIBS: [0.1 - X] setting the vertex density (nodes per mm2) of the surface meshes + 'NbVertices', 15000, ... % SimNIBS: Number of vertices for the cortex surface + 'isEegCaps', 0, ... % SimNIBS: If 1, import the default EEG caps generated by SimNIBS + 'NodeShift', 0.3, ... % FieldTrip: [0 - 0.49] Improves the geometrical properties of the mesh + 'Downsample', 3, ... % FieldTrip: Integer, Downsampling factor to apply to the volumes before meshing + 'Zneck', -115, ... % Input T1/T2: Cut volumes below neck (MNI Z-coordinate) + 'ZefMeshResolution', 3, ... % Zeffiro: mesh resolution: size of the element edge in mm + 'ZefUseGPU', 0, ... % Zeffiro: If 1, use GPU for mesh generation + 'ZefAdvancedInterface', 0); % Zeffireo: If 1, open the BST-Zeffiro advance interface end @@ -499,6 +529,10 @@ iSort = [iSort, iOther(~isnan(iOther))]; OPTIONS.BemFiles = OPTIONS.BemFiles(iSort); TissueLabels = TissueLabels(iSort); + % If there is a CSF layer but nothing inside: rename into BRAIN + if ismember('csf', TissueLabels) && ~ismember('white', TissueLabels) && ~ismember('gray', TissueLabels) + TissueLabels{ismember(TissueLabels, 'csf')} = 'brain'; + end end % Load surfaces bst_progress('text', 'Loading surfaces...'); @@ -937,7 +971,181 @@ newelem = meshreorient(no, el(:,1:4)); elem = [newelem elem(:,5)]; node = no; % need to updates the new list - + + case 'zeffiro' + % Notes: + % This case follows mainly the same steps as Iso2mesh cases, + % with adaptation of the data with Zef Interface + % Some advantage compare to the other methods: + % - Source code in matlab, and no external binary dependencies + % - Ability to use the GPU and Parallel toolboxes + % - Can support intersected meshes and avoid errors observed + % with other methods [example: defaced head + inner +...] + % - Check the https://github.com/sampsapursiainen/zeffiro_interface/wiki + % - Possible issues: very rare instabilities due to unknow issues + % : when using low resolution >4.5mm hole in the meshes + + % Install/load iso2mesh plugin + [isInstalled, errInstall] = bst_plugin('Install', 'zeffiro', isInteractive); + if ~isInstalled + errMsg = [errMsg, errInstall]; + return; + end + % Get the Zeffiro folder + PlugDesc = bst_plugin('GetInstalled', 'zeffiro'); + bst_plugin('SetProgressLogo', 'zeffiro'); + % Use the BST Zef Interface or the advanced Zef Interface + if ~OPTIONS.ZefAdvancedInterface + % If surfaces are not passed in input: get default surfaces + % Zeffiro require inverse order ... from outer to inner (not as the other methods) + bst_progress('text', 'Loading data...'); + if isempty(OPTIONS.BemFiles) + if ~isempty(sSubject.iScalp) && ~isempty(sSubject.iOuterSkull) && ~isempty(sSubject.iInnerSkull) + OPTIONS.BemFiles = {... + sSubject.Surface(sSubject.iScalp).FileName, ... + sSubject.Surface(sSubject.iOuterSkull).FileName, ... + sSubject.Surface(sSubject.iInnerSkull).FileName}; + TissueLabels = {'scalp', 'skull', 'brain'}; + else + errMsg = [errMsg, 'Method "' OPTIONS.Method '" requires three surfaces: head, inner skull and outer skull.' 10 ... + 'Create them with process "Generate BEM surfaces" first.']; + return; + end + % If surfaces are given: get their labels and sort from inner to outer + else + % Get tissue label + for iBem = 1:length(OPTIONS.BemFiles) + [sSubject, iSubject, iSurface] = bst_get('SurfaceFile', OPTIONS.BemFiles{iBem}); + if ~strcmpi(sSubject.Surface(iSurface).SurfaceType, 'Other') + TissueLabels{iBem} = GetFemLabel(sSubject.Surface(iSurface).SurfaceType); + else + TissueLabels{iBem} = GetFemLabel(sSubject.Surface(iSurface).Comment); + end + end + % Sort from inner to outer [For Zeffiro this order will be reverted] + iSort = []; + iOther = 1:length(OPTIONS.BemFiles); + for label = {'white', 'gray', 'csf', 'skull', 'scalp'} + iLabel = find(strcmpi(label{1}, TissueLabels)); + iSort = [iSort, iLabel]; + iOther(iLabel) = NaN; + end + % flip will reverse the order of the surfaces + iSort = flip([iSort, iOther(~isnan(iOther))]); + OPTIONS.BemFiles = OPTIONS.BemFiles(iSort); + TissueLabels = TissueLabels(iSort); + % If there is a CSF layer but nothing inside: rename into BRAIN + if ismember('csf', TissueLabels) && ~ismember('white', TissueLabels) && ~ismember('gray', TissueLabels) + TissueLabels{ismember(TissueLabels, 'csf')} = 'brain'; + end + end + % Load surfaces file and assign the names to Zef + bst_progress('text', 'Loading surfaces...'); + bemMerge = {}; bemComment = {}; + disp(' '); + nBem = length(OPTIONS.BemFiles); + for iBem = 1:nBem + disp(sprintf('FEM> %d. %5s: %s', iBem, TissueLabels{iBem}, OPTIONS.BemFiles{iBem})); + BemMat = in_tess_bst(OPTIONS.BemFiles{iBem}); + bemComment{iBem} = BemMat.Comment; + end + + % ===== CALL ZEFFIRO FROM HERE ===== + bst_progress('text', 'Calling Zeffiro FEM Mesh Generation...'); + disp('Now Calling Zeffiro FEM Mesh Generation ...'); + % basic options ==> that can be used from Brainstorm + % ===== WRITE ZEF SETTING FILE ===== + % documentation : https://github.com/sampsapursiainen/zeffiro_interface/wiki/Finite-Element-Mesh-generation + % Get the Zef folder path for brainstorm utilities + bst2zefInterface = bst_fullfile(PlugDesc.Path, PlugDesc.SubFolder, '+utilities', '+brainstorm2zef'); + % General setting : + % Open the setting file file + settingFile = fullfile(bst2zefInterface, '+m', 'zef_bst_import_settings.m'); + fid = fopen(settingFile, 'wt+'); + fprintf(fid, 'zef = zef_add_bounding_box(zef);\n'); + % zef.exclude_box: include the bounding box in the mesh => default no ==>1 + fprintf(fid, 'zef.exclude_box = %d;\n', 1); + % max_surface_face_count : default 1: fit to the input mesh, if <1:coraser, if >1 finer + % can be tuned from the advanced parameters (recomended is 0.5) + fprintf(fid, 'zef.max_surface_face_count = %d;\n', 0.5); + fprintf(fid, 'zef.mesh_smoothing_on = %d;\n', 1); + % this option smooth all the volum using the Taubin algo + % for advanced users. + fprintf(fid, 'zef.mesh_resolution = %d;\n', OPTIONS.ZefMeshResolution); + % unit is mm; the coarsest value is 4.5mm. value [minValue = 1.3 maxValue = 4.5]mm + % doc: https://journals.plos.org/plosone/article?id=10.1371/journal.pone.0290715 + % IT MAY GO TO 1mm, Fernando will check that, depends on the zef.ini parameters + % see here: plugins\zeffiro\zeffiro_interface-main_development_branch\profile\zeffiro_interface.ini + % CHANGE THE LINE 6: 100 TO 20 if any issue + % Level of parallelization in mesh labeling,100,parallel_vectors,number + % Level of parallelization in mesh labeling,20,parallel_vectors,number + fprintf(fid, 'zef.use_gpu = %d;\n', OPTIONS.ZefUseGPU); + % default is 0, require the GPU hardware and the toolbox + % To change at the zef.ini : in line 11: when GPU is off, CPU is used, + % if GPU is set to 0, then a CPU is used, require parallel toolbox + % it uses 4 cpu per default ==> advanced options + % Parallel threads in CPU forward computing,4,parallel_processes,number + % Check zeffiro_interface.ini + % Refinement + fprintf(fid, 'zef.refinement_on = %d;\n', 1); % + % boolean value to refine the mesh ==> default 1, => set to 1 for BST users + % Other function to check + % edit zef_init_forward_and_inverse_options.m + % edit zef_open_forward_and_inverse_options.m + fprintf(fid, 'zef.refinement_surface_on = %d;\n', 1); + % Activat the refinement of the surface, default 1, set to 1 in for BST users + % wiki page of the refienement:https://github.com/sampsapursiainen/zeffiro_interface/wiki/Finite-Element-Mesh-generation + fprintf(fid, 'import_compartment_list_aux = zef_get_active_compartments(zef);\n'); % list of the compartement + fprintf(fid, 'import_compartment_list_aux = import_compartment_list_aux(end-%d:end-1);\n', nBem); + % Take the outer most from end-%d:end-1, end is the bounding box, do not include for BST users + % List of the active compartment + fprintf(fid, 'import_compartment_list_aux = import_compartment_list_aux(:)'';\n'); + fprintf(fid, 'zef.refinement_surface_compartments = [-1 import_compartment_list_aux];\n'); + % -1 : refine all the compartement that have the sources [Not used here] + % in this case it will also refine the labeld tissue in import_compartment_list_aux indexes + fprintf(fid, 'zef_mesh_tool;\n'); % set the other options to default + fclose(fid); + % ===== WRITE BrainStorm2Zeffiro IMPORT FILE (BST to Zef Interface) ===== + % Get temp folder + TmpDir = bst_get('BrainstormTmpDir', 1, 'zeffiro'); + % writeZefFile() ==> Need to discuss with Sampsa + ZefFile = fullfile(TmpDir, 'BrainStorm2Zeffiro_import.zef'); + fid = fopen(ZefFile, 'wt+'); + bemComment = bemComment(:)'; % from outer to inner + for iBem = 1:nBem + fprintf(fid, ... + 'type,segmentation,name,%s,database,bst,tag,%s,parameter_name,sigma,parameter_value,0.33,invert,1 \n',... + bemComment{iBem}, bemComment{iBem}); + end + fprintf(fid,'type,script,filename,utilities.brainstorm2zef.m.zef_bst_import_settings \n'); + fclose(fid); + + % ===== Run Zeffiro Mesh ===== + % ==> Check with Sampsa + % https://github.com/sampsapursiainen/zeffiro_interface/blob/master/%2Bexamples/zef_meshing_example.m + zef = zeffiro_interface('start_mode','nodisplay','import_to_new_project',... + ZefFile, 'run_script','zef = zef_create_finite_element_mesh(zef);','exit_zeffiro', 1); + % outpts conversion: + node = zef.nodes; + elem = [zef.tetra zef.domain_labels]; + TissueLabels = zef.name_tags(1:end-1); + else % use advanced Zeffiro Interface + bst_progress('text', 'Opening Zeffiro Advanced Panel...'); + pause(2); % some time to display the progress bar. + % Advanced option ==> that will be used from Zef side + % Zef team is developing this interface within the Zef repo + % Get the Zef folder path for brainstorm utilities + zefPath = bst_fullfile(PlugDesc.Path, PlugDesc.SubFolder); + utilities.brainstorm2zef.m.zef_bst_plugin_start(zefPath) + % Sampsa team : export back the output to brainstorm db + % import the data from the Zef outputs and ad to bst database + % add some information to the History field from the Zef codes + + % Return success + isOk = 1; + return + end + otherwise errMsg = [errMsg, 'Invalid method "' OPTIONS.Method '".']; return; @@ -1096,9 +1304,9 @@ function ComputeInteractive(iSubject, iMris, BemFiles) %#ok % Get default options OPTIONS = GetDefaultOptions(); OPTIONS.BemFiles = BemFiles; - % If BEM surfaces are selected, the only possible method is "iso2mesh" + % If BEM surfaces are selected, the possible methods are "iso2mesh" or "zeffiro" if ~isempty(BemFiles) && iscell(BemFiles) - FemMethods = {'Iso2mesh-2021','Iso2mesh'}; + FemMethods = {'Iso2mesh-2021','Iso2mesh', 'Zeffiro'}; DefMethod = 'Iso2mesh-2021'; % More than 2 MRI selected: error elseif (length(iMris) > 2) @@ -1114,7 +1322,7 @@ function ComputeInteractive(iSubject, iMris, BemFiles) %#ok DefMethod = 'SimNIBS4'; % Otherwise: Use the defaults from the folder: Ask for method to use else - FemMethods = {'Iso2mesh-2021','Iso2mesh','Brain2mesh','SimNIBS3','SimNIBS4','ROAST','FieldTrip'}; + FemMethods = {'Iso2mesh-2021','Iso2mesh','Brain2mesh','SimNIBS3','SimNIBS4','ROAST','FieldTrip', 'Zeffiro'}; DefMethod = 'Iso2mesh-2021'; end @@ -1157,7 +1365,10 @@ function ComputeInteractive(iSubject, iMris, BemFiles) %#ok strQuestion = [strQuestion, ... 'FieldTrip:
Call FieldTrip to segment and mesh the T1 MRI.
' ... 'FieldTrip is downloaded automatically as a plugin.

']; - end + case 'Zeffiro' + strQuestion = [strQuestion, ... + 'Zeffiro:
Call Zeffiro to create a tetrahedral mesh from the BEM surfaces.
' ... + 'Zeffiro is downloaded automatically as a plugin.

']; end end % Ask the user to select a method res = java_dialog('question', strQuestion, 'FEM mesh generation method', [], FemMethods, DefMethod); @@ -1274,6 +1485,43 @@ function ComputeInteractive(iSubject, iMris, BemFiles) %#ok case 'roast' % No extra options for now OPTIONS.MeshType = 'tetrahedral'; + + case 'zeffiro' + % Ask user for the Zeffiro Option here + advancedZefMsg = 'Would you like to use the advanced interface for Zeffiro mesh generation?'; + [res, isCancel] = java_dialog('question', [advancedZefMsg 10 ' This will open the advanced Zeffiro panel '], 'Zeffiro FEM Mesh Interface'); + if isCancel || isempty(res) + return + end + if strcmpi(res, 'yes') + OPTIONS.ZefAdvancedInterface = 1; + + else % use 'Brainstorm Basic Options' + OPTIONS.ZefAdvancedInterface = 0; + % Get mesh resolution + [res, isCancel] = java_dialog('input', 'Mesh Resolution (edge length) in [mm]:', 'Zeffiro FEM mesh', [], num2str(OPTIONS.ZefMeshResolution)); + if isCancel || isempty(res) + return + end + OPTIONS.ZefMeshResolution = str2num(res); + % Check the values + if isempty(OPTIONS.ZefMeshResolution) || (OPTIONS.ZefMeshResolution < 1) || (OPTIONS.ZefMeshResolution > 4.5) + errMsg = ['Invalid Mesh resolution value.' 10 'Please use value from this interval [1 - 4.5] mm.']; + java_dialog('msgbox', ['Warning: ' errMsg]); + return; + end + % Use GPU? + [res, isCancel] = java_dialog('question', 'Use GPU for mesh computation?', 'Zeffiro FEM mesh'); + if isCancel || isempty(res) + return + end + if strcmpi(res, 'yes') + OPTIONS.ZefUseGPU = 1; + else + OPTIONS.ZefUseGPU = 0; + end + end + OPTIONS.MeshType = 'tetrahedral'; end % Open progress bar diff --git a/toolbox/process/functions/process_fooof.m b/toolbox/process/functions/process_fooof.m index 32740fc0f..d6c207b7e 100644 --- a/toolbox/process/functions/process_fooof.m +++ b/toolbox/process/functions/process_fooof.m @@ -25,7 +25,7 @@ % For more information type "brainstorm license" at command prompt. % =============================================================================@ % -% Authors: Luc Wilson, Francois Tadel, 2020-2022 +% Authors: Luc Wilson, Francois Tadel, 2020-2024 eval(macro_method); end @@ -53,18 +53,18 @@ sProcess.options.implementation.Controller.python = 'Python'; % === FREQUENCY RANGE sProcess.options.freqrange.Comment = 'Frequency range for analysis: '; - sProcess.options.freqrange.Type = 'freqrange_static'; % 'freqrange' + sProcess.options.freqrange.Type = 'freqrange_static'; sProcess.options.freqrange.Value = {[1 40], 'Hz', 1}; % === POWER LINE sProcess.options.powerline.Comment = {'None', '50 Hz', '60 Hz', 'Ignore power line frequencies:'; '-5', '50', '60', ''}; sProcess.options.powerline.Type = 'radio_linelabel'; - sProcess.options.powerline.Value = '60'; + sProcess.options.powerline.Value = 'None'; sProcess.options.powerline.Class = 'Matlab'; - % === PEAK TYPE - sProcess.options.peaktype.Comment = {'Gaussian', 'Cauchy*', 'Best of both* (* experimental)', 'Peak model:'; 'gaussian', 'cauchy', 'best', ''}; - sProcess.options.peaktype.Type = 'radio_linelabel'; - sProcess.options.peaktype.Value = 'gaussian'; - sProcess.options.peaktype.Class = 'Matlab'; + % === MODEL SELECTION + sProcess.options.method.Comment = {'Default', 'Model selection (experimental)', 'Optimization method:'; 'leastsquare', 'negloglike', ''}; + sProcess.options.method.Type = 'radio_linelabel'; + sProcess.options.method.Value = 'leastsquare'; + sProcess.options.method.Class = 'Matlab'; % === PEAK WIDTH LIMITS sProcess.options.peakwidth.Comment = 'Peak width limits (default=[0.5-12]): '; sProcess.options.peakwidth.Type = 'freqrange_static'; @@ -80,7 +80,7 @@ % === PROXIMITY THRESHOLD sProcess.options.proxthresh.Comment = 'Proximity threshold (default=2): '; sProcess.options.proxthresh.Type = 'value'; - sProcess.options.proxthresh.Value = {2, 'stdev of peak model', 1}; + sProcess.options.proxthresh.Value = {2, 'stdev of peak model', 2}; sProcess.options.proxthresh.Class = 'Matlab'; % === APERIODIC MODE sProcess.options.apermode.Comment = {'Fixed', 'Knee', 'Aperiodic mode (default=fixed):'; 'fixed', 'knee', ''}; @@ -138,8 +138,9 @@ opt.return_spectrum = 0; % SPM/FT: set to 1 % Matlab-only options opt.power_line = sProcess.options.powerline.Value; - opt.peak_type = sProcess.options.peaktype.Value; opt.proximity_threshold = sProcess.options.proxthresh.Value{1}; + opt.optim_obj = sProcess.options.method.Value; % negloglike or leastsquare + opt.peak_type = 'gaussian'; % 'cauchy', for interface simplification opt.guess_weight = sProcess.options.guessweight.Value; opt.thresh_after = true; % Threshold after fitting always selected for Matlab (mirrors the Python FOOOF closest by removing peaks that do not satisfy a user's predetermined conditions) % Python-only options @@ -167,6 +168,11 @@ bst_progress('text',['Standby: FOOOFing spectrum ' num2str(iFile) ' of ' num2str(length(sInputs))]); % Load input file PsdMat = in_bst_timefreq(sInputs(iFile).FileName); + % Check for frequency definition + if iscell(PsdMat.Freqs) + bst_report('Error', sProcess, sInputs, 'FOOOF cannot be computed with PSD using frequency bands.'); + return; + end % Exclude 0Hz from the computation if (opt.freq_range(1) == 0) && (PsdMat.Freqs(1) == 0) && (length(PsdMat.Freqs) >= 2) opt.freq_range(1) = PsdMat.Freqs(2); @@ -176,14 +182,25 @@ % Switch between implementations switch (implementation) case 'matlab' % Matlab standalone FOOOF - [FOOOF_freqs, FOOOF_data] = FOOOF_matlab(PsdMat.TF, PsdMat.Freqs, opt, hasOptimTools); + switch (opt.optim_obj) + case 'leastsquare' + [FOOOF_freqs, FOOOF_data, errMsg] = FOOOF_matlab(PsdMat.TF, PsdMat.Freqs, opt, hasOptimTools); + case 'negloglike' + [FOOOF_freqs, FOOOF_data, errMsg] = FOOOF_matlab_nll(PsdMat.TF, PsdMat.Freqs, opt, hasOptimTools); + end case 'python' opt.peak_type = 'gaussian'; - [FOOOF_freqs, FOOOF_data] = process_fooof_py('FOOOF_python', PsdMat.TF, PsdMat.Freqs, opt); + opt.optim_obj = 'leastsquare'; + [FOOOF_freqs, FOOOF_data, errMsg] = process_fooof_py('FOOOF_python', PsdMat.TF, PsdMat.Freqs, opt); % Remove unnecessary structure level, allowing easy concatenation across channels, e.g. for display. - FOOOF_data = FOOOF_data.FOOOF; + FOOOF_data = [FOOOF_data.FOOOF]; otherwise - error('Invalid implentation.'); + errMsg = ['Invalid FOOOF implentation: ' implementation]; + end + % Return if error + if ~isempty(errMsg) + bst_report('Error', sProcess, sInputs(iFile), errMsg); + return; end % === FOOOF ANALYSIS === @@ -199,11 +216,15 @@ 'peaks', ePeaks, ... 'aperiodics', eAperiodics, ... 'stats', eStats); + mstag = ''; + if ~isempty(strfind(opt.optim_obj, 'negloglike')) + mstag = 'ms-'; + end % Comment: Add FOOOF if ~isempty(strfind(PsdMat.Comment, 'PSD:')) - PsdMat.Comment = strrep(PsdMat.Comment, 'PSD:', 'specparam:'); + PsdMat.Comment = strrep(PsdMat.Comment, 'PSD:', [mstag 'specparam:']); else - PsdMat.Comment = strcat(PsdMat.Comment, ' | specparam'); + PsdMat.Comment = strcat(PsdMat.Comment, [' | ' mstag 'specparam']); end % History: Computation PsdMat = bst_history('add', PsdMat, 'compute', 'specparam'); @@ -225,9 +246,141 @@ %% =================================================================================== % ===== MATLAB FOOOF ================================================================ % =================================================================================== +function [fs, fg, errMsg] = FOOOF_matlab_nll(TF, Freqs, opt, hOT) + errMsg = ''; + % Find all frequency values within user limits + fMask = (round(Freqs.*10)./10 >= opt.freq_range(1)) & (round(Freqs.*10)./10 <= opt.freq_range(2)) & ~mod(sum(abs(round(Freqs.*10)./10-[1;2;3].*str2double(opt.power_line)) >= 2),3); + fs = Freqs(fMask); + spec = log10(squeeze(TF(:,1,fMask))); % extract log spectra + nChan = size(TF,1); + if nChan == 1, spec = spec'; end + % Initalize FOOOF structs + fg(nChan) = struct(... + 'aperiodic_params', [],... + 'peak_params', [],... + 'peak_types', '',... + 'ap_fit', [],... + 'fooofed_spectrum', [],... + 'peak_fit', [],... + 'error', [],... + 'r_squared', []); + % Iterate across channels + bst_progress('text','Standby: ms-specparam is running in parallel'); + try + parfor chan = 1:nChan + bst_progress('set', bst_round(chan / nChan,2) * 100); + % Fit aperiodic + aperiodic_pars = robust_ap_fit(fs, spec(chan,:), opt.aperiodic_mode); + % Remove aperiodic + flat_spec = flatten_spectrum(fs, spec(chan,:), aperiodic_pars, opt.aperiodic_mode); + % estimate valid peaks (and determine max n) + [est_pars, peak_function] = est_peaks(fs, flat_spec, opt.max_peaks, opt.peak_threshold, opt.min_peak_height, ... + opt.peak_width_limits/2, opt.proximity_threshold, opt.border_threshold, opt.peak_type); + model = struct(); + for pk = 0:size(est_pars,1) + aperiodic_pars_tmp = []; + peak_pars_tmp = []; + + peak_pars = est_fit(est_pars(1:pk,:), fs, flat_spec, opt.peak_width_limits/2, opt.peak_type, opt.guess_weight,hOT); + % Refit aperiodic + aperiodic = spec(chan,:); + for peak = 1:size(peak_pars,1) + aperiodic = aperiodic - peak_function(fs,peak_pars(peak,1), peak_pars(peak,2), peak_pars(peak,3)); + end + aperiodic_pars = simple_ap_fit(fs, aperiodic, opt.aperiodic_mode); + guess = peak_pars; + if ~isempty(guess) + lb = [max([ones(size(guess(1:pk,:),1),1).*fs(1) guess(1:pk,1)-guess(1:pk,3)*2],[],2),zeros(size(guess(1:pk,2))),ones(size(guess(1:pk,3)))*opt.peak_width_limits(1)/2]'; + ub = [min([ones(size(guess(1:pk,:),1),1).*fs(end) guess(1:pk,1)+guess(1:pk,3)*2],[],2),inf(size(guess(1:pk,2))),ones(size(guess(1:pk,3)))*opt.peak_width_limits(2)/2]'; + else + lb = []; + ub = []; + end + switch opt.aperiodic_mode + case 'fixed' + lb = [-inf; 0; lb(:)]; + ub = [inf; inf; ub(:)]; + case 'knee' + lb = [-inf; 0; 0; lb(:)]; + ub = [inf; 100; inf; ub(:)]; + end + if opt.return_spectrum + fg(chan).power_spectrum = spec(chan,:); + end + guess = guess(1:pk,:)'; + guess = [aperiodic_pars'; guess(:)]; + options = optimset('Display', 'off', 'TolX', 1e-7, 'TolFun', 1e-9, ... + 'MaxFunEvals', 5000, 'MaxIter', 5000); % Tuned options + params = fmincon(@err_fm_constr,guess,[],[],[],[], ... + lb,ub,[],options,fs,spec(chan,:),opt.aperiodic_mode,opt.peak_type); + switch opt.aperiodic_mode + case 'fixed' + aperiodic_pars_tmp = params(1:2); + if length(params) > 3 + peak_pars_tmp = reshape(params(3:end),[3 length(params(3:end))./3])'; + end + case 'knee' + aperiodic_pars_tmp = params(1:3); + if length(params) > 3 + peak_pars_tmp = reshape(params(4:end),[3 length(params(4:end))./3])'; + end + end + % Generate model fit + ap_fit = gen_aperiodic(fs, aperiodic_pars_tmp, opt.aperiodic_mode); + model_fit = ap_fit; + if length(params) > 3 + for peak = 1:size(peak_pars_tmp,1) + model_fit = model_fit + peak_function(fs,peak_pars_tmp(peak,1),... + peak_pars_tmp(peak,2),peak_pars_tmp(peak,3)); + end + else + peak_pars_tmp = [0 0 0]; + end + % Calculate model error + MSE = sum((spec(chan,:) - model_fit).^2)/length(model_fit); + rsq_tmp = corrcoef(spec(chan,:),model_fit).^2; + loglik = -length(model_fit)/2.*(1+log(MSE)+log(2*pi)); + AIC = 2.*(length(params)-loglik); + BIC = length(params).*log(length(model_fit))-2.*loglik; + model(pk+1).aperiodic_params = aperiodic_pars_tmp; + model(pk+1).peak_params = peak_pars_tmp; + model(pk+1).MSE = MSE; + model(pk+1).r_squared = rsq_tmp(2); + model(pk+1).loglik = loglik; + model(pk+1).AIC = AIC; + model(pk+1).BIC = BIC; + model(pk+1).BF = exp((BIC-model(1).BIC)./2); + end + % insert data from best model + + [~,mi] = min([model.BIC]); + + aperiodic_pars = model(mi).aperiodic_params; + peak_pars = model(mi).peak_params; + % Return FOOOF results + aperiodic_pars(2) = abs(aperiodic_pars(2)); + fg(chan).aperiodic_params = aperiodic_pars; + fg(chan).peak_params = peak_pars; + fg(chan).peak_types = func2str(peak_function); + fg(chan).ap_fit = 10.^gen_aperiodic(fs, aperiodic_pars, opt.aperiodic_mode); + fg(chan).fooofed_spectrum = 10.^build_model(fs, aperiodic_pars, opt.aperiodic_mode, peak_pars, peak_function); + fg(chan).peak_fit = fg(chan).fooofed_spectrum ./ fg(chan).ap_fit; + fg(chan).error = model(mi).MSE; + fg(chan).r_squared = model(mi).r_squared; + fg(chan).loglik = model(mi).loglik; % log-likelihood + fg(chan).AIC = model(mi).AIC; + fg(chan).BIC = model(mi).BIC; + fg(chan).models = model; + end + catch err + errMsg = err.message; + end +end + %% ===== MATLAB STANDALONE FOOOF ===== -function [fs, fg] = FOOOF_matlab(TF, Freqs, opt, hOT) +function [fs, fg, errMsg] = FOOOF_matlab(TF, Freqs, opt, hOT) + errMsg = ''; % Find all frequency values within user limits fMask = (round(Freqs.*10)./10 >= opt.freq_range(1)) & (round(Freqs.*10)./10 <= opt.freq_range(2)) & ~mod(sum(abs(round(Freqs.*10)./10-[1;2;3].*str2double(opt.power_line)) >= 2),3); fs = Freqs(fMask); @@ -253,7 +406,7 @@ flat_spec = flatten_spectrum(fs, spec(chan,:), aperiodic_pars, opt.aperiodic_mode); % Fit peaks [peak_pars, peak_function] = fit_peaks(fs, flat_spec, opt.max_peaks, opt.peak_threshold, opt.min_peak_height, ... - opt.peak_width_limits/2, opt.proximity_threshold, opt.border_threshold, opt.peak_type, opt.guess_weight,hOT); + opt.peak_width_limits/2, opt.proximity_threshold, opt.border_threshold, opt.peak_type, opt.guess_weight, hOT); if opt.thresh_after && ~hOT % Check thresholding requirements are met for unbounded optimization peak_pars(peak_pars(:,2) < opt.min_peak_height,:) = []; % remove peaks shorter than limit peak_pars(peak_pars(:,3) < opt.peak_width_limits(1)/2,:) = []; % remove peaks narrower than limit @@ -405,7 +558,7 @@ end -function ys = expo_fl_function(freqs, params) +function ys = expo_fl_function(f, params) ys = log10(f.^(params(1)) * 10^(params(2)) + params(3)); @@ -432,7 +585,7 @@ % Set guess params for lorentzian aperiodic fit, guess params set at init options = optimset('Display', 'off', 'TolX', 1e-4, 'TolFun', 1e-6, ... - 'MaxFunEvals', 5000, 'MaxIter', 5000); + 'MaxFunEvals', 10000, 'MaxIter', 10000); switch (aperiodic_mode) case 'fixed' % no knee @@ -483,7 +636,7 @@ % Second aperiodic fit - using results of first fit as guess parameters options = optimset('Display', 'off', 'TolX', 1e-4, 'TolFun', 1e-6, ... - 'MaxFunEvals', 5000, 'MaxIter', 5000); + 'MaxFunEvals', 10000, 'MaxIter', 10000); guess_vec = popt; switch (aperiodic_mode) @@ -779,6 +932,209 @@ end +function [guess_params,peak_function] = est_peaks(freqs, flat_iter, max_n_peaks, peak_threshold, min_peak_height, gauss_std_limits, proxThresh, bordThresh, peakType) +% Iteratively fit peaks to flattened spectrum. +% +% Parameters +% ---------- +% freqs : 1xn array +% Frequency values for the power spectrum, in linear scale. +% flat_iter : 1xn array +% Flattened (aperiodic removed) power spectrum. +% max_n_peaks : double +% Maximum number of gaussians to fit within the spectrum. +% peak_threshold : double +% Threshold (in standard deviations of noise floor) to detect a peak. +% min_peak_height : double +% Minimum height of a peak (in log10). +% gauss_std_limits : 1x2 double +% Limits to gaussian (cauchy) standard deviation (gamma) when detecting a peak. +% proxThresh : double +% Minimum distance between two peaks, in st. dev. (gamma) of peaks. +% peakType : {'gaussian', 'cauchy', 'both'} +% Which types of peaks are being fitted +% guess_weight : {'none', 'weak', 'strong'} +% Parameter to weigh initial estimates during optimization (None, Weak, or Strong) +% hOT : 0 or 1 +% Defines whether to use constrained optimization, fmincon, or +% basic simplex, fminsearch. +% +% Returns +% ------- +% guess_params : mx3 array, where m = No. of peaks. +% Parameters that define the peak fit(s). Each row is a peak, as [mean, height, st. dev. (gamma)]. + + switch peakType + case 'gaussian' % gaussian only + peak_function = @gaussian; % Identify peaks as gaussian + % Initialize matrix of guess parameters for gaussian fitting. + guess_params = zeros(max_n_peaks, 3); + % Find peak: Loop through, finding a candidate peak, and fitting with a guess gaussian. + % Stopping procedure based on either the limit on # of peaks, + % or the relative or absolute height thresholds. + for guess = 1:max_n_peaks + % Find candidate peak - the maximum point of the flattened spectrum. + max_ind = find(flat_iter == max(flat_iter)); + max_height = flat_iter(max_ind); + + % Stop searching for peaks once max_height drops below height threshold. + if max_height <= peak_threshold * std(flat_iter) + break + end + + % Set the guess parameters for gaussian fitting - mean and height. + guess_freq = freqs(max_ind); + guess_height = max_height; + + % Halt fitting process if candidate peak drops below minimum height. + if guess_height <= min_peak_height + break + end + + % Data-driven first guess at standard deviation + % Find half height index on each side of the center frequency. + half_height = 0.5 * max_height; + + le_ind = sum(flat_iter(1:max_ind) <= half_height); + ri_ind = length(flat_iter) - sum(flat_iter(max_ind:end) <= half_height)+1; + + % Keep bandwidth estimation from the shortest side. + % We grab shortest to avoid estimating very large std from overalapping peaks. + % Grab the shortest side, ignoring a side if the half max was not found. + % Note: will fail if both le & ri ind's end up as None (probably shouldn't happen). + short_side = min(abs([le_ind,ri_ind]-max_ind)); + + % Estimate std from FWHM. Calculate FWHM, converting to Hz, get guess std from FWHM + fwhm = short_side * 2 * (freqs(2)-freqs(1)); + guess_std = fwhm / (2 * sqrt(2 * log(2))); + + % Check that guess std isn't outside preset std limits; restrict if so. + % Note: without this, curve_fitting fails if given guess > or < bounds. + if guess_std < gauss_std_limits(1) + guess_std = gauss_std_limits(1); + end + if guess_std > gauss_std_limits(2) + guess_std = gauss_std_limits(2); + end + + % Collect guess parameters. + guess_params(guess,:) = [guess_freq, guess_height, guess_std]; + + % Subtract best-guess gaussian. + peak_gauss = gaussian(freqs, guess_freq, guess_height, guess_std); + flat_iter = flat_iter - peak_gauss; + + end + % Remove unused guesses + guess_params(guess_params(:,1) == 0,:) = []; + + % Check peaks based on edges, and on overlap + % Drop any that violate requirements. + guess_params = drop_peak_cf(guess_params, bordThresh, [min(freqs) max(freqs)]); + guess_params = drop_peak_overlap(guess_params, proxThresh); + + case 'cauchy' % cauchy only + peak_function = @cauchy; % Identify peaks as cauchy + guess_params = zeros(max_n_peaks, 3); + for guess = 1:max_n_peaks + max_ind = find(flat_iter == max(flat_iter)); + max_height = flat_iter(max_ind); + if max_height <= peak_threshold * std(flat_iter) + break + end + guess_freq = freqs(max_ind); + guess_height = max_height; + if guess_height <= min_peak_height + break + end + half_height = 0.5 * max_height; + le_ind = sum(flat_iter(1:max_ind) <= half_height); + ri_ind = length(flat_iter) - sum(flat_iter(max_ind:end) <= half_height); + short_side = min(abs([le_ind,ri_ind]-max_ind)); + + % Estimate gamma from FWHM. Calculate FWHM, converting to Hz, get guess gamma from FWHM + fwhm = short_side * 2 * (freqs(2)-freqs(1)); + guess_gamma = fwhm/2; + % Check that guess gamma isn't outside preset limits; restrict if so. + % Note: without this, curve_fitting fails if given guess > or < bounds. + if guess_gamma < gauss_std_limits(1) + guess_gamma = gauss_std_limits(1); + end + if guess_gamma > gauss_std_limits(2) + guess_gamma = gauss_std_limits(2); + end + + % Collect guess parameters. + guess_params(guess,:) = [guess_freq(1), guess_height, guess_gamma]; + + % Subtract best-guess cauchy. + peak_cauchy = cauchy(freqs, guess_freq(1), guess_height, guess_gamma); + flat_iter = flat_iter - peak_cauchy; + + end + guess_params(guess_params(:,1) == 0,:) = []; + guess_params = drop_peak_cf(guess_params, proxThresh, [min(freqs) max(freqs)]); + guess_params = drop_peak_overlap(guess_params, proxThresh); + + end +end + +function model_params = est_fit(guess_params, freqs, flat_spec, gauss_std_limits, peakType, guess_weight,hOT) +% Iteratively fit peaks to flattened spectrum. +% +% Parameters +% ---------- +% freqs : 1xn array +% Frequency values for the power spectrum, in linear scale. +% flat_iter : 1xn array +% Flattened (aperiodic removed) power spectrum. +% max_n_peaks : double +% Maximum number of gaussians to fit within the spectrum. +% peak_threshold : double +% Threshold (in standard deviations of noise floor) to detect a peak. +% min_peak_height : double +% Minimum height of a peak (in log10). +% gauss_std_limits : 1x2 double +% Limits to gaussian (cauchy) standard deviation (gamma) when detecting a peak. +% proxThresh : double +% Minimum distance between two peaks, in st. dev. (gamma) of peaks. +% peakType : {'gaussian', 'cauchy', 'both'} +% Which types of peaks are being fitted +% guess_weight : {'none', 'weak', 'strong'} +% Parameter to weigh initial estimates during optimization (None, Weak, or Strong) +% hOT : 0 or 1 +% Defines whether to use constrained optimization, fmincon, or +% basic simplex, fminsearch. +% +% Returns +% ------- +% guess_params : mx3 array, where m = No. of peaks. +% Parameters that define the peak fit(s). Each row is a peak, as [mean, height, st. dev. (gamma)]. + + switch peakType + case 'gaussian' % gaussian only + + % If there are peak guesses, fit the peaks, and sort results. + if ~isempty(guess_params) + model_params = fit_peak_guess(guess_params, freqs, flat_spec, 1, guess_weight, gauss_std_limits,hOT); + else + model_params = []; + end + + case 'cauchy' % cauchy only + + % If there are peak guesses, fit the peaks, and sort results. + if ~isempty(guess_params) + model_params = fit_peak_guess(guess_params, freqs, flat_spec, 2, guess_weight, gauss_std_limits,hOT); + else + model_params = []; + end + + end +end + + + function guess = drop_peak_cf(guess, bw_std_edge, freq_range) % Check whether to drop peaks based on center's proximity to the edge of the spectrum. % @@ -850,6 +1206,9 @@ end % Drop any peaks guesses that overlap too much, based on threshold. guess(drop_inds,:) = []; + + % Readjust order by amplitude + guess = sortrows(guess,2,'descend'); end function peak_params = fit_peak_guess(guess, freqs, flat_spec, peak_type, guess_weight, std_limits, hOT) @@ -880,20 +1239,48 @@ if hOT % Use OptimToolbox for fmincon - options = optimset('Display', 'off', 'TolX', 1e-3, 'TolFun', 1e-5, ... - 'MaxFunEvals', 3000, 'MaxIter', 3000); % Tuned options lb = [max([ones(size(guess,1),1).*freqs(1) guess(:,1)-guess(:,3)*2],[],2),zeros(size(guess(:,2))),ones(size(guess(:,3)))*std_limits(1)]; ub = [min([ones(size(guess,1),1).*freqs(end) guess(:,1)+guess(:,3)*2],[],2),inf(size(guess(:,2))),ones(size(guess(:,3)))*std_limits(2)]; + options = optimset('Display', 'off', 'TolX', 1e-3, 'TolFun', 1e-5, ... + 'MaxFunEvals', 5000, 'MaxIter', 5000); % Tuned options peak_params = fmincon(@error_model_constr,guess,[],[],[],[], ... lb,ub,[],options,freqs,flat_spec, peak_type); else % Use basic simplex approach, fminsearch, with guess_weight - options = optimset('Display', 'off', 'TolX', 1e-4, 'TolFun', 1e-5, ... + options = optimset('Display', 'off', 'TolX', 1e-5, 'TolFun', 1e-7, ... 'MaxFunEvals', 5000, 'MaxIter', 5000); peak_params = fminsearch(@error_model,... guess, options, freqs, flat_spec, peak_type, guess, guess_weight); end end +function model_fit = build_model(freqs, ap_pars, ap_type, pk_pars, peak_function) +% Builds a full spectral model from parameters. +% +% Parameters +% ---------- +% freqs : 1xn array +% Frequency values for the power spectrum, in linear scale. +% ap_pars : 1xm array +% Parameter estimates for aperiodic fit. +% pk_pars : kx3 array, where k = No. of peaks. +% Guess parameters for peak fits. +% pk_type : {'gaussian', 'cauchy', 'best'} +% Which types of peaks are being fitted. +% +% Returns +% ------- +% model_fit : 1xn array +% Model power spectrum, in log10-space + + ap_fit = gen_aperiodic(freqs, ap_pars, ap_type); + model_fit = ap_fit; + if length(pk_pars) > 1 + for peak = 1:size(pk_pars,1) + model_fit = model_fit + peak_function(freqs,pk_pars(peak,1),... + pk_pars(peak,2),pk_pars(peak,3)); + end + end +end %% ===== ERROR FUNCTIONS ===== function err = error_expo_nk_function(params,xs,ys) @@ -945,6 +1332,25 @@ err = sum((yVals - fitted_vals).^2); end +function err = err_fm_constr(params, xVals, yVals, aperiodic_mode, peak_type) + switch (aperiodic_mode) + case 'fixed' % no knee + npk = (length(params)-2)/3; + fitted_vals = -log10(xVals.^params(2)) + params(1); + case 'knee' + npk = (length(params)-3)/3; + fitted_vals = params(1) - log10(abs(params(2)) +xVals.^params(3)); + end + for set = 1:npk + switch peak_type + case 'gaussian' % gaussian only + fitted_vals = fitted_vals + gaussian(xVals, params(3.*set), params(3*set+1), params(3*set+2)); + case 'cauchy' % Cauchy + fitted_vals = fitted_vals + cauchy(xVals, params(3.*set), params(3*set+1), params(3*set+2)); + end + end + err = sum((yVals - fitted_vals).^2); +end %% =================================================================================== @@ -954,7 +1360,6 @@ % ===== EXTRACT PEAKS ===== % Organize/extract peak components from FOOOF models nChan = numel(ChanNames); - maxEnt = nChan * max_peaks; switch sort_type case 'param' % Initialize output struct @@ -1042,4 +1447,3 @@ eStats(chan).frequency_wise_error = abs(spec-fspec); end end - diff --git a/toolbox/process/functions/process_fooof_py.m b/toolbox/process/functions/process_fooof_py.m index 76c7f73d0..2b222cbe6 100644 --- a/toolbox/process/functions/process_fooof_py.m +++ b/toolbox/process/functions/process_fooof_py.m @@ -26,7 +26,8 @@ %% ===== PYTHON FOOOF ===== -function [fs, fg] = FOOOF_python(TF, Freqs, opt) +function [fs, fg, errMsg] = FOOOF_python(TF, Freqs, opt) + errMsg = ''; % Import python modules modules = py.sys.modules; modules = string(cell(py.list(modules.keys()))); diff --git a/toolbox/process/functions/process_ft_mtmconvol.m b/toolbox/process/functions/process_ft_mtmconvol.m index d4457d494..759cf0b86 100644 --- a/toolbox/process/functions/process_ft_mtmconvol.m +++ b/toolbox/process/functions/process_ft_mtmconvol.m @@ -75,6 +75,8 @@ sProcess.options.mt_taper.Type = 'combobox_label'; sProcess.options.mt_taper.Value = {'dpss', {'dpss', 'hanning', 'rectwin', 'sine'; ... 'dpss', 'hanning', 'rectwin', 'sine'}}; + sProcess.options.mt_taper.Controller.dpss = 'ModFreq'; + sProcess.options.mt_taper.Controller.sine = 'ModFreq'; % Options: Frequencies sProcess.options.mt_frequencies.Comment = 'Frequencies (start:step:stop): '; sProcess.options.mt_frequencies.Type = 'text'; @@ -83,6 +85,7 @@ sProcess.options.mt_freqmod.Comment = 'Modulation factor: '; sProcess.options.mt_freqmod.Type = 'value'; sProcess.options.mt_freqmod.Value = {10, ' ', 0}; + sProcess.options.mt_freqmod.Class = 'ModFreq'; % Options: Time resolution sProcess.options.mt_timeres.Comment = 'Time resolution: '; sProcess.options.mt_timeres.Type = 'value'; diff --git a/toolbox/process/functions/process_headmodel_exclusionzone.m b/toolbox/process/functions/process_headmodel_exclusionzone.m new file mode 100644 index 000000000..dbb03df7b --- /dev/null +++ b/toolbox/process/functions/process_headmodel_exclusionzone.m @@ -0,0 +1,231 @@ +function varargout = process_headmodel_exclusionzone( varargin ) +% PROCESS_HEADMODEL_EXCLUSIONZONE: Remove leadfields and sources within the exclusion zone. +% +% USAGE: OutputFiles = process_headmodel_exclusionzone('Run', sProcess, sInputs) +% [newHMMat, errMsg] = process_headmodel_exclusionzone('Compute', HMMat, ChannelMat, Modality, ExclusionRadius) +% process_headmodel_exclusionzone('ComputeInteractive', HMFile, Modality, iStudy) + +% @============================================================================= +% This function is part of the Brainstorm software: +% https://neuroimage.usc.edu/brainstorm +% +% Copyright (c) University of Southern California & McGill University +% This software is distributed under the terms of the GNU General Public License +% as published by the Free Software Foundation. Further details on the GPLv3 +% license can be found at http://www.gnu.org/copyleft/gpl.html. +% +% FOR RESEARCH PURPOSES ONLY. THE SOFTWARE IS PROVIDED "AS IS," AND THE +% UNIVERSITY OF SOUTHERN CALIFORNIA AND ITS COLLABORATORS DO NOT MAKE ANY +% WARRANTY, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO WARRANTIES OF +% MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE, NOR DO THEY ASSUME ANY +% LIABILITY OR RESPONSIBILITY FOR THE USE OF THIS SOFTWARE. +% +% For more information type "brainstorm license" at command prompt. +% =============================================================================@ +% +% Authors: Takfarinas Medani, Yash Shashank Vakilna, Raymundo Cassani 2024 + +eval(macro_method); +end + + +%% ===== GET DESCRIPTION ===== +function sProcess = GetDescription() + % Description the process + sProcess.Comment = 'Head model exclusion zone'; + sProcess.Category = 'Custom'; + sProcess.SubGroup = 'Sources'; + sProcess.Index = 321; + sProcess.Description = 'https://neuroimage.usc.edu/brainstorm/Tutorials/TutVolSource#Exclusion_zone'; + % Definition of the input accepted by this process + sProcess.InputTypes = {'data', 'raw'}; + sProcess.OutputTypes = {'data', 'raw'}; + sProcess.nInputs = 1; + sProcess.nMinFiles = 0; + sProcess.isSeparator = 1; + % === Usage label + sProcess.options.usage.Comment = GetUsageText(); + sProcess.options.usage.Type = 'label'; + sProcess.options.usage.Value = ''; + % === Modality + sProcess.options.modality.Comment = 'Modality of sensors: '; + sProcess.options.modality.Type = 'text'; + sProcess.options.modality.Value = 'SEEG'; + sProcess.options.modality.InputTypes = {'data', 'raw'}; + % === Exclusion radius + sProcess.options.exclusionradius.Comment = 'Exclusion distance: '; + sProcess.options.exclusionradius.Type = 'value'; + sProcess.options.exclusionradius.Value = {3,'mm',2}; +end + + +%% ===== RUN ===== +function OutputFiles = Run(sProcess, sInputs) %#ok + OutputFiles = {}; + % ===== GET OPTIONS ===== + % Modality + Modality = []; + if isfield(sProcess.options, 'modality') && ~isempty(sProcess.options.modality) && ~isempty(sProcess.options.modality.Value) + Modality = sProcess.options.modality.Value; + end + % Exclusion radius + ExclusionRadius = sProcess.options.exclusionradius.Value{1} ./ 1000; + if ExclusionRadius <= 0 + bst_report('Error', sProcess, [], 'You must define an exclusion distance greater than zero.'); + return; + end + OutputFiles = sInputs; + % Get unique channel files + [sChannels, iChanStudies] = bst_get('ChannelForStudy', unique([sInputs.iStudy])); + % Check if there are channel files everywhere + if (length(sChannels) ~= length(iChanStudies)) + bst_report('Error', sProcess, sInputs, ['Some of the input files are not associated with a channel file.' 10 'Please import the channel files first.']); + return; + end + % Keep only once each channel file + iChanStudies = unique(iChanStudies); + newDefaultHM = {}; % Pairs [iStudy, newHeadmodelFileName] + + % ===== COMPUTE EXCLUSION ZONE ===== + % Start the progress bar + bst_progress('start', 'Leadfield exclusion zone', 'Computing leadfield exclusion zone...', 0, 100); + barStep = 100 ./ length(iChanStudies); + for ix = 1 : length(iChanStudies) + bst_progress('set', barStep * ix); + iStudy = iChanStudies(ix); + % Get default headmodel for study + sHeadmodel = bst_get('HeadModelForStudy', iStudy); + HeadmodelMat = in_bst_headmodel(sHeadmodel.FileName); + % Check that is volumetric + if ~strcmpi(HeadmodelMat.HeadModelType, 'volume') + bst_report('Error', sProcess, [], 'Head model must be volumetric'); + continue + end + % Load Channel file + sChannel = bst_get('ChannelForStudy', iStudy); + ChannelMat = in_bst_channel(sChannel.FileName, 'Channel'); + % Compute exclusion zone + [newHeadmodelMat, errMsg] = Compute(HeadmodelMat, ChannelMat, Modality, ExclusionRadius); + if ~isempty(errMsg) + bst_report('Error', sProcess, [], errMsg); + continue; + end + % Add to database + newHeadmodelFileName = db_add(iStudy, newHeadmodelMat); + newDefaultHM{end+1, 1} = iStudy; + newDefaultHM{end, 2} = newHeadmodelFileName; + end + % Set exclusion-zone headmodel as default in study + for ix = 1 : size(newDefaultHM, 1) + iStudy = newDefaultHM{ix, 1}; + newHeadmodelFileName = newDefaultHM{ix, 2}; + sStudy = bst_get('Study', iStudy); + iHeadModel = find(strcmpi({sStudy.HeadModel.FileName}, newHeadmodelFileName)); + sStudy.iHeadModel = iHeadModel; + bst_set('Study', iStudy, sStudy); + end + % Close progress bar + bst_progress('stop'); + panel_protocols('UpdateTree'); +end + + +%% ===== COMPUTE ===== +function [newHeadmodelMat, errMsg] = Compute(HeadmodelMat, ChannelMat, Modality, ExclusionRadius) + % Computes the exclusion zone in the HeadmodelMat based on the sensor locations, and ExclusionRadius. Only for volumetric grids + + newHeadmodelMat = []; + errMsg = ''; + % Check that is volumetric + if ~strcmpi(HeadmodelMat.HeadModelType, 'volume') + errMsg = 'Head model must be volumetric'; + return + end + % Find selected channels + iChannels = channel_find(ChannelMat.Channel, Modality); + if isempty(iChannels) + errMsg = ['Could not load any sensor for modality: ' Modality]; + return; + end + % Get channel locations + channelLocs = [ChannelMat.Channel(iChannels).Loc]'; + % Indices of grid points in exclusion zone + iBadVertices = []; + for iLocation = 1 : size(channelLocs, 1) + iBadVerticesLoc = find(sqrt(sum(bst_bsxfun(@minus, HeadmodelMat.GridLoc, channelLocs(iLocation, :)) .^ 2, 2)) <= ExclusionRadius); + iBadVertices = [iBadVertices, iBadVerticesLoc']; + end + if isempty(iBadVertices) + errMsg = 'There is no grid points to remove in the exclusion zone.'; + return + end + iBadVertices = unique(iBadVertices); + % Indices to gain indices + iBadGains = sort([3*iBadVertices, 3*iBadVertices - 1, 3*iBadVertices - 2]); + % New head model with exclusion zone + newHeadmodelMat = HeadmodelMat; + newHeadmodelMat.GridLoc(iBadVertices, :) = []; + newHeadmodelMat.Gain(:, iBadGains) = []; + exclusionZoneComment = sprintf('exclusion_zone %.2f mm', ExclusionRadius * 1000); + newHeadmodelMat.Comment = [HeadmodelMat.Comment, ' | ' exclusionZoneComment]; + newHeadmodelMat = bst_history('add', newHeadmodelMat, ['Apply ' exclusionZoneComment]); +end + + +%% ===== COMPUTE/INTERACTIVE ===== +function ComputeInteractive(HeadmodelFileName, Modality, iStudy) + windowTitle = 'Leadfield exclusion zone'; + HeadmodelMat = in_bst_headmodel(HeadmodelFileName); + % Check that is volumetric + if ~strcmpi(HeadmodelMat.HeadModelType, 'volume') + bst_error('Head model must be volumetric', windowTitle, 0); + return + end + % Ask user the distance of the exclusion zone + [res, isCancel] = java_dialog('input', ['' GetUsageText(Modality) '

' ... + 'Exclusion distance (mm):'], ... + windowTitle, [], sprintf('%.2f', 1)); + if isCancel || isempty(res) + return + end + % Exclusion radius in m + ExclusionRadius = str2double(res) ./ 1000; + if ExclusionRadius <= 0 + bst_error('You must define an exclusion distance greater than zero.', 'Leadfield exclusion zone', 0); + return; + end + % Start the progress bar + bst_progress('start', windowTitle, 'Computing leadfield exclusion zone...'); + % Load Channel file + sChannel = bst_get('ChannelForStudy', iStudy); + ChannelMat = in_bst_channel(sChannel.FileName, 'Channel'); + % Compute exclusion zone + [newHeadmodelMat, errMsg] = Compute(HeadmodelMat, ChannelMat, Modality, ExclusionRadius); + if ~isempty(errMsg) + bst_progress('stop'); + bst_error(errMsg, windowTitle, 0); + return; + end + % Add to database + newHeadmodelFileName = db_add(iStudy, newHeadmodelMat); + % Set as default headmodel + sStudy = bst_get('Study', iStudy); + iHeadModel = find(strcmpi({sStudy.HeadModel.FileName}, newHeadmodelFileName)); + sStudy.iHeadModel = iHeadModel; + bst_set('Study', iStudy, sStudy); + panel_protocols('UpdateTree'); + % Close progress bar + bst_progress('stop'); +end + + +%% ===== GET USAGE TEXT ===== +function usageHtmlText = GetUsageText(Modality) + if nargin < 1 || isempty(Modality) + Modality = ''; + end + usageHtmlText = ['Define the exclusion zone around the ' Modality ' sensors.

' ... + 'Warning This approach will remove the leadfield vectors located near the sensors.
' ... + 'This method also remove the sources located in the exlusion zone
'... + 'The exclusion zone is defined by the distance from the sensors to the sources.']; +end \ No newline at end of file diff --git a/toolbox/process/functions/process_headpoints_refine.m b/toolbox/process/functions/process_headpoints_refine.m index 9cf995a6f..f6e4f6db9 100644 --- a/toolbox/process/functions/process_headpoints_refine.m +++ b/toolbox/process/functions/process_headpoints_refine.m @@ -41,7 +41,7 @@ sProcess.options.title.Comment = [... 'Refine the MEG/MRI registration using digitized head points.
' ... 'If (tolerance > 0): fit the head points, remove the digitized points the most
' ... - 'distant to the scalp surface, and fit again the the head points on the scalp.


']; + 'distant to the scalp surface, and fit again the head points on the scalp.


']; sProcess.options.title.Type = 'label'; % Tolerance sProcess.options.tolerance.Comment = 'Tolerance (outlier points to ignore):'; @@ -61,13 +61,13 @@ % Get options tolerance = sProcess.options.tolerance.Value{1} / 100; % Get all the channel files - uniqueChan = unique({sInputs.ChannelFile}); + [uniqueChan, iUniqFiles] = unique({sInputs.ChannelFile}); % Loop on all the channel files for i = 1:length(uniqueChan) % Refine registration [ChannelMat, R, T, isSkip, isUserCancel, strReport] = channel_align_auto(uniqueChan{i}, [], 0, 0, tolerance); if ~isempty(strReport) - bst_report('Info', sProcess, sInputs, strReport); + bst_report('Info', sProcess, sInputs(iUniqFiles(i)), strReport); end end % Return all the files in input diff --git a/toolbox/process/functions/process_import_bids.m b/toolbox/process/functions/process_import_bids.m index 5997290c5..551ff0df2 100644 --- a/toolbox/process/functions/process_import_bids.m +++ b/toolbox/process/functions/process_import_bids.m @@ -473,7 +473,7 @@ end OPTIONS.nVertices = str2double(OPTIONS.nVertices); end - % If there are fiducials define: record these, to use them when importing FreeSurfer (or other) segmentations + % If there are fiducials defined: record these, to use them when importing FreeSurfer (or other) segmentations if ~isempty(SubjectFidMriFile{iSubj}) sMriFid = in_mri(SubjectFidMriFile{iSubj}, 'ALL', 0); else @@ -533,7 +533,7 @@ errorMsg = [errorMsg, 10, errMsg]; end % Generate head surface - tess_isohead(iSubject, 10000, 0, 2); + tess_isohead(iSubject, 15000, 0, 0); else MrisToRegister{end+1} = BstMriFile; end @@ -751,6 +751,10 @@ end % Coordinates can be linked to the scanner/world coordinates of a specific volume in the dataset if isfield(sCoordsystem, 'IntendedFor') && ~isempty(sCoordsystem.IntendedFor) + % Quick bug fix: Only check first file for now if array + if iscell(sCoordsystem.IntendedFor) + sCoordsystem.IntendedFor = sCoordsystem.IntendedFor{1}; + end if file_exist(bst_fullfile(BidsDir, sCoordsystem.IntendedFor)) % Check whether the IntendedFor files is already imported as a volume if ~isempty(MriMatchOrigImport) diff --git a/toolbox/process/functions/process_import_channel.m b/toolbox/process/functions/process_import_channel.m index 8ad96fa86..b2a727576 100644 --- a/toolbox/process/functions/process_import_channel.m +++ b/toolbox/process/functions/process_import_channel.m @@ -66,8 +66,8 @@ 'ChannelIn'}; % DefaultFormats % Option: Default channel files sProcess.options.usedefault.Comment = 'Or use default:'; - sProcess.options.usedefault.Type = 'combobox'; - sProcess.options.usedefault.Value = {1, strList}; + sProcess.options.usedefault.Type = 'combobox_label'; + sProcess.options.usedefault.Value = {'', cat(1, strList, strList)}; % Separator sProcess.options.separator.Type = 'separator'; sProcess.options.separator.Comment = ' '; @@ -100,14 +100,31 @@ % Get filename to import ChannelFile = sProcess.options.channelfile.Value{1}; FileFormat = sProcess.options.channelfile.Value{2}; + + % HANDLING ISSUE #591: https://github.com/brainstorm-tools/brainstorm3/issues/591 + % The list of default caps is changing between versions of Brainstorm, therefore the index of a "combobox" option can't be considered a reliable information. + % On 6-Jan-2022: The option "usedefault" was changed from type "combobox" to "combobox_label" and the use of previous syntax is now an error. + % Users with existing scripts will get an error and will be requested to update their scripts. + if isempty(ChannelFile) && ~ischar(sProcess.options.usedefault.Value{1}) + bst_report('Error', sProcess, [], [... + 'On 6-Jan-2023, the option "usedefault" of process_channel_add loc was changed from type "combobox" to "combobox_label".' 10 ... + 'This parameter was previously an integer, indicating an index in a list that unfortunately changes across versions of Brainstorm.' 10 ... + 'The value now must be a string, which points at a specific default EEG cap with no amibiguity.' 10 10 ... + 'Scripts generated before 30-Jun-2022 and executed with a version of Brainstorm posterior to 30-Jun-2022' 10 ... + 'might have been selecting the wrong EEG cap, and should be fixed and executed again.' 10 10 ... + 'If you get this error, you must edit your processing script:' 10 ... + 'Use the pipeline editor to generate a new script to call process_channel_add.' 10 10 ... + 'More information in GitHub issue #591: ' 10 ... + 'https://github.com/brainstorm-tools/brainstorm3/issues/591']); + return + end % ===== GET DEFAULT ===== if isempty(ChannelFile) % Get registered Brainstorm EEG defaults bstDefaults = bst_get('EegDefaults'); % Get default channel file - iSel = sProcess.options.usedefault.Value{1}; - strDef = sProcess.options.usedefault.Value{2}{iSel}; + strDef = sProcess.options.usedefault.Value{1}; % If there is something selected if ~isempty(strDef) % Format: "group: name" diff --git a/toolbox/process/functions/process_inverse.m b/toolbox/process/functions/process_inverse.m index 2d2442866..cd24be804 100644 --- a/toolbox/process/functions/process_inverse.m +++ b/toolbox/process/functions/process_inverse.m @@ -694,7 +694,10 @@ OPTIONS.NoiseCovRaw = NoiseCov; % Call the mem solver [Results, OPTIONS] = be_main(HeadModel, OPTIONS); - Results.nComponents = round(max(size(Results.ImageGridAmp,1),size(Results.ImagingKernel,1)) / nSources); + if ~isfield(Results, 'nComponents') || isempty(Results.nComponents) + Results.nComponents = round(max(size(Results.ImageGridAmp,1),size(Results.ImagingKernel,1)) / nSources); + end + % Get outputs DataFile = OPTIONS.DataFile; Time = OPTIONS.DataTime; diff --git a/toolbox/process/functions/process_inverse_2018.m b/toolbox/process/functions/process_inverse_2018.m index 1da40f777..233b99826 100644 --- a/toolbox/process/functions/process_inverse_2018.m +++ b/toolbox/process/functions/process_inverse_2018.m @@ -498,6 +498,12 @@ errMessage = [errMessage 'The noise covariance contains NaN values. Please re-calculate it after tagging correctly the bad channels in the recordings.' 10]; break; end + % Check that bad channels in noise covariance are the same as bad channels in recordings + badChNoiseCov_goodChRecs = intersect(find(and(~any(NoiseCovMat.NoiseCov,1), ~any(NoiseCovMat.NoiseCov,2)')), GoodChannel); + if ~isempty(badChNoiseCov_goodChRecs) + errMessage = [errMessage 'Bad channels in noise covariance are different from bad channels in recordings.' 10 'Please re-calculate it after tagging correctly the bad channels in the recordings.' 10]; + break; + end % % Divide noise covariance by number of trials (DEPRECATED IN THIS VERSION) % if ~isempty(nAvg) && (nAvg > 1) % NoiseCovMat.NoiseCov = NoiseCovMat.NoiseCov ./ nAvg; @@ -512,6 +518,11 @@ errMessage = [errMessage 'The data covariance contains NaN values. Please re-calculate it after tagging correctly the bad channels in the recordings.' 10]; break; end + badChDataCov_goodChRecs = intersect(find(and(~any(DataCovMat.NoiseCov,1), ~any(DataCovMat.NoiseCov,2)')), GoodChannel); + if ~isempty(badChDataCov_goodChRecs) + errMessage = [errMessage 'Bad channels in data covariance are different from bad channels in recordings.' 10 'Please re-calculate it after tagging correctly the bad channels in the recordings.' 10]; + break; + end % % Divide data covariance by number of trials % if isempty(nAvg) && (nAvg > 1) % DataCovMat.NoiseCov = DataCovMat.NoiseCov ./ nAvg; @@ -525,7 +536,7 @@ break; end % Shrinkage: Require the FourthMoment matrix - if strcmpi(OPTIONS.NoiseMethod, 'shrink') && ... + if ~strcmpi(OPTIONS.InverseMethod, 'mem') && strcmpi(OPTIONS.NoiseMethod, 'shrink') && ... ((~isempty(DataCovMat) && (~isfield(DataCovMat, 'FourthMoment') || isempty(DataCovMat.FourthMoment))) || ... (~isempty(NoiseCovMat) && (~isfield(NoiseCovMat, 'FourthMoment') || isempty(NoiseCovMat.FourthMoment)))) errMessage = [errMessage 'Please recalculate the noise and data covariance matrices for using the "automatic shrinkage" option.' 10]; @@ -683,7 +694,10 @@ OPTIONS.FunctionName = 'mem'; % Call the mem solver [Results, OPTIONS] = be_main(HeadModel, OPTIONS); - Results.nComponents = round(max(size(Results.ImageGridAmp,1),size(Results.ImagingKernel,1)) / nSources); + if ~isfield(Results, 'nComponents') || isempty(Results.nComponents) + Results.nComponents = round(max(size(Results.ImageGridAmp,1),size(Results.ImagingKernel,1)) / nSources); + end + % Get outputs DataFile = OPTIONS.DataFile; Time = OPTIONS.DataTime; diff --git a/toolbox/process/functions/process_movefile.m b/toolbox/process/functions/process_movefile.m index b3cd3c134..a7789e8d2 100644 --- a/toolbox/process/functions/process_movefile.m +++ b/toolbox/process/functions/process_movefile.m @@ -37,8 +37,8 @@ sProcess.Index = 1024; sProcess.Description = ''; % Definition of the input accepted by this process - sProcess.InputTypes = {'raw', 'data', 'results', 'timefreq', 'matrix'}; - sProcess.OutputTypes = {'raw', 'data', 'results', 'timefreq', 'matrix'}; + sProcess.InputTypes = {'data', 'results', 'timefreq', 'matrix'}; + sProcess.OutputTypes = {'data', 'results', 'timefreq', 'matrix'}; sProcess.nInputs = 1; sProcess.nMinFiles = 1; sProcess.isSeparator = 1; diff --git a/toolbox/process/functions/process_mri_deface.m b/toolbox/process/functions/process_mri_deface.m index 268107649..6d74d62e0 100644 --- a/toolbox/process/functions/process_mri_deface.m +++ b/toolbox/process/functions/process_mri_deface.m @@ -185,6 +185,9 @@ MriHead = []; for iFile = 1:length(MriFiles) bst_progress('text', 'Loading input MRI...'); + % Check if volume is CT + tagFileCt = '_volct'; + isCt = ~isempty(strfind(MriFiles{iFile}, tagFileCt)); % Get subject index [sSubject, iSubject, iAnatomy] = bst_get('MriFile', MriFiles{iFile}); % If MRI was already defaced: skip @@ -306,7 +309,16 @@ bst_memory('UnloadMri', MriFile); % Add new file else - DefacedFiles{end+1} = db_add(iSubject, sMri, 0); + tmp = db_add(iSubject, sMri, 0); + % Add file tag for CT volume + if isCt + [fPath, fBase, fExt] = bst_fileparts(file_fullpath(tmp)); + fBase = [fBase, tagFileCt, fExt]; + tmpNew = bst_fullfile(fPath, fBase); + file_move(file_fullpath(tmp), tmpNew); + tmp = file_short(tmpNew); + end + DefacedFiles{end+1} = tmp; iAnatomy = length(sSubject.Anatomy) + 1; end % Update database registration @@ -347,7 +359,7 @@ bst_set('Subject', iSubject, sSubject); % Compute new head surface sSubject.Anatomy(sSubject.iAnatomy).FileName; - tess_isohead(MriHead, 10000, 0, 2, HeadComment); + tess_isohead(MriHead, 10000, 0, 2, [], HeadComment); end end diff --git a/toolbox/process/functions/process_mtrf_train.m b/toolbox/process/functions/process_mtrf_train.m new file mode 100644 index 000000000..ec091aee0 --- /dev/null +++ b/toolbox/process/functions/process_mtrf_train.m @@ -0,0 +1,173 @@ +function varargout = process_mtrf_train( varargin ) +% process_mtrf_train: Fits an encoding/decoding model using mTRF-Toolbox + +% @============================================================================= +% This function is part of the Brainstorm software: +% https://neuroimage.usc.edu/brainstorm +% +% Copyright (c) University of Southern California & McGill University +% This software is distributed under the terms of the GNU General Public License +% as published by the Free Software Foundation. Further details on the GPLv3 +% license can be found at http://www.gnu.org/copyleft/gpl.html. +% +% FOR RESEARCH PURPOSES ONLY. THE SOFTWARE IS PROVIDED "AS IS," AND THE +% UNIVERSITY OF SOUTHERN CALIFORNIA AND ITS COLLABORATORS DO NOT MAKE ANY +% WARRANTY, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO WARRANTIES OF +% MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE, NOR DO THEY ASSUME ANY +% LIABILITY OR RESPONSIBILITY FOR THE USE OF THIS SOFTWARE. +% +% For more information type "brainstorm license" at command prompt. +% =============================================================================@ +% +% Authors: Anna Zaidi, 2024 +% Raymundo Cassani, 2024 + +eval(macro_method); +end + +%% ===== GET DESCRIPTION ===== +function sProcess = GetDescription() + % Description of the process + sProcess.Comment = 'Temporal Response Function Analyis'; + sProcess.Category = 'Custom'; + sProcess.SubGroup = 'Encoding'; + sProcess.Index = 702; + sProcess.InputTypes = {'data'}; + sProcess.OutputTypes = {'matrix'}; + sProcess.nInputs = 1; + sProcess.nMinFiles = 1; + sProcess.Description = 'https://neuroimage.usc.edu/brainstorm/Tutorials/MultivariateTemporalResponse'; + % === Sensor types + sProcess.options.sensortypes.Comment = 'Sensor types or names (empty=all): '; + sProcess.options.sensortypes.Type = 'text'; + sProcess.options.sensortypes.Value = 'MEG, EEG'; + sProcess.options.sensortypes.InputTypes = {'data', 'raw'}; + % Event name + sProcess.options.labelevt.Comment = 'For multiple events: separate them with commas'; + sProcess.options.labelevt.Type = 'label'; + sProcess.options.eventname.Comment = 'Event names: '; + sProcess.options.eventname.Type = 'text'; + sProcess.options.eventname.Value = ''; + % Minimum time lag + sProcess.options.tmin.Comment = 'Minimun time lag:'; + sProcess.options.tmin.Type = 'value'; + sProcess.options.tmin.Value = {-100, 'ms', 0}; + % Maximum time lag + sProcess.options.tmax.Comment = 'Maximum time lag:'; + sProcess.options.tmax.Type = 'value'; + sProcess.options.tmax.Value = {100, 'ms', 0}; +end + +%% ===== FORMAT COMMENT ===== +function Comment = FormatComment(sProcess) + Comment = sProcess.Comment; +end + +%% ===== RUN ===== +function OutputFiles = Run(sProcess, sInput) + % Initialize output file list + OutputFiles = {}; + + % Install/load mTRF-Toolbox as plugin + if ~exist('mTRFtrain', 'file') + [isInstalled, errMsg] = bst_plugin('Install', 'mtrf'); + if ~isInstalled + error(errMsg); + end + end + + % ===== GET OPTIONS ===== + % Sensor types + sensorTypes = []; + if isfield(sProcess.options, 'sensortypes') && ~isempty(sProcess.options.sensortypes) && ~isempty(sProcess.options.sensortypes.Value) + sensorTypes = sProcess.options.sensortypes.Value; + end + % Get event names + evtNames = strtrim(str_split(sProcess.options.eventname.Value, ',;')); + if isempty(evtNames) + bst_report('Error', sProcess, [], 'No events were provided.'); + return; + end + % Get minimum time lag (ms) + tmin = sProcess.options.tmin.Value{1}; + if isempty(tmin) || ~isnumeric(tmin) || isnan(tmin) + bst_report('Error', sProcess, sInput, 'Invalid tmin.'); + return; + end + tmin = tmin * 1000; + % Get maximum time lag (ms) + tmax = sProcess.options.tmax.Value{1}; + if isempty(tmax) || ~isnumeric(tmax) || isnan(tmax) + bst_report('Error', sProcess, sInput, 'Invalid tmax.'); + return; + end + tmax = tmax * 1000; + % Check for exactly one input file + if length(sInput) ~= 1 + bst_report('Error', sProcess, sInput, 'This process requires exactly one input file.'); + return; + end + + % Load file + DataMat = in_bst_data(sInput.FileName); + if isempty(DataMat) || ~isfield(DataMat, 'F') || isempty(DataMat.F) || ~isnumeric(DataMat.F) + bst_report('Error', sProcess, sInput, 'EEG data is empty or not a numeric matrix.'); + return; + end + % Sampling frequency (Hz) + fs = 1 ./ (DataMat.Time(2) - DataMat.Time(1)); + nSamples = size(DataMat.F,2); + % Load channel file + ChannelFile = sInput.ChannelFile; + ChannelMat = in_bst_channel(ChannelFile); + + % Select sensors + if ~isempty(sensorTypes) + % Find selected channels + iChannels = channel_find(ChannelMat.Channel, sensorTypes); + if isempty(iChannels) + bst_report('Error', sProcess, sInput, 'Could not load any sensor from the input file. Check the sensor selection.'); + return; + end + % Keep only selected channels + F = DataMat.F(iChannels, :); + channelNames = {ChannelMat.Channel(iChannels).Name}'; + else + F = DataMat.F; + channelNames = {ChannelMat.Channel.Name}'; + end + + % mTRF train for each event + for iEvent = 1 : length(evtNames) + stim = zeros(nSamples, 1); + iEvt = find(strcmpi({DataMat.Events.label}, evtNames{iEvent})); + if isempty(iEvt) + continue + end + % Event must be simple event + if size(DataMat.Events(iEvt).times, 1) ~= 1 + bst_report('Warning', sProcess, sInputs, ['Events must be simple. Skipping event: "' evtNames{iEvent} '"' ]); + continue; + end + % Event occurrences (in samples) + iEvtOccur = bst_closest(DataMat.Events(iEvt).times, DataMat.Time); + stim(iEvtOccur) = 1; + + % mTRF train + lambda = 0.1; + model = mTRFtrain(stim, F', fs, 1, tmin, tmax, lambda); + + % Store weights of the mTRF model in a matrix file + OutputMat = db_template('matrixmat'); + OutputMat.Comment = ['TRF Model Weights: ' evtNames{iEvent}]; + OutputMat.Time = squeeze(model.t); + OutputMat.Value = squeeze(model.w(1,:,:))'; + OutputMat.Description = channelNames; + % Save and add to database + OutputFile = bst_process('GetNewFilename', bst_fileparts(sInput.FileName), 'matrix_trf_weights'); + bst_save(OutputFile, OutputMat, 'v6'); + db_add_data(sInput.iStudy, OutputFile, OutputMat); + + OutputFiles{end+1} = OutputFile; + end +end diff --git a/toolbox/process/functions/process_pac_dynamic.m b/toolbox/process/functions/process_pac_dynamic.m index 0349fd28d..2a500326b 100644 --- a/toolbox/process/functions/process_pac_dynamic.m +++ b/toolbox/process/functions/process_pac_dynamic.m @@ -269,8 +269,8 @@ return; end - % Set time window of first file if none specified in parameters and average across trials is requested - if isempty(OPTIONS.TimeWindow) && OPTIONS.isAvgOutput + % If not specified, set time window value for each file. If average across trials is requested, use first file + if isempty(OPTIONS.TimeWindow) && (~OPTIONS.isAvgOutput || iFile == 1) OPTIONS.TimeWindow = sInput.Time([1, end]); end diff --git a/toolbox/process/functions/process_pac_dynamic_sur2.m b/toolbox/process/functions/process_pac_dynamic_sur2.m index 62c80af04..8d476ea08 100644 --- a/toolbox/process/functions/process_pac_dynamic_sur2.m +++ b/toolbox/process/functions/process_pac_dynamic_sur2.m @@ -220,6 +220,10 @@ return; end + % If not specified, set time window value + if isempty(OPTIONS.TimeWindow) + OPTIONS.TimeWindow = sInput.Time([1, end]); + end % Get sampling frequency sRate = 1 / (sInput.Time(2) - sInput.Time(1)); diff --git a/toolbox/process/functions/process_psd.m b/toolbox/process/functions/process_psd.m index fec97bcc8..aa97aaf1b 100644 --- a/toolbox/process/functions/process_psd.m +++ b/toolbox/process/functions/process_psd.m @@ -38,7 +38,6 @@ sProcess.OutputTypes = {'timefreq', 'timefreq', 'timefreq', 'timefreq'}; sProcess.nInputs = 1; sProcess.nMinFiles = 1; - sProcess.isSeparator = 1; % Options: Time window sProcess.options.timewindow.Comment = 'Time window:'; sProcess.options.timewindow.Type = 'timewindow'; diff --git a/toolbox/process/functions/process_psd_features.m b/toolbox/process/functions/process_psd_features.m new file mode 100644 index 000000000..0b27ccb95 --- /dev/null +++ b/toolbox/process/functions/process_psd_features.m @@ -0,0 +1,201 @@ +function varargout = process_psd_features( varargin ) +% PROCESS_PSD_FEATURES: Extract features from the power spectrum + +% @============================================================================= +% This function is part of the Brainstorm software: +% https://neuroimage.usc.edu/brainstorm +% +% Copyright (c) University of Southern California & McGill University +% This software is distributed under the terms of the GNU General Public License +% as published by the Free Software Foundation. Further details on the GPLv3 +% license can be found at http://www.gnu.org/copyleft/gpl.html. +% +% FOR RESEARCH PURPOSES ONLY. THE SOFTWARE IS PROVIDED "AS IS," AND THE +% UNIVERSITY OF SOUTHERN CALIFORNIA AND ITS COLLABORATORS DO NOT MAKE ANY +% WARRANTY, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO WARRANTIES OF +% MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE, NOR DO THEY ASSUME ANY +% LIABILITY OR RESPONSIBILITY FOR THE USE OF THIS SOFTWARE. +% +% For more information type "brainstorm license" at command prompt. +% =============================================================================@ +% +% Authors: Pauline Amrouche, Raymundo Cassani, 2024 + +eval(macro_method); +end + + +%% ===== GET DESCRIPTION ===== +function sProcess = GetDescription() %#ok + % Description the process + sProcess.Comment = 'Compute PSD features'; + sProcess.Category = 'File'; + sProcess.SubGroup = 'Frequency'; + sProcess.Index = 482; + sProcess.Description = 'https://neuroimage.usc.edu/brainstorm/Tutorials/DeviationMaps'; + % Definition of the input accepted by this process + sProcess.InputTypes = {'raw', 'data', 'results', 'matrix'}; + sProcess.OutputTypes = {'timefreq', 'timefreq', 'timefreq', 'timefreq'}; + sProcess.nInputs = 1; + sProcess.nMinFiles = 1; + sProcess.isSeparator = 1; + % Options: Time window + sProcess.options.timewindow.Comment = 'Time window:'; + sProcess.options.timewindow.Type = 'timewindow'; + sProcess.options.timewindow.Value = []; + % Option: Window (Length) + sProcess.options.win_length.Comment = 'Window length: '; + sProcess.options.win_length.Type = 'value'; + sProcess.options.win_length.Value = {1, 's', []}; + % Option: Window (Overlapping ratio) + sProcess.options.win_overlap.Comment = 'Window overlap ratio: '; + sProcess.options.win_overlap.Type = 'value'; + sProcess.options.win_overlap.Value = {50, '%', 1}; + % Options: Units / scaling + sProcess.options.units.Comment = {'Physical: U2/Hz', 'Normalized: U2/Hz/s', ... + 'Before Nov 2020', 'Units:'; ... + 'physical', 'normalized', 'old', ''}; + sProcess.options.units.Type = 'radio_linelabel'; + sProcess.options.units.Value = 'physical'; + % Options: CLUSTERS + sProcess.options.clusters.Comment = ''; + sProcess.options.clusters.Type = 'scout_confirm'; + sProcess.options.clusters.Value = {}; + sProcess.options.clusters.InputTypes = {'results'}; + % Options: Scout function + sProcess.options.scoutfunc.Comment = {'Mean', 'Max', 'PCA', 'Std', 'All', 'Scout function:'}; + sProcess.options.scoutfunc.Type = 'radio_line'; + sProcess.options.scoutfunc.Value = 1; + sProcess.options.scoutfunc.InputTypes = {'results'}; + % Options: Sensor types + sProcess.options.sensortypes.Comment = 'Sensor types or names (empty=all): '; + sProcess.options.sensortypes.Type = 'text'; + sProcess.options.sensortypes.Value = 'MEG, EEG'; + sProcess.options.sensortypes.InputTypes = {'raw','data'}; + % Options: Extract mean + sProcess.options.mean.Comment = 'Extract mean'; + sProcess.options.mean.Type = 'checkbox'; + sProcess.options.mean.Value = 1; + % Options: Extract std + sProcess.options.std.Comment = 'Extract std'; + sProcess.options.std.Type = 'checkbox'; + sProcess.options.std.Value = 1; + % Options: Extract coefficient of variation + sProcess.options.cv.Comment = 'Extract cv'; + sProcess.options.cv.Type = 'checkbox'; + sProcess.options.cv.Value = 1; + % Options: Compute relative power + sProcess.options.relative.Comment = 'Use relative power'; + sProcess.options.relative.Type = 'checkbox'; + sProcess.options.relative.Value = 0; + % Separator + sProcess.options.sep.Type = 'label'; + sProcess.options.sep.Comment = ' '; + % Options: Time-freq + sProcess.options.edit.Comment = {'panel_timefreq_options', ' PSD options: '}; + sProcess.options.edit.Type = 'editpref'; + sProcess.options.edit.Value = []; +end + + +%% ===== FORMAT COMMENT ===== +function Comment = FormatComment(sProcess) %#ok + Comment = sProcess.Comment; +end + + +%% ===== RUN ===== +function OutputFiles = Run(sProcess, sInput) %#ok + OutputFiles = {}; + % Process options + if (sProcess.options.mean.Value && sProcess.options.std.Value) || sProcess.options.cv.Value + sProcess.options.win_std.Value = 'mean+std'; % One PSD file with mean and std across windows + elseif sProcess.options.mean.Value + sProcess.options.win_std.Value = 'mean'; % One PSD file with mean (Welch) + elseif sProcess.options.std.Value + sProcess.options.win_std.Value = 'std'; % One PSD file with std + else + bst_report('Error', sProcess, [], 'Must choose at least one feature.'); return; + end + + % Call TIME-FREQ process + OutputFile = process_timefreq('Run', sProcess, sInput); + + % Extract std and/or cv from one PSD (mean+std) file + if strcmpi(sProcess.options.win_std.Value, 'mean+std') + OutputFiles = ExtractStdCv(sProcess, OutputFile{1}, sInput); + else + OutputFiles = OutputFile; + end +end + +%% ===== EXTRACT PSD FEATURES ===== +function OutputFiles = ExtractStdCv(sProcess, tfMeanStdFile, sInput) + OutputFiles = {tfMeanStdFile}; + % Get options + extractMean = sProcess.options.mean.Value; + extractStd = sProcess.options.std.Value; + extractCv = sProcess.options.cv.Value; + % Load timefreq file with mean+std + timefreqtMat = in_bst_timefreq(tfMeanStdFile); + if isempty(timefreqtMat.Std) + bst_report('Error', sProcess, [], 'Input file must contain Std matrix.'); + return; + end + + % Extract std from mean+std file, and save in new timefreq file + if extractStd + newTF = timefreqtMat.Std; + OutputFile = saveMat(timefreqtMat, tfMeanStdFile, newTF, 'std', sInput); + OutputFiles = [OutputFiles, OutputFile]; + end + + % Extract cv (std/mean) from mean+std file, and save in new timefreq file + if extractCv + newTF = timefreqtMat.Std ./ timefreqtMat.TF; + OutputFile = saveMat(timefreqtMat, tfMeanStdFile, newTF, 'cv', sInput); + OutputFiles = [OutputFiles, OutputFile]; + end + + % Modify or delete initial mean+std file + if extractMean + % Update content of original mean+std file + % Remove std + newMat.Std = []; + % Update the function name + newMat.Options = timefreqtMat.Options; + newMat.Options.WindowFunction = 'mean'; + % Add extraction in history + newMat.History = timefreqtMat.History; + newMat = bst_history('add', newMat, 'extract_std_cv', sprintf('mean matrix extracted from %s', tfMeanStdFile)); + fileName = file_fullpath(tfMeanStdFile); + bst_save(fileName, newMat, [], 1); + else + % Delete mean+std file + bst_process('CallProcess', 'process_delete', tfMeanStdFile, [], 'target', 1); + OutputFiles(1) = []; + end +end + +%% ===== UPDATE AND SAVE TIMEFREQ ===== +function OutputFile = saveMat(timefreqtMat, tfMeanStdFile, newTF, function_name, sInput) + % Update TF field + newMat = timefreqtMat; + newMat.TF = newTF; + newMat.Std = []; + % Update across-windows function name + newMat.Options.WindowFunction = function_name; + % Update Comment, append function name + newMat.Comment = [timefreqtMat.Comment ' ' function_name]; + % Add extraction in history + newMat = bst_history('add', newMat, 'extract_std_cv', sprintf('%s matrix extracted from %s', function_name, tfMeanStdFile)); + % New file name + [tfMeanStdFilePath, tfMeanStdFileBase, tfMeanStdFileExt] = bst_fileparts(tfMeanStdFile); + output = bst_fullfile(tfMeanStdFilePath, [tfMeanStdFileBase, '_' function_name, tfMeanStdFileExt]); + output = file_unique(output); + % Save the file + bst_save(output, newMat, 'v6'); + db_add_data(sInput.iStudy, output, newMat); + OutputFile = {output}; +end + diff --git a/toolbox/process/functions/process_remove_evoked.m b/toolbox/process/functions/process_remove_evoked.m index 38497d64b..a55e2bc82 100644 --- a/toolbox/process/functions/process_remove_evoked.m +++ b/toolbox/process/functions/process_remove_evoked.m @@ -9,12 +9,12 @@ % @============================================================================= % This function is part of the Brainstorm software: % https://neuroimage.usc.edu/brainstorm -% +% % Copyright (c) University of Southern California & McGill University % This software is distributed under the terms of the GNU General Public License % as published by the Free Software Foundation. Further details on the GPLv3 % license can be found at http://www.gnu.org/copyleft/gpl.html. -% +% % FOR RESEARCH PURPOSES ONLY. THE SOFTWARE IS PROVIDED "AS IS," AND THE % UNIVERSITY OF SOUTHERN CALIFORNIA AND ITS COLLABORATORS DO NOT MAKE ANY % WARRANTY, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO WARRANTIES OF diff --git a/toolbox/process/functions/process_segment_cat12.m b/toolbox/process/functions/process_segment_cat12.m index f7ea79a35..bf8f8c3a6 100644 --- a/toolbox/process/functions/process_segment_cat12.m +++ b/toolbox/process/functions/process_segment_cat12.m @@ -117,11 +117,11 @@ else isCerebellum = 1; end - % TPM atlas + % % TPM atlas, preferably from SPM plugin if isfield(sProcess.options, 'tpmnii') && isfield(sProcess.options.tpmnii, 'Value') && ~isempty(sProcess.options.tpmnii.Value) && ~isempty(sProcess.options.tpmnii.Value{1}) TpmNii = sProcess.options.tpmnii.Value{1}; else - TpmNii = bst_get('SpmTpmAtlas'); + TpmNii = bst_get('SpmTpmAtlas', 'SPM'); end % Thickness maps if isfield(sProcess.options, 'extramaps') && isfield(sProcess.options.extramaps, 'Value') && ~isempty(sProcess.options.extramaps.Value) @@ -191,7 +191,8 @@ end % Check provided TPM.nii if isempty(TpmNii) - TpmNii = bst_get('SpmTpmAtlas'); + % TPM atlas, preferably from SPM plugin + TpmNii = bst_get('SpmTpmAtlas', 'SPM'); end % ===== GET SUBJECT ===== @@ -430,8 +431,9 @@ function ComputeInteractive(iSubject, iAnatomy) %#ok end % Open progress bar bst_progress('start', 'CAT12', 'CAT12 MRI segmentation...'); + % TPM atlas, preferably from SPM plugin + TpmNii = bst_get('SpmTpmAtlas', 'SPM'); % Run CAT12 - TpmNii = bst_get('SpmTpmAtlas'); isInteractive = 1; isSphReg = 1; isCerebellum = 0; diff --git a/toolbox/process/functions/process_select_files_data.m b/toolbox/process/functions/process_select_files_data.m index 0795d291b..92164a658 100644 --- a/toolbox/process/functions/process_select_files_data.m +++ b/toolbox/process/functions/process_select_files_data.m @@ -67,6 +67,11 @@ sProcess.options.includecommon.Comment = 'Include the folder "Common files"'; sProcess.options.includecommon.Type = 'checkbox'; sProcess.options.includecommon.Value = 0; + % USE FOUND FILES IN PROCESS TABS + sProcess.options.outprocesstab.Comment = 'Use found files in Process tab'; + sProcess.options.outprocesstab.Type = 'combobox_label'; + sProcess.options.outprocesstab.Value = {'no', {'No', 'Process1', 'Process2A', 'Process2B'; ... + 'no', 'process1', 'process2a', 'process2b'}}; end @@ -160,6 +165,11 @@ end % Get files OutputFiles = SelectFiles([SubjectName '/' Condition], FileType, IncludeBad, IncludeIntra, IncludeCommon, CommentTag); + % Add files to process tab + if isfield(sProcess.options, 'outprocesstab') && isfield(sProcess.options.outprocesstab, 'Value') && ~isempty(sProcess.options.outprocesstab.Value) && ~strcmpi('no', sProcess.options.outprocesstab.Value{1}) + panel_nodelist('ResetList', sProcess.options.outprocesstab.Value{1}) + panel_nodelist('AddFiles', sProcess.options.outprocesstab.Value{1}, OutputFiles); + end % Build process comment strInfo = [FormatComment(sProcess) ' ']; diff --git a/toolbox/process/functions/process_select_files_matrix.m b/toolbox/process/functions/process_select_files_matrix.m index 314eb8ed8..029a66651 100644 --- a/toolbox/process/functions/process_select_files_matrix.m +++ b/toolbox/process/functions/process_select_files_matrix.m @@ -63,6 +63,11 @@ sProcess.options.includecommon.Comment = 'Include the folder "Common files"'; sProcess.options.includecommon.Type = 'checkbox'; sProcess.options.includecommon.Value = 0; + % USE FOUND FILES IN PROCESS TABS + sProcess.options.outprocesstab.Comment = 'Use found files in Process tab'; + sProcess.options.outprocesstab.Type = 'combobox_label'; + sProcess.options.outprocesstab.Value = {'no', {'No', 'Process1', 'Process2A', 'Process2B'; ... + 'no', 'process1', 'process2a', 'process2b'}}; end diff --git a/toolbox/process/functions/process_select_files_results.m b/toolbox/process/functions/process_select_files_results.m index 8474cca83..3de1fc11c 100644 --- a/toolbox/process/functions/process_select_files_results.m +++ b/toolbox/process/functions/process_select_files_results.m @@ -63,6 +63,11 @@ sProcess.options.includecommon.Comment = 'Include the folder "Common files"'; sProcess.options.includecommon.Type = 'checkbox'; sProcess.options.includecommon.Value = 0; + % USE FOUND FILES IN PROCESS TABS + sProcess.options.outprocesstab.Comment = 'Use found files in Process tab'; + sProcess.options.outprocesstab.Type = 'combobox_label'; + sProcess.options.outprocesstab.Value = {'no', {'No', 'Process1', 'Process2A', 'Process2B'; ... + 'no', 'process1', 'process2a', 'process2b'}}; end diff --git a/toolbox/process/functions/process_select_files_timefreq.m b/toolbox/process/functions/process_select_files_timefreq.m index 53db90e07..c3f98237c 100644 --- a/toolbox/process/functions/process_select_files_timefreq.m +++ b/toolbox/process/functions/process_select_files_timefreq.m @@ -63,6 +63,11 @@ sProcess.options.includecommon.Comment = 'Include the folder "Common files"'; sProcess.options.includecommon.Type = 'checkbox'; sProcess.options.includecommon.Value = 0; + % USE FOUND FILES IN PROCESS TABS + sProcess.options.outprocesstab.Comment = 'Use found files in Process tab'; + sProcess.options.outprocesstab.Type = 'combobox_label'; + sProcess.options.outprocesstab.Value = {'no', {'No', 'Process1', 'Process2A', 'Process2B'; ... + 'no', 'process1', 'process2a', 'process2b'}}; end diff --git a/toolbox/process/functions/process_select_search.m b/toolbox/process/functions/process_select_search.m index c4ce17a70..bd631dcdd 100644 --- a/toolbox/process/functions/process_select_search.m +++ b/toolbox/process/functions/process_select_search.m @@ -51,6 +51,11 @@ sProcess.options.includebad.Comment = 'Include the bad trials'; sProcess.options.includebad.Type = 'checkbox'; sProcess.options.includebad.Value = 1; + % USE FOUND FILES IN PROCESS TABS + sProcess.options.outprocesstab.Comment = 'Use found files in Process tab'; + sProcess.options.outprocesstab.Type = 'combobox_label'; + sProcess.options.outprocesstab.Value = {'no', {'No', 'Process1', 'Process2A', 'Process2B'; ... + 'no', 'process1', 'process2a', 'process2b'}}; end @@ -111,6 +116,11 @@ bst_report('Info', sProcess, [], strReport); % Return only the filenames that passed the search OutputFiles = {sInputs(iFiles).FileName}; + % Use files in process tab + if isfield(sProcess.options, 'outprocesstab') && isfield(sProcess.options.outprocesstab, 'Value') && ~isempty(sProcess.options.outprocesstab.Value) && ~strcmpi('no', sProcess.options.outprocesstab.Value{1}) + panel_nodelist('ResetList', sProcess.options.outprocesstab.Value{1}) + panel_nodelist('AddFiles', sProcess.options.outprocesstab.Value{1}, OutputFiles); + end end function sOutputs = GetAllProtocolFiles(FileTypes, IncludeBad) diff --git a/toolbox/process/functions/process_source_atlas.m b/toolbox/process/functions/process_source_atlas.m index 15ed5bcfe..7dd1d5987 100644 --- a/toolbox/process/functions/process_source_atlas.m +++ b/toolbox/process/functions/process_source_atlas.m @@ -197,7 +197,8 @@ isempty(strfind(TestTags, '_abs')) && ... isempty(strfind(TestTags, '_norm')) && ... isempty(strfind(TestTags, 'NIRS')) && ... - isempty(strfind(TestTags, 'Summed_sensitivities')); + isempty(strfind(TestTags, 'Summed_sensitivities')) && ... + isempty(strfind(TestTags, 'bold')); ImageGridAmp(iScout,:) = bst_scout_value(Fscout, sScouts(iScout).Function, ScoutOrient, ResultsMat.nComponents, [], isFlipSign); elseif isNorm ImageGridAmp(iScout,:) = bst_scout_value(Fscout, sScouts(iScout).Function, ScoutOrient, ResultsMat.nComponents, 'norm'); diff --git a/toolbox/process/functions/process_sprint.m b/toolbox/process/functions/process_sprint.m index 3092b5135..b31a45b71 100644 --- a/toolbox/process/functions/process_sprint.m +++ b/toolbox/process/functions/process_sprint.m @@ -91,10 +91,10 @@ sProcess.options.freqrange.Comment = 'Frequency range for analysis: '; sProcess.options.freqrange.Type = 'freqrange_static'; sProcess.options.freqrange.Value = {[1 40], 'Hz', 1}; - % Option: Peak type - sProcess.options.peaktype.Comment = {'Gaussian', 'Cauchy (experimental)', 'Peak model:'; 'gaussian', 'cauchy', ''}; - sProcess.options.peaktype.Type = 'radio_linelabel'; - sProcess.options.peaktype.Value = 'gaussian'; + % Option: Optimization type + sProcess.options.optimobj.Comment = {'Default', 'Model selection (experimental)', 'Optimization type:'; 'leastsquare', 'negloglike', ''}; + sProcess.options.optimobj.Type = 'radio_linelabel'; + sProcess.options.optimobj.Value = 'leastsquare'; % Option: Peak width limits sProcess.options.peakwidth.Comment = 'Peak width limits (default=[0.5-12]): '; sProcess.options.peakwidth.Type = 'freqrange_static'; diff --git a/toolbox/process/deprecated/process_ssmooth.m b/toolbox/process/functions/process_ssmooth.m similarity index 50% rename from toolbox/process/deprecated/process_ssmooth.m rename to toolbox/process/functions/process_ssmooth.m index 0ddaeac62..87c3f1c1e 100644 --- a/toolbox/process/deprecated/process_ssmooth.m +++ b/toolbox/process/functions/process_ssmooth.m @@ -1,5 +1,5 @@ function varargout = process_ssmooth( varargin ) -% PROCESS_SSMOOTH: Spatial smoothing of the sources (DEPRECATED) +% PROCESS_SSMOOTH: Spatial smoothing of the sources % @============================================================================= % This function is part of the Brainstorm software: @@ -20,6 +20,8 @@ % =============================================================================@ % % Authors: Francois Tadel, 2010-2016 +% Edouard Delaire, Raymundo Cassani, 2023 + eval(macro_method); end @@ -28,11 +30,11 @@ %% ===== GET DESCRIPTION ===== function sProcess = GetDescription() %#ok % Description the process - sProcess.Comment = 'Spatial smoothing (DEPRECATED)'; + sProcess.Comment = 'Spatial smoothing [2024]'; sProcess.FileTag = 'ssmooth'; sProcess.Category = 'Filter'; sProcess.SubGroup = 'Sources'; - sProcess.Index = 0; + sProcess.Index = 336; sProcess.Description = 'https://neuroimage.usc.edu/brainstorm/Tutorials/VisualGroup'; % Definition of the input accepted by this process sProcess.InputTypes = {'results', 'timefreq'}; @@ -50,13 +52,15 @@ sProcess.options.fwhm.Type = 'value'; sProcess.options.fwhm.Value = {10, 'mm', 0}; % === METHOD - sProcess.options.label2.Comment = '
Distance between vertices (v1,v2):'; + sProcess.options.label2.Comment = 'Distance between a pair of vertices:'; sProcess.options.label2.Type = 'label'; - sProcess.options.method.Comment = {'Euclidean distance: norm(v1-v2)', ... - 'Path length: number of edges between v1 and v2', ... - 'Average: (euclidian distance + path length) / 2'}; - sProcess.options.method.Type = 'radio'; - sProcess.options.method.Value = 3; + sProcess.options.method.Comment = {[' Geodesic (mm)
', ... + '(recommended)'], ... + [' Path length (edges)
', ... + 'FWHM is converted to edges for each connected surface']; ... + 'geodesic_dist', 'geodesic_edge'}; + sProcess.options.method.Type = 'radio_label'; + sProcess.options.method.Value = 'geodesic_dist'; end @@ -68,29 +72,16 @@ else strAbs = ''; end - % Method - switch (sProcess.options.method.Value) - case 1, Method = 'euclidian'; - case 2, Method = 'path'; - case 3, Method = 'average'; - otherwise, error(['Unknown method: ' sProcess.options.method.Value]); - end % Final comment - Comment = sprintf('%s (%d%c%s)', sProcess.Comment, sProcess.options.fwhm.Value{1}, Method(1), strAbs); + Comment = sprintf('%s (%1.2f mm%s)', sProcess.Comment, sProcess.options.fwhm.Value{1}, strAbs); end %% ===== RUN ===== function sInput = Run(sProcess, sInput) %#ok - global GlobalData; % Get options - FWHM = sProcess.options.fwhm.Value{1} / 1000; - switch (sProcess.options.method.Value) - case 1, Method = 'euclidian'; - case 2, Method = 'path'; - case 3, Method = 'average'; - otherwise, error(['Unknown method: ' sProcess.options.method.Value]); - end + FWHM = sProcess.options.fwhm.Value{1} / 1000; % meters + Method = sProcess.options.method.Value; % ===== LOAD SURFACE ===== % Load the surface filename from results file @@ -121,9 +112,41 @@ return; end - % ===== COMPUTE SMOOTHING OPERATOR ===== + % Perform smoothing + [sInput.A, msgInfo, errInfo] = compute(FileMat.SurfaceFile, sInput.A, FWHM, Method); + % Error handling + if ~isempty(errInfo) + bst_report('Error', sProcess, sInputs, errInfo); + return; + end + + % Force the output comment + sInput.CommentTag = FormatComment(sProcess); + sInput.HistoryComment = msgInfo; + + % Do not keep the Std field in the output + if isfield(sInput, 'Std') && ~isempty(sInput.Std) + sInput.Std = []; + end +end + +function [sData, msgInfo, errInfo] = compute(SurfaceFile, sData, FWHM, Method) + global GlobalData; + + msgInfo = ''; + errInfo = ''; + + SurfaceMat = in_tess_bst(SurfaceFile); + + switch Method + case 'geodesic_dist' + msgInfo = sprintf('Spatial smoothing using %1.2f mm kernel calculating distance using geodesic distance', FWHM*1000); + case 'geodesic_edge' + msgInfo = sprintf('Spatial smoothing using %1.2f mm kernel calculating distance using edge path length distance', FWHM*1000); + end + % Get existing interpolation for this surface - Signature = sprintf('ssmooth(%d,%s):%s', round(FWHM*1000), Method, FileMat.SurfaceFile); + Signature = sprintf('ssmooth(%1.2f,%s):%s', round(FWHM*1000), Method, SurfaceFile); WInterp = []; if isfield(GlobalData, 'Interpolations') && ~isempty(GlobalData.Interpolations) && isfield(GlobalData.Interpolations, 'Signature') iInterp = find(cellfun(@(c)isequal(c,Signature), {GlobalData.Interpolations.Signature}), 1); @@ -131,18 +154,37 @@ WInterp = GlobalData.Interpolations(iInterp).WInterp; end end + % Calculate new interpolation matrix if isempty(WInterp) % Load surface file - SurfaceMat = in_tess_bst(FileMat.SurfaceFile); - % Compute the smoothing operator - WInterp = tess_smooth_sources(SurfaceMat.Vertices, SurfaceMat.Faces, SurfaceMat.VertConn, FWHM, Method); - % Check for errors - if isempty(WInterp) - sInput = []; - return; + nVertices = size(SurfaceMat.Vertices,1); + switch Method + case 'geodesic_dist' + Dist = bst_tess_distance(SurfaceMat, 1:nVertices, 1:nVertices, 'geodesic_dist'); % in meter + % One region + subRegions(1) = SurfaceMat; + subRegions(1).Indices = (1 : nVertices)'; + subRegions(1).VertDist = Dist; + case 'geodesic_edge' + Dist = bst_tess_distance(SurfaceMat, 1:nVertices, 1:nVertices, 'geodesic_edge'); % in edges + % Connected regions + subRegions = GetConnectedRegions(SurfaceMat,Dist); end - % Save interpolation in memory for future calls + + % Full smooth operator + WInterp = sparse(nVertices, nVertices); + for iSubRegion = 1:length(subRegions) + % Subregion smoothing operator + WInterpTmp = tess_smooth_sources(subRegions(iSubRegion), FWHM, Method); + % Check for errors + if isempty(WInterpTmp) + errInfo = sprintf('Cannot compute the smoothig %s.', Signature); + return; + end + WInterp(subRegions(iSubRegion).Indices, subRegions(iSubRegion).Indices) = WInterpTmp(:,:); + end + sInterp = db_template('interpolation'); sInterp.WInterp = WInterp; sInterp.Signature = Signature; @@ -151,18 +193,56 @@ else GlobalData.Interpolations(end+1) = sInterp; end + end - % ===== APPLY TO THE DATA ===== % Apply smoothing operator - for iFreq = 1:size(sInput.A,3) - sInput.A(:,:,iFreq) = WInterp * sInput.A(:,:,iFreq); + for iFreq = 1:size(sData,3) + sData(:,:,iFreq) = WInterp * sData(:,:,iFreq); end - % Force the output comment - sInput.CommentTag = [sProcess.FileTag, num2str(FWHM*1000), Method(1)]; - % Do not keep the Std field in the output - if isfield(sInput, 'Std') && ~isempty(sInput.Std) - sInput.Std = []; +end + +function sSubRegions = GetConnectedRegions(SurfaceMat, Dist) + % Find connenected regions (subregions) of the surface + sSubRegion = struct('Vertices', [], ... + 'VertConn', [], ... + 'Faces', [], ... + 'Indices', [], ... + 'VertDist', []); + sSubRegions = repmat(sSubRegion, 0, 0); + for k=1:size(Dist,1) + nn_in = find(~isinf(Dist(k,:))); + found = 0; + for i=1:length(sSubRegions) + if length(nn_in) == length(sSubRegions(i).Indices) && all(nn_in == sSubRegions(i).Indices) + found = 1; + break; + end + end + if ~found + sSubRegions(end+1).Indices = nn_in; + end + end + + % Subregion elements + for i = 1:length(sSubRegions) + % Vertices + sSubRegions(i).Vertices = SurfaceMat.Vertices(sSubRegions(i).Indices, :); + iRemoveVert = setdiff(1:size(SurfaceMat.Vertices,1), sSubRegions(i).Indices); + iKeptVert = sSubRegions(i).Indices; + iVertMap = zeros(1, size(SurfaceMat.Vertices, 1)); + iVertMap(iKeptVert) = 1:length(iKeptVert); + % Faces + sSubRegions(i).Faces = SurfaceMat.Faces; + iRemoveFace = find(sum(ismember(SurfaceMat.Faces, iRemoveVert), 2)); + sSubRegions(i).Faces(iRemoveFace, :) = []; + % Renumber indices for faces + sSubRegions(i).Faces = iVertMap(sSubRegions(i).Faces); + % VertConn + sSubRegions(i).VertConn = SurfaceMat.VertConn(sSubRegions(i).Indices, sSubRegions(i).Indices); + % Distances + sSubRegions(i).VertDist = Dist(sSubRegions(i).Indices, sSubRegions(i).Indices); + end end diff --git a/toolbox/process/functions/process_ssmooth_surfstat.m b/toolbox/process/functions/process_ssmooth_surfstat.m index 7829d080c..3ab969b22 100644 --- a/toolbox/process/functions/process_ssmooth_surfstat.m +++ b/toolbox/process/functions/process_ssmooth_surfstat.m @@ -20,6 +20,7 @@ % =============================================================================@ % % Authors: Peter Donhauser, Francois Tadel, 2015-2016 +% Edouard Delaire, 2023 eval(macro_method); end @@ -32,7 +33,7 @@ sProcess.FileTag = 'ssmooth'; sProcess.Category = 'Filter'; sProcess.SubGroup = 'Sources'; - sProcess.Index = 336; + sProcess.Index = 336.1; % Definition of the input accepted by this process sProcess.InputTypes = {'results', 'timefreq'}; sProcess.OutputTypes = {'results', 'timefreq'}; @@ -54,6 +55,15 @@ sProcess.options.fwhm.Comment = 'FWHM (Full width at half maximum): '; sProcess.options.fwhm.Type = 'value'; sProcess.options.fwhm.Value = {3, 'mm', 0}; + % === METHOD + sProcess.options.label.Comment = 'Method:'; + sProcess.options.label.Type = 'label'; + sProcess.options.method.Comment = {'Before 2023 (not recommended)', ... + 'Fixed FWHM for all surfaces', ... + 'Adjust FWHM for each disconnected surface (slower)'; ... + 'before_2023', 'fixed_fwhm', 'adaptive_fwhm'}; + sProcess.options.method.Type = 'radio_label'; + sProcess.options.method.Value = 'before_2023'; end @@ -66,13 +76,18 @@ strAbs = ''; end % Final comment - Comment = sprintf('%s (%1.2f%s)', sProcess.Comment, sProcess.options.fwhm.Value{1}, strAbs); + Comment = sprintf('%s (%1.2f mm%s)', sProcess.Comment, sProcess.options.fwhm.Value{1}, strAbs); end %% ===== RUN ===== function sInput = Run(sProcess, sInput) %#ok % Get options + if ~isfield(sProcess.options, 'method') || isempty(sProcess.options.method.Value) + method = 'before_2023'; + else + method = sProcess.options.method.Value; + end FWHM = sProcess.options.fwhm.Value{1} / 1000; % ===== LOAD DATA ===== @@ -82,7 +97,13 @@ FileMat = in_bst_results(sInput.FileName, 0, 'SurfaceFile', 'GridLoc', 'Atlas', 'nComponents', 'HeadModelType'); nComponents = FileMat.nComponents; case 'timefreq' - FileMat = in_bst_timefreq(sInput.FileName, 0, 'SurfaceFile', 'GridLoc', 'Atlas', 'HeadModelType'); + FileMat = in_bst_timefreq(sInput.FileName, 0, 'DataType', 'SurfaceFile', 'GridLoc', 'Atlas', 'HeadModelType'); + % Check the data type: timefreq must be source/surface based, and no kernel-based file + if ~strcmpi(FileMat.DataType, 'results') + errMsg = 'Only cortical maps can be smoothed.'; + bst_report('Error', 'process_ssmooth_surfstat', sInput.FileName, errMsg); + return; + end nComponents = 1; otherwise error('Unsupported file format.'); @@ -93,7 +114,7 @@ sInput = []; return; % Error: cannot smooth results that are already based on atlases - elseif ~isempty(FileMat.Atlas) + elseif ~isempty(FileMat.Atlas) && isempty(strfind(sInput.FileName, '_connect1')) bst_report('Error', sProcess, sInput, 'Spatial smoothing is not supported for sources based on atlases.'); sInput = []; return; @@ -103,36 +124,99 @@ sInput = []; return; end - % Load surface - SurfaceMat = in_tess_bst(FileMat.SurfaceFile); - - + % ===== PROCESS ===== + % Smooth surface + [sInput.A, msgInfo, warmInfo] = compute(FileMat.SurfaceFile, sInput.A, FWHM, method); + if iscell(msgInfo) + tmp = sprintf('Smoothing %d independent regions \n', length(msgInfo)); + for iRegion = 1:length(msgInfo) + tmp = [tmp, sprintf('Region %d: %s \n', iRegion, msgInfo{iRegion})]; + end + msgInfo = tmp; + end + bst_report('Info', sProcess, sInput, msgInfo); + + if ~isempty(warmInfo) + bst_report('Warning', sProcess, sInput, warmInfo); + end + + % Force the output comment + sInput.CommentTag = FormatComment(sProcess); + % Format the output history + tmp = strsplit(msgInfo,'\n'); + HistoryComment = [sprintf('%s %.1f mm',sProcess.FileTag,FWHM*1000) newline]; + for iLine = 1:length(tmp) + if ~isempty(tmp{iLine}) + HistoryComment = [HistoryComment ... + '|-------- ' strrep(tmp{iLine},'=',':') newline ]; + end + end + if ~isempty(warmInfo) + HistoryComment = [HistoryComment ... + '|-------- ' warmInfo]; + end + + sInput.HistoryComment = HistoryComment; +end + +function [sData, msgInfo, warmInfo] = compute(SurfaceFile, sData, FWHM, version) + warmInfo = ''; + + % Get surface + if ischar(SurfaceFile) + SurfaceMat = in_tess_bst(SurfaceFile); + else + SurfaceMat = SurfaceFile; + end + + if strcmp(version,'adaptive_fwhm') + % Smooth each connenected part of the surface separately + % first estimate the connected regions + nVertices = size(SurfaceMat.Vertices,1); + Dist = bst_tess_distance(SurfaceMat, 1:nVertices, 1:nVertices, 'geodesic_edge'); + subRegions = process_ssmooth('GetConnectedRegions', SurfaceMat, Dist); + % Smooth each region separately + msgInfo = cell(1,length(subRegions)); + for i = 1:length(subRegions) + [sData(subRegions(i).Indices,:,:), msgInfo{i}] = compute(subRegions(i), sData(subRegions(i).Indices,:,:), FWHM, 'fixed_fwhm'); + end + return; + end % Convert surface to SurfStat format cortS.tri = SurfaceMat.Faces; cortS.coord = SurfaceMat.Vertices'; % Get the average edge length [vi,vj] = find(SurfaceMat.VertConn); - Vertices = SurfaceMat.VertConn; + if strcmp(version,'before_2023') + Vertices = SurfaceMat.VertConn; + elseif strcmp(version,'fixed_fwhm') + Vertices = SurfaceMat.Vertices; + end + meanDist = mean(sqrt((Vertices(vi,1) - Vertices(vj,1)).^2 + (Vertices(vi,2) - Vertices(vj,2)).^2 + (Vertices(vi,3) - Vertices(vj,3)).^2)); + % FWHM in surfstat is in mesh units: Convert from millimeters to "edges" FWHMedge = FWHM ./ meanDist; - + % Display the result of this conversion msgInfo = ['Average distance between two vertices: ' num2str(round(meanDist*10000)/10) ' mm' 10 ... 'SurfStatSmooth called with FWHM=' num2str(round(FWHMedge * 1000)/1000) ' edges']; - bst_report('Info', sProcess, sInput, msgInfo); - disp(['SMOOTH> ' strrep(msgInfo, char(10), [10 'SMOOTH> '])]); - - % Smooth surface - for iFreq = 1:size(sInput.A,3) - sInput.A(:,:,iFreq) = SurfStatSmooth(sInput.A(:,:,iFreq)', cortS, FWHMedge)'; + disp(['SMOOTH> ' strrep(msgInfo, char(10), [10 'SMOOTH> '])]); + + if strcmp(version,'before_2023') + Vertices = SurfaceMat.Vertices; + true_meanDist = mean(sqrt((Vertices(vi,1) - Vertices(vj,1)).^2 + (Vertices(vi,2) - Vertices(vj,2)).^2 + (Vertices(vi,3) - Vertices(vj,3)).^2)); + used_FWHM = FWHMedge * true_meanDist; + + warmInfo = sprintf('This process is using a FWHM of %.2f mm instead of %.2f mm. Please consult https://github.com/brainstorm-tools/brainstorm3/pull/645 for more information.',used_FWHM*1000,FWHM*1000); + end + + for iFreq = 1:size(sData,3) + sData(:,:,iFreq) = SurfStatSmooth(sData(:,:,iFreq)', cortS, FWHMedge)'; end - - % Force the output comment - sInput.CommentTag = [sProcess.FileTag, num2str(FWHM*1000)]; -end +end diff --git a/toolbox/process/functions/process_ssp2.m b/toolbox/process/functions/process_ssp2.m index 2fb1d908c..7edceb5af 100644 --- a/toolbox/process/functions/process_ssp2.m +++ b/toolbox/process/functions/process_ssp2.m @@ -638,6 +638,7 @@ chanmask(iChannels) = 1; % Call computation function proj = Compute(F, chanmask); + proj.Method = Method; % % Select the components with a singular value > threshold % if isempty(ForceSelect) % singThresh = 0.12; @@ -676,6 +677,7 @@ proj.CompMask = 1; proj.Status = 1; proj.SingVal = []; + proj.Method = Method; % === ICA: JADE === @@ -698,6 +700,18 @@ bst_progress('text', 'Calling external function: EEGLAB''s runica()...'); % Remove the mean F = bst_bsxfun(@minus, F, mean(F,2)); + rankF = rank(F); + isLowRank = rank(F) < size(F,1); + if isLowRank + % Warning message + strWarning = [sprintf('INFOMAX: The rank of your data (%d) is lower than the number of channels (%d) in it.', rankF, size(F,1)), 10 ... + ' This could be caused because a refrence channel is included, or AVERAGE re-referencing is applied to this data.', 10 ... + ' Please consider limiting the "Number of ICA components" to the rank of the data.']; + % Add to the report + bst_report('Warning', sProcess, [], strWarning); + % Display on the console + disp([10 'WARNING: ' strWarning]); + end % Run EEGLAB ICA function if ~isempty(nIcaComp) && (nIcaComp ~= 0) [icaweights, icasphere] = runica(F, 'pca', nIcaComp, 'lrate', 0.001, 'extended', 1, 'interupt', 'off'); @@ -759,7 +773,8 @@ proj.Components = Wall; proj.CompMask = zeros(size(Wall,2), 1); % No component selected by default proj.Status = 1; - proj.SingVal = 'ICA'; + proj.Method = Method; + % Apply component selection (if set explicitly) if ~isempty(SelectComp) proj.CompMask(SelectComp) = 1; @@ -768,22 +783,33 @@ if isempty(Y) Y = W * F; end - % Sort ICA components + % Compute mixing matrix + if diff(size(W)) == 0 + M = inv(W); + else + M = pinv(W); + end + % Variance in recovered data explained by each component + varIcs = sum(M.^2, 1) .* sum(Y.^2, 2)'; + varIcs = varIcs ./ sum(varIcs); + % Find sorting order for ICA components + iSort = []; if ~isempty(icaSort) % By correlation with reference channel C = bst_corrn(Fref, Y); - [corrs, iSort] = sort(max(abs(C),[],1), 'descend'); - proj.Components = proj.Components(:,iSort); + [~, iSort] = sort(max(abs(C),[],1), 'descend'); elseif ismember(Method, {'ICA_picard', 'ICA_fastica'}) - % By explained variance - if diff(size(W)) == 0 - M = inv(W); - else - M = pinv(W); - end - var = sum(M.^2, 1) .* sum(Y.^2, 2)'; - [var, iSort] = sort(var, 'descend'); + [~, iSort] = sort(varIcs, 'descend'); + end + % Explained variance ratio + Fdiff = (F - M * Y); + rVarExp = 1 - (sum(sum(Fdiff.^2, 2)) ./ sum(sum(F.^2, 2))); + % Variance in original data by each component + proj.SingVal = rVarExp * varIcs; + % Sort components and their variances + if ~isempty(iSort) proj.Components = proj.Components(:,iSort); + proj.SingVal = proj.SingVal(iSort); end end @@ -945,15 +971,17 @@ % % COMMENTS: % There are 5 categories of projectors: -% - SSP_pca: CompMask=[Ncomp x 1], SingVal=[Ncomp x 1], Components=[Nchan x Ncomp]=U -% - SSP_mean: CompMask=1, SingVal=[], Components=[Nchan x 1]=U -% - ICA: CompMask=[Ncomp x 1], SingVal='ICA', Components=[Nchan x Ncomp]=W' -% - REF: CompMask=[], SingVal='REF', Components=[Nchan x Ncomp]=Wmontage -% - Other: CompMask=[], SingVal=[], Components=[Nchan x Nchan]=Projector=I-UUt +% - SSP_pca: Method = 'SSP_pca' CompMask=[Ncomp x 1], SingVal=[Ncomp x 1], Components=[Nchan x Ncomp]=U +% - SSP_mean: Method = 'SSP_pca' CompMask=1, SingVal=[], Components=[Nchan x 1]=U +% - ICA: Method = 'ICA_variant' CompMask=[Ncomp x 1], SingVal=[Ncomp x 1], Components=[Nchan x Ncomp]=W' +% - REF: Method = 'REF' CompMask=[], SingVal=[], Components=[Nchan x Ncomp]=Wmontage +% - Other: Method = 'Other' CompMask=[], SingVal=[], Components=[Nchan x Nchan]=Projector=I-UUt +% +% For ICA projectors, 'SingVal' contains the fraction explained variance with respect to the original signal % % Description of the notations used here: -% - W: Unmixing matrix [Nelectrodes x Ncomponents] -% - Winv = pinv(W) = [Ncomponents x Nelectrodes] +% - W: Unmixing matrix [Ncomponents x Nelectrodes] +% - Winv = pinv(W) = [Nelectrodes x Ncomponents] % - In EEGLAB: W = icaweights * icasphere; % - Activations_IC = W * Data % - CleanData = Winv(:,iComp) * Activations(iComp,:) @@ -974,11 +1002,12 @@ iProjSsp = []; U = []; for i = 1:length(ListProj) + ListProj(i) = ConvertOldFormat(ListProj(i)); % Is entry not selected: skip if ~ismember(ListProj(i).Status, ProjStatus) || (~isempty(ListProj(i).CompMask) && all(ListProj(i).CompMask == 0)) iProjDel(end+1) = i; % New SSP: Stack selected vectors all together - elseif ~isempty(ListProj(i).CompMask) && ~isequal(ListProj(i).SingVal, 'ICA') && ~isequal(ListProj(i).SingVal, 'REF') + elseif ~isempty(ListProj(i).CompMask) && ~ismember(ListProj(i).Method(1:3), {'ICA', 'REF'}) iProjSsp(end+1) = i; U = [U, ListProj(i).Components(:,ListProj(i).CompMask == 1)]; end @@ -1030,7 +1059,7 @@ % Add the projectors in the order of appearance for i = 1:length(ListProj) % ICA - if isequal(ListProj(i).SingVal, 'ICA') + if isequal(ListProj(i).Method(1:3), 'ICA') % Get selected channels (find the non-zero channels) iChan = find(any(ListProj(i).Components ~= 0, 2)); % Get selected components @@ -1068,9 +1097,34 @@ proj = []; elseif ~isstruct(OldProj) proj = db_template('projector'); - proj.Components = OldProj; - proj.Comment = 'Unnamed'; - proj.Status = 1; + proj.Components = OldProj; + proj.Comment = 'Unnamed'; + proj.Status = 1; + proj.Method = 'Other'; + elseif ~isfield(OldProj, 'Method') || isempty(OldProj.Method) + proj = db_template('projector'); + proj = struct_copy_fields(proj, OldProj, 1); + % Add projector method + if isnumeric(proj.SingVal) && (length(proj.SingVal) == length(proj.CompMask)) + proj.Method = 'SSP_pca'; + elseif isempty(proj.SingVal) && length(proj.CompMask) == 1 && proj.CompMask == 1 + proj.Method = 'SSP_mean'; + elseif ischar(proj.SingVal) && strcmpi(proj.SingVal, 'ICA') + proj.Method = 'ICA'; + proj.SingVal = []; + elseif ischar(proj.SingVal) && strcmpi(proj.SingVal, 'REF') + proj.Method = 'REF'; + proj.SingVal = []; + elseif isempty(proj.SingVal) && isempty(proj.CompMask) + proj.Method = 'Other'; + end + % Try to get ICA method from comment + if strcmp(proj.Method, 'ICA') + tmp = regexp(proj.Comment, 'ICA_\w*', 'match'); + if ~isempty(tmp) + proj.Method = tmp{1}; + end + end else proj = OldProj; end diff --git a/toolbox/process/functions/process_sync_recordings.m b/toolbox/process/functions/process_sync_recordings.m new file mode 100644 index 000000000..333b00df9 --- /dev/null +++ b/toolbox/process/functions/process_sync_recordings.m @@ -0,0 +1,310 @@ +function varargout = process_sync_recordings(varargin) +% process_sync_recordings: Synchronize multiple signals based on common event + +% @============================================================================= +% This function is part of the Brainstorm software: +% https://neuroimage.usc.edu/brainstorm +% +% Copyright (c) University of Southern California & McGill University +% This software is distributed under the terms of the GNU General Public License +% as published by the Free Software Foundation. Further details on the GPLv3 +% license can be found at http://www.gnu.org/copyleft/gpl.html. +% +% FOR RESEARCH PURPOSES ONLY. THE SOFTWARE IS PROVIDED "AS IS," AND THE +% UNIVERSITY OF SOUTHERN CALIFORNIA AND ITS COLLABORATORS DO NOT MAKE ANY +% WARRANTY, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO WARRANTIES OF +% MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE, NOR DO THEY ASSUME ANY +% LIABILITY OR RESPONSIBILITY FOR THE USE OF THIS SOFTWARE. +% +% For more information type "brainstorm license" at command prompt. +% =============================================================================@ +% +% Authors: Edouard Delaire, 2021-2023 +% Raymundo Cassani, 2024 + +eval(macro_method); +end + + +%% ===== GET DESCRIPTION ===== +function sProcess = GetDescription() + % Description the process + sProcess.Comment = 'Synchronyze files'; + sProcess.Category = 'Custom'; + sProcess.SubGroup = 'Synchronize'; + sProcess.Index = 681; + % Definition of the input accepted by this process + sProcess.InputTypes = {'data', 'raw'}; + sProcess.OutputTypes = {'data', 'raw'}; + sProcess.nInputs = 1; + sProcess.nMinFiles = 2; + + %Description of options + sProcess.options.inputs.Comment = ['For synchronization, please choose an
event type ' ... + 'which is available in all datasets.

']; + sProcess.options.inputs.Type = 'label'; + + % Source Event name for synchronization + sProcess.options.src.Comment = 'Sync event name: '; + sProcess.options.src.Type = 'text'; + sProcess.options.src.Value = ''; + % Sync method + sProcess.options.method_title.Comment = '
Method for event synchronization::'; + sProcess.options.method_title.Type = 'label'; + sProcess.options.method.Comment = {'xcorr : maximize the correlation between the event timing of the two files', ... + 'mean : minimize the mean difference between the event timing of the two files'; ... + 'xcorr', 'mean'}; + sProcess.options.method.Type = 'radio_label'; + sProcess.options.method.Value = 'xcorr'; +end + + +%% ===== FORMAT COMMENT ===== +function Comment = FormatComment(sProcess) %#ok + Comment = sProcess.Comment; +end + + +%% ===== RUN ===== +function OutputFiles = Run(sProcess, sInputs) + OutputFiles = {}; + + % === Sync event management === % + syncEventName = sProcess.options.src.Value; + nInputs = length(sInputs); + sEvtSync = repmat(db_template('event'), 1, nInputs); + sOldTiming = cell(1, nInputs); + fs = zeros(1, nInputs); + + % Check: Same FileType for all files + is_raw = strcmp({sInputs.FileType},'raw'); + if ~all(is_raw) && ~all(~is_raw) + bst_error('Please do not mix continous (raw) and imported data', 'Synchronize signal', 0); + return; + end + is_raw = is_raw(1); + + bst_progress('start', 'Synchronizing files', 'Loading data...', 0, 3*nInputs); + + % Get Time vector, events and sampling frequency for each file + for iInput = 1:nInputs + if strcmp(sInputs(iInput).FileType, 'data') % Imported data structure + sData = in_bst_data(sInputs(iInput).FileName, 'Time', 'Events'); + sOldTiming{iInput}.Time = sData.Time; + sOldTiming{iInput}.Events = sData.Events; + elseif strcmp(sInputs(iInput).FileType, 'raw') % Continuous data file + sDataRaw = in_bst_data(sInputs(iInput).FileName, 'Time', 'F'); + sOldTiming{iInput}.Time = sDataRaw.Time; + sOldTiming{iInput}.Events = sDataRaw.F.events; + end + fs(iInput) = 1/(sOldTiming{iInput}.Time(2) - sOldTiming{iInput}.Time(1)); % in Hz + iSyncEvt = strcmp({sOldTiming{iInput}.Events.label}, syncEventName); + if any(iSyncEvt) + sEvtSync(iInput) = sOldTiming{iInput}.Events(iSyncEvt); + end + end + + % Check: Sync event must be present in all files + if any(~(strcmp({sEvtSync.label}, syncEventName))) + bst_error(['Sync event ("' syncEventName '") must be present in all files'], 'Synchronize signal', 0); + return; + end + + % Check: Sync event must be simple event + if any(cellfun(@(x) size(x,1), {sEvtSync.times}) ~= 1) + bst_error(['Sync event ("' syncEventName '") must be simple event in all the files'], 'Synchronize signal', 0); + return; + end + + bst_progress('inc', nInputs); + bst_progress('text', 'Synchronizing...'); + + % First Input is the one wiht highest sampling frequency + [~, im] = max(fs); + sInputs([1, im]) = sInputs([im, 1]); + sEvtSync([1, im]) = sEvtSync([im, 1]); + sOldTiming([1, im]) = sOldTiming([im, 1]); + fs([1, im]) = fs([im, 1]); + + % Compute shifiting between file i and first file + new_times = cell(1,nInputs); + new_times{1} = sOldTiming{1}.Time; + mean_shifting = zeros(1, nInputs); + for iInput = 2:nInputs + isSameNumberEvts = size(sEvtSync(iInput).times, 2) == size(sEvtSync(1).times, 2); + % Require same number of events + if ~isSameNumberEvts + bst_report('Error', sProcess, sInputs, 'Files doesnt have the same number of sync events.'); + return + end + % Sync + if strcmpi(sProcess.options.method.Value, 'mean') + % Mean difference + shifting = sEvtSync(iInput).times - sEvtSync(1).times; + mean_shifting(iInput) = mean(shifting); + offsetStd = std(shifting); + elseif strcmpi(sProcess.options.method.Value, 'xcorr') + % Cross-correlate trigger signals; need to be at the same sampling frequency + tmp_fs = max(fs(iInput), fs(1)); + tmp_time_a = sOldTiming{iInput}.Time(1):1/tmp_fs:sOldTiming{iInput}.Time(end); + tmp_time_b = sOldTiming{1}.Time(1):1/tmp_fs:sOldTiming{1}.Time(end); + + blocA = zeros(1 , length(tmp_time_a)); + for i_event = 1:size(sEvtSync(iInput).times,2) + i_intra_event = panel_time('GetTimeIndices', tmp_time_a, sEvtSync(iInput).times(i_event) + [0 1]'); + blocA(1,i_intra_event) = 1; + end + + blocB = zeros(1 , length(tmp_time_b)); + for i_event = 1:size(sEvtSync(1).times,2) + i_intra_event = panel_time('GetTimeIndices', tmp_time_b, sEvtSync(1).times(i_event) + [0 1]'); + blocB(1,i_intra_event) = 1; + end + + [c,lags] = xcorr(blocA,blocB); + [~,colum] = max(c); + + mean_shifting(iInput) = lags(colum) / tmp_fs; + offsetStd = 0; + end + new_times{iInput} = sOldTiming{iInput}.Time - mean_shifting(iInput); + + fprintf('Lag difference between %s and %s : %.2f ms (std: %.2f ms) \n', ... + sInputs(1).Condition, sInputs(iInput).Condition, mean_shifting(iInput)*1000, offsetStd*1000); + end + + % New start and new end + new_start = max(cellfun(@(x)min(x), new_times)); + new_end = min(cellfun(@(x)max(x), new_times)); + + % Compute new time vectors, and new events times + sNewTiming = sOldTiming; + pool_events = []; + for iInput = 1:nInputs + index = panel_time('GetTimeIndices', new_times{iInput}, [new_start, new_end]); + sNewTiming{iInput}.Time = new_times{iInput}(index) - new_times{iInput}(index(1)); + tmp_events = sNewTiming{iInput}.Events; + for i_event = 1:length(tmp_events) + % Update event times + tmp_events(i_event).times = tmp_events(i_event).times - mean_shifting(iInput) - new_times{iInput}(index(1)); + % Remove events outside new time range + timeRange = [sNewTiming{iInput}.Time(1), sNewTiming{iInput}.Time(end)]; + iEventTimesDel = all(or(tmp_events(i_event).times < timeRange(1), tmp_events(i_event).times > timeRange(2)), 1); + tmp_events(i_event).times(:,iEventTimesDel) = []; + tmp_events(i_event).epochs(iEventTimesDel) = []; + if ~isempty(tmp_events(i_event).channels) + tmp_events(i_event).channels(iEventTimesDel) = []; + end + if ~isempty(tmp_events(i_event).notes) + tmp_events(i_event).notes(iEventTimesDel) = []; + end + if ~isempty(tmp_events(i_event).reactTimes) + tmp_events(i_event).reactTimes(iEventTimesDel) = []; + end + % Clip values to time range + tmp_events(i_event).times(tmp_events(i_event).times < timeRange(1)) = timeRange(1); + tmp_events(i_event).times(tmp_events(i_event).times > timeRange(2)) = timeRange(2); + % Aggregate eventes across files + if isempty(pool_events) + pool_events = tmp_events(i_event); + elseif ~strcmp(tmp_events(i_event).label,syncEventName) || (strcmp(tmp_events(i_event).label,syncEventName) && ~any(strcmp({pool_events.label},syncEventName))) + pool_events = [pool_events tmp_events(i_event)]; + end + end + end + % Update polled events + for iInput = 1:nInputs + sNewTiming{iInput}.Events = pool_events; + end + + bst_progress('inc', nInputs); + bst_progress('text', 'Saving files...'); + + % Save sync data to file + for iInput = 1:nInputs + if ~is_raw + % Load original data + sDataSync = in_bst_data(sInputs(iInput).FileName); + % Set new time and events + sDataSync.Comment = [sDataSync.Comment ' | Synchronized ']; + sDataSync.Time = sNewTiming{iInput}.Time; + sDataSync.Events = sNewTiming{iInput}.Events; + % Update data + index = panel_time('GetTimeIndices', new_times{iInput}, [new_start, new_end]); + sDataSync.F = sDataSync.F(:,index); + % History: List of sync files + sDataSync = bst_history('add', sDataSync, 'sync', ['List of synchronized files (event = "', syncEventName , '"):']); + for ix = 1:nInputs + sDataSync = bst_history('add', sDataSync, 'sync', [' - ' sInputs(ix).FileName]); + end + % Save data + OutputFile = bst_process('GetNewFilename', bst_fileparts(sInputs(iInput).FileName), 'data_sync'); + sDataSync.FileName = file_short(OutputFile); + bst_save(OutputFile, sDataSync, 'v7'); + % Register in database + db_add_data(sInputs(iInput).iStudy, OutputFile, sDataSync); + else + % New raw condition + newCondition = [sInputs(iInput).Condition '_synced']; + iNewStudy = db_add_condition(sInputs(iInput).SubjectName, newCondition); + sNewStudy = bst_get('Study', iNewStudy); + % Sync videos + sOldStudy = bst_get('Study', sInputs(iInput).iStudy); + if isfield(sOldStudy,'Image') && ~isempty(sOldStudy.Image) + for iOldVideo = 1 : length(sOldStudy.Image) + sOldVideo = load(file_fullpath(sOldStudy.Image(iOldVideo).FileName)); + if isempty(sOldVideo.VideoStart) + sOldVideo.VideoStart = 0; + end + iNewVideo = import_video(iNewStudy, sOldVideo.LinkTo); + sNewStudy = bst_get('Study', iNewStudy); + figure_video('SetVideoStart', file_fullpath(sNewStudy.Image(iNewVideo).FileName), sprintf('%.3f', sOldVideo.VideoStart - mean_shifting(iInput) - new_start)); + end + end + newStudyPath = bst_fileparts(file_fullpath(sNewStudy.FileName)); + % Save channel definition + ChannelMat = in_bst_channel(sInputs(iInput).ChannelFile); + [~, iChannelStudy] = bst_get('ChannelForStudy', iNewStudy); + db_set_channel(iChannelStudy, ChannelMat, 0, 0); + % Link to raw file + OutputFile = bst_process('GetNewFilename', bst_fileparts(sNewStudy.FileName), 'data_0raw_sync'); + % Raw file + [~, rawBaseOut, rawBaseExt] = bst_fileparts(newStudyPath); + rawBaseOut = strrep([rawBaseOut rawBaseExt], '@raw', ''); + RawFileOut = bst_fullfile(newStudyPath, [rawBaseOut '.bst']); + % Load original link to raw data + sDataRawSync = in_bst_data(sInputs(iInput).FileName, 'F'); + sFileIn = sDataRawSync.F; + % Set new time and events + sFileIn.events = sNewTiming{iInput}.Events; + sFileIn.header.nsamples = length( sNewTiming{iInput}.Time); + sFileIn.prop.times = [ sNewTiming{iInput}.Time(1), sNewTiming{iInput}.Time(end)]; + sFileOut = out_fopen(RawFileOut, 'BST-BIN', sFileIn, ChannelMat); + % Set Output sFile structure + sDataSync = in_bst(sInputs(iInput).FileName, [], 1, 1, 'no'); + sOutMat = rmfield(sDataSync, 'F'); + sOutMat.format = 'BST-BIN'; + sOutMat.DataType = 'raw'; + sOutMat.F = sFileOut; + sOutMat.Comment = [sDataSync.Comment ' | Synchronized']; + % History: List of sync files + sOutMat = bst_history('add', sOutMat, 'sync', ['List of synchronized files (event = "', syncEventName , '"):']); + for ix = 1:nInputs + sOutMat = bst_history('add', sOutMat, 'sync', [' - ' sInputs(ix).FileName]); + end + % Update raw data + index = panel_time('GetTimeIndices', new_times{iInput}, [new_start, new_end]); + sDataSync.F = sDataSync.F(:,index); + % Save new link to raw .mat file + bst_save(OutputFile, sOutMat, 'v6'); + % Write block + out_fwrite(sFileOut, ChannelMat, 1, [], [], sDataSync.F); + % Register in BST database + db_add_data(iNewStudy, OutputFile, sOutMat); + end + OutputFiles{iInput} = OutputFile; + bst_progress('inc', 1); + end + bst_progress('stop'); +end + diff --git a/toolbox/process/functions/process_test_normative.m b/toolbox/process/functions/process_test_normative.m new file mode 100644 index 000000000..4002c4910 --- /dev/null +++ b/toolbox/process/functions/process_test_normative.m @@ -0,0 +1,419 @@ +function varargout = process_test_normative( varargin ) +% PROCESS_TEST_NORMATIVE: Compare PSDs of each file A with the distribution of PSDs from files B +% For testing the normality of the residuals, the Shapiro-Wilk test is used. +% Careful, the test is less appropriate for large sample sizes (n>=50) [1] and may be too conservative. +% +% References: +% [1] Mishra, Prabhaker, Chandra M Pandey, Uttam Singh, Anshul Gupta, Chinmoy Sahu, and Amit Keshri, +% Descriptive Statistics and Normality Tests for Statistical Data, +% Annals of Cardiac Anaesthesia 22, no. 1 (2019): 67–72. https://doi.org/10.4103/aca.ACA_157_18. + +% @============================================================================= +% This function is part of the Brainstorm software: +% https://neuroimage.usc.edu/brainstorm +% +% Copyright (c) University of Southern California & McGill University +% This software is distributed under the terms of the GNU General Public License +% as published by the Free Software Foundation. Further details on the GPLv3 +% license can be found at http://www.gnu.org/copyleft/gpl.html. +% +% FOR RESEARCH PURPOSES ONLY. THE SOFTWARE IS PROVIDED "AS IS," AND THE +% UNIVERSITY OF SOUTHERN CALIFORNIA AND ITS COLLABORATORS DO NOT MAKE ANY +% WARRANTY, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO WARRANTIES OF +% MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE, NOR DO THEY ASSUME ANY +% LIABILITY OR RESPONSIBILITY FOR THE USE OF THIS SOFTWARE. +% +% For more information type "brainstorm license" at command prompt. +% =============================================================================@ +% +% Authors: Pauline Amrouche, 2024 +% Raymundo Cassani, 2024 + +eval(macro_method); +end + + +%% ===== GET DESCRIPTION ===== +function sProcess = GetDescription() %#ok + % Description the process + sProcess.Comment = 'Compare (A) to normative PSDs (B)'; + sProcess.FileTag = ''; + sProcess.Category = 'Custom'; + sProcess.SubGroup = 'Test'; + sProcess.Index = 110; + sProcess.Description = 'https://neuroimage.usc.edu/brainstorm/Tutorials/DeviationMaps'; + % Definition of the input accepted by this process + sProcess.InputTypes = {'timefreq'}; + sProcess.OutputTypes = {'timefreq'}; + sProcess.nInputs = 2; + sProcess.nMinFiles = 1; + sProcess.isSeparator = 1; + % Options: Log values + sProcess.options.islog.Comment = 'Use log10 values'; + sProcess.options.islog.Type = 'checkbox'; + sProcess.options.islog.Value = 1; + % Options : Select deviation level + sProcess.options.devlevel.Comment = 'Deviation level (range 0-1):'; + sProcess.options.devlevel.Type = 'value'; + sProcess.options.devlevel.Value = {0.05, '', 2}; + % Options: Normal distribution + sProcess.options.isnormal.Comment = 'Assume normal distribution of residuals'; + sProcess.options.isnormal.Type = 'checkbox'; + sProcess.options.isnormal.Value = 0; + sProcess.options.isnormal.Controller = 'Normal'; + % Options : Shapiro-Wilk test for normality + sProcess.options.shapiro.Comment = 'Test for normality of residuals (Shapiro-Wilk)'; + sProcess.options.shapiro.Type = 'checkbox'; + sProcess.options.shapiro.Value = 1; + sProcess.options.shapiro.Class = 'Normal'; + % Options: Frequency definition + % === Frequency output + sProcess.options.freqout.Comment = {'Same as input', 'Frequency range', 'Frequency bands', 'Frequency definition:'; ... + 'input', 'range', 'bands', ''}; + sProcess.options.freqout.Type = 'radio_linelabel'; + sProcess.options.freqout.Value = 'input'; + sProcess.options.freqout.Controller.range = 'range'; + sProcess.options.freqout.Controller.bands = 'bands'; + % === Individual frequency range + sProcess.options.freqrange.Comment = 'Frequency range: '; + sProcess.options.freqrange.Type = 'freqrange_static'; % 'freqrange' + sProcess.options.freqrange.Value = {[1 150], 'Hz', 1}; + sProcess.options.freqrange.Class = 'range'; + % === Frequency bands + sProcess.options.freqbands.Comment = ''; + sProcess.options.freqbands.Type = 'groupbands'; + sProcess.options.freqbands.Value = bst_get('DefaultFreqBands'); + sProcess.options.freqbands.Class = 'bands'; +end + + +%% ===== FORMAT COMMENT ===== +function Comment = FormatComment(sProcess) %#ok + Comment = sProcess.Comment; +end + + +%% ===== RUN ===== +function sOutput = Run(sProcess, sInputsA, sInputsB) %#ok + % Initialize output + sOutput = cell(1, length(sInputsA)); + + % Fetch user options + OPTIONS.IsLog = sProcess.options.islog.Value; + OPTIONS.FreqOut = sProcess.options.freqout.Value; + OPTIONS.FreqRange = sProcess.options.freqrange.Value{1}; + OPTIONS.FreqBands = sProcess.options.freqbands.Value; + OPTIONS.DevLevel = sProcess.options.devlevel.Value{1}; + OPTIONS.IsNormal = sProcess.options.isnormal.Value; + OPTIONS.TestNormality = sProcess.options.shapiro.Value; + + % FilesA in FilesB + Lia = ismember({sInputsA.FileName}, {sInputsB.FileName}); + sInputsUnique = [sInputsB, sInputsA(~Lia)]; + + % Validate inputs + [errMsg, iInputErr, nRows, Freqs] = CheckInputs(sInputsUnique); + if ~isempty(errMsg) && ~isempty(iInputErr) + bst_report('Error', sProcess, sInputsUnique(iInputErr), errMsg); + return + end + + % Frequency options + errMsg = ''; + switch OPTIONS.FreqOut + case 'bands' + if iscell(Freqs) + requestedBands = cellfun(@(x,y,z) strjoin({x,y,z}, ';'), OPTIONS.FreqBands(:,1), OPTIONS.FreqBands(:,2), OPTIONS.FreqBands(:,3), 'UniformOutput', 0); + availableBands = cellfun(@(x,y,z) strjoin({x,y,z}, ';'), Freqs(:,1), Freqs(:,2), Freqs(:,3), 'UniformOutput', 0); + [Lia,Locb] = ismember(requestedBands, availableBands); + if ~all(Lia) + errMsg = 'Not all requested bands are defined in the frequency bands of the input files.'; + end + freqMask = false(1, size(Freqs,1)); + freqMask(Locb) = true; + OPTIONS.FreqBands = []; + else + tmp = str2num(strjoin(OPTIONS.FreqBands(:,2), ',')); + requestedBandsLimits(1) = min(tmp); + requestedBandsLimits(2) = max(tmp); + if requestedBandsLimits(1) < Freqs(1) || requestedBandsLimits(2) > Freqs(end) + errMsg = 'Frequency bands must be defined inside the frequency range of the input files.'; + end + freqMask = true(1, size(OPTIONS.FreqBands,1)); + end + + case 'range' + OPTIONS.FreqBands = []; + if iscell(Freqs) + errMsg = 'Cannot use frequency range ouput if input files are defined in frequency bands.'; + else + if OPTIONS.FreqRange(1) < Freqs(1) || OPTIONS.FreqRange(2) > Freqs(end) + errMsg = 'Frequency bands must be defined inside the frequency range of the input files.'; + end + % find closest + iFreqRange1 = bst_closest(OPTIONS.FreqRange(1), Freqs); + iFreqRange2 = bst_closest(OPTIONS.FreqRange(2), Freqs); + freqMask = false(1, length(Freqs)); + freqMask(iFreqRange1:iFreqRange2) = true; + end + + case 'input' + % Keep all frequencies + OPTIONS.FreqBands = []; + freqMask = true(1, length(Freqs)); + + end + if ~isempty(errMsg) + bst_report('Error', sProcess, sInputsB(1), errMsg); + return + end + + % Initialize array to hold distributions + tfDataB = zeros(nRows, sum(freqMask), length(sInputsB)); + % Load normative values + for iInputB = 1 : length(sInputsB) + % Load and preprocess timefreq file + timefreqMat = PreProcessInput(sInputsB(iInputB), freqMask, OPTIONS); + tfDataB(:,:,iInputB) = squeeze(timefreqMat.TF); + end + % Compute normative distribution + normDistrib = ComputeNormDistrib(sProcess, tfDataB, OPTIONS); + clear tfDataB + % Compare each SubjectA PSD to the normative distribution + for iSubA = 1:length(sInputsA) + % Load and preprocess timefreq file + timefreqMat = PreProcessInput(sInputsA(iSubA), freqMask, OPTIONS); + % Compute significance deviation map, replace TF field + timefreqMat = CompareToNormDistrib(timefreqMat, normDistrib, OPTIONS); + % Create the output file + sOutput{iSubA} = SaveData(sInputsA(iSubA), sInputsB, timefreqMat, OPTIONS); + end +end + + +%% ===== FORMAT FILE COMMENT ===== +function comment = GetComment(tfMat, options) + % Initialize suffix for file comment + comment_suffix = ''; + if options.IsLog + comment_suffix = [comment_suffix, ' log']; + end + if options.IsNormal + comment_suffix = [comment_suffix, ' normal']; + end + % Check that the options are valid and update the comment suffix + switch options.FreqOut + case 'bands' + comment_suffix = [comment_suffix, ' bands']; + + case 'range' + comment_suffix = [comment_suffix, sprintf(' %d-%dHz', options.FreqRange(1), options.FreqRange(2))]; + + case 'input' + if iscell(tfMat.Freqs) + comment_suffix = [comment_suffix, ' bands']; + end + end + % Format the comment suffix + if ~isempty(comment_suffix) + comment_suffix = ['| ' comment_suffix]; + end + comment = sprintf('comp. to norm: devLevel (%.2f) %s', options.DevLevel, comment_suffix); +end + + +%% ===== PREPROCESS INPUT DATA ===== +% Load and preprocess timefreq file +function timefreqMat = PreProcessInput(sInput, freqMask, options) + % Load PSD file + timefreqMat = in_bst_timefreq(sInput.FileName, 1); + % Extract frequency bands (input has Frequency vector, output resquested in Bands) + if ~isempty(options.FreqBands) + timefreqMat = process_tf_bands('Compute', timefreqMat, options.FreqBands, []); + end + % Keep requested frequencies + timefreqMat.TF = timefreqMat.TF(:, 1, freqMask); + if iscell(timefreqMat.Freqs) + timefreqMat.Freqs = timefreqMat.Freqs(freqMask, :); + else + timefreqMat.Freqs = timefreqMat.Freqs(freqMask); + end + % If log values, apply log + if options.IsLog + timefreqMat.TF = log10(timefreqMat.TF); + end +end + + +%% ===== VALIDATE INPUT FILES ===== +% Check that all files are PSD files, have same DataType, have same space, and have same frequency definition +function [errMsg, iInputErr, nRows, Freqs] = CheckInputs(sInputs) + errMsg = ''; + nRows = []; + Freqs = []; + % Files must: be PSD, have same DataType, have same space, and have same frequency definition + for iInput = 1 : length(sInputs) + iInputErr = iInput; + timefreqMat = in_bst_timefreq(sInputs(iInput).FileName, 0, 'Method', 'RowNames', 'DataType', 'HeadModelType', 'SurfaceFile', 'HeadModelFile', 'Freqs'); + if ~strcmpi(timefreqMat.Method, 'psd') + errMsg = 'Input files must be PSD files.'; + return + end + % Verify files that must be common + if iInput == 1 + % Get reference fields + refDataType = timefreqMat.DataType; + switch refDataType + case {'data', 'matrix'} + refCommonSpaceFile = timefreqMat.RowNames; + case 'results' + refHeadModelType = timefreqMat.HeadModelType; + switch refHeadModelType + case 'surface' + refCommonSpaceFile = timefreqMat.SurfaceFile; + case 'volume' + refCommonSpaceFile = timefreqMat.HeadModelFile; + otherwise + errMsg = ['HeadModel of type ' refHeadModelType ' is not supported.']; + return + end + end + refRowNames = timefreqMat.RowNames; + nRows = length(refRowNames); + refFreqs = timefreqMat.Freqs; + + else + % Check against reference DataType + if ~strcmpi(timefreqMat.DataType, refDataType) + errMsg = 'All PSD files must share the same "DataType"'; + return + end + % PSD files must be computed in the same modality and space + switch timefreqMat.DataType + case {'data', 'matrix'} + if ~isequal(refCommonSpaceFile, timefreqMat.RowNames) + errMsg = 'PSD files from sensors (or matrices) must share the same channel names.'; + return + end + case 'results' + switch timefreqMat.HeadModelType + case 'surface' + if ~isequal(refCommonSpaceFile, timefreqMat.SurfaceFile) + errMsg = 'PSD files from surface sources must share the same surface file.'; + return + end + case 'volume' + if ~isequal(refCommonSpaceFile, timefreqMat.RowNames) + errMsg = 'PSD files from volume sources must share the head model (volume grid) file.'; + return + end + otherwise + errMsg = ['HeadModel of type ' timefreqMat.HeadModelType ' is not supported.']; + return + end + end + % Check frequency definition + if isequal(size(timefreqMat.Freqs), size(refFreqs)) + if iscell(refFreqs) + for iBand = 1 : size(timefreqMat.Freqs, 1) + if ~strcmpi(strjoin(timefreqMat.Freqs(iBand, :)), strjoin(refFreqs(iBand, :))) + errMsg = 'PSD files have different frequency band definition.'; + return + end + end + else + if abs(sum(timefreqMat.Freqs - refFreqs)) > 1e-6 + errMsg = 'PSD files have different frequency axes.'; + return + end + end + else + errMsg = 'PSD files must have the same frequency definition.'; + return + end + end + end + Freqs = refFreqs; + iInputErr = []; +end + + +%% ===== COMPUTE NORMATIVE DISTRIBUTION ===== +% Compute statistics for normative distribution +function normDistrib = ComputeNormDistrib(sProcess, norm_values, options) + [nRows, nFreqs, ~] = size(norm_values); + % Compute the mean and std of the normative values, across subjects + normDistrib.norm_means = mean(norm_values, 3); + normDistrib.norm_stds = std(norm_values, 0, 3); + + % Compute the residuals (z-scores) for normative values + residuals = (norm_values - normDistrib.norm_means) ./ normDistrib.norm_stds; + + if options.IsNormal && options.TestNormality + % Assuming that the residuals are normally distributed + % We can test the normality of the residuals using the Shapiro-Wilk test + % Results are displayed in the report + p_shapiro = zeros(nRows, nFreqs); + % Compute the Shapiro-Wilk test for normality + for iRow = 1:nRows + for iFreq = 1:nFreqs + [~, p] = swtest(residuals(iRow, iFreq, :)); + p_shapiro(iRow, iFreq) = p; + end + end + % Report the results of normality test + bst_report('Info', sProcess, [], ['Shapiro-Wilk test for normality of residuals:', 10, ... + sprintf('Significant at p < 0.05 : %d/%d', sum(p_shapiro < 0.05, "all"), nRows*nFreqs), 10,... + sprintf('Significant at p < 0.1 : %d/%d', sum(p_shapiro < 0.1, "all"), nRows*nFreqs)]); + end + + % Compute the percentiles of the distribution of residuals + if options.IsNormal + normDistrib.norm_percentile = norminv(1-options.DevLevel/2); + else + normDistrib.percentiles = prctile(residuals, [(options.DevLevel/2)*100, (1-(options.DevLevel/2))*100], 3); + end +end + + +%% ===== COMPARE TO NORMATIVE DISTRIBUTION ===== +% Compute the deviation map wrt the normative distribution +function timefreqMat = CompareToNormDistrib(timefreqMat, normDistrib, options) + % Compute the z-scores + z_scores = (squeeze(timefreqMat.TF) - normDistrib.norm_means) ./ normDistrib.norm_stds; + % Identify z_scores that are significantly different from the normative distribution + if options.IsNormal + signif = abs(z_scores) > normDistrib.norm_percentile; + else + signif = (z_scores < normDistrib.percentiles(:, :, 1)) | (z_scores > normDistrib.percentiles(:, :, 2)); + end + % Restore time dimenstion + signif = permute(signif, [1,3,2]); + timefreqMat.TF = signif; +end + + +%% ===== SAVE TEST RESULTS ===== +function output = SaveData(sInputA, sInputsB, tfMat, options) + % Add comment, change filename and save + tfMat.ColormapType = 'stat2'; + + % Get file comment from options + tfMat.Comment = GetComment(tfMat, options); + + % History + tfMat = bst_history('add', tfMat, 'comp2norm', sprintf('File compared to normative: %s', sInputA.FileName)); + tfMat = bst_history('add', tfMat, 'comp2norm', sprintf('devLevel = %.2f, isNorm = %d, isLog10 = %d', options.DevLevel, options.IsNormal, options.IsLog)); + % History: List files used for normative + tfMat = bst_history('add', tfMat, 'comp2norm', 'List of files used for normative distribution:'); + for i = 1:length(sInputsB) + tfMat = bst_history('add', tfMat, 'comp2norm', [' - ' sInputsB(i).FileName]); + end + % Output filename + [originalPath, originalBase, originalExt] = bst_fileparts(file_fullpath(sInputA.FileName)); + output = bst_fullfile(originalPath, [originalBase, '_' 'comp2norm', originalExt]); + output = file_unique(output); + % Save the file + bst_save(output, tfMat, 'v6'); + db_add_data(sInputA.iStudy, output, tfMat); +end \ No newline at end of file diff --git a/toolbox/process/functions/process_timefreq.m b/toolbox/process/functions/process_timefreq.m index 9c0b86a8e..643387c85 100644 --- a/toolbox/process/functions/process_timefreq.m +++ b/toolbox/process/functions/process_timefreq.m @@ -93,6 +93,7 @@ case 'process_hilbert', strProcess = 'hilbert'; case 'process_fft', strProcess = 'fft'; case 'process_psd', strProcess = 'psd'; + case 'process_psd_features', strProcess = 'psd'; case 'process_sprint', strProcess = 'sprint'; case 'process_ft_mtmconvol', strProcess = 'mtmconvol'; otherwise, error('Unsupported process.'); @@ -163,16 +164,33 @@ tfOPTIONS.WinLength = sProcess.options.win_length.Value{1}; tfOPTIONS.WinOverlap = sProcess.options.win_overlap.Value{1}; end - if isfield(sProcess.options, 'win_std') && ~isempty(sProcess.options.win_std) && ~isempty(sProcess.options.win_std.Value) - tfOPTIONS.WinStd = sProcess.options.win_std.Value; - if tfOPTIONS.WinStd - tfOPTIONS.Comment = [tfOPTIONS.Comment ' std']; + % Aggregating function across windows (PSD) + if isfield(sProcess.options, 'win_std') && isfield(sProcess.options.win_std, 'Value') && ~isempty(sProcess.options.win_std.Value) + switch lower(sProcess.options.win_std.Value) + case {0, 'mean'} + tfOPTIONS.WinFunc = 'mean'; + case {1, 'std'} + tfOPTIONS.WinFunc = 'std'; + tfOPTIONS.Comment = [tfOPTIONS.Comment, ' ', tfOPTIONS.WinFunc]; + case {2, 'mean+std'} + tfOPTIONS.WinFunc = 'mean+std'; + otherwise + bst_report('Error', sProcess, [], ['Invalid "' num2str(lower(sProcess.options.win_std.Value)) '" window aggregating function.']); + return; end end % If units specified (PSD) if isfield(sProcess.options, 'units') && ~isempty(sProcess.options.units) && ~isempty(sProcess.options.units.Value) tfOPTIONS.PowerUnits = sProcess.options.units.Value; - end + end + % Compute relative power (PSD) + if isfield(sProcess.options, 'relative') && ~isempty(sProcess.options.relative) && ~isempty(sProcess.options.relative.Value) + tfOPTIONS.IsRelative = sProcess.options.relative.Value; + if tfOPTIONS.IsRelative + % Add relative to comment + tfOPTIONS.Comment = [tfOPTIONS.Comment, ' relative']; + end + end % Multitaper options if isfield(sProcess.options, 'mt_taper') && ~isempty(sProcess.options.mt_taper) && ~isempty(sProcess.options.mt_taper.Value) if iscell(sProcess.options.mt_taper.Value) @@ -250,6 +268,7 @@ 'timewindow', tfOPTIONS.TimeWindow, ... 'scouts', tfOPTIONS.Clusters, ... 'scoutfunc', ExtractScoutFunc, ... % If ScoutFunc is not defined, use the scout function available in each scout + 'flatten', 0, ... 'isflip', isflip, ... 'isnorm', 0, ... 'concatenate', 0, ... diff --git a/toolbox/process/functions/process_timeoffset.m b/toolbox/process/functions/process_timeoffset.m index 7c2f72dbb..592e2dfac 100644 --- a/toolbox/process/functions/process_timeoffset.m +++ b/toolbox/process/functions/process_timeoffset.m @@ -20,6 +20,7 @@ % =============================================================================@ % % Authors: Francois Tadel, 2010-2016 +% Raymundo Cassani, 2024 eval(macro_method); end @@ -30,13 +31,13 @@ % Description the process sProcess.Comment = 'Add time offset'; sProcess.FileTag = 'timeoffset'; - sProcess.Category = 'Filter'; + sProcess.Category = 'File'; sProcess.SubGroup = 'Pre-process'; sProcess.Index = 76; sProcess.Description = ''; % Definition of the input accepted by this process - sProcess.InputTypes = {'data', 'results', 'matrix', 'timefreq'}; - sProcess.OutputTypes = {'data', 'results', 'matrix', 'timefreq'}; + sProcess.InputTypes = {'data', 'raw', 'matrix'}; + sProcess.OutputTypes = {'data', 'raw', 'matrix'}; sProcess.nInputs = 1; sProcess.nMinFiles = 1; @@ -52,6 +53,11 @@ sProcess.options.offset.Comment = 'Time offset:'; sProcess.options.offset.Type = 'value'; sProcess.options.offset.Value = {0, 'ms', []}; + % === Overwrite + sProcess.options.overwrite.Comment = 'Overwrite input files'; + sProcess.options.overwrite.Type = 'checkbox'; + sProcess.options.overwrite.Value = 0; + sProcess.options.overwrite.Group = 'output'; end @@ -62,11 +68,81 @@ %% ===== RUN ===== -function sInput = Run(sProcess, sInput) %#ok +function OutputFile = Run(sProcess, sInput) %#ok + OutputFile = {}; + % Get inputs - TimeOffset = sProcess.options.offset.Value{1}; - % Apply offset - sInput.TimeVector = sInput.TimeVector + TimeOffset; + OffsetTime = sProcess.options.offset.Value{1}; + isOverwrite = sProcess.options.overwrite.Value; + + % ===== LOAD FILE ===== + % Get file descriptor + isRaw = strcmpi(sInput.FileType, 'raw'); + % Load file + DataMat = in_bst_data(sInput.FileName); + if isRaw + sEvents = DataMat.F.events; + sFreq = DataMat.F.prop.sfreq; + DataMat.Time = [DataMat.Time(1), DataMat.Time(end)]; + else + sEvents = DataMat.Events; + sFreq = 1 ./ (DataMat.Time(2) - DataMat.Time(1)); + end + + % ===== PROCESS ===== + % Apply offset to time + DataMat.Time = DataMat.Time + OffsetTime; + if isRaw + DataMat.F.prop.times = DataMat.Time; + if isfield(DataMat.F, 'epochs') && ~isempty(DataMat.F.epochs) + [DataMat.F.epochs(:).times] = deal(DataMat.Time); + end + end + + % Add offset to all events + for iEvt = 1:length(sEvents) + sEvents(iEvt).times = round((sEvents(iEvt).times + OffsetTime) .* sFreq) ./ sFreq; + end + if isRaw + DataMat.F.events = sEvents; + else + DataMat.Events = sEvents; + end + + % ===== SAVE FILE ===== + % Add history entry + DataMat = bst_history('add', DataMat, 'timeoffset', sprintf('Added time offset %1.4fs', OffsetTime)); + DataMat.Comment = [DataMat.Comment ' | ' sProcess.FileTag]; + + % Overwrite the input file + if isOverwrite + OutputFile = file_fullpath(sInput.FileName); + bst_save(OutputFile, DataMat, 'v6'); + % Reload study + db_reload_studies(sInput.iStudy); + % Save new file + else + % Create new raw condition + if isRaw + ChannelFile = sInput.ChannelFile; + newCondition = [sInput.Condition '_' sProcess.FileTag]; + iStudy = db_add_condition(sInput.SubjectName, newCondition); + sNewStudy = bst_get('Study', iStudy); + db_set_channel(iStudy, ChannelFile, 0, 0); + newStudyPath = bst_fileparts(file_fullpath(sNewStudy.FileName)); + [~, base, ext] = bst_fileparts(sInput.FileName); + OutputFile = bst_fullfile(newStudyPath, [base '.' ext]); + else + OutputFile = file_fullpath(sInput.FileName); + iStudy = sInput.iStudy; + end + % Unique output filename + OutputFile = file_unique(strrep(OutputFile, '.mat', ['_' sProcess.FileTag '.mat'])); + % Save file + bst_save(OutputFile, DataMat, 'v6'); + % Add file to database structure + db_add_data(iStudy, OutputFile, DataMat); + end end diff --git a/toolbox/process/panel_process2.m b/toolbox/process/panel_process2.m index 8b37cf096..c1bcc9aa0 100644 --- a/toolbox/process/panel_process2.m +++ b/toolbox/process/panel_process2.m @@ -139,7 +139,7 @@ 'process_plv1', 'process_plv1n', 'process_plv2'}) && ... (~isfield(sProcesses(iProc).options.tfedit, 'Value') || isempty(sProcesses(iProc).options.tfedit.Value))) % check 'tfedit' field bst_error(['Please check the advanced options of the process "', sProcesses(iProc).Comment, '" before running the pipeline.'], 'Pipeline editor', 0); - panel_process_select('ShowPanel', {sFilesA.FileName}, {sFilesB.FileName}, sProcesses); + panel_process_select('ShowPanel', [{sFilesA.FileName}, {sFilesB.FileName}], sProcesses); return end end diff --git a/toolbox/process/panel_process_select.m b/toolbox/process/panel_process_select.m index fda548c8e..7c3a6f778 100644 --- a/toolbox/process/panel_process_select.m +++ b/toolbox/process/panel_process_select.m @@ -1107,7 +1107,15 @@ function UpdateProcessOptions() end % Set validation callbacks java_setcb(jCombo, 'ActionPerformedCallback', @(h,ev)SetOptionValue(iProcess, optNames{iOpt}, {cellValues{2,ev.getSource().getSelectedIndex()+1}, option.Value{2}})); - + % If class controller not selected, toggle off class + if isfield(option, 'Controller') && ~isempty(option.Controller) && isstruct(option.Controller) + for f = fieldnames(option.Controller)' + if ~strcmpi(f{1}, option.Value{1}) && ~isempty(option.Controller.(f{1})) && ~(isfield(option.Controller, option.Value{1}) && isequal(option.Controller.(option.Value{1}), option.Controller.(f{1}))) + ClassesToToggleOff{end+1} = option.Controller.(f{1}); + end + end + end + case 'freqsel' % Load Freq field from the input file if strcmpi(sFiles(1).FileType, 'timefreq') @@ -1384,13 +1392,35 @@ function UpdateProcessOptions() end end % Create controls - gui_component('label', jPanelOpt, [], ['', option.Comment, '  ']); + jLabel = gui_component('label', jPanelOpt, [], ['', option.Comment, '  ']); jText = gui_component('text', jPanelOpt, [], strFiles); jText.setEditable(0); jText.setPreferredSize(java_scaled('dimension', 210, 20)); isUpdateTime = strcmpi(option.Type, 'datafile'); - gui_component('button', jPanelOpt, '', '...', [],[], @(h,ev)PickFile_Callback(iProcess, optNames{iOpt}, jText, isUpdateTime)); - + if strcmp(strFunction, 'process_export_file') + if length(sFiles) > 1 + % Export multiple files, suggest dir name to export files (filenames from Brainstorm DB) + jLabel.setText('Output dir'); + GlobalData.Processes.Current(iProcess).options.(optNames{iOpt}).Value{7} = 'dirs'; + LastUsedDirs = bst_get('LastUsedDirs'); + GlobalData.Processes.Current(iProcess).options.(optNames{iOpt}).Value{1} = LastUsedDirs.ExportData; + jText.setText(LastUsedDirs.ExportData); + else + % Export one file, suggest filename for new file from Input file + jLabel.setText('Output file'); + GlobalData.Processes.Current(iProcess).options.(optNames{iOpt}).Value{7} = 'files'; + if isempty(GlobalData.Processes.Current(iProcess).options.(optNames{iOpt}).Value{1}) || strcmp(option.Value{7}, 'dirs') + % Used in SaveFile_Callback() to suggeste name of export file + GlobalData.Processes.Current(iProcess).options.(optNames{iOpt}).Value{1} = sFiles(1).FileName; + end + jText.setText(GlobalData.Processes.Current(iProcess).options.(optNames{iOpt}).Value{1}); + end + gui_component('button', jPanelOpt, '', '...', [],[], @(h,ev)SaveFile_Callback(iProcess, optNames{iOpt}, jText)); + else + % Pick file or dir (Open File or Select Dir to Save) + gui_component('button', jPanelOpt, '', '...', [],[], @(h,ev)PickFile_Callback(iProcess, optNames{iOpt}, jText, isUpdateTime)); + end + case 'editpref' gui_component('label', jPanelOpt, [], ['', option.Comment{2}, '   ']); gui_component('button', jPanelOpt, [], 'Edit...', [],[], @(h,ev)EditProperties_Callback(iProcess, optNames{iOpt})); @@ -1516,6 +1546,44 @@ function UpdateProcessOptions() eventList.add('br', jScroll); optionPanel.add(eventList); jPanelOpt.add(optionPanel); + + case {'list_vertical', 'list_horizontal'} + % List items + listModel = javax.swing.DefaultListModel(); + for iItem = 1 : length(option.Comment)-1 + listModel.addElement(option.Comment{iItem}); + end + % Create list + jList = java_create('javax.swing.JList'); + % Orientation + if strcmpi(option.Type, 'list_vertical') + jList.setLayoutOrientation(jList.HORIZONTAL_WRAP); + else + jList.setLayoutOrientation(jList.VERTICAL_WRAP); + end + jList.setModel(listModel); + jList.setVisibleRowCount(-1); + jList.setCellRenderer(BstStringListRenderer(fontSize)); + jList.setEnabled(1); + % Last item in list is the list comment + gui_component('label', jPanelOpt, [], option.Comment{end}); + % Restore previous selected items + gui_component('label', jPanelOpt, 'hfill', ' ', [],[],[],[]); + if ~isempty(sProcess.options.(optNames{iOpt}).Value) + [~, iSelItems] = ismember(sProcess.options.(optNames{iOpt}).Value, option.Comment); + iSelItems(iSelItems==0) = []; + if length(iSelItems) == length(sProcess.options.(optNames{iOpt}).Value) + jList.setSelectedIndices(iSelItems-1); + end + end + java_setcb(jList, 'ValueChangedCallback', @(h,ev)ItemSelection_Callback(iProcess, optNames{iOpt}, jList)); + % Create scroll panel + jScroll = javax.swing.JScrollPane(jList); + % Horizontal glue + jPanelOpt.add('br hfill vfill', jScroll); + % Set preferred size for the container + prefPanelSize = java_scaled('dimension', 250,180); + end jPanelOpt.setPreferredSize(prefPanelSize); end @@ -1796,6 +1864,141 @@ function PickFile_Callback(iProcess, optName, jText, isUpdateTime) end + %% ===== OPTIONS: SAVE FILE CALLBACK ===== + function SaveFile_Callback(iProcess, optName, jText) + % Get default import directory and formats + LastUsedDirs = bst_get('LastUsedDirs'); + DefaultFormats = bst_get('DefaultFormats'); + % Get file selection options + selectOptions = GlobalData.Processes.Current(iProcess).options.(optName).Value; + if (length(selectOptions) == 9) + DialogType = selectOptions{3}; + WindowTitle = selectOptions{4}; + DefaultOutFile = selectOptions{5}; + SelectionMode = selectOptions{6}; + FilesOrDir = selectOptions{7}; + Filters = selectOptions{8}; + DefaultFormat = selectOptions{9}; + if isfield(DefaultFormats, DefaultFormat) && isempty(selectOptions{2}) + defaultFilter = DefaultFormats.(DefaultFormat); + else + defaultFilter = selectOptions{2}; + end + else + DialogType = 'save'; + WindowTitle = 'Export file'; + DefaultOutFile = ''; + SelectionMode = 'single'; + FilesOrDir = 'files'; + Filters = {{'*'}, 'All files (*.*)', 'ALL'}; + defaultFilter = []; + end + + % First input file + inBstFile = selectOptions{1}; + % Filters and extension according to file type + fileType = file_gettype(inBstFile); + isRaw = 0; + if strcmp(fileType, 'data') && ~isempty(strfind(inBstFile, '_0raw')) + isRaw = 1; + fileType = 'raw'; + end + if isempty(Filters) + Filters = bst_get('FileFilters', [fileType, 'out']); + end + % Select only Filter if not provided + if isempty(defaultFilter) + switch fileType + case 'raw' + defaultFilter = 'BST-BIN'; + case {'data', 'results', 'timefreq', 'matrix'} + defaultFilter = 'BST'; + end + end + % Get extension for filter + iFilter = find(ismember(Filters(:,3), defaultFilter), 1, 'first'); + if isempty(iFilter) + iFilter = 1; + end + fExt = Filters{iFilter, 1}{1}; + % Verify that extension for BST format ends in '.ext' (no 'BST' format for raw data) + if strcmp(defaultFilter, 'BST') && isempty(regexp(DefaultOutFile, '\.\w+$', 'once')) && ~(strcmp(fileType, 'data') && isRaw) + fExt = '.mat'; + end + + % Suggest filename or dir + switch FilesOrDir + % Suggest filename + case 'files' + switch(fileType) + case 'data' + [~, fBase] = bst_fileparts(inBstFile); + fBase = strrep(fBase, '_data', ''); + fBase = strrep(fBase, 'data_', ''); + fBase = strrep(fBase, '0raw_', ''); + + case {'results', 'link'} + if strcmp(fileType, 'link') + [kernelFile, dataFile] = file_resolve_link(inBstFile); + [~, kBase] = bst_fileparts(kernelFile); + [~, fBase] = bst_fileparts(dataFile); + fBase = [kBase, '_' ,fBase]; + else + [~, fBase] = bst_fileparts(inBstFile); + end + fBase = strrep(fBase, '_results', ''); + fBase = strrep(fBase, 'results_', ''); + + case 'timefreq' + [~, fBase] = bst_fileparts(inBstFile); + fBase = strrep(fBase, '_timefreq', ''); + fBase = strrep(fBase, 'timefreq_', ''); + + case 'matrix' + [~, fBase] = bst_fileparts(inBstFile); + fBase = strrep(fBase, '_matrix', ''); + fBase = strrep(fBase, 'matrix_', ''); + + otherwise + % e.g., user set outfile more than once + [~, fBase] = bst_fileparts(inBstFile); + + end + DefaultOutFile = bst_fullfile(LastUsedDirs.ExportData, [fBase, fExt]); + + % Suggest directory + case 'dirs' + DefaultOutFile = bst_fullfile(LastUsedDirs.ExportData); + end + + % Pick a file + [OutputFile, FileFormat] = java_getfile(DialogType, WindowTitle, DefaultOutFile, SelectionMode, FilesOrDir, Filters, defaultFilter); + % If nothing selected + if isempty(OutputFile) + return + end + % Update ExportData path + if strcmp(FilesOrDir, 'dirs') + % Remove extension (introduced by the Filters) + [fPath, fBase] = bst_fileparts(OutputFile); + OutputFile = bst_fullfile(fPath, fBase); + LastUsedDirs.ExportData = OutputFile; + elseif strcmp(FilesOrDir, 'files') + fPath = bst_fileparts(OutputFile); + LastUsedDirs.ExportData = fPath; + end + bst_set('LastUsedDirs', LastUsedDirs); + + % Update the values + selectOptions{1} = OutputFile; + selectOptions{2} = FileFormat; + % Save the new values + SetOptionValue(iProcess, optName, selectOptions); + % Update the text field + jText.setText(OutputFile); + end + + %% ===== OPTIONS: EDIT PROPERTIES CALLBACK ===== function EditProperties_Callback(iProcess, optName) % Get current value: {@panel, sOptions} @@ -2080,6 +2283,20 @@ function ScoutSelection_Callback(iProcess, optName, AtlasList, jCombo, jList, jC end + %% ===== OPTIONS: SELECT ITEM CALLBACK ===== + function ItemSelection_Callback(iProcess, optName, jList) + listModel = jList.getModel(); + iSels = jList.getSelectedIndices(); + elems = {}; + + % Update saved selected list + for iSel = 1:length(iSels) + elems{end + 1} = listModel.elementAt(iSels(iSel)); + end + SetOptionValue(iProcess, optName, elems); + end + + %% ===== OPTIONS: GET EVENT LIST ===== function EventList = GetEventList(varargin) excludeSpikes = 0; @@ -2119,7 +2336,10 @@ function SetOptionValue(iProcess, optName, value) UpdateProcessesList(); % Save option value for future uses optType = GlobalData.Processes.Current(iProcess).options.(optName).Type; - if ismember(optType, {'value', 'range', 'freqrange', 'freqrange_static', 'checkbox', 'radio', 'radio_line', 'radio_label', 'radio_linelabel', 'combobox', 'combobox_label', 'text', 'textarea', 'channelname', 'subjectname', 'atlas', 'groupbands', 'montage', 'freqsel', 'scout', 'scout_confirm'}) ... + if ismember(optType, {'value', 'range', 'freqrange', 'freqrange_static', 'checkbox', ... + 'radio', 'radio_line', 'radio_label', 'radio_linelabel', 'combobox', 'combobox_label', ... + 'text', 'textarea', 'channelname', 'subjectname', 'atlas', 'groupbands', 'montage', ... + 'freqsel', 'scout', 'scout_confirm', 'list_vertical', 'list_horizontal'}) ... || (strcmpi(optType, 'filename') && (length(value)>=7) && strcmpi(value{7},'dirs') && strcmpi(value{3},'save')) % Get processing options ProcessOptions = bst_get('ProcessOptions'); @@ -2134,7 +2354,10 @@ function SetOptionValue(iProcess, optName, value) opt = GlobalData.Processes.Current(iProcess).options.(optName); if strcmp(optType, 'checkbox') && ~isempty(opt.Controller) ToggleClass(opt.Controller, value); - elseif ismember(optType, {'radio_label', 'radio_linelabel'}) && ~isempty(opt.Controller) && isstruct(opt.Controller) + elseif ismember(optType, {'radio_label', 'radio_linelabel', 'combobox_label'}) && ~isempty(opt.Controller) && isstruct(opt.Controller) + if strcmpi(optType, 'combobox_label') + value = value{1}; + end for cl = fieldnames(opt.Controller)' % Ignore a disabled class that is associated with 2 options, one selected and one not selected if ~strcmp(cl{1}, value) && isfield(opt.Controller, value) && isequal(opt.Controller.(cl{1}), opt.Controller.(value)) @@ -2634,9 +2857,13 @@ function ParseProcessFolder(isForced) %#ok bstFunc = union(usrFunc, bstFunc); end - % Get processes from installed plugins ($HOME/.brainstorm/plugins/*) + % Get processes from installed (a supported) plugins ($HOME/.brainstorm/plugins/*) plugFunc = {}; - PlugAll = bst_plugin('GetInstalled'); + plugList = []; + PlugSupported = bst_plugin('GetSupported'); + PlugInstalled = bst_plugin('GetInstalled'); + [~, iPlug] = intersect({PlugInstalled.Name}, {PlugSupported.Name}); + PlugAll = PlugInstalled(iPlug); for iPlug = 1:length(PlugAll) if ~isempty(PlugAll(iPlug).Processes) % Keep only the processes with function names that are not already defined in Brainstorm @@ -2656,7 +2883,9 @@ function ParseProcessFolder(isForced) %#ok end % Add plugin processes to list of processes if ~isempty(plugFunc) - bstFunc = union(plugFunc, bstFunc); + iFunc = cellfun(@(x)exist(x,'file') > 0 , plugFunc); + plugList = cellfun(@dir, plugFunc(iFunc)); + bstFunc = union(plugFunc, bstFunc); end % ===== CHECK FOR MODIFICATIONS ===== @@ -2668,8 +2897,8 @@ function ParseProcessFolder(isForced) %#ok for i = 1:length(usrList) sig = [sig, usrList(i).name, usrList(i).date, num2str(usrList(i).bytes)]; end - for i = 1:length(plugFunc) - sig = [sig, plugFunc{i}]; + for i = 1:length(plugList) + sig = [sig, plugList(i).name, plugList(i).date, num2str(plugList(i).bytes)]; end % If signature is same as previously: do not reload all the files if ~isForced @@ -2720,7 +2949,16 @@ function ParseProcessFolder(isForced) %#ok try desc = Function('GetDescription'); catch - disp(['BST> Invalid plug-in function: "' bstFunc{iFile} '"']); + if ismember(bstFunc{iFile}, usrFunc) + processType = 'User'; + elseif ismember(bstFunc{iFile}, {bstList.name}) + processType = 'Brainstorm'; + elseif ismember(bstFunc{iFile}, plugFunc) + processType = 'Plug-in'; + else + processType = char(8); % backspace + end + disp(['BST> Invalid ' processType ' function: "' bstFunc{iFile} '"']); continue; end % Copy fields to returned structure @@ -2850,7 +3088,7 @@ function ParseProcessFolder(isForced) %#ok % Radio button: check the index of the selection if ismember(option.Type, {'radio','radio_line'}) && (savedOpt > length(option.Comment)) % Error: ignoring previous option - elseif strcmpi(option.Type, 'radio_label') && ~ismember(savedOpt, option.Comment(2,:)) + elseif strcmpi(option.Type, 'radio_label') && ~isnumeric(savedOpt) && ~ismember(savedOpt, option.Comment(2,:)) % Error: ignoring previous option elseif strcmpi(option.Type, 'radio_linelabel') && ~ismember(savedOpt, option.Comment(2,1:end-1)) % Error: ignoring previous option diff --git a/toolbox/script/README.md b/toolbox/script/README.md new file mode 100644 index 000000000..506c6fa05 --- /dev/null +++ b/toolbox/script/README.md @@ -0,0 +1,107 @@ +# Brainstorm scripts +This directory contains the scripts to replicate most [Brainstorm tutorials](https://neuroimage.usc.edu/brainstorm/Tutorials) and to test different parts of Brainstorm. +Tutorials can be executed individually with the respective `tutorial_TUTORIALNAME.m` script, or by calling the script `test-tutorial.m`. +These scripts can be run on Brainstorm releases (**source** and **binary**). + +## Tutorial scripts + +| Tutorial name | Info | Report | Locally
source | Locally
binary | :octocat:
runner | :octocat:
exec time | +|---------------------------|-------|--------|-------------------|-------------------|---------------------|-------------------------| +| tutorial_introduction | [Link](https://neuroimage.usc.edu/brainstorm/Tutorials/AllIntroduction) | [Report](https://neuroimage.usc.edu/bst/examples/report_TutorialIntroduction.html) | 🐧🪟🍎 | 🐧🪟🍎 | 🐧🪟🍎 | 70 min | +| tutorial_connectivity | [Link](https://neuroimage.usc.edu/brainstorm/Tutorials/Connectivity) | [Report](https://neuroimage.usc.edu/bst/examples/report_TutorialConnectivity.html) | 🐧🪟🍎 | 🐧🪟🍎 | 🐧🪟🍎 | 05 min | +| tutorial_coherence | [Link](https://neuroimage.usc.edu/brainstorm/Tutorials/CorticomuscularCoherence) | [Report](https://neuroimage.usc.edu/bst/examples/report_TutorialCMC.html) | 🐧🪟🍎 | 🐧🪟🍎 | 🐧🪟🍎 | 100 min | +| tutorial_ephys | [Link](https://neuroimage.usc.edu/brainstorm/e-phys/Introduction) | [Report](https://neuroimage.usc.edu/bst/examples/report_Tutorial_e-Phys.html) | 🐧🪟🍎 | 🐧🪟🍎 | 🐧🪟🍎 | 25 min | +| tutorial_dba | [Link](https://neuroimage.usc.edu/brainstorm/Tutorials/DeepAtlas) | [Report](https://neuroimage.usc.edu/bst/examples/report_TutorialDba.html) | 🐧🪟🍎 | 🐧🪟🍎 | N/A | N/A | +| tutorial_epilepsy | [Link](https://neuroimage.usc.edu/brainstorm/Tutorials/Epilepsy) | [Report](https://neuroimage.usc.edu/bst/examples/report_TutorialEpilepsy.html) | 🐧🪟🍎 | 🐧🪟🍎 | 🐧🪟🍎 | 15 min | +| tutorial_epileptogenicity | [Link](https://neuroimage.usc.edu/brainstorm/Tutorials/Epileptogenicity) | [Report](https://neuroimage.usc.edu/bst/examples/report_TutorialEpimap.html) | 🐧🪟🍎 | 🐧🪟🍎 | 🐧🪟🍎 | 15 min | +| tutorial_fem_charm | [Link](https://neuroimage.usc.edu/brainstorm/Tutorials/FemMedianNerveCharm) | [Report](https://neuroimage.usc.edu/brainstorm/Tutorials/FemMedianNerveCharm) | 🐧🪟🍎 | 🐧🪟🍎 | N/A | N/A | +| tutorial_fem_tensors | [Link](https://neuroimage.usc.edu/brainstorm/Tutorials/FemTensors) | [Report](https://neuroimage.usc.edu/brainstorm/Tutorials/FemTensors) | 🐧🪟🍎 | 🐧🪟🍎 | N/A | N/A | +| tutorial_frontiers2018 | [Link](https://neuroimage.usc.edu/brainstorm/Tutorials/VisualSingle) | [Report](https://neuroimage.usc.edu/brainstorm/Tutorials/VisualSingle) | 🐧🪟🍎 | 🐧🪟🍎 | N/A | N/A | +| tutorial_visual | [Link](https://neuroimage.usc.edu/brainstorm/Tutorials/VisualSingle) | [Report](https://neuroimage.usc.edu/brainstorm/Tutorials/VisualSingle) | 🐧🪟🍎 | 🐧🪟🍎 | N/A | N/A | +| tutorial_hcp | [Link](https://neuroimage.usc.edu/brainstorm/Tutorials/HCP-MEG) | [Report](https://neuroimage.usc.edu/brainstorm/Tutorials/HCP-MEG) | 🐧🪟🍎 | 🐧🪟🍎 | N/A | N/A | +| tutorial_neuromag | [Link](https://neuroimage.usc.edu/brainstorm/Tutorials/TutMindNeuromag) | [Report](https://neuroimage.usc.edu/bst/examples/report_TutorialNeuromag.html) | 🐧🪟🍎 | 🐧🪟🍎 | 🐧🪟🍎 | 20 min | +| tutorial_omega | [Link](https://neuroimage.usc.edu/brainstorm/Tutorials/RestingOmega) | [Report](https://neuroimage.usc.edu/brainstorm/Tutorials/RestingOmega) | 🐧🪟🍎 | 🐧🪟🍎 | N/A | N/A | +| tutorial_phantom_ctf | [Link](https://neuroimage.usc.edu/brainstorm/Tutorials/PhantomCtf) | [Report](https://neuroimage.usc.edu/bst/examples/report_TutorialPhantom.html) | 🐧🪟🍎 | 🐧🪟🍎 | 🐧🪟🍎 | 20 min | +| tutorial_phantom_elekta | [Link](https://neuroimage.usc.edu/brainstorm/Tutorials/PhantomElekta) | [Report](https://neuroimage.usc.edu/bst/examples/report_TutorialPhantomElekta.html) | 🐧🪟🍎 | 🐧🪟🍎 | 🐧🪟🍎 | 10 min | +| tutorial_practicalmeeg | [Link](https://neuroimage.usc.edu/brainstorm/WorkshopParis2019) | [Report](https://neuroimage.usc.edu/bst/examples/report_PracticalMEEG.html) | 🐧🪟🍎 | 🐧🪟🍎 | 🐧🪟🍎 | 30 min | +| tutorial_raw | [Link](https://neuroimage.usc.edu/brainstorm/Tutorials/MedianNerveCtf) | [Report](https://neuroimage.usc.edu/bst/examples/report_TutorialRaw.html) | 🐧🪟🍎 | 🐧🪟🍎 | 🐧🪟🍎 | 10 min | +| tutorial_resting | [Link](https://neuroimage.usc.edu/brainstorm/Tutorials/Resting) | [Report](https://neuroimage.usc.edu/bst/examples/report_TutorialResting.html) | 🐧🪟🍎 | 🐧🪟🍎 | 🐧🪟🍎 | 85 min | +| tutorial_simulations | [Link](https://neuroimage.usc.edu/brainstorm/Tutorials/Simulations) | [Report](https://neuroimage.usc.edu/bst/examples/report_TutorialSimulation.html) | 🐧🪟🍎 | 🐧🪟🍎 | 🐧🪟🍎 | 40 min | +| tutorial_yokogawa | [Link](https://neuroimage.usc.edu/brainstorm/Tutorials/Yokogawa) | [Report](https://neuroimage.usc.edu/bst/examples/report_TutorialYokogawa.html) | 🐧🪟🍎 | 🐧🪟🍎 | 🐧🪟🍎 | 50 min | + +\* `N\A` indicates that this tutorial is not run on GitHub runners, [see below](#1-on-github-runners) + +# Function `test_tutorial.m` +This function allows to run one or more tutorial scripts. This function handles fetching the required data and sending reports by email. +Source code can be found in: + +The `test_tutorial.m` can be run: +1. On GitHub-hosted runners (only for source distribution) +2. Locally (source and compiled distributions) + +## 1. On GitHub-hosted runners +The function `test_tutorial.m` can be run on [GitHub-hosted runners](https://docs.github.com/en/actions/using-github-hosted-runners) for a specific **tutorial script** by using the **Test tutorial (source)** (`run_tutorial.yaml`) GitHub workflow in the `master` branch of the `brainstorm-tools/brainstorm3`. This workflow makes use of the [Matlab GitHub actions](https://github.com/matlab-actions) to setup a Matlab environment and run code. The workflow starts 3 runners (Linux, Windows and macOS) then, for each runner Matlab is installed, and Brainstorm is started in server mode to run `test_tutorial.m` with the **tutorial script** is indicated in the workflow **Tutorial name** droplist. The three runners run simultaneously. + +:bulb: Some tutorial scripts are not available on `Test tutorial (source)` GitHub workflow because these tutorial scripts: +* are not supported on the server mode (`tutorial_dba`), or +* require large datasets (`tutorial_frontiers2018`, `tutorial_visual` , `tutorial_hcp`, `tutorial_omega`), or +* require additional software such as SimNIBS or BrainSuite (`tutorial_fem_charm`, `tutorial_fem_tensors`) +As such these are not shown on the `Test tutorial (source)` GitHub action, and indicated in the [Table above](#tutorial-scripts), with the legend `N/A`. + +## 2. Locally +It is also possible to run the tutorial scripts locally using `test_tutorial.m`, to tests the source and compiled Brainstorm distributions. +When run locally, the `test_tutorial.m` scripts requires this parameters: + +**tutorialNames** : Tutorial or {Tutorials} to run +**dataDir** : (optional) Directory wtih tutorial data files (default = 'pwd'/tmpdir) +**reportDir** : (optional) Directory to save reports (default = reports are not saved) +**bstUser** : (optional) BST user to receive email with report (default = no email) +**bstPwd** : (optional) Password for BST user to download data if needed (default = empty) + +### Source distribution +A local installation of Matlab and a local copy of Brainstorm source are required. +It can be run directly from inside the **Matlab IDE** or from the OS **Terminal** making use of Brainstorm capability of executing scripts when in server mode. +The following sections show the commands to run `test_tutorial.m`, assuming that the current directory is `brainstorm3/` + +#### Matlab IDE +This execution is the same regardless of the OS +``` +brainstorm ./toolbox/script/test_tutorial.m TUTORIALNAME DATADIR REPORTDIR BSTUSER BSTPWD +brainstorm ./toolbox/script/test_tutorial.m {'TUTORIALNAME1','TUTORIALNAME2'} DATADIR REPORTDIR BSTUSER BSTPWD +``` + +#### Terminal +The parameters given to Matlab differ a bit among OS. +If an argument needs to be set to empty, it can be replaced with two single quotes ``''`` + +**Linux and macOS** +``` +matlab -nodisplay -r "brainstorm ./toolbox/script/test_tutorial.m TUTORIALNAME DATADIR REPORTDIR BSTUSER BSTPWD" +matlab -nodisplay -r "brainstorm ./toolbox/script/test_tutorial.m {'TUTORIALNAME1','TUTORIALNAME2'} DATADIR REPORTDIR BSTUSER BSTPWD" +``` + +**Windows** +``` +matlab.exe -batch "brainstorm .\toolbox\script\test_tutorial.m TUTORIALNAME DATADIR REPORTDIR BSTUSER BSTPWD" +matlab.exe -batch "brainstorm .\toolbox\script\test_tutorial.m {'TUTORIALNAME1','TUTORIALNAME2'} DATADIR REPORTDIR BSTUSER BSTPWD" +``` + +### Compiled distribution +You need a physical (or a virtual) machine with the OS to test, a copy of compiled Brainstorm and the installed [Matlab Runtime](https://www.mathworks.com/products/compiler/matlab-runtime.html) that matches the OS and Runtime required by the compiled Brainstorm. The following sections show the commands to run `test_tutorial.m`, assuming that the current directory is `brainstorm3/bin/R2023a` + +#### Terminal +This execution differs a bit among OS. +:bulb: Be careful if `BSTUSER` or `BSTPWD` contain special characters are [escaping](https://en.wikipedia.org/wiki/Escape_character) them will be needed + +**Linux and macOS** +The parameter `MATLABROOT` corresponds to the Matlab Runtime full path, e.g `/usr/local/MATLAB/MATLAB_Runtime/R2023a` or `/Applications/MATLAB/MATLAB_Runtime/R2023a` +``` +./brainstorm3.command MATLABROOT ../../toolbox/script/test_tutorial.m TUTORIALNAME DATADIR REPORTDIR BSTUSER BSTPWD +./brainstorm3.command MATLABROOT ../../toolbox/script/test_tutorial.m "{'TUTORIALNAME1','TUTORIALNAME2'}" DATADIR REPORTDIR BSTUSER BSTPWD +``` + +**Windows** +``` +./brainstorm3.bat ..\..\toolbox\script\test_tutorial.m TUTORIALNAME DATADIR REPORTDIR BSTUSER BSTPWD +./brainstorm3.bat ..\..\toolbox\script\test_tutorial.m "{'TUTORIALNAME1','TUTORIALNAME2'}" DATADIR REPORTDIR BSTUSER BSTPWD +``` diff --git a/toolbox/script/generate_phantom_ctf.m b/toolbox/script/generate_phantom_ctf.m index fcf869bde..209a0ebff 100644 --- a/toolbox/script/generate_phantom_ctf.m +++ b/toolbox/script/generate_phantom_ctf.m @@ -113,7 +113,7 @@ function generate_phantom_ctf(SubjectName) % ===== GENERATE HEAD SURFACE ===== % Create surface -[HeadFile, iSurface] = tess_isohead(iSubject, 10000, 0, 0, 'Phantom head (mask)'); +[HeadFile, iSurface] = tess_isohead(iSubject, 10000, 0, 0, [], 'Phantom head (mask)'); diff --git a/toolbox/script/generate_phantom_elekta.m b/toolbox/script/generate_phantom_elekta.m index 2272e26fa..3a1167084 100644 --- a/toolbox/script/generate_phantom_elekta.m +++ b/toolbox/script/generate_phantom_elekta.m @@ -140,7 +140,7 @@ % ===== GENERATE HEAD SURFACE ===== % Create surface -[HeadFile, iSurface] = tess_isohead(iSubject, 10000, 0, 0, 'Phantom head (mask)'); +[HeadFile, iSurface] = tess_isohead(iSubject, 10000, 0, 0, [], 'Phantom head (mask)'); diff --git a/toolbox/script/get_tutorial_data.m b/toolbox/script/get_tutorial_data.m new file mode 100644 index 000000000..87b3bbf2a --- /dev/null +++ b/toolbox/script/get_tutorial_data.m @@ -0,0 +1,69 @@ +function dataFullFile = get_tutorial_data(dataDir, dataFile, bstUser, bstPwd) +% GET_TUTORIAL_DATA check if the 'dataFile' needed for tutorial scripts is in 'dataDir' +% otherwise, if BST user and password are provided, data is downloaded from server +% +% USAGE: get_tutorial_data(dataDir, dataFile, bstUser, bstPwd) +% +% INPUTS: +% - dataDir : Directory to search (or save) dataFile +% - dataFile : File to search (or download) +% - bstUser : (opt) BST user (default = empty) +% - bstPwd : (opt) Password for BST user (default = empty) +% +% @============================================================================= +% This function is part of the Brainstorm software: +% https://neuroimage.usc.edu/brainstorm +% +% Copyright (c) University of Southern California & McGill University +% This software is distributed under the terms of the GNU General Public License +% as published by the Free Software Foundation. Further details on the GPLv3 +% license can be found at http://www.gnu.org/copyleft/gpl.html. +% +% FOR RESEARCH PURPOSES ONLY. THE SOFTWARE IS PROVIDED "AS IS," AND THE +% UNIVERSITY OF SOUTHERN CALIFORNIA AND ITS COLLABORATORS DO NOT MAKE ANY +% WARRANTY, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO WARRANTIES OF +% MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE, NOR DO THEY ASSUME ANY +% LIABILITY OR RESPONSIBILITY FOR THE USE OF THIS SOFTWARE. +% +% For more information type "brainstorm license" at command prompt. +% =============================================================================@ +% +% Authors: Raymundo Cassani, 2024 + + +%% ===== PARAMETERS ===== +if nargin < 2 + error('At least two parameters are needed'); +end +if nargin <= 3 || isempty(bstUser) || isempty(bstPwd) + bstUser = ''; + bstPwd = ''; +end + + +%% ===== GET FILE ===== +% Search or download dataFile for tutorial +dataFullFile = bst_fullfile(dataDir, dataFile); +% Try to download if file does not exist +if ~exist(dataFullFile, 'file') + if ~isempty(bstUser) && ~isempty(bstPwd) + dwnUrl = sprintf('http://neuroimage.usc.edu/bst/download.php?file=%s&user=%s&mdp=%s', ... + urlencode(dataFile), urlencode(bstUser), urlencode(bstPwd)); + dataFullFile = bst_fullfile(dataDir, dataFile); + errMsg = bst_websave(dataFullFile, dwnUrl); + % Return if error + if ~isempty(errMsg) || ~exist(dataFullFile, 'file') + dataFullFile = ''; + return + end + else + dataFullFile = ''; + return + end +end +% Check size, if less than 50 bytes, error with the downloaded file +d = dir(dataFullFile); +if d.bytes < 50 + dataFullFile = ''; + return +end diff --git a/toolbox/script/script_view_sources.m b/toolbox/script/script_view_sources.m index 969f87f26..6841b18b3 100644 --- a/toolbox/script/script_view_sources.m +++ b/toolbox/script/script_view_sources.m @@ -1,5 +1,5 @@ function [hFig, iDS, iFig] = script_view_sources(ResultsFile, DisplayMode) -% SCRIPT_VIEW_SOURCES: Display the sources in a brainstorm figure. +% SCRIPT_VIEW_SOURCES: Display the sources or timefreq from sources in a brainstorm figure. % % USAGE: [hFig, iDS, iFig] = script_view_sources(ResultsFile, DisplayMode) % @@ -26,9 +26,20 @@ % =============================================================================@ % % Author: Francois Tadel, 2009-2010 +% Raymundo Cassani, 2024 -% Find results file in database -[sStudy, iStudy, iResult] = bst_get('ResultsFile', ResultsFile); +% Find Study for input file in database +[sStudy, ~, ~, fileType, sItem] = bst_get('AnyFile', ResultsFile); +% Check file type +if ~ismember(fileType, {'results', 'timefreq'}) + error('Input file must contain sources, or be a timefreq file from sources.'); +end +% TimeFreq must be from sources +if strcmpi(fileType, 'timefreq') + if ~strcmpi(sItem.DataType, 'results') + error('Input file must be a timefreq file from sources.'); + end +end % Find subject in database sSubject = bst_get('Subject', sStudy.BrainStormSubject); diff --git a/toolbox/script/test_tutorial.m b/toolbox/script/test_tutorial.m new file mode 100644 index 000000000..6b28233b9 --- /dev/null +++ b/toolbox/script/test_tutorial.m @@ -0,0 +1,315 @@ +function test_tutorial(tutorialNames, dataDir, reportDir, bstUser, bstPwd) +% TEST_TUTORIAL Test Brainstorm by running tutorial scripts +% If Brainstorm is not running, it is started without GUI and with 'local' database +% +% USAGE: test_brainstorm(tutorialNames, dataDir, reportDir, bstUser, bstPwd) +% +% INPUTS: +% - tutorialNames : Tutorial or {Tutorials} to run, usually scripts in "./toolbox/scripts" +% - dataDir : (opt) Directory wtih tutorial data files (default = 'pwd'/tmpdir) +% - reportDir : (opt) Directory to save reports (default = reports are not saved) +% - bstUser : (opt) BST user to receive email with report (default = no email) +% - bstPwd : (opt) Password for BST user to download data if needed (default = empty) +% +% For a given tutorial in 'tutorialNames', the script does: +% 1. Find/get the tutorial data (download if bstUser and bstPwd are available) +% 2. Run tutorial script +% 3. Send report by email to bstUser +% +% @============================================================================= +% This function is part of the Brainstorm software: +% https://neuroimage.usc.edu/brainstorm +% +% Copyright (c) University of Southern California & McGill University +% This software is distributed under the terms of the GNU General Public License +% as published by the Free Software Foundation. Further details on the GPLv3 +% license can be found at http://www.gnu.org/copyleft/gpl.html. +% +% FOR RESEARCH PURPOSES ONLY. THE SOFTWARE IS PROVIDED "AS IS," AND THE +% UNIVERSITY OF SOUTHERN CALIFORNIA AND ITS COLLABORATORS DO NOT MAKE ANY +% WARRANTY, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO WARRANTIES OF +% MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE, NOR DO THEY ASSUME ANY +% LIABILITY OR RESPONSIBILITY FOR THE USE OF THIS SOFTWARE. +% +% For more information type "brainstorm license" at command prompt. +% =============================================================================@ +% +% Authors: Raymundo Cassani, 2023-2024 + + +%% ===== PARAMETERS ===== +if nargin < 2 || isempty(dataDir) + dataDir = bst_fullfile(pwd, 'tmpdir'); +end +if nargin < 3 || isempty(reportDir) + reportDir = ''; +end +if nargin < 4 || isempty(bstUser) + bstUser = ''; +end +if nargin < 5 || isempty(bstPwd) + bstPwd = ''; +end + +% All tutorials +if ischar(tutorialNames) + if strcmpi(tutorialNames, 'all') + tutorialNames = {'tutorial_introduction', ... + 'tutorial_connectivity', ... + 'tutorial_coherence', ... + 'tutorial_ephys', ... + 'tutorial_dba', ... + 'tutorial_epilepsy', ... + 'tutorial_epileptogenicity', ... + 'tutorial_fem_charm', ... + 'tutorial_fem_tensors', ... + 'tutorial_frontiers2018', ... + 'tutorial_visual', ... + 'tutorial_hcp', ... + 'tutorial_neuromag', ... + 'tutorial_omega', ... + 'tutorial_phantom_ctf', ... + 'tutorial_phantom_elekta', ... + 'tutorial_practicalmeeg', ... + 'tutorial_raw', ... + 'tutorial_resting', ... + 'tutorial_simulations', ... + 'tutorial_yokogawa', ... + }; + else + tutorialNames = {tutorialNames}; + end +end + + +%% ===== START BRAINSTORM ===== +% Check that Brainstorm is in the Matlab path +res = exist('brainstorm.m', 'file'); +if res ~=2 + error('Could not find "brainstorm.m" in Matlab path.'); +end +% Start Brainstorm without GUI and with local database +stopBstAtEnd = 0; +if ~brainstorm('status') + brainstorm nogui local + stopBstAtEnd = 1; +end + + +%% ===== DATA AND REPORT DIRS ===== +% Data directory +if ~exist(dataDir, 'dir') + mkdir(dataDir); +end +% Report dir +if ~isempty(reportDir) && ~exist(reportDir, 'dir') + mkdir(reportDir) +end + + +%% ===== RUN TUTORIALS, SAVE REPORTS AND SEND EMAIL ===== +for iTutorial = 1 : length(tutorialNames) + tutoriallName = tutorialNames{iTutorial}; + % Clean report history + bst_report('ClearHistory', 0); + infoStr = 'Error preparing file for tutorial'; + % === Run tutorial + switch tutoriallName + case 'tutorial_introduction' + dataFile = get_tutorial_data(dataDir, 'sample_introduction.zip', bstUser, bstPwd); + if exist(dataFile, 'file') + bst_unzip(dataFile, dataDir); + tutorial_introduction(dataDir); + end + + case 'tutorial_connectivity' + tutorial_connectivity(); + + case 'tutorial_coherence' + dataFile = bst_fullfile(dataDir, 'SubjectCMC.zip'); + if ~exist(dataFile, 'file') + bst_websave(dataFile, 'https://download.fieldtriptoolbox.org/tutorial/SubjectCMC.zip'); + end + if exist(dataFile, 'file') + bst_unzip(dataFile, bst_fullfile(dataDir, 'SubjectCMC')); + tutorial_coherence(dataDir); + end + + case 'tutorial_dba' + dataFile = get_tutorial_data(dataDir, 'TutorialDba.zip', bstUser, bstPwd); + if exist(dataFile, 'file') + tutorial_dba(dataFile); + end + + case 'tutorial_ephys' + dataFile = get_tutorial_data(dataDir, 'sample_ephys.zip', bstUser, bstPwd); + if exist(dataFile, 'file') + bst_unzip(dataFile, dataDir); + tutorial_ephys(dataDir); + end + + case 'tutorial_epilepsy' + dataFile = get_tutorial_data(dataDir, 'sample_epilepsy.zip', bstUser, bstPwd); + if exist(dataFile, 'file') + bst_unzip(dataFile, dataDir); + tutorial_epilepsy(dataDir); + end + + case 'tutorial_epileptogenicity' + dataFile = get_tutorial_data(dataDir, 'tutorial_epimap_bids.zip', bstUser, bstPwd); + if exist(dataFile, 'file') + bst_unzip(dataFile, dataDir); + tutorial_epileptogenicity(dataDir); + end + + case 'tutorial_fem_charm' + infoStr = 'REQUIRES TO INSTALL SimNIBS'; +% tmpDwnFile = mget(bstFtp, '/pub/tutorials/sample_fem.zip', tmpDir); +% bst_unzip(tmpDwnFile{:}, tmpDir); +% tutorial_fem_charm(tmpDir); + + case 'tutorial_fem_tensors' + infoStr = 'REQUIRES TO BrainSuite'; +% dataFile1 = bst_fullfile(dataDir, 'BrainSuiteTutorialSVReg.zip'); +% if ~exist(dataFile1, 'file') +% bst_websave(dataFile, 'http://brainsuite.org/WebTutorialData/BrainSuiteTutorialSVReg_Sept16.zip'); +% end +% dataFile2 = bst_fullfile(dataDir, 'DWI.zip'); +% if ~exist(dataFile2, 'file') +% bst_websave(dataFile2, 'http://brainsuite.org/WebTutorialData/DWI_Feb15.zip'); +% end +% if exist(dataFile1, 'file') && exist(dataFile2, 'file') +% bst_unzip(dataFile1, dataDir); +% bst_unzip(dataFile2, dataDir); +% tutorial_fem_tensors(dataDir); +% end + + case 'tutorial_frontiers2018' + infoStr = 'REQUIRES TO DOWNLOAD ~100GB https://openneuro.org/datasets/ds000117'; +% tutorial_frontiers2018(tmpDir); + + case 'tutorial_visual' + infoStr = 'REQUIRES TO DOWNLOAD ~100GB https://openneuro.org/datasets/ds000117'; +% tutorial_visual(tmpDir); + + case 'tutorial_hcp' + infoStr = 'REQUIRES TO DOWNLOAD ~20GB HCP-MEG2 distribution: subject #175237'; +% tutorial_hcp(tmpDir); + + case 'tutorial_neuromag' + dataFile = get_tutorial_data(dataDir, 'sample_neuromag.zip', bstUser, bstPwd); + if exist(dataFile, 'file') + bst_unzip(dataFile, dataDir); + tutorial_neuromag(dataDir); + end + + case 'tutorial_omega' + infoStr = 'REQUIRES TO DOWNLOAD ~12GB https://openneuro.org/datasets/ds000247'; + % tutorial_omega(tmpDir); + + case 'tutorial_phantom_ctf' + dataFile = get_tutorial_data(dataDir, 'sample_phantom_ctf.zip', bstUser, bstPwd); + if exist(dataFile, 'file') + bst_unzip(dataFile, dataDir); + tutorial_phantom_ctf(dataDir); + end + + case 'tutorial_phantom_elekta' + dataFile = get_tutorial_data(dataDir, 'sample_phantom_elekta.zip', bstUser, bstPwd); + if exist(dataFile, 'file') + bst_unzip(dataFile, dataDir); + tutorial_phantom_elekta(dataDir); + end + + case 'tutorial_practicalmeeg' + dataFile = get_tutorial_data(dataDir, 'tutorial_practicalmeeg.zip', bstUser, bstPwd); + if exist(dataFile, 'file') + bst_unzip(dataFile, dataDir); + tutorial_practicalmeeg(bst_fullfile(dataDir, 'tutorial_practicalmeeg')); + end + + case 'tutorial_raw' + dataFile = get_tutorial_data(dataDir, 'sample_raw.zip', bstUser, bstPwd); + if exist(dataFile, 'file') + bst_unzip(dataFile, dataDir); + tutorial_raw(dataDir); + end + + case 'tutorial_resting' + dataFile = get_tutorial_data(dataDir, 'sample_resting.zip', bstUser, bstPwd); + if exist(dataFile, 'file') + bst_unzip(dataFile, dataDir); + tutorial_resting(dataDir); + end + + case 'tutorial_simulations' + tutorial_simulations(); + + case 'tutorial_yokogawa' + dataFile = get_tutorial_data(dataDir, 'sample_yokogawa.zip', bstUser, bstPwd); + if exist(dataFile, 'file') + bst_unzip(dataFile, dataDir); + tutorial_yokogawa(dataDir); + end + end + + % Get report (available if tutorial was run) + [~, ReportFile] = bst_report('GetReport', 'last'); + % Was the tutorial run? + wasRun = ~isempty(ReportFile); + % Generate not-executed report + if ~wasRun + tmp = struct('Comment', tutoriallName); % Dummy struct to give name to report + bst_report('Reset'); + bst_report('Add', 'start', [], [], tmp); + bst_report('Info', '', '', sprintf('"%s" was not executed', tutoriallName)); + bst_report('Info', '', '', infoStr); + ReportFile = bst_report('Save'); + end + + % === Save report file + if ~isempty(reportDir) && ~isempty(ReportFile) + [~, baseName] = bst_fileparts(ReportFile); + bst_report('Export', ReportFile, bst_fullfile(reportDir, [baseName, '.html'])); + end + + % === Report email + if ~isempty(bstUser) + % Tutorial info + tutorialInfo = sprintf('Tutorial: "%s"', tutoriallName); + % Hostname and OS + [~, hostName] = system('hostname'); + hostInfo = sprintf('Host: "%s" with %s', strtrim(hostName), bst_get('OsName')); + % Matlab info + matlabInfo = sprintf('Matlab: %s', bst_get('MatlabReleaseName')); + % Brainstorm info + bstVersion = bst_get('Version'); + bstVariant = 'source'; + if bst_iscompiled() + bstVariant = 'standalone'; + end + bstInfo = sprintf('BST: %s (%s)', bstVersion.Version, bstVariant); + % Status + if wasRun + statusInfo = '[Complete]'; + else + statusInfo = '[Not exec]'; + end + % Subject string + strSubject = [statusInfo, ' ' tutorialInfo, ', ' hostInfo, ', ' matlabInfo, ', ', bstInfo]; + + % Process: Send report by email + bst_process('CallProcess', 'process_report_email', [], [], ... + 'username', bstUser, ... + 'cc', '', ... + 'subject', strSubject, ... + 'reportfile', ReportFile, ... + 'full', 1); + end +end + +% Stop Brainstorm +if stopBstAtEnd + brainstorm stop +end +end diff --git a/toolbox/script/tutorial_BEst.m b/toolbox/script/tutorial_BEst.m new file mode 100644 index 000000000..74b927255 --- /dev/null +++ b/toolbox/script/tutorial_BEst.m @@ -0,0 +1,209 @@ +function tutorial_BEst(tutorial_dir, reports_dir) +% TUTORIAL_BEST: Script that runs all the Brain Entropy in space and time introduction tutorials. +% +% CORRESPONDING ONLINE TUTORIAL: +% https://neuroimage.usc.edu/brainstorm/Tutorials/TutBEst +% +% INPUTS: +% - tutorial_dir : Directory where the sample_introduction.zip file has been unzipped +% - reports_dir : Directory where to save the execution report (instead of displaying it) + +% @============================================================================= +% This function is part of the Brainstorm software: +% https://neuroimage.usc.edu/brainstorm +% +% Copyright (c) University of Southern California & McGill University +% This software is distributed under the terms of the GNU General Public License +% as published by the Free Software Foundation. Further details on the GPLv3 +% license can be found at http://www.gnu.org/copyleft/gpl.html. +% +% FOR RESEARCH PURPOSES ONLY. THE SOFTWARE IS PROVIDED "AS IS," AND THE +% UNIVERSITY OF SOUTHERN CALIFORNIA AND ITS COLLABORATORS DO NOT MAKE ANY +% WARRANTY, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO WARRANTIES OF +% MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE, NOR DO THEY ASSUME ANY +% LIABILITY OR RESPONSIBILITY FOR THE USE OF THIS SOFTWARE. +% +% For more information type "brainstorm license" at command prompt. +% =============================================================================@ +% +% Author: Edouard Delaire, 2024 +% Raymundo Cassani, 2024 + + +% ===== FILES TO IMPORT ===== +% Output folder for reports +if (nargin < 2) || isempty(reports_dir) || ~isdir(reports_dir) + reports_dir = []; +end +% Folder in which the Introduction tutorial dataset is unzipped (if needed) +if (nargin == 0) || isempty(tutorial_dir) || ~file_exist(tutorial_dir) + tutorial_dir = []; +end + +% Re-inialize random number generator +if (bst_get('MatlabVersion') >= 712) + rng('default'); +end + + +%% ===== VERIFY REQUIRED PROTOCOL ===== +ProtocolName = 'TutorialIntroduction'; +SubjectName = 'Subject01'; + +iProtocolIntroduction = bst_get('Protocol', ProtocolName); +if isempty(iProtocolIntroduction) + % Produce the Introduction protocol + tutorial_introduction(tutorial_dir, reports_dir) +else + % Select input protocol + gui_brainstorm('SetCurrentProtocol', iProtocolIntroduction); +end + + +%% ===== REQUIRED PLUGIN ===== +% Install and Load Brain Entropy plugin +[isInstalled, errMsg] = bst_plugin('Install', 'brainentropy'); +if ~isInstalled + error(errMsg); +end + + +%% ===== FIND FILES ===== +% Process: Select recordings in: Subject01/S01_AEF_20131218_01_600Hz_notch +sFiles01 = bst_process('CallProcess', 'process_select_files_data', [], [], ... + 'subjectname', SubjectName, ... + 'condition', 'S01_AEF_20131218_01_600Hz_notch', ... + 'includebad', 0); +% Process: Select file comments with tag: deviant +sFilesAvgDeviant01 = bst_process('CallProcess', 'process_select_tag', sFiles01, [], ... + 'tag', 'Avg: deviant', ... + 'search', 2, ... % Search the file comments + 'select', 1); % Select only the files with the tag + + +%% ===== HEAD MODEL ===== +disp([10 'BST> Head model' 10]); + +% Process: Generate BEM surfaces +bst_process('CallProcess', 'process_generate_bem', [], [], ... + 'subjectname', SubjectName, ... + 'nscalp', 1922, ... + 'nouter', 1922, ... + 'ninner', 1922, ... + 'thickness', 4, ... + 'method', 'brainstorm'); +% Process: Compute head model +bst_process('CallProcess', 'process_headmodel', sFilesAvgDeviant01, [], ... + 'sourcespace', 1, ... % Cortex surface + 'meg', 4, ... % OpenMEEG BEM + 'openmeeg', struct(... + 'BemSelect', [0, 0, 1], ... + 'BemCond', [0.33, 0.0165, 0.33], ... + 'BemNames', {{'Scalp', 'Skull', 'Brain'}}, ... + 'BemFiles', {{}}, ... + 'isAdjoint', 1, ... + 'isAdaptative', 1, ... + 'isSplit', 0, ... + 'SplitLength', 4000)); + + +%% ===== SOURCE ESTIMATION ===== +% coherent Maximum Entropy on the Mean (cMEM) +disp([10 'BST> Source estimation using cMEM' 10]); + +% Process: Compute sources: BEst +mem_option = be_pipelineoptions(be_main, 'cMEM'); +mem_option.optional = struct_copy_fields(mem_option.optional, ... + struct(... + 'TimeSegment', [0.05, 0.15], ... + 'BaselineType', {{'within-data'}}, ... + 'Baseline', [], ... + 'BaselineHistory', {{'within'}}, ... + 'BaselineSegment', [-0.1, 0], ... + 'groupAnalysis', 0, ... + 'display', 0)); +sAvgSrcMEM = bst_process('CallProcess', 'process_inverse_mem', sFilesAvgDeviant01, [], ... + 'comment', 'MEM', ... + 'mem', struct('MEMpaneloptions', mem_option), ... + 'sensortypes', 'MEG'); +% Process: Snapshot: Sources (one time) +bst_process('CallProcess', 'process_snapshot', sAvgSrcMEM, [], ... + 'target', 8, ... % Sources (one time) + 'modality', 1, ... % MEG (All) + 'orient', 1, ... % left + 'time', 83.3*1e-3, ... + 'threshold', 0, ... + 'Comment', 'Average Deviant (cMEM)'); + + +% wavelet Maximum Entropy on the Mean (wMEM) +disp([10 'BST> Source estimation using WMEM' 10]); + +% Process: Compute sources: BEst +wMEM_options = be_pipelineoptions(be_main, 'wMEM'); +wMEM_options.optional = struct_copy_fields(wMEM_options.optional, ... + struct(... + 'TimeSegment', [0.05, 0.15], ... + 'BaselineType', {{'within-data'}}, ... + 'Baseline', [], ... + 'BaselineHistory', {{'within'}}, ... + 'BaselineSegment', [-0.1, 0], ... + 'groupAnalysis', 0, ... + 'display', 0)); + +% 1. Localizing only scale 4: +wMEM_options.wavelet.selected_scales = [4]; +sAvgSrwMEM_scale4 = bst_process('CallProcess', 'process_inverse_mem', sFilesAvgDeviant01, [], ... + 'comment', 'MEM', ... + 'mem', struct( 'MEMpaneloptions', wMEM_options), ... + 'sensortypes', 'MEG'); +% Process: Snapshot: Sources (one time) +bst_process('CallProcess', 'process_snapshot', sAvgSrwMEM_scale4, [], ... + 'target', 8, ... % Sources (one time) + 'modality', 1, ... % MEG (All) + 'orient', 1, ... % left + 'time', 83.3*1e-3, ... + 'threshold', 0, ... + 'Comment', 'Average Deviant (wMEM - scale 4)'); + +% 2. Localizing only scale 5: +wMEM_options.wavelet.selected_scales = [5]; +sAvgSrwMEM_scale5 = bst_process('CallProcess', 'process_inverse_mem', sFilesAvgDeviant01, [], ... + 'comment', 'MEM', ... + 'mem', struct( 'MEMpaneloptions', wMEM_options), ... + 'sensortypes', 'MEG'); +% Process: Snapshot: Sources (one time) +bst_process('CallProcess', 'process_snapshot', sAvgSrwMEM_scale5, [], ... + 'target', 8, ... % Sources (one time) + 'modality', 1, ... % MEG (All) + 'orient', 1, ... % left + 'time', 83.3*1e-3, ... + 'threshold', 0, ... + 'Comment', 'Average Deviant (wMEM - scale 5 )'); + +% 3. Localizing all scales: +wMEM_options.wavelet.selected_scales = [1,2,3,4,5]; +sAvgSrwMEM_scaleAll = bst_process('CallProcess', 'process_inverse_mem', sFilesAvgDeviant01, [], ... + 'comment', 'MEM', ... + 'mem', struct( 'MEMpaneloptions', wMEM_options), ... + 'sensortypes', 'MEG'); +% Process: Snapshot: Sources (one time) +bst_process('CallProcess', 'process_snapshot', sAvgSrwMEM_scaleAll, [], ... + 'target', 8, ... % Sources (one time) + 'modality', 1, ... % MEG (All) + 'orient', 1, ... % left + 'time', 83.3*1e-3, ... + 'threshold', 0, ... + 'Comment', 'Average Deviant (wMEM - all scale)'); + + +%% ===== SAVE REPORT ===== +% Save and display report +ReportFile = bst_report('Save', []); +if ~isempty(reports_dir) && ~isempty(ReportFile) + bst_report('Export', ReportFile, reports_dir); +else + bst_report('Open', ReportFile); +end + +disp([10 'BST> tutorial_BEst: Done.' 10]); diff --git a/toolbox/script/tutorial_brain_fingerprint.m b/toolbox/script/tutorial_brain_fingerprint.m new file mode 100644 index 000000000..42c560d63 --- /dev/null +++ b/toolbox/script/tutorial_brain_fingerprint.m @@ -0,0 +1,339 @@ +function tutorial_brain_fingerprint(ProtocolNameOmega, reports_dir) +% TUTORIAL_BRAIN_FINGERPRINT: Script that reproduces the results of the online tutorial "Brain-fingerprint". +% +% CORRESPONDING ONLINE TUTORIAL: +% https://neuroimage.usc.edu/brainstorm/Tutorials/BrainFingerprint +% +% INPUTS: +% - ProtocolNameOmega : Name of the protocol created with all the data imported (TutorialOmega) +% - reports_dir : Directory where to save the execution report (instead of displaying it) +% +% @============================================================================= +% This function is part of the Brainstorm software: +% https://neuroimage.usc.edu/brainstorm +% +% Copyright (c) University of Southern California & McGill University +% This software is distributed under the terms of the GNU General Public License +% as published by the Free Software Foundation. Further details on the GPLv3 +% license can be found at http://www.gnu.org/copyleft/gpl.html. +% +% FOR RESEARCH PURPOSES ONLY. THE SOFTWARE IS PROVIDED "AS IS," AND THE +% UNIVERSITY OF SOUTHERN CALIFORNIA AND ITS COLLABORATORS DO NOT MAKE ANY +% WARRANTY, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO WARRANTIES OF +% MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE, NOR DO THEY ASSUME ANY +% LIABILITY OR RESPONSIBILITY FOR THE USE OF THIS SOFTWARE. +% +% For more information type "brainstorm license" at command prompt. +% =============================================================================@ +% +% Author: Jason da Silva Castanheira, Raymundo Cassani, 2024 + + +%% ===== CHECK PROTOCOL ===== +% Start brainstorm without the GUI +if ~brainstorm('status') + brainstorm nogui +end +% Output folder for reports +if (nargin < 2) || isempty(reports_dir) || ~isdir(reports_dir) + reports_dir = []; +end +% You have to specify the folder in which the tutorial dataset is unzipped +if (nargin < 1) || isempty(ProtocolNameOmega) + ProtocolNameOmega = 'TutorialOmega'; +end + + +%% ===== BRAIN-FINGERPRINT PARAMETERS ===== +% Subjects +SubjectNames = {'sub-0002', 'sub-0003', 'sub-0004', 'sub-0006', 'sub-0007'}; +nSubjects = length(SubjectNames); +% Frequency range +LowerFreq = 4; % Hz, inclusive +UpperFreq = 150; % Hz, exclusive +% Cortical parcellation (atlas) + % Note we downsample the cortical surface using an atlas for computation efficiency +AtlasName = 'Destrieux'; +AtlasScouts = {'G_Ins_lg_and_S_cent_ins L', 'G_Ins_lg_and_S_cent_ins R', 'G_and_S_cingul-Ant L', 'G_and_S_cingul-Ant R', 'G_and_S_cingul-Mid-Ant L', 'G_and_S_cingul-Mid-Ant R', 'G_and_S_cingul-Mid-Post L', 'G_and_S_cingul-Mid-Post R', 'G_and_S_frontomargin L', 'G_and_S_frontomargin R', 'G_and_S_occipital_inf L', 'G_and_S_occipital_inf R', 'G_and_S_paracentral L', 'G_and_S_paracentral R', 'G_and_S_subcentral L', 'G_and_S_subcentral R', 'G_and_S_transv_frontopol L', 'G_and_S_transv_frontopol R', 'G_cingul-Post-dorsal L', 'G_cingul-Post-dorsal R', 'G_cingul-Post-ventral L', 'G_cingul-Post-ventral R', 'G_cuneus L', 'G_cuneus R', 'G_front_inf-Opercular L', 'G_front_inf-Opercular R', 'G_front_inf-Orbital L', 'G_front_inf-Orbital R', 'G_front_inf-Triangul L', 'G_front_inf-Triangul R', 'G_front_middle L', 'G_front_middle R', 'G_front_sup L', 'G_front_sup R', 'G_insular_short L', 'G_insular_short R', 'G_oc-temp_lat-fusifor L', 'G_oc-temp_lat-fusifor R', 'G_oc-temp_med-Lingual L', 'G_oc-temp_med-Lingual R', 'G_oc-temp_med-Parahip L', 'G_oc-temp_med-Parahip R', 'G_occipital_middle L', 'G_occipital_middle R', 'G_occipital_sup L', 'G_occipital_sup R', 'G_orbital L', 'G_orbital R', 'G_pariet_inf-Angular L', 'G_pariet_inf-Angular R', 'G_pariet_inf-Supramar L', 'G_pariet_inf-Supramar R', 'G_parietal_sup L', 'G_parietal_sup R', 'G_postcentral L', 'G_postcentral R', 'G_precentral L', 'G_precentral R', 'G_precuneus L', 'G_precuneus R', 'G_rectus L', 'G_rectus R', 'G_subcallosal L', 'G_subcallosal R', 'G_temp_sup-G_T_transv L', 'G_temp_sup-G_T_transv R', 'G_temp_sup-Lateral L', 'G_temp_sup-Lateral R', 'G_temp_sup-Plan_polar L', 'G_temp_sup-Plan_polar R', 'G_temp_sup-Plan_tempo L', 'G_temp_sup-Plan_tempo R', 'G_temporal_inf L', 'G_temporal_inf R', 'G_temporal_middle L', 'G_temporal_middle R', 'Lat_Fis-ant-Horizont L', 'Lat_Fis-ant-Horizont R', 'Lat_Fis-ant-Vertical L', 'Lat_Fis-ant-Vertical R', 'Lat_Fis-post L', 'Lat_Fis-post R', 'Pole_occipital L', 'Pole_occipital R', 'Pole_temporal L', 'Pole_temporal R', 'S_calcarine L', 'S_calcarine R', 'S_central L', 'S_central R', 'S_cingul-Marginalis L', 'S_cingul-Marginalis R', 'S_circular_insula_ant L', 'S_circular_insula_ant R', 'S_circular_insula_inf L', 'S_circular_insula_inf R', 'S_circular_insula_sup L', 'S_circular_insula_sup R', 'S_collat_transv_ant L', 'S_collat_transv_ant R', 'S_collat_transv_post L', 'S_collat_transv_post R', 'S_front_inf L', 'S_front_inf R', 'S_front_middle L', 'S_front_middle R', 'S_front_sup L', 'S_front_sup R', 'S_interm_prim-Jensen L', 'S_interm_prim-Jensen R', 'S_intrapariet_and_P_trans L', 'S_intrapariet_and_P_trans R', 'S_oc-temp_lat L', 'S_oc-temp_lat R', 'S_oc-temp_med_and_Lingual L', 'S_oc-temp_med_and_Lingual R', 'S_oc_middle_and_Lunatus L', 'S_oc_middle_and_Lunatus R', 'S_oc_sup_and_transversal L', 'S_oc_sup_and_transversal R', 'S_occipital_ant L', 'S_occipital_ant R', 'S_orbital-H_Shaped L', 'S_orbital-H_Shaped R', 'S_orbital_lateral L', 'S_orbital_lateral R', 'S_orbital_med-olfact L', 'S_orbital_med-olfact R', 'S_parieto_occipital L', 'S_parieto_occipital R', 'S_pericallosal L', 'S_pericallosal R', 'S_postcentral L', 'S_postcentral R', 'S_precentral-inf-part L', 'S_precentral-inf-part R', 'S_precentral-sup-part L', 'S_precentral-sup-part R', 'S_suborbital L', 'S_suborbital R', 'S_subparietal L', 'S_subparietal R', 'S_temporal_inf L', 'S_temporal_inf R', 'S_temporal_sup L', 'S_temporal_sup R', 'S_temporal_transverse L', 'S_temporal_transverse R'}; +% Bands for ICC cortex plot +BandNames = {'theta', 'alpha', 'beta', 'gamma', 'highgamma'}; +BandLowerFreqs = [ 4, 8, 13, 30, 50]; % Hz, inclusive +BandUpperFreqs = [ 8, 13, 30, 50, 150]; % Hz, exclusive + +%% ===== VERIFY REQUIRED PROTOCOL ===== +% Check Protocol that it exists +iProtocolOmega = bst_get('Protocol', ProtocolNameOmega); +if isempty(iProtocolOmega) + error(['Unknown protocol: ' ProtocolNameOmega]); +end +% Select input protocol +gui_brainstorm('SetCurrentProtocol', iProtocolOmega); + +% Verify the Subjects +ProtocolSubjects = bst_get('ProtocolSubjects'); +ProtocolSubjectNames = {ProtocolSubjects.Subject.Name}; +if ~all(ismember(SubjectNames, ProtocolSubjectNames)) + error(['All requested subjects must be present in the ' ProtocolNameOmega ' protocol.']); +end + + +%% ===== FIND FILES ===== +bst_report('Start'); + +% Get raw and source files for each Subject +sRawDataFiles = []; +sSourcesFiles = []; + +for iSubject = 1 : nSubjects + % Process: Select data files in: sub-000*/* + sFiles = bst_process('CallProcess', 'process_select_files_data', [], [], ... + 'subjectname', SubjectNames{iSubject}, ... + 'condition', '', ... + 'tag', '', ... + 'includebad', 0, ... + 'includeintra', 0, ... + 'includecommon', 0); + sRawDataFiles = [sRawDataFiles, sFiles]; + + % Process: Select results files in: sub-000*/* + sFiles = bst_process('CallProcess', 'process_select_files_results', [], [], ... + 'subjectname', SubjectNames{iSubject}, ... + 'condition', '', ... + 'tag', '', ... + 'includebad', 0, ... + 'includeintra', 0, ... + 'includecommon', 0); + sSourcesFiles = [sSourcesFiles, sFiles]; +end + + +%% Compute PSD for all ROIs of an atlas +% For each subject, their recordings are split in two parts (data segments) +% these two data segments will be used to create PSDs +% These two PSDs are the features which will define the brain-fingerprint + +nSegments = 2; % Training and Validation +timeIni = zeros(nSubjects, nSegments); +timeFin = zeros(nSubjects, nSegments); +sPsdFiles = repmat(db_template('processfile'), nSubjects, nSegments); +for iSubject = 1 : nSubjects + sData = load(file_fullpath(sRawDataFiles(iSubject).FileName), 'Time'); + halfTime = diff(sData.Time) / 2; + % First segment + timeIni(iSubject, 1) = sData.Time(1) + 30; + timeFin(iSubject, 1) = halfTime - 30; + % Second segment + timeIni(iSubject, 2) = halfTime + 30; + timeFin(iSubject, 2) = sData.Time(2) - 30; + % Compute PSD + for iSegment = 1 : size(timeIni, 2) + % Process: Power spectrum density (Welch) + sPsdFiles(iSubject, iSegment) = bst_process('CallProcess', 'process_psd', sSourcesFiles(iSubject).FileName, [], ... + 'timewindow', [timeIni(iSubject, iSegment), timeFin(iSubject, iSegment)], ... + 'win_length', 2, ... + 'win_overlap', 50, ... + 'units', 'physical', ... % Physical: U2/Hz + 'clusters', {AtlasName, AtlasScouts}, ... + 'scoutfunc', 1, ... % Mean + 'win_std', 0, ... + 'edit', struct(... + 'Comment', 'Scouts,Power', ... + 'TimeBands', [], ... + 'Freqs', [], ... + 'ClusterFuncTime', 'before', ... + 'Measure', 'power', ... + 'Output', 'all', ... + 'SaveKernel', 0)); + end +end + + +%% ===== VECTORIZE PSD DATA FOR FINGERPRINTING ===== +% Find size of requested PSD data +sPsdMat = in_bst_timefreq(sPsdFiles(1,1).FileName, 0, 'RowNames', 'Freqs'); +Freqs = sPsdMat.Freqs; +ixLowerFreq = find(Freqs >= LowerFreq, 1, 'first'); +ixUpperFreq = find(Freqs < UpperFreq, 1, 'last'); +Freqs = sPsdMat.Freqs(ixLowerFreq:ixUpperFreq); +nFreqs = (ixUpperFreq - ixLowerFreq + 1); +ScoutNames = sPsdMat.RowNames; +nScouts = length(ScoutNames); + +% Subject vectors +trainingVectors = zeros(iSubject, nScouts * nFreqs); +validationVectors = zeros(iSubject, nScouts * nFreqs); + +% Vectorize Scout PSDs +for iSubject = 1 : nSubjects + for iSegment = 1 : nSegments + sPsdMat = in_bst_timefreq(sPsdFiles(iSubject, iSegment).FileName, 0, 'TF'); + if iSegment == 1 + trainingVectors(iSubject,:) = reshape(squeeze(sPsdMat.TF(:, :, ixLowerFreq:ixUpperFreq)), 1, []); + elseif iSegment == 2 + validationVectors(iSubject,:) = reshape(squeeze(sPsdMat.TF(:, :, ixLowerFreq:ixUpperFreq)), 1, []); + end + end +end + + +%% ==== SIMILARITY AND DIFFERENTIABILITY ===== +% Subject similarity matrix +% It is generally symmetric although it does not necessarily have to be! +SubjectCorrMatrix= corr(trainingVectors', validationVectors'); +% Differentiability +diff_1= (diag(SubjectCorrMatrix)-mean(SubjectCorrMatrix,1) )/ std(SubjectCorrMatrix,0,1); % along columns +diff_2= (diag(SubjectCorrMatrix)-mean(SubjectCorrMatrix,2)')/ std(SubjectCorrMatrix,0,2)'; % along rows +% Differentiability across rows and columns are generally strongly correlated +% Take the mean for a summary statistic per participant +Differentiability = (diff_1 + diff_2)/2; % Mean differentiability derived from rows and columns + +% IntrClass Correlations (ICC) +zTraining = (trainingVectors - mean(trainingVectors, 2)) ./ std(trainingVectors, 0, 2); +zValidation= (validationVectors - mean(validationVectors, 2)) ./ std(validationVectors, 0, 2); +df_b = nSubjects-1; % degrees of freedom for s2 +df_w = nSubjects*(nSegments-1); % degrees of freedom for r +nFeatures = nScouts * nFreqs; +icc = zeros(1, nFeatures); +for iFeature = 1 : nFeatures + x = [zTraining(:, iFeature), zValidation(:, iFeature)]; + x_w_mean = mean(x, 2); + x_g_mean = mean(x(:)); + ss_t = sum((x - x_g_mean).^2, 'all'); + ss_w = sum((x - x_w_mean).^2, 'all'); + ss_b = ss_t - ss_w; + ms_b = ss_b / df_b; + ms_w = ss_w / df_w; + icc(iFeature) = (ms_b - ms_w) ./ (ms_b + ((nSegments-1).*ms_w)); +end +iccFreqsScouts = reshape(icc, [nFreqs, nScouts]); +% Compute Scout ICC for frequency bands +nBands = length(BandNames); +iccBands = zeros(nScouts, nBands); +for iBand = 1 : nBands + ixLowerFreq = find(Freqs >= BandLowerFreqs(iBand), 1, 'first'); + ixUpperFreq = find(Freqs < BandUpperFreqs(iBand), 1, 'last'); + iccBands(: ,iBand) = mean(iccFreqsScouts(ixLowerFreq:ixUpperFreq, :), 1)'; +end + +%% ===== SAVE OUTCOME IN BRAINSTORM DATABASE ===== +% Retrieve 'Group_analysis' subject +sNormSubj = bst_get('NormalizedSubject'); +% Study to save files +[sOutputStudy, iOutputStudy] = bst_get('StudyWithCondition', bst_fullfile(sNormSubj.Name, bst_get('DirAnalysisIntra'))); + +% Similarity matrix +sSimilarityMat = db_template('timefreqmat'); +% Reshape: [nA x nB x nTime x nFreq] => [nA*nB x nTime x nFreq] +sSimilarityMat.TF = reshape(SubjectCorrMatrix, [], 1, 1); +sSimilarityMat.Comment = 'Similarity matrix'; +sSimilarityMat.DataType = 'matrix'; +sSimilarityMat.Time = [0, 1]; +sSimilarityMat.RefRowNames = cellfun(@(x) ['Train ', x], SubjectNames, 'UniformOutput', false); +sSimilarityMat.RowNames = cellfun(@(x) ['Validation ', x], SubjectNames, 'UniformOutput', false); +% Output filename +SimilarityFile = bst_process('GetNewFilename', bst_fileparts(sOutputStudy.FileName), 'timefreq_connectn_corr'); +% Save file +bst_save(SimilarityFile, sSimilarityMat, 'v6'); +% Add file to database structure +db_add_data(iOutputStudy, SimilarityFile, sSimilarityMat); + +% Differentiability matrix +sDiffMat = db_template('matrixmat'); +sDiffMat.Value = Differentiability; +sDiffMat.Comment = 'Differentiability'; +sDiffMat.Time = [0, 1]; +sDiffMat.Description = SubjectNames; +% Output filename +DiffFile = bst_process('GetNewFilename', bst_fileparts(sOutputStudy.FileName), 'matrix'); +% Save file +bst_save(DiffFile, sDiffMat, 'v6'); +% Add file to database structure +db_add_data(iOutputStudy, DiffFile, sDiffMat); + +% Save band ICC values (scout level) +for iBand = 1 : nBands + sIccBandMat = db_template('matrixmat'); + sIccBandMat.Value = iccBands(:, iBand); + sIccBandMat.Comment = ['ICC_' BandNames{iBand}]; + sIccBandMat.Time = [0, 1]; + sIccBandMat.Description = ScoutNames; + % Output filename + IccBandFile = bst_process('GetNewFilename', bst_fileparts(sOutputStudy.FileName), 'matrix'); + % Save file + bst_save(IccBandFile, sIccBandMat, 'v6'); + % Add file to database structure + db_add_data(iOutputStudy, IccBandFile, sIccBandMat); +end + +% Cortex surface display for band ICC values +% Verify destination surface +sSubjectGroup = bst_get('Subject', sOutputStudy.BrainStormSubject); +sSurfFile = sSubjectGroup.Surface(sSubjectGroup.iCortex); +sSurfMat = in_tess_bst(sSurfFile.FileName); +iAtlas = find(ismember({sSurfMat.Atlas.Name}, AtlasName)); +if isempty(iAtlas) + error('Default surface in "%s" does not contain "%s" atlas', bst_get('NormalizedSubjectName'), AtlasName); +end +sAtlas = sSurfMat.Atlas(iAtlas); +if ~all(ismember({sAtlas.Scouts.Label}, ScoutNames)) + error('Atlas "%s" in "%s" does not contain the same scouts as ICC', AtlasName, bst_get('NormalizedSubjectName')); +end +% Create one full results file per band ICC values +IccBandFiles = {}; +for iBand = 1 : nBands + sIccBandMat = db_template('resultsmat'); + sIccBandMat.SurfaceFile = sSurfFile.FileName; + sIccBandMat.ImageGridAmp = nan(size(sSurfMat.Vertices, 1), 1); + sIccBandMat.Comment = ['ICC_' BandNames{iBand}]; + sIccBandMat.Time = [0, 1]; + % Assign ICC values to vertices in Scout + for ix = 1 : nScouts + iScout = find(ismember({sAtlas.Scouts.Label}, ScoutNames(ix))); + sIccBandMat.ImageGridAmp(sAtlas.Scouts(iScout).Vertices) = deal(iccBands(ix, iBand)); + end + % Output filename + IccBandFile = bst_process('GetNewFilename', bst_fileparts(sOutputStudy.FileName), 'results'); + IccBandFiles = [IccBandFiles, IccBandFile]; + % Save file + bst_save(IccBandFile, sIccBandMat, 'v6'); + % Add file to database structure + db_add_data(iOutputStudy, IccBandFile, sIccBandMat); +end + +% Reload database +db_reload_studies(iOutputStudy) + + +%% ===== SNAPSHOTS ===== +% Process: Snapshot: Similarity matrix +bst_process('CallProcess', 'process_snapshot', SimilarityFile, [], ... + 'type', 'connectimage', ... % Connectivity matrix + 'Comment', 'Similarity matrix'); + +% Process: Snapshot: Recordings time series +bst_process('CallProcess', 'process_snapshot', DiffFile, [], ... + 'type', 'data', ... % Recordings time series + 'time', 0, ... + 'Comment', 'Differentiability'); + +for iBand = 1 : nBands + % Process: Snapshot: Sources (one time) + bst_process('CallProcess', 'process_snapshot', IccBandFiles{iBand}, [], ... + 'type', 'sources', ... % Sources (one time) + 'orient', 1, ... % left + 'time', 0, ... + 'contact_time', [0, 0.1], ... + 'contact_nimage', 12, ... + 'threshold', 0, ... + 'surfsmooth', 30, ... + 'mni', [0, 0, 0], ... + 'Comment', ['ICC_' BandNames{iBand}]); +end + +% Save report +ReportFile = bst_report('Save', []); +if ~isempty(reports_dir) && ~isempty(ReportFile) + bst_report('Export', ReportFile, reports_dir); +else + bst_report('Open', ReportFile); +end diff --git a/toolbox/script/tutorial_coherence.m b/toolbox/script/tutorial_coherence.m index 1df191627..f52dcc9f3 100644 --- a/toolbox/script/tutorial_coherence.m +++ b/toolbox/script/tutorial_coherence.m @@ -40,10 +40,10 @@ function tutorial_coherence(tutorial_dir, reports_dir) % Subject name SubjectName = 'Subject01'; % Channel selection -emg_channel = 'EMGlft'; % Name of EMG channel -meg_sensor = 'MRC21'; % MEG sensor over the left motor-cortex (MRC21) +emg_channel = 'EMGlft'; % Name of EMG channel +meg_sensor = 'MRC21'; % MEG sensor over the left motor-cortex (MRC21) % Coherence parameters -cohmeasure = 'mscohere'; % Magnitude-squared Coherence|C|^2 = |Cxy|^2/(Cxx*Cyy) +cohmeasure = 'mscohere'; % Magnitude-squared Coherence |C|^2 = |Cxy|^2/(Cxx*Cyy) win_length = 0.5; % 500ms overlap = 50; % 50% maxfreq = 80; % 80Hz @@ -229,6 +229,10 @@ function tutorial_coherence(tutorial_dir, reports_dir) 'freq', [], ... 'baseline', 'all', ... 'blsensortypes', 'MEG'); +% Process: Uniform epoch time +sFilesEpochs = bst_process('CallProcess', 'process_stdtime', sFilesEpochs, [], ... + 'method', 'spline', ... % spline + 'overwrite', 1); % Process: Snapshot: Recordings time series bst_process('CallProcess', 'process_snapshot', sFilesEpochs(1), [], ... 'type', 'data', ... % Recordings time series @@ -244,14 +248,14 @@ function tutorial_coherence(tutorial_dir, reports_dir) %% ===== COHERENCE: EMG x MEG ===== -% Process: Magnitude-squared coherence: |C|^2 = |Cxy|^2/(Cxx*Cyy) +% Process: Coherence NxN [2023] sFileCoh1N = bst_process('CallProcess', 'process_cohere1', {sFilesEpochs.FileName}, [], ... 'timewindow', [], ... 'src_channel', emg_channel, ... 'dest_sensors', 'MEG', ... 'includebad', 0, ... 'removeevoked', 0, ... - 'cohmeasure', cohmeasure, ... % Magnitude-squared coherence: |C|^2 = |Cxy|^2/(Cxx*Cyy) + 'cohmeasure', cohmeasure, ... % Magnitude-squared coherence 'tfmeasure', 'stft', ... % Fourier transform 'tfedit', struct(... 'Comment', 'Complex', ... @@ -429,7 +433,7 @@ function tutorial_coherence(tutorial_dir, reports_dir) 'includebad', 0, ... 'includeintra', 0, ... 'includecommon', 0); - % Process: Magnitude-squared coherence + % Process: Coherence NxN [2023] sFileCoh1N = bst_process('CallProcess', 'process_cohere2', sFilesRecEmg, sFilesResMeg, ... 'timewindow', [], ... 'src_channel', emg_channel, ... @@ -460,7 +464,7 @@ function tutorial_coherence(tutorial_dir, reports_dir) 'timeres', 'none', ... % None 'avgwinlength', 1, ... 'avgwinoverlap', 50, ... - 'outputmode', 'avg'); % separately for each file + 'outputmode', 'avg'); % across combined files/epochs % Process: Add tag sFileCoh1N = bst_process('CallProcess', 'process_add_tag', sFileCoh1N, [], ... 'tag', sourceType, ... @@ -499,51 +503,51 @@ function tutorial_coherence(tutorial_dir, reports_dir) 'includecommon', 0); % Only performed for (surface)(Constrained) sourceType = '(surface)(Constr)'; -sFilesResSrfUnc = bst_process('CallProcess', 'process_select_files_results', [], [], ... +sFilesResSrfCon = bst_process('CallProcess', 'process_select_files_results', [], [], ... 'subjectname', SubjectName, ... 'condition', '', ... 'tag', sourceType, ... 'includebad', 0, ... 'includeintra', 0, ... 'includecommon', 0); - % Process: Magnitude-squared coherence - sFileCoh1N = bst_process('CallProcess', 'process_cohere2', sFilesRecEmg, sFilesResSrfUnc, ... - 'timewindow', [], ... - 'src_channel', emg_channel, ... - 'dest_scouts', {'Schaefer_100_17net', {'Background+FreeSurfer_Defined_Medial_Wall L', 'Background+FreeSurfer_Defined_Medial_Wall R', 'ContA_IPS_1 L', 'ContA_IPS_1 R', 'ContA_PFCl_1 L', 'ContA_PFCl_1 R', 'ContA_PFCl_2 L', 'ContA_PFCl_2 R', 'ContB_IPL_1 R', 'ContB_PFCld_1 R', 'ContB_PFClv_1 L', 'ContB_PFClv_1 R', 'ContB_Temp_1 R', 'ContC_Cingp_1 L', 'ContC_Cingp_1 R', 'ContC_pCun_1 L', 'ContC_pCun_1 R', 'ContC_pCun_2 L', 'DefaultA_IPL_1 R', 'DefaultA_PFCd_1 L', 'DefaultA_PFCd_1 R', 'DefaultA_PFCm_1 L', 'DefaultA_PFCm_1 R', 'DefaultA_pCunPCC_1 L', 'DefaultA_pCunPCC_1 R', 'DefaultB_IPL_1 L', 'DefaultB_PFCd_1 L', 'DefaultB_PFCd_1 R', 'DefaultB_PFCl_1 L', 'DefaultB_PFCv_1 L', 'DefaultB_PFCv_1 R', 'DefaultB_PFCv_2 L', 'DefaultB_PFCv_2 R', 'DefaultB_Temp_1 L', 'DefaultB_Temp_2 L', 'DefaultC_PHC_1 L', 'DefaultC_PHC_1 R', 'DefaultC_Rsp_1 L', 'DefaultC_Rsp_1 R', 'DorsAttnA_ParOcc_1 L', 'DorsAttnA_ParOcc_1 R', 'DorsAttnA_SPL_1 L', 'DorsAttnA_SPL_1 R', 'DorsAttnA_TempOcc_1 L', 'DorsAttnA_TempOcc_1 R', 'DorsAttnB_FEF_1 L', 'DorsAttnB_FEF_1 R', 'DorsAttnB_PostC_1 L', 'DorsAttnB_PostC_1 R', 'DorsAttnB_PostC_2 L', 'DorsAttnB_PostC_2 R', 'DorsAttnB_PostC_3 L', 'LimbicA_TempPole_1 L', 'LimbicA_TempPole_1 R', 'LimbicA_TempPole_2 L', 'LimbicB_OFC_1 L', 'LimbicB_OFC_1 R', 'SalVentAttnA_FrMed_1 L', 'SalVentAttnA_FrMed_1 R', 'SalVentAttnA_Ins_1 L', 'SalVentAttnA_Ins_1 R', 'SalVentAttnA_Ins_2 L', 'SalVentAttnA_ParMed_1 L', 'SalVentAttnA_ParMed_1 R', 'SalVentAttnA_ParOper_1 L', 'SalVentAttnA_ParOper_1 R', 'SalVentAttnB_IPL_1 R', 'SalVentAttnB_PFCl_1 L', 'SalVentAttnB_PFCl_1 R', 'SalVentAttnB_PFCmp_1 L', 'SalVentAttnB_PFCmp_1 R', 'SomMotA_1 L', 'SomMotA_1 R', 'SomMotA_2 L', 'SomMotA_2 R', 'SomMotA_3 R', 'SomMotA_4 R', 'SomMotB_Aud_1 L', 'SomMotB_Aud_1 R', 'SomMotB_Cent_1 L', 'SomMotB_Cent_1 R', 'SomMotB_S2_1 L', 'SomMotB_S2_1 R', 'SomMotB_S2_2 L', 'SomMotB_S2_2 R', 'TempPar_1 L', 'TempPar_1 R', 'TempPar_2 R', 'TempPar_3 R', 'VisCent_ExStr_1 L', 'VisCent_ExStr_1 R', 'VisCent_ExStr_2 L', 'VisCent_ExStr_2 R', 'VisCent_ExStr_3 L', 'VisCent_ExStr_3 R', 'VisCent_Striate_1 L', 'VisPeri_ExStrInf_1 L', 'VisPeri_ExStrInf_1 R', 'VisPeri_ExStrSup_1 L', 'VisPeri_ExStrSup_1 R', 'VisPeri_StriCal_1 L', 'VisPeri_StriCal_1 R'}}, ... - 'flatten', 0, ... - 'scouttime', 'before', ... % before connectivity metric - 'scoutfunc', 'mean', ... % Mean - 'scoutfuncaft', 'mean', ... % Mean - 'pcaedit', struct(... - 'Method', 'pcaa', ... - 'Baseline', [], ... - 'DataTimeWindow', [], ... - 'RemoveDcOffset', 'file'), ... - 'removeevoked', 0, ... - 'cohmeasure', cohmeasure, ... % Magnitude-squared coherence - 'tfmeasure', 'stft', ... % Fourier transform - 'tfedit', struct(... - 'Comment', 'Complex', ... - 'TimeBands', [], ... - 'Freqs', [], ... - 'StftWinLen', win_length, ... - 'StftWinOvr', overlap, ... - 'StftFrqMax', maxfreq, ... - 'ClusterFuncTime', 'none', ... - 'Measure', 'none', ... - 'Output', 'all', ... - 'SaveKernel', 0), ... - 'timeres', 'none', ... % None - 'avgwinlength', 1, ... - 'avgwinoverlap', 50, ... - 'outputmode', 'avg'); % separately for each file +% Process: Coherence NxN [2023] +sFileCoh1N = bst_process('CallProcess', 'process_cohere2', sFilesRecEmg, sFilesResSrfCon, ... + 'timewindow', [], ... + 'src_channel', emg_channel, ... + 'dest_scouts', {'Schaefer_100_17net', {'Background+FreeSurfer_Defined_Medial_Wall L', 'Background+FreeSurfer_Defined_Medial_Wall R', 'ContA_IPS_1 L', 'ContA_IPS_1 R', 'ContA_PFCl_1 L', 'ContA_PFCl_1 R', 'ContA_PFCl_2 L', 'ContA_PFCl_2 R', 'ContB_IPL_1 R', 'ContB_PFCld_1 R', 'ContB_PFClv_1 L', 'ContB_PFClv_1 R', 'ContB_Temp_1 R', 'ContC_Cingp_1 L', 'ContC_Cingp_1 R', 'ContC_pCun_1 L', 'ContC_pCun_1 R', 'ContC_pCun_2 L', 'DefaultA_IPL_1 R', 'DefaultA_PFCd_1 L', 'DefaultA_PFCd_1 R', 'DefaultA_PFCm_1 L', 'DefaultA_PFCm_1 R', 'DefaultA_pCunPCC_1 L', 'DefaultA_pCunPCC_1 R', 'DefaultB_IPL_1 L', 'DefaultB_PFCd_1 L', 'DefaultB_PFCd_1 R', 'DefaultB_PFCl_1 L', 'DefaultB_PFCv_1 L', 'DefaultB_PFCv_1 R', 'DefaultB_PFCv_2 L', 'DefaultB_PFCv_2 R', 'DefaultB_Temp_1 L', 'DefaultB_Temp_2 L', 'DefaultC_PHC_1 L', 'DefaultC_PHC_1 R', 'DefaultC_Rsp_1 L', 'DefaultC_Rsp_1 R', 'DorsAttnA_ParOcc_1 L', 'DorsAttnA_ParOcc_1 R', 'DorsAttnA_SPL_1 L', 'DorsAttnA_SPL_1 R', 'DorsAttnA_TempOcc_1 L', 'DorsAttnA_TempOcc_1 R', 'DorsAttnB_FEF_1 L', 'DorsAttnB_FEF_1 R', 'DorsAttnB_PostC_1 L', 'DorsAttnB_PostC_1 R', 'DorsAttnB_PostC_2 L', 'DorsAttnB_PostC_2 R', 'DorsAttnB_PostC_3 L', 'LimbicA_TempPole_1 L', 'LimbicA_TempPole_1 R', 'LimbicA_TempPole_2 L', 'LimbicB_OFC_1 L', 'LimbicB_OFC_1 R', 'SalVentAttnA_FrMed_1 L', 'SalVentAttnA_FrMed_1 R', 'SalVentAttnA_Ins_1 L', 'SalVentAttnA_Ins_1 R', 'SalVentAttnA_Ins_2 L', 'SalVentAttnA_ParMed_1 L', 'SalVentAttnA_ParMed_1 R', 'SalVentAttnA_ParOper_1 L', 'SalVentAttnA_ParOper_1 R', 'SalVentAttnB_IPL_1 R', 'SalVentAttnB_PFCl_1 L', 'SalVentAttnB_PFCl_1 R', 'SalVentAttnB_PFCmp_1 L', 'SalVentAttnB_PFCmp_1 R', 'SomMotA_1 L', 'SomMotA_1 R', 'SomMotA_2 L', 'SomMotA_2 R', 'SomMotA_3 R', 'SomMotA_4 R', 'SomMotB_Aud_1 L', 'SomMotB_Aud_1 R', 'SomMotB_Cent_1 L', 'SomMotB_Cent_1 R', 'SomMotB_S2_1 L', 'SomMotB_S2_1 R', 'SomMotB_S2_2 L', 'SomMotB_S2_2 R', 'TempPar_1 L', 'TempPar_1 R', 'TempPar_2 R', 'TempPar_3 R', 'VisCent_ExStr_1 L', 'VisCent_ExStr_1 R', 'VisCent_ExStr_2 L', 'VisCent_ExStr_2 R', 'VisCent_ExStr_3 L', 'VisCent_ExStr_3 R', 'VisCent_Striate_1 L', 'VisPeri_ExStrInf_1 L', 'VisPeri_ExStrInf_1 R', 'VisPeri_ExStrSup_1 L', 'VisPeri_ExStrSup_1 R', 'VisPeri_StriCal_1 L', 'VisPeri_StriCal_1 R'}}, ... + 'flatten', 0, ... + 'scouttime', 'before', ... % before connectivity metric + 'scoutfunc', 'mean', ... % Mean + 'scoutfuncaft', 'mean', ... % Mean + 'pcaedit', struct(... + 'Method', 'pcaa', ... + 'Baseline', [], ... + 'DataTimeWindow', [], ... + 'RemoveDcOffset', 'file'), ... + 'removeevoked', 0, ... + 'cohmeasure', cohmeasure, ... % Magnitude-squared coherence + 'tfmeasure', 'stft', ... % Fourier transform + 'tfedit', struct(... + 'Comment', 'Complex', ... + 'TimeBands', [], ... + 'Freqs', [], ... + 'StftWinLen', win_length, ... + 'StftWinOvr', overlap, ... + 'StftFrqMax', maxfreq, ... + 'ClusterFuncTime', 'none', ... + 'Measure', 'none', ... + 'Output', 'all', ... + 'SaveKernel', 0), ... + 'timeres', 'none', ... % None + 'avgwinlength', 1, ... + 'avgwinoverlap', 50, ... + 'outputmode', 'avg'); % across combined files/epochs % Process: Add tag sFileCoh1N = bst_process('CallProcess', 'process_add_tag', sFileCoh1N, [], ... 'tag', sourceType, ... 'output', 1); % Add to file name % Highlight scout of interest -bst_figures('SetSelectedRows', {'DorsAttnB_FEF_1 R', 'SomMotA_2 R', 'SomMotA_4 R'}); +bst_figures('SetSelectedRows', {'SomMotA_2 R', 'SomMotA_4 R'}); % Process: Snapshot: Frequency spectrum bst_process('CallProcess', 'process_snapshot', sFileCoh1N, [], ... 'target', 10, ... % Frequency spectrum @@ -556,6 +560,71 @@ function tutorial_coherence(tutorial_dir, reports_dir) 'Comment', ['MSC 14.65Hz,' sourceType, ' Before']); +%% ===== COHERENCE: SCOUTS x SCOUTS (BEFORE) ===== +% Only performed for (surface)(Constrained) +sourceType = '(surface)(Constr)'; +sFilesResSrfCon = bst_process('CallProcess', 'process_select_files_results', [], [], ... + 'subjectname', SubjectName, ... + 'condition', '', ... + 'tag', sourceType, ... + 'includebad', 0, ... + 'includeintra', 0, ... + 'includecommon', 0); +% Process: Coherence NxN [2023] +sFileCohNN = bst_process('CallProcess', 'process_cohere1n', sFilesResSrfCon, [], ... + 'timewindow', [], ... + 'scouts', {'Schaefer_100_17net', {'SomMotA_2 R', 'SomMotA_4 R', 'VisCent_ExStr_2 L'}}, ... + 'flatten', 0, ... + 'scouttime', 'before', ... % before + 'scoutfunc', 'mean', ... % Mean + 'scoutfuncaft', 'mean', ... % Mean + 'pcaedit', struct(... + 'Method', 'pcaa', ... + 'Baseline', [], ... + 'DataTimeWindow', [], ... + 'RemoveDcOffset', 'file'), ... + 'removeevoked', 0, ... + 'cohmeasure', 'mscohere', ... % Magnitude-squared coherence + 'tfmeasure', 'stft', ... % Fourier transform + 'tfedit', struct(... + 'Comment', 'Complex', ... + 'TimeBands', [], ... + 'Freqs', [], ... + 'StftWinLen', 0.5, ... + 'StftWinOvr', 50, ... + 'StftFrqMax', 80, ... + 'ClusterFuncTime', 'none', ... + 'Measure', 'none', ... + 'Output', 'all', ... + 'SaveKernel', 0), ... + 'timeres', 'none', ... % None + 'avgwinlength', 1, ... + 'avgwinoverlap', 50, ... + 'outputmode', 'avg'); % across combined files/epochs +% Process: Add tag +sFileCohNN = bst_process('CallProcess', 'process_add_tag', sFileCohNN, [], ... + 'tag', sourceType, ... + 'output', 1); % Add to file name +% Highlight scout of interest +bst_figures('SetSelectedRows', {'SomMotA_2 R', 'SomMotA_4 R'}); +% Process: Snapshot: Frequency spectrum +bst_process('CallProcess', 'process_snapshot', sFileCohNN, [], ... + 'target', 10, ... % Frequency spectrum + 'freq', 14.65, ... + 'Comment', ['MSC ,' sourceType, ' Before']); +% Process: Snapshot: Connectivity matrix +bst_process('CallProcess', 'process_snapshot', sFileCohNN, [], ... + 'type', 'connectimage', ... % Connectivity matrix + 'freq', 14.65, ... + 'Comment', ['MSC 14.65Hz,' sourceType, ' Before']); +% Process: Snapshot: Connectivity graph +bst_process('CallProcess', 'process_snapshot', sFileCohNN, [], ... + 'type', 'connectgraph', ... % Connectivity graph + 'freq', 14.65, ... + 'threshold', 0, ... + 'Comment', ['MSC 14.65Hz,' sourceType, ' Before']); + + %% ===== SAVE REPORT ===== % Save and display report ReportFile = bst_report('Save', []); diff --git a/toolbox/script/tutorial_connectivity.m b/toolbox/script/tutorial_connectivity.m index 81eb09e85..d7abc4065 100644 --- a/toolbox/script/tutorial_connectivity.m +++ b/toolbox/script/tutorial_connectivity.m @@ -267,30 +267,34 @@ function tutorial_connectivity(reports_dir) %% ===== PHASE LOCKING VALUE ===== -% Process: Phase locking value -sFiles = bst_process('CallProcess', 'process_plv1n', sFileSim, [], ... - 'timewindow', [], ... - 'plvmethod', 'plv', ... % Phase locking value - 'plvmeasure', 2, ... % Magnitude - 'tfmeasure', 'hilbert', ... % Hilbert transform - 'tfedit', struct(... - 'Comment', 'Complex', ... - 'TimeBands', [], ... - 'Freqs', {{'delta', '2, 4', 'mean'; 'theta', '5, 7', 'mean'; 'alpha', '8, 12', 'mean'; 'beta', '15, 29', 'mean'; 'gamma1', '30, 59', 'mean'}}, ... - 'ClusterFuncTime', 'none', ... - 'Measure', 'none', ... - 'Output', 'all', ... - 'SaveKernel', 0), ... - 'timeres', 'none', ... % None - 'avgwinlength', 1, ... - 'avgwinoverlap', 50, ... - 'outputmode', 'input'); % separately for each file - -% Process: Snapshot: Frequency spectrum -bst_process('CallProcess', 'process_snapshot', sFiles, [], ... - 'type', 'spectrum', ... % Frequency spectrum - 'Comment', 'Phase locking value NxN'); - +plv_variants = {'plv', ... % Phase locking value + 'ciplv', ... % Lagged phase synchronization / Corrected imaginary PLV + 'wpli'}; % Weighted phase lag index +for ix = 1 : length(plv_variants) + % Process: Phase locking value + sFiles = bst_process('CallProcess', 'process_plv1n', sFileSim, [], ... + 'timewindow', [], ... + 'plvmethod', plv_variants{ix}, ... + 'plvmeasure', 2, ... % Magnitude + 'tfmeasure', 'hilbert', ... % Hilbert transform + 'tfedit', struct(... + 'Comment', 'Complex', ... + 'TimeBands', [], ... + 'Freqs', {{'delta', '2, 4', 'mean'; 'theta', '5, 7', 'mean'; 'alpha', '8, 12', 'mean'; 'beta', '15, 29', 'mean'; 'gamma1', '30, 59', 'mean'}}, ... + 'ClusterFuncTime', 'none', ... + 'Measure', 'none', ... + 'Output', 'all', ... + 'SaveKernel', 0), ... + 'timeres', 'none', ... % None + 'avgwinlength', 1, ... + 'avgwinoverlap', 50, ... + 'outputmode', 'input'); % separately for each file + + % Process: Snapshot: Frequency spectrum + bst_process('CallProcess', 'process_snapshot', sFiles, [], ... + 'type', 'spectrum', ... % Frequency spectrum + 'Comment', ['Phase locking value (' plv_variants{ix} ') NxN']); +end %% ===== PHASE TRANSFER ENTROPY ===== % Process: Phase Transfer Entropy NxN diff --git a/toolbox/script/tutorial_dba.m b/toolbox/script/tutorial_dba.m new file mode 100644 index 000000000..9921f3578 --- /dev/null +++ b/toolbox/script/tutorial_dba.m @@ -0,0 +1,294 @@ +function tutorial_dba(zip_file, reports_dir) +% TUTORIAL_DBA: script that runs the Brainstorm deep brain activity (DBA) tutorial. +% https://neuroimage.usc.edu/brainstorm/Tutorials/DeepAtlas +% +% INPUTS: +% - zip_file : Full path to the TutorialDba.zip file +% - reports_dir : Directory where to save the execution report (instead of displaying it) +% +% @============================================================================= +% This function is part of the Brainstorm software: +% https://neuroimage.usc.edu/brainstorm +% +% Copyright (c) University of Southern California & McGill University +% This software is distributed under the terms of the GNU General Public License +% as published by the Free Software Foundation. Further details on the GPLv3 +% license can be found at http://www.gnu.org/copyleft/gpl.html. +% +% FOR RESEARCH PURPOSES ONLY. THE SOFTWARE IS PROVIDED "AS IS," AND THE +% UNIVERSITY OF SOUTHERN CALIFORNIA AND ITS COLLABORATORS DO NOT MAKE ANY +% WARRANTY, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO WARRANTIES OF +% MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE, NOR DO THEY ASSUME ANY +% LIABILITY OR RESPONSIBILITY FOR THE USE OF THIS SOFTWARE. +% +% For more information type "brainstorm license" at command prompt. +% =============================================================================@ +% +% Author: Raymundo Cassani, 2024 + +% Output folder for reports +if (nargin < 2) || isempty(reports_dir) || ~exist(reports_dir, 'dir') + reports_dir = []; +end +% You have to specify the folder in which the tutorial dataset is unzipped +if (nargin == 0) || isempty(zip_file) || ~file_exist(zip_file) + error('The first argument must be the full path to the dataset zip file.'); +end + +%% ===== MIXED MODEL PARAMETERS ===== +% Cortex name +cortexComment = 'cortex_15002V'; +% Surface with subcortical segmentation (ASEG) +subCortexComment = 'aseg atlas'; +% Subcortical structures to keep from ASEG +subCortexKeep = {'Amygdala L', 'Amygdala R', 'Hippocampus L', 'Hippocampus R', 'Thalamus L', 'Thalamus R'}; +% Mixed cortex name +mixedComment = 'cortex_mixed' ; + +% Mixed model structures: Cortex + Subcortical structures +% Table with structures, and their locations and orientations constraints +% Location constraint : 'Surface', 'Volume', 'Deep brain'*, 'Exclude' +% Orientation constraint: 'Constrained', 'Unconstrained', 'Loose' +% * If 'Deep brain' is used as location constraint the location and orientation constraints +% will be set automatically according to the scout name. +% +% 'ScoutName' , 'Location' , 'Orientation' +mixedStructs = {'Amygdala L' , 'Deep brain', ''; ... % Deep brain --> Volume , Unconstrained + 'Amygdala R' , 'Deep brain', ''; ... % Deep brain --> Volume , Unconstrained + 'Hippocampus L', 'Deep brain', ''; ... % Deep brain --> Surface, Constrained + 'Hippocampus R', 'Deep brain', ''; ... % Deep brain --> Surface, Constrained + 'Thalamus L' , 'Deep brain', ''; ... % Deep brain --> Volume, Unconstrained + 'Thalamus R' , 'Deep brain', ''; ... % Deep brain --> Volume, Unconstrained + 'Cortex L' , 'Deep brain', ''; ... % Deep brain --> Surface, Constrained + 'Cortex R' , 'Deep brain', ''}; % Deep brain --> Surface, Constrained + + +%% ===== LOAD PROTOCOL ===== +% Start brainstorm without the GUI +if ~brainstorm('status') + brainstorm nogui +end +% Check Brainstorm mode +if bst_get('GuiLevel') < 0 + error('For the moment the tutorial "tutorial_dba" is not supported on Brainstorm server mode.'); +end +ProtocolName = 'TutorialDba'; +[~, fBase] = bst_fileparts(zip_file); +if ~strcmpi(fBase, ProtocolName) + error('Incorrect .zip file.'); +end +% Delete existing protocol +gui_brainstorm('DeleteProtocol', ProtocolName); +% Import protocol from zip file +import_protocol(zip_file); +% Start a new report +bst_report('Start'); + + +%% ===== SELECT DEEP STRUCTURES ===== +% Get Subject for Default Anatomy +sSubject = bst_get('Subject', bst_get('DirDefaultSubject')); +% Find subcortical atlas, and downsize it +iAseg = find(strcmpi(subCortexComment, {sSubject.Surface.Comment})); +newAsegFile = tess_downsize(sSubject.Surface(iAseg).FileName, 15000, 'reducepatch'); +% Create atlas with only selected structures +panel_scout('SetCurrentSurface', newAsegFile); +sScouts = panel_scout('GetScouts'); +[~, iScouts] = ismember(subCortexKeep, {sScouts.Label}); +panel_scout('SetSelectedScouts', iScouts); +newAsegFile = panel_scout('NewSurface', 1); +% Find cortex +sSubject = bst_get('Subject', bst_get('DirDefaultSubject')); +iCortex = find(strcmpi(cortexComment, {sSubject.Surface.Comment})); +% Merge cortex with selected DBA structures +mixedFile = tess_concatenate({sSubject.Surface(iCortex).FileName, newAsegFile}, mixedComment, 'Cortex'); +% Atlas with structures +atlasName = 'Structures'; +% Display mixed cortex +hFigMix = view_surface(mixedFile); +[~, sSurf] = panel_scout('GetScouts'); +iAtlas = find(strcmpi(atlasName, {sSurf.Atlas.Name})); +panel_scout('SetCurrentAtlas', iAtlas, 1); +panel_surface('SelectHemispheres', 'struct'); +bst_report('Snapshot', hFigMix, mixedFile, 'Mix cortex: Cortex + Subcortical structures'); +pause(1); +% Unload everything +bst_memory('UnloadAll', 'Forced'); + + +%% ===== LOCATIONS AND ORIENTATIONS CONSTRAINTS ===== +% Select atlas with structures +panel_scout('SetCurrentSurface', mixedFile); +[~, sSurf] = panel_scout('GetScouts'); +iAtlas = find(strcmpi(atlasName, {sSurf.Atlas.Name})); +panel_scout('SetCurrentAtlas', iAtlas, 1); +% Create source model atlas +panel_scout('CreateAtlasInverse'); +% Set modeling options +sScouts = panel_scout('GetScouts'); +% Set location and orientation constraints +for iScout = 1 : length(sScouts) + % Select this scout + iRow = find(ismember(sScouts(iScout).Label, mixedStructs(:,1))); + % Set location constraint + panel_scout('SetLocationConstraint', iScout, mixedStructs{iRow,2}); + % Set orientation constraint + panel_scout('SetOrientationConstraint', iScout, mixedStructs{iRow,3}); +end +% Unload everything +bst_memory('UnloadAll', 'Forced'); + + +%% ===== SOURCE ESTIMATION ===== +% Find all recordings files for all Subjects, except 'Empty_Subject' +% Process: Select data files in: */*/Trial (#1) +sRecFiles = bst_process('CallProcess', 'process_select_files_data', [], [], ... + 'subjectname', 'All', ... + 'condition', '', ... + 'tag', '', ... + 'includebad', 0, ... + 'includeintra', 0, ... + 'includecommon', 0); +iDelete = strcmpi('Empty_Subject', {sRecFiles.SubjectName}); +sRecFiles(iDelete) = []; + +% Process: Compute head model +bst_process('CallProcess', 'process_headmodel', sRecFiles, [], ... + 'comment', '', ... + 'sourcespace', 3, ... + 'meg', 3); % Overlapping spheres + +% Display surface and volume grids +sStudy = bst_get('AnyFile', sRecFiles(1).FileName); +headmodelFile = sStudy.HeadModel.FileName; +hFigSrfGrid = view_gridloc(file_fullpath(headmodelFile), 'S'); +hFigVolGrid = view_gridloc(file_fullpath(headmodelFile), 'V'); +figure_3d('SetStandardView', hFigSrfGrid, 'top'); +figure_3d('SetStandardView', hFigVolGrid, 'top'); +bst_report('Snapshot', hFigSrfGrid, headmodelFile, 'Mix head model, surface grid'); +bst_report('Snapshot', hFigVolGrid, headmodelFile, 'Mix head model, volume grid'); +pause(1); +close([hFigSrfGrid, hFigVolGrid]); + +% Minimum norm options +InverseOptions = struct(... + 'Comment', 'MN: MEG', ... + 'InverseMethod', 'minnorm', ... + 'InverseMeasure', 'amplitude', ... + 'SourceOrient', [], ... + 'Loose', 0.2, ... + 'UseDepth', 1, ... + 'WeightExp', 0.5, ... + 'WeightLimit', 10, ... + 'NoiseMethod', 'reg', ... + 'NoiseReg', 0.1, ... + 'SnrMethod', 'fixed', ... + 'SnrRms', 1e-6, ... + 'SnrFixed', 3, ... + 'ComputeKernel', 1, ... + 'DataTypes', {{'MEG'}}); + +% Process: Compute sources [2018] +sSrcFiles = bst_process('CallProcess', 'process_inverse_2018', sRecFiles, [], ... + 'output', 1, ... % Kernel only: one per file + 'inverse', InverseOptions); + +% Display sources +hSrcFig = view_surface_data([], sSrcFiles(1).FileName); +panel_time('SetCurrentTime', 4.897); +bst_report('Snapshot', hSrcFig, sSrcFiles(1).FileName); +panel_surface('SelectHemispheres', 'struct'); +bst_report('Snapshot', hSrcFig, sSrcFiles(1).FileName); +pause(1); + +% Unload everything +bst_memory('UnloadAll', 'Forced'); + + +%% ===== COMPUTE STATISTICS ===== +% Process: MEAN: [all], abs +sSrcAvgFiles = bst_process('CallProcess', 'process_average_time', sSrcFiles, [], ... + 'timewindow', [], ... + 'avg_func', 'mean', ... % Arithmetic average: mean(x) + 'overwrite', 0, ... + 'source_abs', 1); +% Split average source files by condition +% YF = 'Eyes closed' +iYF = strcmpi('YF', {sSrcAvgFiles.Condition}); +sYfSrcAvgFiles = sSrcAvgFiles(iYF); +iYO = strcmpi('YO', {sSrcAvgFiles.Condition}); +sYoSrcAvgFiles = sSrcAvgFiles(iYO); +% Process: Perm t-test equal [all] H0:(A=B), H1:(A<>B) +sTestFile = bst_process('CallProcess', 'process_test_permutation2', sYfSrcAvgFiles, sYoSrcAvgFiles, ... + 'timewindow', [], ... + 'scoutsel', {}, ... + 'scoutfunc', 1, ... % Mean + 'isnorm', 0, ... + 'avgtime', 0, ... + 'iszerobad', 0, ... + 'Comment', '', ... + 'test_type', 'ttest_equal', ... % Student's t-test (equal variance) t = (mean(A)-mean(B)) / (Sx * sqrt(1/nA + 1/nB))Sx = sqrt(((nA-1)*var(A) + (nB-1)*var(B)) / (nA+nB-2)) + 'randomizations', 1000, ... + 'tail', 'two'); % Two-tailed + +% Set display properties +StatThreshOptions = bst_get('StatThreshOptions'); +StatThreshOptions.pThreshold = 0.01; +StatThreshOptions.Correction = 'fdr'; +StatThreshOptions.Control = [1 2 3]; +bst_set('StatThreshOptions', StatThreshOptions); +% Display test result +hSrcFig = view_surface_data([], sTestFile.FileName); +panel_surface('SelectHemispheres', 'left'); +panel_stat('UpdatePanel'); +bst_report('Snapshot', hSrcFig, sTestFile.FileName); +pause(1); +% Unload everything +bst_memory('UnloadAll', 'Forced'); + + +%% ===== VOLUME SCOUTS ===== +volumeScouts = {'Amygdala L', 'Amygdala R', 'Thalamus L', 'Thalamus R'}; +% Load one source file +resultsMat = in_bst_results(sSrcFiles(1).FileName); +% Get headmodel, needed to retrieve information about the source grid +headmodelMat = in_bst_headmodel(resultsMat.HeadModelFile); +% Create volume atlas +sAtlas = db_template('atlas'); +sAtlas.Name = sprintf('Volume %d', size(headmodelMat.GridLoc, 1)); +panel_scout('SetCurrentSurface', headmodelMat.SurfaceFile); +panel_scout('SetAtlas', [], 'Add', sAtlas); +% Create a volume scout for each volume structure +iNewScouts = zeros(1,length(volumeScouts)); +for ix = 1 : length(volumeScouts) + % Index of the structure in the Grid Atlas (in headmodel data) + iGridScout = find(strcmpi(volumeScouts{ix}, {headmodelMat.GridAtlas.Scouts.Label})); + % New scout + sNewScout = db_template('Scout'); + sNewScout.Label = headmodelMat.GridAtlas.Scouts(iGridScout).Label; + sNewScout.Vertices = headmodelMat.GridAtlas.Scouts(iGridScout).GridRows; + sNewScout.Region = headmodelMat.GridAtlas.Scouts(iGridScout).Region(1); + sNewScout = panel_scout('SetScoutsSeed', sNewScout, headmodelMat.GridLoc); + % Register new scout + iNewScout = panel_scout('SetScouts', [], 'Add', sNewScout); + iNewScouts(ix) = iNewScout; +end +% Configure scouts display +panel_scout('SetScoutsOptions', 1, 1, 1, 'all', 0.7, 1, 1, 0); +hFigScouts = view_scouts({sSrcFiles(1).FileName}, iNewScouts); +bst_report('Snapshot', hFigScouts, sSrcFiles(1).FileName, 'Volume scouts'); +pause(1); +% Unload everything +bst_memory('UnloadAll', 'Forced'); + + +%% ===== SAVE REPORT ===== +% Save and display report +ReportFile = bst_report('Save', []); +if ~isempty(reports_dir) && ~isempty(ReportFile) + bst_report('Export', ReportFile, reports_dir); +else + bst_report('Open', ReportFile); +end + + diff --git a/toolbox/script/tutorial_ephys.m b/toolbox/script/tutorial_ephys.m index 612ebb02b..b1be3288a 100644 --- a/toolbox/script/tutorial_ephys.m +++ b/toolbox/script/tutorial_ephys.m @@ -109,7 +109,8 @@ function tutorial_ephys(tutorial_dir) hFig = view_surface(CortexFile{1}, 0.8, [1 0 0], hFig); figure_3d('SetStandardView', hFig, 'right'); bst_report('Snapshot', hFig, [], 'Anatomy'); -close(hFig); +% Unload everything +bst_memory('UnloadAll', 'Forced'); %% ===== SPIKE SORTING ===== @@ -176,6 +177,7 @@ function tutorial_ephys(tutorial_dir) 'eventsel', {'Stim On 1', 'Stim On 2', 'Stim On 3', 'Stim On 4', 'Stim On 5', 'Stim On 6', 'Stim On 7', 'Stim On 8', 'Stim On 9'}, ... 'spikesel', {'Spikes Channel AD06', 'Spikes Channel AD08 |1|'}, ... 'timewindow', [0.05, 0.12]); +close(findobj('Type','figure')); %% ===== NOISE CORRELATION ===== diff --git a/toolbox/script/tutorial_epilepsy.m b/toolbox/script/tutorial_epilepsy.m index 49967b675..44671272d 100644 --- a/toolbox/script/tutorial_epilepsy.m +++ b/toolbox/script/tutorial_epilepsy.m @@ -283,6 +283,38 @@ function tutorial_epilepsy(tutorial_dir, reports_dir) 'Comment', 'Average spike'); +% Install and Load Brain Entropy plugin +[isInstalled, errMsg] = bst_plugin('Install', 'brainentropy'); +if isInstalled + % Use default options of cMEM + mem_option = be_pipelineoptions(be_main, 'cMEM'); + mem_option.optional = struct_copy_fields(mem_option.optional, ... + struct(... + 'TimeSegment', [-0.30078, 0.5], ... + 'BaselineType', {{'within-data'}}, ... + 'Baseline', [], ... + 'BaselineHistory', {{'within'}}, ... + 'BaselineSegment', [-0.30078, -0.10156], ... + 'groupAnalysis', 0, ... + 'display', 0)); + % Process: Compute sources: BEst + sAvgSrcMEM = bst_process('CallProcess', 'process_inverse_mem', sFilesAvg, [], ... + 'comment', 'MEM', ... + 'mem', struct('MEMpaneloptions', mem_option), ... + 'sensortypes', 'EEG'); + % Process: Snapshot: Sources (one time) + bst_process('CallProcess', 'process_snapshot', sAvgSrcMEM, [], ... + 'target', 8, ... % Sources (one time) + 'modality', 1, ... % MEG (All) + 'orient', 3, ... % top + 'time', 0, ... + 'threshold', 30, ... + 'Comment', 'Average spike (cMEM)'); +else + bst_report('Error', [], [], errMsg); +end + + % ===== SOURCE ANALYSIS: VOLUME ===== % Process: Compute head model bst_process('CallProcess', 'process_headmodel', sFilesAvg, [], ... diff --git a/toolbox/sensors/channel_align_auto.m b/toolbox/sensors/channel_align_auto.m index 86671ea89..71d8fcd48 100644 --- a/toolbox/sensors/channel_align_auto.m +++ b/toolbox/sensors/channel_align_auto.m @@ -14,7 +14,7 @@ % % INPUTS: % - ChannelFile : Channel file to align on its anatomy -% - ChannelMat : If specified, do not read or write any information from/to ChannelFile +% - ChannelMat : If specified, do not read or write any information from/to ChannelFile (except to get scalp surface). % - isWarning : If 1, display warning in case of errors (default = 1) % - isConfirm : If 1, ask the user for confirmation before proceeding % - tolerance : Percentage of outliers head points, ignored in the final fit @@ -101,7 +101,7 @@ sSubject = bst_get('Subject', sStudy.BrainStormSubject); if isempty(sSubject) || isempty(sSubject.iScalp) if isWarning - bst_error('No scalp surface available for this subject', 'Align EEG sensors', 0); + bst_error('No scalp surface available for this subject', 'Automatic EEG-MEG/MRI registration', 0); else disp('BST> No scalp surface available for this subject.'); end @@ -162,19 +162,19 @@ %% ===== FIND OPTIMAL FIT ===== % Find best possible rigid transformation (rotation+translation) -[R,T,tmp,dist] = bst_meshfit(SurfaceMat.Vertices, SurfaceMat.Faces, HP); +[R,T,tmp,dist] = bst_meshfit(SurfaceMat.Vertices, SurfaceMat.Faces, HP, tolerance); % Remove outliers and fit again -if ~isempty(dist) && ~isempty(tolerance) && (tolerance > 0) - % Sort points by distance to scalp - [tmp__, iSort] = sort(dist, 1, 'descend'); - iRemove = iSort(1:nRemove); - % Remove from list of destination points - HP(iRemove,:) = []; - % Fit again - [R,T,tmp,dist] = bst_meshfit(SurfaceMat.Vertices, SurfaceMat.Faces, HP); -else - nRemove = 0; -end +% if ~isempty(dist) && ~isempty(tolerance) && (tolerance > 0) +% % Sort points by distance to scalp +% [tmp__, iSort] = sort(dist, 1, 'descend'); +% iRemove = iSort(1:nRemove); +% % Remove from list of destination points +% HP(iRemove,:) = []; +% % Fit again +% [R,T,tmp,dist] = bst_meshfit(SurfaceMat.Vertices, SurfaceMat.Faces, HP); +% else +% nRemove = 0; +% end % Current position cannot be optimized if isempty(R) bst_progress('stop'); @@ -190,24 +190,32 @@ ' | Number of outlier points removed: ' sprintf('%d (%d%%)', nRemove, round(tolerance*100)), 10 ... ' | Initial number of head points: ' num2str(size(HeadPoints.Loc,2))]; +% Create [4,4] transform matrix from digitized SCS to MRI SCS according to this fit. +DigToMriTransf = eye(4); +DigToMriTransf(1:3,1:3) = R; +DigToMriTransf(1:3,4) = T; + %% ===== ROTATE SENSORS AND HEADPOINTS ===== -for i = 1:length(ChannelMat.Channel) - % Rotate and translate location of channel - if ~isempty(ChannelMat.Channel(i).Loc) && ~all(ChannelMat.Channel(i).Loc(:) == 0) - ChannelMat.Channel(i).Loc = R * ChannelMat.Channel(i).Loc + T * ones(1,size(ChannelMat.Channel(i).Loc, 2)); +if ~isequal(DigToMriTransf, eye(4)) + for i = 1:length(ChannelMat.Channel) + % Rotate and translate location of channel + if ~isempty(ChannelMat.Channel(i).Loc) && ~all(ChannelMat.Channel(i).Loc(:) == 0) + ChannelMat.Channel(i).Loc = R * ChannelMat.Channel(i).Loc + T * ones(1,size(ChannelMat.Channel(i).Loc, 2)); + end + % Only rotate normal vector to channel + if ~isempty(ChannelMat.Channel(i).Orient) && ~all(ChannelMat.Channel(i).Orient(:) == 0) + ChannelMat.Channel(i).Orient = R * ChannelMat.Channel(i).Orient; + end end - % Only rotate normal vector to channel - if ~isempty(ChannelMat.Channel(i).Orient) && ~all(ChannelMat.Channel(i).Orient(:) == 0) - ChannelMat.Channel(i).Orient = R * ChannelMat.Channel(i).Orient; + % Rotate and translate head points + if isfield(ChannelMat, 'HeadPoints') && ~isempty(ChannelMat.HeadPoints) && ~isempty(ChannelMat.HeadPoints.Loc) + ChannelMat.HeadPoints.Loc = R * ChannelMat.HeadPoints.Loc + ... + T * ones(1, size(ChannelMat.HeadPoints.Loc, 2)); end end -% Rotate and translate head points -if isfield(ChannelMat, 'HeadPoints') && ~isempty(ChannelMat.HeadPoints) && ~isempty(ChannelMat.HeadPoints.Loc) - ChannelMat.HeadPoints.Loc = R * ChannelMat.HeadPoints.Loc + ... - T * ones(1, size(ChannelMat.HeadPoints.Loc, 2)); -end %% ===== SAVE TRANSFORMATION ===== +% We could decide to skip this if the transformation is identity. % Initialize fields if ~isfield(ChannelMat, 'TransfEeg') || ~iscell(ChannelMat.TransfEeg) ChannelMat.TransfEeg = {}; @@ -221,13 +229,9 @@ if ~isfield(ChannelMat, 'TransfEegLabels') || ~iscell(ChannelMat.TransfEegLabels) || (length(ChannelMat.TransfEeg) ~= length(ChannelMat.TransfEegLabels)) ChannelMat.TransfEegLabels = cell(size(ChannelMat.TransfEeg)); end -% Create [4,4] transform matrix -newtransf = eye(4); -newtransf(1:3,1:3) = R; -newtransf(1:3,4) = T; % Add a rotation/translation to the lists -ChannelMat.TransfMeg{end+1} = newtransf; -ChannelMat.TransfEeg{end+1} = newtransf; +ChannelMat.TransfMeg{end+1} = DigToMriTransf; +ChannelMat.TransfEeg{end+1} = DigToMriTransf; % Add the comments ChannelMat.TransfMegLabels{end+1} = 'refine registration: head points'; ChannelMat.TransfEegLabels{end+1} = 'refine registration: head points'; diff --git a/toolbox/sensors/channel_align_manual.m b/toolbox/sensors/channel_align_manual.m index 8d847b538..77f57e157 100644 --- a/toolbox/sensors/channel_align_manual.m +++ b/toolbox/sensors/channel_align_manual.m @@ -191,7 +191,7 @@ % ===== DISPLAY HEAD POINTS ===== % Display head points -figure_3d('ViewHeadPoints', hFig, 1); +figure_3d('ViewHeadPoints', hFig, 1, 1); % visible and color-coded % Get patch and vertices hHeadPointsMarkers = findobj(hFig, 'Tag', 'HeadPointsMarkers'); hHeadPointsLabels = findobj(hFig, 'Tag', 'HeadPointsLabels'); @@ -204,7 +204,9 @@ HeadPointsHpiLoc = []; if isHeadPoints % More transparency to view points inside. - panel_surface('SetSurfaceTransparency', hFig, 1, 0.5); + panel_surface('SetSurfaceTransparency', hFig, 1, 0.4); + % Hide MEG helmet + set(hHelmetPatch, 'visible', 'off'); % Get markers positions HeadPointsMarkersLoc = get(hHeadPointsMarkers, 'Vertices'); % Hide HeadPoints when looking at EEG and number of EEG channels is the same as headpoints @@ -232,7 +234,7 @@ % ===== DISPLAY MRI FIDUCIALS ===== % Get the fiducials positions defined in the MRI volume -sMri = load(file_fullpath(sSubject.Anatomy(sSubject.iAnatomy).FileName), 'SCS'); +sMri = load(file_fullpath(sSubject.Anatomy(sSubject.iAnatomy).FileName), 'SCS', 'History'); if ~isempty(sMri.SCS.NAS) && ~isempty(sMri.SCS.LPA) && ~isempty(sMri.SCS.RPA) % Convert coordinates MRI => SCS MriFidLoc = [cs_convert(sMri, 'mri', 'scs', sMri.SCS.NAS ./ 1000); ... @@ -406,6 +408,8 @@ bst_progress('stop'); end +% Check and print to command window if previously auto/manual registration, and if MRI fids updated. +process_adjust_coordinates('CheckPrevAdjustments', in_bst_channel(ChannelFile), sMri); end %% ===== MOUSE CALLBACKS ===== @@ -742,6 +746,7 @@ function AlignKeyPress_Callback(hFig, keyEvent) end % Ask if needed to update also the other modalities if isempty(isAll) + % TODO We might have < 10 but still want to update electrodes. Verify instead if there are real EEG (not just ECG EOG) if (gChanAlign.isMeg || gChanAlign.isNirs) && (length(iEeg) > 10) isAll = java_dialog('confirm', 'Do you want to apply the same transformation to the EEG electrodes ?', 'Align sensors'); elseif ~gChanAlign.isMeg && ~isempty(iMeg) @@ -851,41 +856,47 @@ function AlignKeyPress_Callback(hFig, keyEvent) function AlignClose_Callback(varargin) global gChanAlign; if gChanAlign.isChanged + isCancel = false; + % Get new positions + [ChannelMat, Transf, iChannels] = GetCurrentChannelMat(); + % Load original channel file + ChannelMatOrig = in_bst_channel(gChanAlign.ChannelFile); + % Ask user to save changes (only if called as a callback) if (nargin == 3) - SaveChanged = 1; + SaveChanges = 1; else - SaveChanged = java_dialog('confirm', ['The sensors locations changed.' 10 10 ... - 'Would you like to save changes? ' 10 10], 'Align sensors'); + [SaveChanges, isCancel] = java_dialog('confirm', ['The sensors locations changed.' 10 10 ... + 'Would you like to save changes? ' 10 10], 'Align sensors'); + end + % Don't close figure if cancelled. + if isCancel + return; + end + % Report (in command window) max head and sensor displacements from changes. + if SaveChanges || gChanAlign.isHeadPoints + process_adjust_coordinates('CheckCurrentAdjustments', ChannelMat, ChannelMatOrig); end % Save changes to channel file and close figure - if SaveChanged + if SaveChanges % Progress bar bst_progress('start', 'Align sensors', 'Updating channel file...'); % Restore standard close callback for 3DViz figures set(gChanAlign.hFig, 'CloseRequestFcn', gChanAlign.Figure3DCloseRequest_Bak); drawnow; - % Get new positions - [ChannelMat, Transf, iChannels] = GetCurrentChannelMat(); - % Load original channel file - ChannelMatOrig = in_bst_channel(gChanAlign.ChannelFile); % Save new electrodes positions in ChannelFile bst_save(gChanAlign.ChannelFile, ChannelMat, 'v7'); % Get study associated with channel file [sStudy, iStudy] = bst_get('ChannelFile', gChanAlign.ChannelFile); % Reload study file db_reload_studies(iStudy); + % Apply to other recordings with same sensor locations in the same subject + CopyToOtherFolders(ChannelMatOrig, iStudy, Transf, iChannels); bst_progress('stop'); end - else - SaveChanged = 0; end % Only close figure gChanAlign.Figure3DCloseRequest_Bak(varargin{1:2}); - % Apply to other recordings with same sensor locations in the same subject - if SaveChanged - CopyToOtherFolders(ChannelMatOrig, iStudy, Transf, iChannels); - end end diff --git a/toolbox/sensors/channel_apply_transf.m b/toolbox/sensors/channel_apply_transf.m index 75f312b62..a7871c45b 100644 --- a/toolbox/sensors/channel_apply_transf.m +++ b/toolbox/sensors/channel_apply_transf.m @@ -47,7 +47,7 @@ if isnumeric(Transf) R = Transf(1:3,1:3); T = Transf(1:3,4); - Transf = @(Loc)(R * Loc + T * ones(1, size(Loc,2))); + TransfFunc = @(Loc)(R * Loc + T * ones(1, size(Loc,2))); else R = []; end @@ -82,7 +82,7 @@ Orient = ChannelMat.Channel(iChan(i)).Orient; % Update location if ~isempty(Loc) && ~isequal(Loc, [0;0;0]) - ChannelMat.Channel(iChan(i)).Loc = Transf(Loc); + ChannelMat.Channel(iChan(i)).Loc = TransfFunc(Loc); end % Update orientation if ~isempty(Orient) && ~isequal(Orient, [0;0;0]) @@ -95,7 +95,7 @@ end % If needed: transform the digitized head points if isHeadPoints && ~isempty(ChannelMat.HeadPoints.Loc) - ChannelMat.HeadPoints.Loc = Transf(ChannelMat.HeadPoints.Loc); + ChannelMat.HeadPoints.Loc = TransfFunc(ChannelMat.HeadPoints.Loc); end % If a TransfMeg field with translations/rotations available diff --git a/toolbox/sensors/channel_detect_eegcap_auto.m b/toolbox/sensors/channel_detect_eegcap_auto.m new file mode 100644 index 000000000..9f15d99f0 --- /dev/null +++ b/toolbox/sensors/channel_detect_eegcap_auto.m @@ -0,0 +1,241 @@ +function varargout = channel_detect_eegcap_auto(varargin) +% CHANNEL_DETECT_EEGCAP_AUTO: Automatic electrode detection and labelling of 3D Scanner acquired mesh +% +% USAGE: [capCenters2d, capImg2d, surface3dscannerUv] = channel_detect_eegcap_auto('FindElectrodesEegCap', surface3dscanner, isWhiteCap) +% channel_detect_eegcap_auto('WarpLayout2Mesh', capCenters2d, capImg2d, surface3dscannerUv, channelRef, eegPoints) +% eegCapLandmarkLabels = channel_detect_eegcap_auto('GetEegCapLandmarkLabels', eegCapName) +% +% PARAMETERS: +% - surface3dscanner : The 3D mesh surface obtained from the 3d Scanner loaded into brainstorm +% - isWhiteCap : Set if the 3D mesh surface correspongs to a white EEG cap +% - surface3dscannerUv : 'surface3dscanner' above along with the UV texture information of the surface +% - capImg2d : Flattend 2D grayscale image of the mesh +% - capCenters2d : The ceters of the various electrodes detected in the flattened 2D image of the mesh +% - channelRef : The channel file containing all the layout information of the cap +% - eegCapName : Name of the EEG cap +% - eegCapLandmarkLabels : The manually chosen list of labels of the electrodes to be used as initilization for automation +% - nLandmarkLabels : The count for the number of chosen electrode labels above +% +% @============================================================================= +% This function is part of the Brainstorm software: +% https://neuroimage.usc.edu/brainstorm +% +% Copyright (c) University of Southern California & McGill University +% This software is distributed under the terms of the GNU General Public License +% as published by the Free Software Foundation. Further details on the GPLv3 +% license can be found at http://www.gnu.org/copyleft/gpl.html. +% +% FOR RESEARCH PURPOSES ONLY. THE SOFTWARE IS PROVIDED "AS IS," AND THE +% UNIVERSITY OF SOUTHERN CALIFORNIA AND ITS COLLABORATORS DO NOT MAKE ANY +% WARRANTY, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO WARRANTIES OF +% MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE, NOR DO THEY ASSUME ANY +% LIABILITY OR RESPONSIBILITY FOR THE USE OF THIS SOFTWARE. +% +% For more information type "brainstorm license" at command prompt. +% =============================================================================@ +% +% Authors: Anand A. Joshi, 2024 +% Chinmay Chinara, 2024 +% Raymundo Cassani, 2024 + +eval(macro_method); +end + +%% ===== FIND ELECTRODES ON THE EEG CAP ===== +function [capCenters2d, capImg2d, surface3dscannerUv] = FindElectrodesEegCap(surface3dscanner, isWhiteCap) + % Hyperparameters for circle detection + % NOTE: these values can vary for new caps + minRadius = 1; + maxRadius = 25; + + % create a copy of the input mesh to add UV texture information to it as well + surface3dscannerUv = surface3dscanner; + + % Flatten the 3D mesh to 2D space + [surface3dscannerUv.u, surface3dscannerUv.v] = bst_project_2d(surface3dscanner.Vertices(:,1), surface3dscanner.Vertices(:,2), surface3dscanner.Vertices(:,3), '2dcap'); + + % Perform image processing to detect the electrode locations + % Convert to grayscale + grayness = surface3dscanner.Color*[1;1;1]/sqrt(3); + + % Interpolate and fit flattended mesh image to a 512x512 grid + % NOTE: Should work with any flattened cap mesh but needs more testing + ll=linspace(-1,1,512); + [X,Y]=meshgrid(ll,ll); + capImg2d = 0*X; + warning('off','MATLAB:scatteredInterpolant:DupPtsAvValuesWarnId'); + capImg2d(:) = griddata(surface3dscannerUv.u(1:end),surface3dscannerUv.v(1:end),grayness,X(:),Y(:),'linear'); + warning('on','MATLAB:scatteredInterpolant:DupPtsAvValuesWarnId'); + + % For white caps + if isWhiteCap + capImg2d = imcomplement(capImg2d); + end + + % Detect the centers of the electrodes which appear as circles in the flattened image whose radii are in the range below + warning('off','images:imfindcircles:warnForSmallRadius'); + warning('off','images:imfindcircles:warnForLargeRadiusRange'); + capCenters2d = imfindcircles(capImg2d, [minRadius maxRadius]); + warning('on','images:imfindcircles:warnForSmallRadius'); + warning('on','images:imfindcircles:warnForLargeRadiusRange'); + +end + +%% ===== WARP ELECTRODE LOCATIONS FROM EEG CAP MANUFACTURER LAYOUT AVAILABLE IN BRAINSTORM TO THE MESH ===== +function capPoints = WarpLayout2Mesh(capCenters2d, capImg2d, surface3dscannerUv, channelRef, eegPoints) + capPoints = struct(); + % Hyperparameters for warping and interpolation + % NOTE: these values can vary for new caps + % Number of iterations to run warp-interpolation on + numIters = 1000; + % Defines the rigidity of the warping (check the 'tpsGetWarp' function for more details) + lambda = 100000; + % Dimension of the flattened cap from mesh + capImgDim = length(capImg2d); + % Threshold for ignoring some border pixels that might be bad detections + ignorePix = 15; + + % Get current montage + DigitizeOptions = bst_get('DigitizeOptions'); + panel_fun = @panel_digitize; + eegPointsLabel = eegPoints.Label; + if isfield(DigitizeOptions, 'Version') && strcmpi(DigitizeOptions.Version, '2024') + panel_fun = @panel_digitize_2024; + eegPointsLabel = {eegPoints.Label}; + end + curMontage = panel_fun('GetCurrentMontage'); + % Get EEG cap landmark labels used for initialization + capLandmarkLabels = GetEegCapLandmarkLabels(curMontage.Name); + + % Check that all landmarks are acquired + if ~all(ismember([capLandmarkLabels], eegPointsLabel)) + bst_error('Not all EEG landmarks are provided', 'Auto electrode location', 1); + return + end + + % Convert EEG cap manufacturer layout from 3D to 2D + capLayoutPts3d = [channelRef.Loc]'; + [X1, Y1] = bst_project_2d(capLayoutPts3d(:,1), capLayoutPts3d(:,2), capLayoutPts3d(:,3), '2dcap'); + capLayoutPts2d = [X1 Y1]; + capLayoutNames = {channelRef.Name}; + + % Indices for capLayoutPts2dSorted for points to compute warp + [~, iwarp] = ismember(eegPointsLabel, capLayoutNames); + + % Warping EEG cap layout electrodes to mesh + % Get 2D projected landmark points to be used for initialization + capLayoutPts2dInit = capLayoutPts2d(iwarp, :); + % Get 2D projected points of the 3D points selected by the user on the mesh + % Check if using new version + if isfield(DigitizeOptions, 'Version') && strcmpi(DigitizeOptions.Version, '2024') + eegPointsLoc = cat(1, eegPoints.Loc); + else + eegPointsLoc = cat(1, eegPoints.EEG); + end + [x2, y2] = bst_project_2d(eegPointsLoc(:,1), eegPointsLoc(:,2), eegPointsLoc(:,3), '2dcap'); + % Reprojection into the space of the flattened mesh dimensions + capUserSelectPts2d = ([x2 y2]+1) * capImgDim/2; + + % Delete the manual electrodes selected in figure to update it with the automatic detected ones + for i=1 : length(eegPoints) + panel_fun('DeletePoint_Callback'); + end + + % Do the warping and interpolation + warp = tpsGetWarp(10, capLayoutPts2dInit(:,1)', capLayoutPts2dInit(:,2)', capUserSelectPts2d(:,1)', capUserSelectPts2d(:,2)' ); + [xsR,ysR] = tpsInterpolate(warp, capLayoutPts2d(:,1)', capLayoutPts2d(:,2)', 0); + capLayoutPts2d(:,1) = xsR; + capLayoutPts2d(:,2) = ysR; + % 'ignorePix' is just a hyperparameter. It is because if some point is detected near the border then it is + % too close to the border; it moves it inside. It leaves a margin of 'ignorePix' pixels around the border + capLayoutPts2d = max(min(capLayoutPts2d,capImgDim-ignorePix),ignorePix); + + bst_progress('start', '3Dscanner', 'Automatic labelling of EEG sensors...', 0, 100); + % Warp and interpolate to get the best point fitting + for numIter=1:numIters + % Show progress + progressPrc = round(100 .* numIter ./ numIters); + if progressPrc > 0 && ~mod(progressPrc, 5) + bst_progress('set', progressPrc); + end + % Nearest point search between the layout and detected circle centers from the 2D flattened mesh + % 'k' is an index into points from the available layout + k = dsearchn(capLayoutPts2d, capCenters2d); + [vecLayoutPts,ind] = unique(k); + + % distance between the layout and detected circle centers from the 2D flattened mesh + vecLayout2Mesh = capCenters2d(ind,:)-capLayoutPts2d(vecLayoutPts,:); + dist = sqrt(vecLayout2Mesh(:,1).^2+vecLayout2Mesh(:,2).^2); + + % Identify outliers with 3*scaled_MAD from median and remove them + % Use 'rmoutliers' for Matlab >= R2018b + if bst_get('MatlabVersion') >= 905 + [~, isoutlier] = rmoutliers(dist); + % Implementation + else + mad = median(abs(dist-median(dist))); + c = -1/(sqrt(2) * erfcinv(3/2)) * 2; + scaled_mad = c * mad; + isoutlier = find(abs(dist-median(dist)) > 3*scaled_mad); + end + ind(isoutlier) = []; + vecLayoutPts(isoutlier) = []; + + % Perform warping and interpolation to fit the points + warp = tpsGetWarp(lambda, capLayoutPts2d(vecLayoutPts,1)', capLayoutPts2d(vecLayoutPts,2)', capCenters2d(ind,1)', capCenters2d(ind,2)' ); + [xsR,ysR] = tpsInterpolate(warp, capLayoutPts2d(:,1)', capLayoutPts2d(:,2)', 0); + + % Perform gradual warping for half the iterations and fast warping for the rest of the iterations + if numIter 'vertices', 'channel' -> 'surface' +% +% OUTPUT: +% - ChannelMat : Same type as ChannelMat input % @============================================================================= % This function is part of the Brainstorm software: @@ -28,25 +33,32 @@ % =============================================================================@ % % Authors: Francois Tadel, 2017 +% Raymundo Cassani, 2024 % Parse inputs +if (nargin < 4) || isempty(isVertices) + isVertices = 0; +end if (nargin < 3) || isempty(isConfirmFix) isConfirmFix = 1; end -% Get EEG channels -iEEG = good_channel(ChannelMat.Channel, [], {'EEG','SEEG','ECOG','Fiducial'}); -% If not enough channels: nothing to do -if (length(iEEG) <= 8) && (length(iEEG) ~= length(ChannelMat.Channel)) - return; -end - -% Get all EEG locations -eegLoc = []; -for k = 1:length(iEEG) - if ~isempty(ChannelMat.Channel(iEEG(k)).Loc) && ~isequal(ChannelMat.Channel(iEEG(k)).Loc, [0;0;0]) - eegLoc = [eegLoc, ChannelMat.Channel(iEEG(k)).Loc(:,1)]; +if isstruct(ChannelMat) + % Get EEG channels + iEEG = good_channel(ChannelMat.Channel, [], {'EEG','SEEG','ECOG','Fiducial'}); + % If not enough channels: nothing to do + if (length(iEEG) <= 8) && (length(iEEG) ~= length(ChannelMat.Channel)) + return; end + % Get all EEG locations + eegLoc = []; + for k = 1:length(iEEG) + if ~isempty(ChannelMat.Channel(iEEG(k)).Loc) && ~isequal(ChannelMat.Channel(iEEG(k)).Loc, [0;0;0]) + eegLoc = [eegLoc, ChannelMat.Channel(iEEG(k)).Loc(:,1)]; + end + end +else + eegLoc = ChannelMat'; end if isempty(eegLoc) return; @@ -64,22 +76,33 @@ strFactor = num2str(FactorTest(iFactor)); % Ask user if we should scale the distances if isConfirmFix + % Strings for GUI message + locType = 'EEG electrodes'; + fileType = 'channel'; + if isVertices + locType = 'vertices'; + fileType = 'surface'; + end strFactor = java_dialog('question', ... - ['Warning: The EEG electrodes locations might not be in the expected units (' FileUnits ').' 10 ... - 'Please select a scaling factor for the units (suggested: ' strFactor '):' 10 10], 'Import channel file', ... + ['Warning: The ' locType ' locations might not be in the expected units (' FileUnits ').' 10 ... + 'Please select a scaling factor for the units (suggested: ' strFactor '):' 10 10], ['Import ' fileType ' file'], ... [], {'0.001', '0.01', '0.1', '1', '10', '100' '1000'}, strFactor); end % If user accepted to scale if ~isempty(strFactor) && ~isequal(strFactor, '1') Factor = str2num(strFactor); % Apply correction to location values - for k = 1:length(iEEG) - ChannelMat.Channel(iEEG(k)).Loc = ChannelMat.Channel(iEEG(k)).Loc .* Factor; - end - % Apply correction to head points - isHeadPoints = isfield(ChannelMat, 'HeadPoints') && ~isempty(ChannelMat.HeadPoints.Loc); - if isHeadPoints - ChannelMat.HeadPoints.Loc = ChannelMat.HeadPoints.Loc .* Factor; + if isstruct(ChannelMat) + for k = 1:length(iEEG) + ChannelMat.Channel(iEEG(k)).Loc = ChannelMat.Channel(iEEG(k)).Loc .* Factor; + end + % Apply correction to head points + isHeadPoints = isfield(ChannelMat, 'HeadPoints') && ~isempty(ChannelMat.HeadPoints.Loc); + if isHeadPoints + ChannelMat.HeadPoints.Loc = ChannelMat.HeadPoints.Loc .* Factor; + end + else + ChannelMat = eegLoc' .* Factor; end end end diff --git a/toolbox/sensors/panel_digitize.m b/toolbox/sensors/panel_digitize.m index dd9ee9dbb..47dc31186 100644 --- a/toolbox/sensors/panel_digitize.m +++ b/toolbox/sensors/panel_digitize.m @@ -26,6 +26,7 @@ % =============================================================================@ % % Authors: Elizabeth Bock & Francois Tadel, 2012-2017 +% Chinmay Chinara, 2024 eval(macro_method); end @@ -39,51 +40,22 @@ %#function icinterface %% ===== START ===== -function Start() %#ok - global Digitize; - % ===== PREPARE DATABASE ===== - % If no protocol: exit - if (bst_get('iProtocol') <= 0) - bst_error('Please create a protocol first.', 'Digitize', 0); - return; - end - % Get subject - SubjectName = 'Digitize'; - [sSubject, iSubject] = bst_get('Subject', SubjectName); - % Create if subject doesnt exist - if isempty(iSubject) - % Default anat / one channel file per subject - UseDefaultAnat = 1; - UseDefaultChannel = 0; - [sSubject, iSubject] = db_add_subject(SubjectName, iSubject, UseDefaultAnat, UseDefaultChannel); - % Update tree - panel_protocols('UpdateTree'); - end - - % ===== PATIENT ID ===== - % Get Digitize options - DigitizeOptions = bst_get('DigitizeOptions'); - % Ask for subject id - PatientId = java_dialog('input', 'Please, enter subject name or id:', 'Digitize', [], DigitizeOptions.PatientId); - if isempty(PatientId) - return; - end - % Save the new default patient id - DigitizeOptions.PatientId = PatientId; - bst_set('DigitizeOptions', DigitizeOptions); - +function Start(varargin) %#ok + global Digitize % ===== INITIALIZE CONNECTION ===== % Intialize global variable Digitize = struct(... + 'Type' , [], ... 'SerialConnection', [], ... 'Mode', 0, ... 'hFig', [], ... 'iDS', [], ... 'FidSets', 2, ... 'EEGlabels', [], ... - 'SubjectName', SubjectName, ... + 'SubjectName', [], ... 'ConditionName', [], ... 'BeepWav', [], ... + 'isEditPts', 0, ... 'Points', struct(... 'nasion', [], ... 'LPA', [], ... @@ -92,10 +64,104 @@ function Start() %#ok 'hpiL', [], ... 'hpiR', [], ... 'EEG', [], ... + 'Label', [], ... 'headshape',[], ... 'trans', [])); + + % ===== PARSE INPUT ===== + DigitizerType = 'Digitize'; + sSubject = []; + iSubject = []; + surfaceFile = []; + if nargin > 0 && ~isempty(varargin{1}) + DigitizerType = varargin{1}; + end + if nargin > 1 && ~isempty(varargin{2}) + sSubject = varargin{2}; + end + if nargin > 2 && ~isempty(varargin{3}) + iSubject = varargin{3}; + end + if nargin > 3 && ~isempty(varargin{4}) + surfaceFile = varargin{4}; + end + Digitize.Type = DigitizerType; + switch DigitizerType + case 'Digitize' + % Do nothing + case '3DScanner' + % Simulate + SetSimulate(1); + otherwise + bst_error(sprintf('DigitizerType : "%s" is not supported', DigitizerType)); + return + end + + % Get Digitize options + DigitizeOptions = bst_get('DigitizeOptions'); + % Check if using new version + if isfield(DigitizeOptions, 'Version') && strcmpi(DigitizeOptions.Version, '2024') + if strcmpi(Digitize.Type, '3DScanner') + bst_call(@panel_digitize_2024, 'Start', varargin{:}); + else + bst_call(@panel_digitize_2024, 'Start'); + end + return; + end + + % ===== PREPARE DATABASE ===== + % If no protocol: exit + if (bst_get('iProtocol') <= 0) + bst_error('Please create a protocol first.', Digitize.Type, 0); + return; + end + + % ===== PATIENT ID ===== + if isempty(sSubject) + % Get Digitize options + DigitizeOptions = bst_get('DigitizeOptions'); + % Ask for subject id + PatientId = java_dialog('input', 'Please, enter subject ID:', Digitize.Type, [], DigitizeOptions.PatientId); + if isempty(PatientId) + return; + end + % Save the new default patient id + DigitizeOptions.PatientId = PatientId; + bst_set('DigitizeOptions', DigitizeOptions); + + % ===== GET SUBJECT ===== + if strcmpi(Digitize.Type, '3DScanner') + SubjectName = [Digitize.Type, '_', PatientId]; + else + SubjectName = Digitize.Type; + end + + [sSubject, iSubject] = bst_get('Subject', SubjectName); + else + SubjectName = sSubject.Name; + end + + % Save the new SubjectName + Digitize.SubjectName = SubjectName; + + % Create if subject doesnt exist + if isempty(iSubject) + % Default anat / one channel file per subject + if strcmpi(Digitize.Type, '3DScanner') + [sSubject, iSubject] = db_add_subject(SubjectName, iSubject); + sTemplates = bst_get('AnatomyDefaults'); + db_set_template(iSubject, sTemplates(1), 1); + else + UseDefaultAnat = 1; + UseDefaultChannel = 0; + [sSubject, iSubject] = db_add_subject(SubjectName, iSubject, UseDefaultAnat, UseDefaultChannel); + end + % Update tree + panel_protocols('UpdateTree'); + end + % Start Serial Connection - if ~CreateSerialConnection(); + if ~CreateSerialConnection() return; end @@ -107,7 +173,7 @@ function Start() %#ok % Generate new condition name ConditionName = sprintf('%s_%02d%02d%02d_%02d', DigitizeOptions.PatientId, c(1), c(2), c(3), i); % Get condition - [sStudy, iStudy] = bst_get('StudyWithCondition', [SubjectName '/' ConditionName]); + sStudy = bst_get('StudyWithCondition', [SubjectName '/' ConditionName]); % If condition doesn't exist: ok, keep this one if isempty(sStudy) break; @@ -126,10 +192,58 @@ function Start() %#ok db_reload_studies(iStudy); % Save condition name Digitize.ConditionName = ConditionName; + + if strcmpi(Digitize.Type, '3DScanner') + if isempty(surfaceFile) + % Import surface + iSurface = find(cellfun(@(x)~isempty(regexp(x, 'tess_textured', 'match')), {sSubject.Surface.FileName})); + if isempty(iSurface) + [~, surfaceFiles] = import_surfaces(iSubject); + if isempty(surfaceFiles) + return + end + surfaceFile = file_short(surfaceFiles{end}); + else + [res, isCancel] = java_dialog('question', ['There is already scanned mesh available for this subject.' 10 10 ... + 'What do you want to do?'], ... + 'Import surface', [], {'Use existing', 'Add new', 'Cancel'}, 'Use existing'); + if strcmpi(res, 'cancel') || isCancel + return + elseif strcmpi(res, 'use existing') + % If more than one surface present, user can choose + if length(iSurface) > 1 + texSurfComment = java_dialog('combo', 'Select the textured surface:

', 'Choose textured surface', [], {sSubject.Surface(iSurface).Comment}); + texSurfComment = strrep(texSurfComment, '_defaced', ''); + if isempty(texSurfComment) + return + end + iSurfFile = find(cellfun(@(x)~isempty(regexp(x, [texSurfComment '.mat'], 'match')), {sSubject.Surface.FileName})); + surfaceFile = sSubject.Surface(iSurfFile).FileName; + % If only one surface is present, then load it directly + else + surfaceFile = sSubject.Surface(iSurface(end)).FileName; + end + elseif strcmpi(res, 'add new') + % Import a new textured mesh and append it to the list + [~, surfaceFiles] = import_surfaces(iSubject); + if isempty(surfaceFiles) + return + end + surfaceFile = file_short(surfaceFiles{end}); + end + end + end + bst_progress('start', Digitize.Type, 'Loading surface file...'); + Digitize.surfaceFile = surfaceFile; + sSurf = bst_memory('LoadSurface', Digitize.surfaceFile); + % Display surface + view_surface_matrix(sSurf.Vertices, sSurf.Faces, [], sSurf.Color, [], [], Digitize.surfaceFile); + end % ===== DISPLAY DIGITIZE WINDOW ===== % Display panel - panelContainer = gui_show('panel_digitize', 'JavaWindow', 'Digitize', [], [], [], []); + % Set window title to Digitize.Type + panelContainer = gui_show('panel_digitize', 'JavaWindow', Digitize.Type, [], [], [], []); % Hide Brainstorm window jBstFrame = bst_get('BstFrame'); jBstFrame.setVisible(0); @@ -142,11 +256,8 @@ function Start() %#ok ResetDataCollection(); % Load beep sound - if bst_iscompiled() - wavfile = bst_fullfile(bst_get('BrainstormHomeDir'), 'toolbox', 'sensors', 'private', 'bst_beep_wav.mat'); - filemat = load(wavfile, 'wav'); - Digitize.BeepWav = filemat.wav; - end + wavfile = bst_fullfile(bst_get('BrainstormHomeDir'), 'toolbox', 'sensors', 'private', 'bst_beep.wav'); + [Digitize.BeepWav.data, Digitize.BeepWav.fs] = audioread(wavfile); end @@ -156,6 +267,7 @@ function Start() %#ok %% ===== CREATE PANEL ===== function [bstPanelNew, panelName] = CreatePanel() %#ok + global Digitize % Constants panelName = 'Digitize'; % Java initializations @@ -171,32 +283,49 @@ function Start() %#ok fontSize = round(11 * bst_get('InterfaceScaling') / 100); % ===== MENU BAR ===== + jPanelMenu = gui_component('panel'); jMenuBar = java_create('javax.swing.JMenuBar'); - jPanelNew.add(jMenuBar, BorderLayout.NORTH); - % File menu + jPanelMenu.add(jMenuBar, BorderLayout.NORTH); + jLabelNews = gui_component('label', jPanelMenu, BorderLayout.CENTER, ... + ['
Digitize version: "legacy"
' ... + '&bull Try the new Digitize version: File > Switch to Digitize "2024"   ' ... + '&bull More details: Help > Digitize tutorial'], [], [], [], fontSize); + jLabelNews.setHorizontalAlignment(SwingConstants.CENTER); + jLabelNews.setOpaque(true); + jLabelNews.setBackground(java.awt.Color.yellow); + + % ===== FILE MENU ===== jMenu = gui_component('Menu', jMenuBar, [], 'File', [], [], [], []); gui_component('MenuItem', jMenu, [], 'Start over', IconLoader.ICON_RELOAD, [], @(h,ev)bst_call(@ResetDataCollection, 1), []); gui_component('MenuItem', jMenu, [], 'Save as...', IconLoader.ICON_SAVE, [], @(h,ev)bst_call(@Save_Callback), []); jMenu.addSeparator(); gui_component('MenuItem', jMenu, [], 'Edit settings...', IconLoader.ICON_EDIT, [], @(h,ev)bst_call(@EditSettings), []); - gui_component('MenuItem', jMenu, [], 'Reset serial connection', IconLoader.ICON_FLIP, [], @(h,ev)bst_call(@CreateSerialConnection), []); + gui_component('MenuItem', jMenu, [], 'Switch to Digitize "2024"', [], [], @(h,ev)bst_call(@SwitchVersion), []); + if ~strcmpi(Digitize.Type, '3DScanner') + gui_component('MenuItem', jMenu, [], 'Reset serial connection', IconLoader.ICON_FLIP, [], @(h,ev)bst_call(@CreateSerialConnection), []); + end jMenu.addSeparator(); - if exist('bst_headtracking') + if exist('bst_headtracking', 'file') && ~strcmpi(Digitize.Type, '3DScanner') gui_component('MenuItem', jMenu, [], 'Start head tracking', IconLoader.ICON_ALIGN_CHANNELS, [], @(h,ev)bst_call(@(h,ev)bst_headtracking([],1,1)), []); jMenu.addSeparator(); end gui_component('MenuItem', jMenu, [], 'Save and exit', IconLoader.ICON_RESET, [], @(h,ev)bst_call(@Close_Callback), []); - % EEG Montage menu + % ===== EEG MONTAGE MENU ===== jMenuEeg = gui_component('Menu', jMenuBar, [], 'EEG montage', [], [], [], []); CreateMontageMenu(jMenuEeg); + % ===== HELP MENU ===== + jMenuHelp = gui_component('Menu', jMenuBar, [], 'Help', [], [], [], []); + gui_component('MenuItem', jMenuHelp, [], 'Digitize tutorial', [], [], @(h,ev)web('https://neuroimage.usc.edu/brainstorm/Tutorials/TutDigitizeLegacy', '-browser'), []); - % ===== Control Panel ===== + jPanelNew.add(jPanelMenu, BorderLayout.NORTH); + + % ===== CONTROL PANEL ===== jPanelControl = java_create('javax.swing.JPanel'); jPanelControl.setLayout(BoxLayout(jPanelControl, BoxLayout.Y_AXIS)); jPanelControl.setBorder(BorderFactory.createEmptyBorder(7,7,7,7)); modeButtonGroup = javax.swing.ButtonGroup(); - % ===== Coils panel ===== + % ===== COILS PANEL ===== jPanelCoils = gui_river([5,4], [10,10,10,10], 'Head Localization Coils'); % Fiducials jButtonhpiN = gui_component('toggle', jPanelCoils, [], 'HPI-N', {modeButtonGroup}, 'Center Coil', @(h,ev)SwitchToNewMode(1), largeFontSize); @@ -218,7 +347,7 @@ function Start() %#ok jPanelControl.add(jPanelCoils); jPanelControl.add(Box.createVerticalStrut(20)); - % ===== Fiducials panel ===== + % ===== FIDUCIALS PANEL ===== jPanelHeadCoord = gui_river([5,4], [10,10,10,10], 'Anatomical fiducials'); % Fiducials jButtonNasion = gui_component('toggle', jPanelHeadCoord, [], 'Nasion', {modeButtonGroup}, 'Nasion', @(h,ev)SwitchToNewMode(4), largeFontSize); @@ -243,15 +372,23 @@ function Start() %#ok jPanelControl.add(jPanelHeadCoord); jPanelControl.add(Box.createVerticalStrut(20)); - % ===== EEG panel ===== + % ===== EEG PANEL ===== jPanelEEG = gui_river([5,4], [10,10,10,10], 'EEG electrodes coordinates'); % Start EEG coord collection jButtonEEGStart = gui_component('toggle', jPanelEEG, [], 'EEG', {modeButtonGroup}, 'Start/Restart EEG digitization', @(h,ev)SwitchToNewMode(7), largeFontSize); newButtonSize = Dimension(initButtonSize.getWidth()*1.5, initButtonSize.getHeight()*1.5); jButtonEEGStart.setPreferredSize(newButtonSize); jButtonEEGStart.setFocusable(0); - % Separator - gui_component('label', jPanelEEG, 'hfill', ''); + + if strcmpi(Digitize.Type, '3DScanner') + % Auto EEG cap electrodes detection button + jButtonEEGAutoDetectElectrodes = gui_component('button', jPanelEEG, [], 'Auto', [], GenerateTooltipTextAuto(), @EEGAutoDetectElectrodes, largeFontSize); + jButtonEEGAutoDetectElectrodes.setPreferredSize(newButtonSize); + else + % Separator + jButtonEEGAutoDetectElectrodes = gui_component('label', jPanelEEG, 'hfill', ''); + end + % Number jTextFieldEEG = gui_component('text',jPanelEEG, [], '1', [], 'EEG Sensor # to be digitized', @EEGChangePoint_Callback, largeFontSize); jTextFieldEEG.setPreferredSize(newButtonSize) @@ -259,14 +396,22 @@ function Start() %#ok jPanelControl.add(jPanelEEG); jPanelControl.add(Box.createVerticalStrut(20)); - % ===== Extra points panel ===== + % ===== EXTRA POINTS PANEL ===== jPanelExtra = gui_river([5,4], [10,10,10,10], 'Head shape coordinates'); % Start Extra coord collection jButtonExtraStart = gui_component('toggle',jPanelExtra, [], 'Shape', {modeButtonGroup}, 'Start/Restart head shape digitization', @(h,ev)SwitchToNewMode(8), largeFontSize); jButtonExtraStart.setPreferredSize(newButtonSize); jButtonExtraStart.setFocusable(0); - % Separator - gui_component('label', jPanelExtra, 'hfill', ''); + + if strcmpi(Digitize.Type, '3DScanner') + % Add 150 random head shape points generation button + jButtonRandomHeadPts = gui_component('button', jPanelExtra, [], 'Random', [], 'Collect 150 head shape points from mesh', @CollectRandomHeadPts_Callback, largeFontSize); + jButtonRandomHeadPts.setPreferredSize(newButtonSize); + else + % Separator + jButtonRandomHeadPts = gui_component('label', jPanelExtra, 'hfill', ''); + end + % Number jTextFieldExtra = gui_component('text',jPanelExtra, [], '1',[], 'Head shape point to be digitized', @ExtraChangePoint_Callback, largeFontSize); jTextFieldExtra.setPreferredSize(newButtonSize) @@ -274,23 +419,28 @@ function Start() %#ok jPanelControl.add(jPanelExtra); jPanelControl.add(Box.createVerticalStrut(20)); - % ===== Extra buttons ===== + % ===== EXTRA BUTTONS ===== jPanelMisc = gui_river([5,4], [2,4,4,0]); - gui_component('button', jPanelMisc, [], 'Collect point', [], [], @ManualCollect_Callback); + if ~strcmpi(Digitize.Type, '3DScanner') + gui_component('button', jPanelMisc, [], 'Collect point', [], [], @(h,ev)bst_call(@ManualCollect_Callback)); + end jButtonDeletePoint = gui_component('button', jPanelMisc, 'hfill', 'Delete last point', [], [], @DeletePoint_Callback); gui_component('Button', jPanelMisc, [], 'Save as...', [], [], @Save_Callback); jPanelControl.add(jPanelMisc); jPanelControl.add(Box.createVerticalStrut(20)); jPanelNew.add(jPanelControl, BorderLayout.WEST); - % ===== Coordinate Display Panel ===== + % ===== COORDINATE DISPLAY PANEL ===== jPanelDisplay = gui_component('Panel'); jPanelDisplay.setBorder(java_scaled('titledborder', 'Coordinates (cm)')); % List of coordinates jListCoord = JList(largeFontSize); jListCoord.setCellRenderer(BstStringListRenderer(fontSize)); + java_setcb(jListCoord, ... + 'KeyTypedCallback', @(h,ev)bst_call(@CoordListKeyTyped_Callback,h,ev), ... + 'MouseClickedCallback', @(h,ev)bst_call(@CoordListClick_Callback,h,ev)); % Size - jPanelScrollList = JScrollPane(); + jPanelScrollList = JScrollPane(jListCoord); jPanelScrollList.getLayout.getViewport.setView(jListCoord); jPanelScrollList.setHorizontalScrollBarPolicy(jPanelScrollList.HORIZONTAL_SCROLLBAR_NEVER); jPanelScrollList.setVerticalScrollBarPolicy(jPanelScrollList.VERTICAL_SCROLLBAR_ALWAYS); @@ -298,25 +448,102 @@ function Start() %#ok jPanelDisplay.add(jPanelScrollList, BorderLayout.CENTER); jPanelNew.add(jPanelDisplay, BorderLayout.CENTER); - % create the controls structure - ctrl = struct('jMenuEeg', jMenuEeg, ... - 'jButtonNasion', jButtonNasion, ... - 'jButtonLPA', jButtonLPA, ... - 'jButtonRPA', jButtonRPA, ... - 'jLabelCoilMessage', jLabelCoilMessage, ... - 'jLabelFidMessage', jLabelFidMessage, ... - 'jButtonhpiN', jButtonhpiN, ... - 'jButtonhpiL', jButtonhpiL, ... - 'jButtonhpiR', jButtonhpiR, ... - 'jListCoord', jListCoord, ... - 'jButtonEEGStart', jButtonEEGStart, ... - 'jTextFieldEEG', jTextFieldEEG, ... - 'jButtonExtraStart', jButtonExtraStart, ... - 'jTextFieldExtra', jTextFieldExtra, ... - 'jButtonDeletePoint', jButtonDeletePoint); + % Create the controls structure + ctrl = struct('jMenuEeg', jMenuEeg, ... + 'jButtonNasion', jButtonNasion, ... + 'jButtonLPA', jButtonLPA, ... + 'jButtonRPA', jButtonRPA, ... + 'jLabelCoilMessage', jLabelCoilMessage, ... + 'jLabelFidMessage', jLabelFidMessage, ... + 'jButtonhpiN', jButtonhpiN, ... + 'jButtonhpiL', jButtonhpiL, ... + 'jButtonhpiR', jButtonhpiR, ... + 'jListCoord', jListCoord, ... + 'jButtonEEGStart', jButtonEEGStart, ... + 'jButtonEEGAutoDetectElectrodes', jButtonEEGAutoDetectElectrodes, ... + 'jTextFieldEEG', jTextFieldEEG, ... + 'jButtonExtraStart', jButtonExtraStart, ... + 'jButtonRandomHeadPts', jButtonRandomHeadPts, ... + 'jTextFieldExtra', jTextFieldExtra, ... + 'jButtonDeletePoint', jButtonDeletePoint); bstPanelNew = BstPanel(panelName, jPanelNew, ctrl); + + %% ================================================================================= + % === INTERNAL CALLBACKS ========================================================= + % ================================================================================= + %% ===== COORDINATE LIST KEY TYPED CALLBACK ===== + function CoordListKeyTyped_Callback(h, ev) + switch(uint8(ev.getKeyChar())) + % Delete + case {ev.VK_DELETE, ev.VK_BACK_SPACE} + ctrl = bst_get('PanelControls', 'Digitize'); + % If contact list rendering is blank in panel then dont't proceed + if ctrl.jListCoord.isSelectionEmpty() + return; + end + + [sCoordName, iSelCoord] = GetSelectedCoord(); + spl = regexp(sCoordName,'\s+','split'); + nameFinal = spl{1}; + if (~strcmpi(nameFinal, 'Nasion') &&... + ~strcmpi(nameFinal, 'LPA') &&... + ~strcmpi(nameFinal, 'RPA')) + listModel = ctrl.jListCoord.getModel(); + listModel.setElementAt(nameFinal, iSelCoord-1); + RemoveCoordinates('EEG', iSelCoord-3); + Digitize.isEditPts = 1; + SwitchToNewMode(7); + end + end + end + + %% ===== COORDINATE LIST CLICK CALLBACK ===== + function CoordListClick_Callback(h, ev) + % If single click + if (ev.getClickCount() == 1) + ctrl = bst_get('PanelControls', 'Digitize'); + % If contact list rendering is blank in panel then dont't proceed + if ctrl.jListCoord.isSelectionEmpty() + return; + end + + [sCoordName, ~] = GetSelectedCoord(); + spl = regexp(sCoordName,'\s+','split'); + nameFinal = spl{1}; + bst_figures('SetSelectedRows', nameFinal); + end + end +end + +%% ===== GET SELECTED ELECTRODE ===== +function [sCoordName, iSelCoord] = GetSelectedCoord() + % Get panel handles + ctrl = bst_get('PanelControls', 'Digitize'); + if isempty(ctrl) + return; + end + + % Get JList selected indices + iSelCoord = uint16(ctrl.jListCoord.getSelectedIndices())' + 1; + listModel = ctrl.jListCoord.getModel(); + sCoordName = listModel.getElementAt(iSelCoord-1); end +%% ===== SWITCH TO 2024 VERSION ===== +function SwitchVersion() + % Always confirm this switch. + if ~java_dialog('confirm', ['Switch to new (2024) version of the Digitize panel?
', ... + 'See Digitize tutorial (Digitize panel > Help menu).
', ... + 'This will close the window. Any unsaved points will be lost.'], 'Digitize version') + return; + end + % Close this panel + Close_Callback(); + % Save the preferred version. Must be after closing + DigitizeOptions = bst_get('DigitizeOptions'); + DigitizeOptions.Version = '2024'; + bst_set('DigitizeOptions', DigitizeOptions); +end %% ===== CLOSE ===== function Close_Callback() @@ -325,7 +552,8 @@ function Close_Callback() %% ===== HIDING CALLBACK ===== function isAccepted = PanelHidingCallback() %#ok - global Digitize; + global Digitize + % If Brainstorm window was hidden before showing the Digitizer if bst_get('isGUI') % Get Brainstorm frame @@ -346,6 +574,17 @@ function Close_Callback() else db_reload_studies(iStudy); end + % Close serial connection, to allow switching Digitize version, and to avoid further callbacks if stylus is pressed. + if ~isempty(Digitize.SerialConnection) + fclose(Digitize.SerialConnection); + delete(Digitize.SerialConnection); + end + % Check for any remaining open connection + s = instrfind('status','open'); + if ~isempty(s) + fclose(s); + delete(s); + end % Unload everything bst_memory('UnloadAll', 'Forced'); isAccepted = 1; @@ -354,51 +593,89 @@ function Close_Callback() %% ===== EDIT SETTINGS ===== function isOk = EditSettings() + global Digitize + isOk = 0; % Get options DigitizeOptions = bst_get('DigitizeOptions'); + % Ask for new options - [res, isCancel] = java_dialog('input', ... - {'Serial connection settings

Serial port name (COM1):', ... - 'Unit Type (Fastrak or Patriot):', ... - '
Collection settings

Digitize MEG HPI coils (0=no, 1=yes):', ... - 'How many times do you want to collect
the three fiducials (NAS,LPA,RPA):', ... - 'Beep when collecting point (0=no, 1=yes):'}, ... - 'Digitizer configuration', [], ... - {DigitizeOptions.ComPort, ... - DigitizeOptions.UnitType, ... - num2str(DigitizeOptions.isMEG), ... - num2str(DigitizeOptions.nFidSets), ... - num2str(DigitizeOptions.isBeep)}); - if isempty(res) || isCancel + options_all = {'Serial connection settings

Serial port name (COM1):', ... + DigitizeOptions.ComPort; + 'Unit Type (Fastrak or Patriot):', ... + DigitizeOptions.UnitType; + '
Collection settings

Digitize MEG HPI coils (0=no, 1=yes):', ... + num2str(DigitizeOptions.isMEG); + 'How many times do you want to collect
the three fiducials (NAS,LPA,RPA):', ... + num2str(DigitizeOptions.nFidSets); + 'Beep when collecting point (0=no, 1=yes):', ... + num2str(DigitizeOptions.isBeep)}; + + % Options to show for each type + switch lower(Digitize.Type) + case 'digitize' + iOptionsType = [1:5]; + case '3dscanner' + iOptionsType = [3:5]; + end + + % Ask options + [resType, isCancel] = java_dialog('input', options_all(iOptionsType,1), [Digitize.Type ' configuration'], [], options_all(iOptionsType,2)); + if isempty(resType) || isCancel return end + + % Results from options asked to user + options_all(iOptionsType, 3) = resType; + % Results from options not asked to user + iOptionsKeep = setdiff([1:length(options_all)], iOptionsType); + options_all(iOptionsKeep, 3) = options_all(iOptionsKeep,2); + res = options_all(:,3); + % Check values - if (length(res) < 5) || isempty(res{1}) || isempty(res{2}) || ~ismember(str2double(res{3}), [0 1]) || isnan(str2double(res{4})) || ~ismember(str2double(res{5}), [0 1]) - bst_error('Invalid values.', 'Digitize', 0); + if length(resType) < length(iOptionsType) || (ismember(1, iOptionsType) && isempty(res{1})) || ... + (ismember(2, iOptionsType) && isempty(res{2})) || ... + (ismember(3, iOptionsType) && ~ismember(str2double(res{3}), [0 1])) || ... + (ismember(4, iOptionsType) && isnan(str2double(res{4}))) || ... + (ismember(5, iOptionsType) && ~ismember(str2double(res{5}), [0 1])) + bst_error('Invalid values.', 'Digitize', 0); return; end - % Get entered values - DigitizeOptions.ComPort = res{1}; - DigitizeOptions.UnitType = lower(res{2}); - DigitizeOptions.isMEG = str2double(res{3}); - DigitizeOptions.nFidSets = str2double(res{4}); - DigitizeOptions.isBeep = str2double(res{5}); - - if strcmp(DigitizeOptions.UnitType,'fastrak') + % Digitizer: COM port + DigitizeOptions.ComPort = res{1}; + % Digitizer: Type + DigitizeOptions.UnitType = lower(res{2}); + % Digitizer: COM properties + if isempty(DigitizeOptions.UnitType) + % Do nothing + elseif strcmp(DigitizeOptions.UnitType,'fastrak') DigitizeOptions.ComRate = 9600; DigitizeOptions.ComByteCount = 94; elseif strcmp(DigitizeOptions.UnitType,'patriot') DigitizeOptions.ComRate = 115200; DigitizeOptions.ComByteCount = 120; else - bst_error('Incorrect unit type.', 'Digitize', 0); + bst_error('Incorrect unit type.', Digitize.Type, 0); return; end + + % Common: Digitize MEG HPI coils + if ~isempty(res{3}) + DigitizeOptions.isMEG = str2double(res{3}); + end + % Common: Number of fiducial sets + if ~isempty(res{4}) + DigitizeOptions.nFidSets = str2double(res{4}); + end + % Common: Beep + if ~isempty(res{5}) + DigitizeOptions.isBeep = str2double(res{5}); + end % Save values bst_set('DigitizeOptions', DigitizeOptions); - %ResetDataCollection(); + % ResetDataCollection(); + UpdateList(); isOk = 1; end @@ -422,7 +699,8 @@ function SetSimulate(isSimulate) %#ok %% ===== RESET DATA COLLECTION ===== function ResetDataCollection(isResetSerial) global Digitize - bst_progress('start', 'Digitize', 'Initializing...'); + + bst_progress('start', Digitize.Type, 'Initializing...'); % Reset serial? if (nargin == 1) && isequal(isResetSerial, 1) CreateSerialConnection(); @@ -434,10 +712,11 @@ function ResetDataCollection(isResetSerial) 'nasion', [], ... 'LPA', [], ... 'RPA', [], ... - 'hpiN', [], ... - 'hpiL', [], ... - 'hpiR', [], ... + 'hpiN', [], ... + 'hpiL', [], ... + 'hpiR', [], ... 'EEG', [], ... + 'Label', [], ... 'headshape', [], ... 'trans', []); % Reset counters @@ -445,10 +724,17 @@ function ResetDataCollection(isResetSerial) ctrl.jTextFieldEEG.setText(java.lang.String.valueOf(int16(1))); ctrl.jTextFieldExtra.setText(java.lang.String.valueOf(int16(1))); end - % Reset figure - if isfield(Digitize, 'hFig') && ~isempty(Digitize.hFig) && ishandle(Digitize.hFig) - %close(Digitize.hFig); + % Reset figure (also unloads in global data) + if ~isempty(Digitize.hFig) && ishandle(Digitize.hFig) bst_figures('DeleteFigure', Digitize.hFig, []); + + % For 3D Scanner reload the surface + if strcmpi(Digitize.Type, '3DScanner') + % load the surface + sSurf = bst_memory('LoadSurface', Digitize.surfaceFile); + % Display surface + view_surface_matrix(sSurf.Vertices, sSurf.Faces, [], sSurf.Color, [], [], Digitize.surfaceFile); + end end Digitize.iDS = []; @@ -474,6 +760,7 @@ function ResetDataCollection(isResetSerial) % - Mode 8 = Headshape function SwitchToNewMode(mode) global Digitize + % Get controls ctrl = bst_get('PanelControls', 'Digitize'); % Get options @@ -494,7 +781,8 @@ function SwitchToNewMode(mode) ctrl.jButtonLPA.setEnabled(0); ctrl.jButtonRPA.setEnabled(0); ctrl.jButtonDeletePoint.setEnabled(0); - % always switch to next mode to start with the nasion + ctrl.jButtonEEGAutoDetectElectrodes.setEnabled(0); + % Always switch to next mode to start with the nasion SwitchToNewMode(1); else ctrl.jButtonhpiN.setEnabled(0); @@ -504,13 +792,15 @@ function SwitchToNewMode(mode) ctrl.jButtonLPA.setEnabled(1); ctrl.jButtonRPA.setEnabled(1); ctrl.jButtonDeletePoint.setEnabled(0); - % always switch to next mode to start with the nasion + ctrl.jButtonEEGAutoDetectElectrodes.setEnabled(0); + % Always switch to next mode to start with the nasion SwitchToNewMode(4); end ctrl.jButtonEEGStart.setEnabled(0); ctrl.jTextFieldEEG.setEnabled(0); ctrl.jButtonExtraStart.setEnabled(0); + ctrl.jButtonRandomHeadPts.setEnabled(0); ctrl.jTextFieldExtra.setEnabled(0); @@ -572,6 +862,10 @@ function SwitchToNewMode(mode) % Shape case 8 ctrl.jButtonExtraStart.setEnabled(1); + if strcmpi(Digitize.Type, '3DScanner') + ctrl.jButtonEEGAutoDetectElectrodes.setEnabled(0); + ctrl.jButtonRandomHeadPts.setEnabled(1); + end ctrl.jTextFieldExtra.setEnabled(1); SetSelectedButton(8); Digitize.Mode = 8; @@ -581,7 +875,8 @@ function SwitchToNewMode(mode) %% ===== UPDATE LIST ===== function UpdateList() - global Digitize; + global Digitize + % Get controls ctrl = bst_get('PanelControls', 'Digitize'); % Define the model @@ -684,12 +979,18 @@ function UpdateList() ctrl.jListCoord.setModel(listModel); ctrl.jListCoord.repaint(); drawnow; - % Scroll down + % Scroll to last collected point (non-empty Loc), +1 if more points listed. lastIndex = min(listModel.getSize(), 12 + nRecEEG + nHeadShape); - selRect = ctrl.jListCoord.getCellBounds(lastIndex-1, lastIndex-1); - %ctrl.jListCoord.scrollRectToVisible(selRect); - ctrl.jListCoord.repaint(); - ctrl.jListCoord.getParent().getParent().repaint(); + if listModel.getSize() > lastIndex + ctrl.jListCoord.ensureIndexIsVisible(lastIndex); % 0-indexed + else + ctrl.jListCoord.ensureIndexIsVisible(lastIndex-1); % 0-indexed, -1 works even if 0 + end + + % Update tooltip text for 'Auto' button + if strcmpi(Digitize.Type, '3DScanner') + ctrl.jButtonEEGAutoDetectElectrodes.setToolTipText(GenerateTooltipTextAuto()); + end end @@ -724,15 +1025,89 @@ function SetSelectedButton(iButton) end end +%% 3DSCANNER: AUTOMATICALLY DETECT AND LABEL EEG CAP ELECTRODES +function EEGAutoDetectElectrodes(h, ev) + global Digitize + + % Add disclaimer to users that 'Auto' feature is experimental + if ~java_dialog('confirm', [' Automatic detection of EEG sensors is an experimental feature.
' ... + 'Please verify the results carefully.

' ... + 'Do you want to continue?'], 'Auto detect EEG electrodes') + return + end + % Get controls + ctrl = bst_get('PanelControls', 'Digitize'); + % Disable Auto button + ctrl.jButtonEEGAutoDetectElectrodes.setEnabled(0); + % Progress bar + bst_progress('start', Digitize.Type, 'Automatic labelling of EEG sensors...'); + + % Get current montage + curMontage = GetCurrentMontage(); + isWhiteCap = 0; + % For white caps change the color space by inverting the colors + % NOTE: only 'Acticap' is the tested white cap (needs work on finding a better aprrooach) + if ~isempty(regexp(curMontage.Name, 'ActiCap', 'match')) + isWhiteCap = 1; + end + + % Get the cap surface from 3D scanner + hFig = bst_figures('GetCurrentFigure','3D'); + TessInfo = getappdata(hFig, 'Surface'); + sSurf = bst_memory('LoadSurface', TessInfo.SurfaceFile); + + % Automatically find electrodes locations on EEG cap + [capCenters2d, capImg2d, surface3dscannerUv] = channel_detect_eegcap_auto('FindElectrodesEegCap', sSurf, isWhiteCap); + DigitizeOptions = bst_get('DigitizeOptions'); + if isempty(DigitizeOptions.Montages(DigitizeOptions.iMontage).ChannelFile) + bst_error('EEG cap layout not selected. Go to EEG', Digitize.Type, 1); + bst_progress('stop'); + return; + else + ChannelMat = in_bst_channel(DigitizeOptions.Montages(DigitizeOptions.iMontage).ChannelFile); + end + + + % Warp points from layout to mesh + capPoints3d = channel_detect_eegcap_auto('WarpLayout2Mesh', capCenters2d, capImg2d, surface3dscannerUv, ChannelMat.Channel, Digitize.Points); + + % Plot the electrodes and their labels + for i= 1:size(capPoints3d.EEG, 1) + % Get current montage + [curMontage, nEEG] = GetCurrentMontage(); + % Find found point in current montage + [~, iPoint] = ismember(capPoints3d.Label{i}, [curMontage.Labels]); + % Transform coordinate + Digitize.Points.EEG(iPoint,:) = capPoints3d.EEG(i, :); + % Add the point to the display + Digitize.Points.Label{iPoint} = cell2mat(capPoints3d.Label{i}); + PlotCoordinate(Digitize.Points.EEG(iPoint,:), Digitize.Points.Label{iPoint}, 'EEG', iPoint) + % Update text field counter to the next point in the list + nextPoint = max(size(Digitize.Points.EEG,1)+1, 1); + if nextPoint > nEEG + % All EEG points have been collected, switch to next mode + ctrl.jTextFieldEEG.setText(java.lang.String.valueOf(int16(nEEG))); + SwitchToNewMode(8); + else + ctrl.jTextFieldEEG.setText(java.lang.String.valueOf(int16(nextPoint))); + end + end + + UpdateList(); + % Enable Random button + ctrl.jButtonRandomHeadPts.setEnabled(1); + bst_progress('stop'); +end %% ===== MANUAL COLLECT CALLBACK ====== -function ManualCollect_Callback(h, ev) +function ManualCollect_Callback() global Digitize + % Get Digitize options DigitizeOptions = bst_get('DigitizeOptions'); % Simulation: call the callback directly if DigitizeOptions.isSimulate - BytesAvailable_Callback(h, ev); + BytesAvailable_Callback([], []); % Else: Send a collection request to the Polhemus else % User clicked the button, collect a point @@ -741,14 +1116,97 @@ function ManualCollect_Callback(h, ev) end end +%% ===== COLLECT RANDOM HEADPOINTS ===== +function CollectRandomHeadPts_Callback(h, ev) + global Digitize; + % Get controls + ctrl = bst_get('PanelControls', 'Digitize'); + % Disable Random button + ctrl.jButtonRandomHeadPts.setEnabled(0); + % Progress bar + bst_progress('start', Digitize.Type, 'Plotting 150 random head shape points...'); + + hFig = bst_figures('GetCurrentFigure','3D'); + TessInfo = getappdata(hFig, 'Surface'); + TessMat = bst_memory('LoadSurface', TessInfo.SurfaceFile); + + % Brainstorm recommends to collect approximately 100-150 points from the head + % 5-10 points from the boney part of the nose + PlotHeadShapePoints(TessMat.Vertices, 'nose', 10); + % 10-20 points across the left eyebrow + PlotHeadShapePoints(TessMat.Vertices, 'leyebrow', 20); + % 10-20 points across the right eyebrow + PlotHeadShapePoints(TessMat.Vertices, 'reyebrow', 20); + % 100 points on the scalp + PlotHeadShapePoints(TessMat.Vertices, 'scalp', 100); + + UpdateList(); + bst_progress('stop'); +end + +%% ===== PLOT HEAD SHAPE POINTS ===== +function PlotHeadShapePoints(Vertices, plotRegion, nPoints) + global Digitize + % Get controls + ctrl = bst_get('PanelControls', 'Digitize'); + + % Get the plotting parameters based on the region in the head + switch plotRegion + case 'nose' + nosePoint = Digitize.Points.nasion; + % Get 600 nearest points to the 'nosePoint' and choose 'nPoints' from it + nearPointsIdx = bst_nearest(Vertices, nosePoint, 600, 0, []); + range = length(nearPointsIdx); + stepFactor = range/nPoints; + case 'leyebrow' + lEyebrowPoint = (1.25 * Digitize.Points.nasion) + (0.5 * Digitize.Points.LPA); + % Get 400 nearest points to the 'lEyebrowPoint' and choose 'nPoints' from it + nearPointsIdx = bst_nearest(Vertices, lEyebrowPoint, 400, 0, []); + range = length(nearPointsIdx); + stepFactor = range/nPoints; + case 'reyebrow' + rEyebrowPoint = (1.25 * Digitize.Points.nasion) + (0.5 * Digitize.Points.RPA); + % Get 400 nearest points to the 'rEyebrowPoint' and choose 'nPoints' from it + nearPointsIdx = bst_nearest(Vertices, rEyebrowPoint, 400, 0, []); + range = length(nearPointsIdx); + stepFactor = range/nPoints; + case 'scalp' + range = length(Vertices); + stepFactor = ceil(range/nPoints); + otherwise + bst_error([plotRegion 'is invalid'], 'Plot Head Shape Points', 0); + bst_progress(stop); + return + end + + % Plot the head shape points + for i= 1:stepFactor:range + if strcmpi(plotRegion, 'scalp') + pointCoord = Vertices(i, :); + else + pointCoord = Vertices(nearPointsIdx(i), :); + end + % Find the index for the current point in the headshape points + iPoint = str2double(ctrl.jTextFieldExtra.getText()); + % Transformed points_pen from original points_pen + Digitize.Points.headshape(iPoint,:) = pointCoord; + % Add the point to the display (in cm) + PlotCoordinate(Digitize.Points.headshape(iPoint,:), 'EXTRA', 'EXTRA', iPoint) + % Update text field counter to the next point in the list + nextPoint = iPoint+1; + ctrl.jTextFieldExtra.setText(java.lang.String.valueOf(int16(nextPoint))); + end +end + %% ===== DELETE POINT CALLBACK ===== function DeletePoint_Callback(h, ev) %#ok global Digitize + % Get controls ctrl = bst_get('PanelControls', 'Digitize'); DigitizeOptions = bst_get('DigitizeOptions'); - % only remove cardinal points when MEG coils are used for the + % Only remove cardinal points when MEG coils are used for the % transformation. if ismember(Digitize.Mode, [4 5 6]) && DigitizeOptions.isMEG % Remove the last cardinal point that was collected @@ -761,30 +1219,30 @@ function DeletePoint_Callback(h, ev) %#ok end if Digitize.Mode == 7 - % find the last EEG point collected + % Find the last EEG point collected iPoint = str2double(ctrl.jTextFieldEEG.getText()) - 1; if iPoint == 0 - % if no EEG points are collected, delete the last cardinal point + % If no EEG points are collected, delete the last cardinal point iPoint = size(Digitize.Points.nasion,1); coordInd = (iPoint-1)*3; point_type = 'cardinal'; else - % delete last EEG point + % Delete last EEG point point_type = 'eeg'; end elseif Digitize.Mode == 8 - % headshape point + % Headshape point iPoint = str2double(ctrl.jTextFieldExtra.getText()) - 1; if iPoint == 0 % If no headpoints are collected: [tmp, nEEG] = GetCurrentMontage(); if nEEG > 0 - % check for EEG, then delete the last point + % Check for EEG, then delete the last point iPoint = str2double(ctrl.jTextFieldEEG.getText()); point_type = 'eeg'; else - % delete the last cardinal point + % Delete the last cardinal point iPoint = size(Digitize.Points.nasion,1); coordInd = (iPoint-1)*3; point_type = 'cardinal'; @@ -818,6 +1276,7 @@ function DeletePoint_Callback(h, ev) %#ok end case 'eeg' Digitize.Points.EEG(iPoint,:) = []; + Digitize.Points.Label{iPoint} = {}; RemoveCoordinates('EEG', iPoint); ctrl.jTextFieldEEG.setText(java.lang.String.valueOf(int16(iPoint))); SwitchToNewMode(7) @@ -828,25 +1287,23 @@ function DeletePoint_Callback(h, ev) %#ok SwitchToNewMode(8) end - - % Update coordinates list UpdateList(); end - %% ===== COMPUTE TRANFORMATION ===== function ComputeTransform() global Digitize + % Get controls ctrl = bst_get('PanelControls', 'Digitize'); % Get options DigitizeOptions = bst_get('DigitizeOptions'); - % if MEG coils are used, these will determine the coodinate system + % If MEG coils are used, these will determine the coodinate system if DigitizeOptions.isMEG - % find the difference between the first two collections to determine error + % Find the difference between the first two collections to determine error if (size(Digitize.Points.hpiN, 1) > 1) diffPoint = Digitize.Points.hpiN(1,:) - Digitize.Points.hpiN(2,:); normPoint(1) = sum(sqrt(diffPoint(1)^2+diffPoint(2)^2+diffPoint(3)^2)); @@ -858,14 +1315,13 @@ function ComputeTransform() ctrl.jLabelCoilMessage.setText('Difference error exceeds 5 mm'); end end - % find the average across collections to compute the transformation + % Find the average across collections to compute the transformation na = mean(Digitize.Points.hpiN,1); % these values are in meters la = mean(Digitize.Points.hpiL,1); ra = mean(Digitize.Points.hpiR,1); else - % if only EEG is used, the cardinal points will determine the - % coordinate system + % If only EEG is used, the cardinal points will determine the coordinate system % find the difference between the first two collections to determine error if (size(Digitize.Points.nasion, 1) > 1) @@ -949,7 +1405,8 @@ function ComputeTransform() %% ===== CREATE FIGURE ===== function CreateHeadpointsFigure() - global Digitize + global Digitize + if isempty(Digitize.hFig) || ~ishandle(Digitize.hFig) || isempty(Digitize.iDS) % Get study sStudy = bst_get('StudyWithCondition', [Digitize.SubjectName '/' Digitize.ConditionName]); @@ -958,10 +1415,10 @@ function CreateHeadpointsFigure() % Hide head surface panel_surface('SetSurfaceTransparency', hFig, 1, 0.8); % Get Digitizer JFrame - bstContainer = get(bst_get('Panel','Digitize'), 'container'); + bstContainer = get(bst_get('Panel', 'Digitize'), 'container'); % Get maximum figure position decorationSize = bst_get('DecorationSize'); - [jBstArea, FigArea] = gui_layout('GetScreenBrainstormAreas', bstContainer.handle{1}); + [~, FigArea] = gui_layout('GetScreenBrainstormAreas', bstContainer.handle{1}); FigPos = FigArea(1,:) + [decorationSize(1), decorationSize(4), - decorationSize(1) - decorationSize(3), - decorationSize(2) - decorationSize(4)]; if (FigPos(3) > 0) && (FigPos(4) > 0) set(hFig, 'Position', FigPos); @@ -971,12 +1428,56 @@ function CreateHeadpointsFigure() % Save handles in global variable Digitize.hFig = hFig; Digitize.iDS = iDS; - end + else + % Hide figure + set(Digitize.hFig, 'Visible', 'off'); + % Get study + sStudy = bst_get('StudyWithCondition', [Digitize.SubjectName '/' Digitize.ConditionName]); + % Plot head points + [hFig, iDS] = view_headpoints(file_fullpath(sStudy.Channel.FileName)); + % Get the surface + sSurf = bst_memory('LoadSurface', Digitize.surfaceFile); + % Apply the transformation + sSurf.Vertices = (Digitize.Points.trans * [sSurf.Vertices ones(size(sSurf.Vertices, 1),1)]')'; + % Remove the surface + panel_surface('RemoveSurface', hFig, 1); + % Deface the surface + if isempty(regexp(sSurf.Comment, 'defaced', 'match')) + sSurf = tess_deface(sSurf); + end + % Save the surface and update the node + ProtocolInfo = bst_get('ProtocolInfo'); + surfaceFile = bst_fullfile(ProtocolInfo.SUBJECTS, Digitize.surfaceFile); + bst_save(surfaceFile, sSurf, 'v7'); + [~, iSubject] = bst_get('Subject', Digitize.SubjectName); + db_reload_subjects(iSubject); + % Hide head surface + if ~strcmpi(Digitize.Type, '3DScanner') + panel_surface('SetSurfaceTransparency', hFig, 1, 0.8); + end + % Get Digitizer JFrame + bstContainer = get(bst_get('Panel', 'Digitize'), 'container'); + % Get maximum figure position + decorationSize = bst_get('DecorationSize'); + [~, FigArea] = gui_layout('GetScreenBrainstormAreas', bstContainer.handle{1}); + FigPos = FigArea(1,:) + [decorationSize(1), decorationSize(4), - decorationSize(1) - decorationSize(3), - decorationSize(2) - decorationSize(4)]; + % Display updated surface + view_surface_matrix(sSurf.Vertices, sSurf.Faces, [], sSurf.Color, hFig, [], Digitize.surfaceFile); + if (FigPos(3) > 0) && (FigPos(4) > 0) + set(hFig, 'Position', FigPos); + end + % Remove the close handle function + set(hFig, 'CloseRequestFcn', []); + % Save handles in global variable + Digitize.hFig = hFig; + Digitize.iDS = iDS; + end end %% ===== PLOT POINTS ===== function PlotCoordinate(Loc, Label, Type, iPoint) global Digitize GlobalData + sStudy = bst_get('StudyWithCondition', [Digitize.SubjectName '/' Digitize.ConditionName]); ChannelFile = file_fullpath(sStudy.Channel.FileName); ChannelMat = load(ChannelFile); @@ -984,7 +1485,7 @@ function PlotCoordinate(Loc, Label, Type, iPoint) % Add EEG sensor locations to channel stucture if strcmp(Type, 'EEG') if isempty(ChannelMat.Channel) - % first point in the list + % First point in the list ChannelMat.Channel = db_template('channeldesc'); end ChannelMat.Channel(iPoint).Name = Label; @@ -1010,19 +1511,22 @@ function PlotCoordinate(Loc, Label, Type, iPoint) figure_3d('ViewHeadPoints', Digitize.hFig, 1); figure_3d('ViewSensors',Digitize.hFig, 1, 1, 0,'EEG'); % Hide head surface - panel_surface('SetSurfaceTransparency', Digitize.hFig, 1, 1); + if ~strcmpi(Digitize.Type, '3DScanner') + panel_surface('SetSurfaceTransparency', Digitize.hFig, 1, 1); + end end %% ===== EEG CHANGE POINT CALLBACK ===== function EEGChangePoint_Callback(h, ev) %#ok global Digitize + % Get controls ctrl = bst_get('PanelControls', 'Digitize'); % Get digitize options DigitizeOptions = bst_get('DigitizeOptions'); initPoint = str2num(ctrl.jTextFieldEEG.getText()); - % restrict to a maximum of points collected or defined max points and minimum of '1' + % Restrict to a maximum of points collected or defined max points and minimum of '1' [curMontage, nEEG] = GetCurrentMontage(); newPoint = max(min(initPoint, min(length(Digitize.Points.EEG)+1, nEEG)), 1); ctrl.jTextFieldEEG.setText(java.lang.String.valueOf(int16(newPoint))); @@ -1031,11 +1535,12 @@ function EEGChangePoint_Callback(h, ev) %#ok %% ===== EXTRA CHANGE POINT CALLBACK ===== function ExtraChangePoint_Callback(h, ev) %#ok global Digitize + % Get controls ctrl = bst_get('PanelControls', 'Digitize'); initPoint = str2num(ctrl.jTextFieldExtra.getText()); %#ok<*ST2NM> - % restrict to a maximum of points collected and minimum of '1' + % Restrict to a maximum of points collected and minimum of '1' newPoint = max(min(initPoint, length(Digitize.Points.headshape)+1), 1); ctrl.jTextFieldExtra.setText(java.lang.String.valueOf(int16(newPoint))); end @@ -1043,6 +1548,7 @@ function ExtraChangePoint_Callback(h, ev) %#ok %% ===== SAVE CALLBACK ===== function Save_Callback(h, ev) %#ok global Digitize + sStudy = bst_get('StudyWithCondition', [Digitize.SubjectName '/' Digitize.ConditionName]); ChannelFile = file_fullpath(sStudy.Channel.FileName); export_channel( ChannelFile ); @@ -1050,6 +1556,9 @@ function Save_Callback(h, ev) %#ok %% ===== CREATE MONTAGE MENU ===== function CreateMontageMenu(jMenu) + import org.brainstorm.icon.*; + global Digitize + % Get menu pointer if not in argument if (nargin < 1) || isempty(jMenu) ctrl = bst_get('PanelControls', 'Digitize'); @@ -1073,13 +1582,24 @@ function CreateMontageMenu(jMenu) end % Add new montage / reset list jMenu.addSeparator(); - gui_component('MenuItem', jMenu, [], 'Add EEG montage...', [], [], @(h,ev)bst_call(@AddMontage), []); + + if strcmpi(Digitize.Type, '3DScanner') + jMenuAddMontage = gui_component('Menu', jMenu, [], 'Add EEG montage...', [], [], [], []); + gui_component('MenuItem', jMenuAddMontage, [], 'From file...', [], [], @(h,ev)bst_call(@AddMontage), []); + % Creating montages from EEG cap layout mat files (only for 3DScanner) + jMenuEegCaps = gui_component('Menu', jMenuAddMontage, [], 'From default EEG cap', IconLoader.ICON_CHANNEL, [], [], []); + % Use default channel file + menu_default_eegcaps(jMenuEegCaps); + else % if not 3DScanner + gui_component('MenuItem', jMenu, [], 'Add EEG montage...', [], [], @(h,ev)bst_call(@AddMontage), []); + end gui_component('MenuItem', jMenu, [], 'Unload all montages', [], [], @(h,ev)bst_call(@UnloadAllMontages), []); end - %% ===== SELECT MONTAGE ===== function SelectMontage(iMontage) + global Digitize + % Get Digitize options DigitizeOptions = bst_get('DigitizeOptions'); % Default montage: ask for number of channels @@ -1118,6 +1638,20 @@ function SelectMontage(iMontage) ResetDataCollection(); end +%% ===== TOOLTIP TEXT FOR AUTO BUTTON ===== +function autoButtonTooltip = GenerateTooltipTextAuto() + % Get current montage + curMontage = GetCurrentMontage(); + % Get cap landmark labels for selected montage + eegCapLandmarkLabels = channel_detect_eegcap_auto('GetEegCapLandmarkLabels', curMontage.Name); + autoButtonTooltip = 'Auto localization of EEG sensor is not suported for this cap.'; + if ~isempty(eegCapLandmarkLabels) + strSensors = sprintf('%s, ',eegCapLandmarkLabels{:}); + strSensors = strSensors(1:end-2); + autoButtonTooltip = ['Set at least sensors: [' strSensors '] to enable.']; + end +end + %% ===== GET CURRENT MONTAGE ===== function [curMontage, nEEG] = GetCurrentMontage() % Get Digitize options @@ -1128,46 +1662,69 @@ function SelectMontage(iMontage) end %% ===== ADD EEG MONTAGE ===== -function AddMontage() - % Get recently used folders - LastUsedDirs = bst_get('LastUsedDirs'); - % Open file - MontageFile = java_getfile('open', 'Select montage file...', LastUsedDirs.ImportChannel, 'single', 'files', ... - {{'*.txt'}, 'Text files', 'TXT'}, 0); - if isempty(MontageFile) - return; - end - % Get filename - [MontageDir, MontageName] = bst_fileparts(MontageFile); - % Intialize new montage - newMontage.Name = MontageName; - newMontage.Labels = {}; - - % Open file - fid = fopen(MontageFile,'r'); - if (fid == -1) - error('Cannot open file.'); - end - % Read file - while (1) - tline = fgetl(fid); - if ~ischar(tline) - break; +function AddMontage(ChannelFile) + global Digitize + + % Add Montage from text file + if nargin<1 + % Get recently used folders + LastUsedDirs = bst_get('LastUsedDirs'); + % Open file + MontageFile = java_getfile('open', 'Select montage file...', LastUsedDirs.ImportChannel, 'single', 'files', ... + {{'*.txt'}, 'Text files', 'TXT'}, 0); + if isempty(MontageFile) + return; end - spl = regexp(tline,'\s+','split'); - if (length(spl) >= 2) - newMontage.Labels{end+1} = spl{2}; + % Get filename + [MontageDir, MontageName] = bst_fileparts(MontageFile); + % Intialize new montage + newMontage.Name = MontageName; + newMontage.Labels = {}; + + % Open file + fid = fopen(MontageFile,'r'); + if (fid == -1) + error('Cannot open file.'); end + % Read file + while (1) + tline = fgetl(fid); + if ~ischar(tline) + break; + end + spl = regexp(tline,'\s+','split'); + if (length(spl) >= 2) + newMontage.Labels{end+1} = spl{2}; + end + end + % Close file + fclose(fid); + % If no labels were read: exit + if isempty(newMontage.Labels) + return + end + % Save last dir + LastUsedDirs.ImportChannel = MontageDir; + bst_set('LastUsedDirs', LastUsedDirs); + % Add Montage from mat file of EEG caps + else + % Load existing file + ChannelMat = in_bst_channel(ChannelFile); + + % Intialize new montage + newMontage.Name = ChannelMat.Comment; + newMontage.Labels = {}; + newMontage.ChannelFile = ChannelFile; + + % Get cap landmark labels + eegCapLandmarkLabels = channel_detect_eegcap_auto('GetEegCapLandmarkLabels', newMontage.Name); + + % Sort as per the initialization landmark labels of EEG Cap + nonLandmarkLabelsIdx = find(~ismember({ChannelMat.Channel.Name},eegCapLandmarkLabels)); + allLabels = {ChannelMat.Channel.Name}; + newMontage.Labels = cat(2, eegCapLandmarkLabels, allLabels(nonLandmarkLabelsIdx)); end - % Close file - fclose(fid); - % If no labels were read: exit - if isempty(newMontage.Labels) - return - end - % Save last dir - LastUsedDirs.ImportChannel = MontageDir; - bst_set('LastUsedDirs', LastUsedDirs); + % Get Digitize options DigitizeOptions = bst_get('DigitizeOptions'); @@ -1187,8 +1744,13 @@ function AddMontage() bst_set('DigitizeOptions', DigitizeOptions); % Reload Menu CreateMontageMenu(); - % Restart acquisition - ResetDataCollection(); + if nargin<1 + % Restart acquisition + ResetDataCollection(); + else + % Update List + UpdateList(); + end end %% ===== UNLOAD ALL MONTAGES ===== @@ -1198,15 +1760,19 @@ function UnloadAllMontages() % Remove all montages DigitizeOptions.Montages = [... struct('Name', 'No EEG', ... - 'Labels', []), ... + 'Labels', [], ... + 'ChannelFile', []), ... struct('Name', 'Default', ... - 'Labels', [])]; + 'Labels', [], ... + 'ChannelFile', [])]; % Reset to "No EEG" DigitizeOptions.iMontage = 1; % Save Digitize options bst_set('DigitizeOptions', DigitizeOptions); % Reload menu bar CreateMontageMenu(); + % Update List + UpdateList(); end @@ -1221,37 +1787,42 @@ function RemoveCoordinates(type, iPoint) ChannelFile = file_fullpath(sStudy.Channel.FileName); ChannelMat = load(ChannelFile); if isempty(type) - % remove all points + % Remove all points ChannelMat.HeadPoints = []; ChannelMat.Channel = []; % Save file back save(ChannelFile, '-struct', 'ChannelMat'); - % close the figure + % Close the figure if ishandle(Digitize.hFig) - %close(Digitize.hFig); + % close(Digitize.hFig); bst_figures('DeleteFigure', Digitize.hFig, []); end else % find group and remove selected point if isempty(iPoint) - % create a mask of points to keep that exclude the specified + % Create a mask of points to keep that exclude the specified % type mask = cellfun(@isempty,regexp([ChannelMat.HeadPoints.Type], type)); ChannelMat.HeadPoints.Loc = ChannelMat.HeadPoints.Loc(:,mask); ChannelMat.HeadPoints.Label = ChannelMat.HeadPoints.Label(mask); ChannelMat.HeadPoints.Type = ChannelMat.HeadPoints.Type(mask); if strcmp(type, 'EEG') - % remove all EEG channels from channel struct + % Remove all EEG channels from channel struct ChannelMat.Channel = []; end else - % find the point in the type and create a mask of points to keep + % Find the point in the type and create a mask of points to keep % that excludes the specified point if strcmp(type, 'EEG') - % remove specific EEG channel - ChannelMat.Channel(iPoint) = []; + if strcmpi(Digitize.Type, '3DScanner') + % Remove only location + ChannelMat.Channel(iPoint).Loc = []; + else + % Remove specific EEG channel + ChannelMat.Channel(iPoint) = []; + end else - % all other types + % All other types iType = find(~cellfun(@isempty,regexp([ChannelMat.HeadPoints.Type], type))); mask = true(1,size(ChannelMat.HeadPoints.Type,2)); iDelete = iType(iPoint); @@ -1262,7 +1833,7 @@ function RemoveCoordinates(type, iPoint) end end - % save changes + % Save changes save(ChannelFile, '-struct', 'ChannelMat'); GlobalData.DataSet(Digitize.iDS).HeadPoints = ChannelMat.HeadPoints; GlobalData.DataSet(Digitize.iDS).Channel = ChannelMat.Channel; @@ -1277,7 +1848,7 @@ function RemoveCoordinates(type, iPoint) % View headpoints figure_3d('ViewHeadPoints', Digitize.hFig, 1); - % manually remove any remaining EEG markers if the channel file is + % Manually remove any remaining EEG markers if the channel file is % empty. if isempty(ChannelMat.Channel) hSensorMarkers = findobj(hAxes, 'Tag', 'SensorsMarkers'); @@ -1286,7 +1857,7 @@ function RemoveCoordinates(type, iPoint) delete(hSensorLabels); end - % view EEG sensors + % View EEG sensors figure_3d('ViewSensors',Digitize.hFig, 1, 1, 0,'EEG'); end end @@ -1299,6 +1870,7 @@ function RemoveCoordinates(type, iPoint) %% ===== CREATE SERIAL COLLECTION ===== function isOk = CreateSerialConnection(h, ev) %#ok global Digitize + isOk = 0; while ~isOk % Get COM port options @@ -1318,7 +1890,7 @@ function RemoveCoordinates(type, iPoint) if strcmp(DigitizeOptions.UnitType,'patriot') SerialConnection.terminator = 'CR'; end - % set up the Bytes Available function and open the connection (if needed) + % Set up the Bytes Available function and open the connection (if needed) SerialConnection.BytesAvailableFcnCount = DigitizeOptions.ComByteCount; SerialConnection.BytesAvailableFcnMode = 'byte'; SerialConnection.BytesAvailableFcn = @BytesAvailable_Callback; @@ -1340,7 +1912,7 @@ function RemoveCoordinates(type, iPoint) pause(0.2); catch %#ok % If the connection cannot be established: error message - bst_error(['Cannot open serial connection.' 10 10 'Please check the serial port configuration.' 10], 'Digitize', 0); + bst_error(['Cannot open serial connection.' 10 10 'Please check the serial port configuration.' 10], Digitize.Type, 0); % Ask user to edit the port options isChanged = EditSettings(); % If edit was canceled: exit @@ -1361,8 +1933,9 @@ function RemoveCoordinates(type, iPoint) %% ===== BYTES AVAILABLE CALLBACK ===== -function BytesAvailable_Callback(h, ev) %#ok +function BytesAvailable_Callback(h, ev) global Digitize rawpoints + % Get controls ctrl = bst_get('PanelControls', 'Digitize'); % Get digitizer options @@ -1370,14 +1943,34 @@ function BytesAvailable_Callback(h, ev) %#ok % Simulate: Generate random points if DigitizeOptions.isSimulate - switch (Digitize.Mode) - case 1, pointCoord = [.08 0 -.01]; - case 2, pointCoord = [-.01 .07 0]; - case 3, pointCoord = [-.01 -.07 0]; - case 4, pointCoord = [.08 0 0]; - case 5, pointCoord = [0 .07 0]; - case 6, pointCoord = [0 -.07 0]; - otherwise, pointCoord = rand(1,3) * .15 - .075; + if strcmpi(Digitize.Type, '3DScanner') + % Get current 3D figure + [hFig,~,iDS] = bst_figures('GetCurrentFigure', '3D'); + if isempty(hFig) + return + end + % Get current selected point + CoordinatesSelector = getappdata(hFig, 'CoordinatesSelector'); + isSelectingCoordinates = getappdata(hFig, 'isSelectingCoordinates'); + if isempty(CoordinatesSelector) || isempty(CoordinatesSelector.MRI) + return; + else + if isSelectingCoordinates + pointCoord = CoordinatesSelector.SCS; + end + end + Digitize.hFig = hFig; + Digitize.iDS = iDS; + else + switch (Digitize.Mode) + case 1, pointCoord = [.08 0 -.01]; + case 2, pointCoord = [-.01 .07 0]; + case 3, pointCoord = [-.01 -.07 0]; + case 4, pointCoord = [.08 0 0]; + case 5, pointCoord = [0 .07 0]; + case 6, pointCoord = [0 -.07 0]; + otherwise, pointCoord = rand(1,3) * .15 - .075; + end end % Else: Get digitized point coordinates else @@ -1424,16 +2017,10 @@ function BytesAvailable_Callback(h, ev) %#ok end % Beep at each click AND not for headshape points if DigitizeOptions.isBeep - % Beep not working in compiled version, replacing with this: - if bst_iscompiled() && (Digitize.Mode ~= 8) - sound(Digitize.BeepWav(6000:2:16000,1), 22000); - else - beep on; - beep(); - end + sound(Digitize.BeepWav.data, Digitize.BeepWav.fs); end - % check for change in Mode (click at least 1 meter away from transmitter) - if any(abs(pointCoord) > 1.5) + % Check for change in Mode (click at least 1 meter away from transmitter) + if ~strcmpi(Digitize.Type, '3DScanner') && any(abs(pointCoord) > 1.5) newMode = Digitize.Mode +1; SwitchToNewMode(newMode); return; @@ -1452,7 +2039,7 @@ function BytesAvailable_Callback(h, ev) %#ok Digitize.Points.hpiN(iPoint,:) = (Digitize.Points.trans * [pointCoord 1]')'; PlotCoordinate(Digitize.Points.hpiN(iPoint,:), 'HPI-N', 'HPI', iPoint); else - % used to compute transform + % Used to compute transform Digitize.Points.hpiN(iPoint,:) = pointCoord; end SwitchToNewMode(2); @@ -1464,7 +2051,7 @@ function BytesAvailable_Callback(h, ev) %#ok Digitize.Points.hpiL(iPoint,:) = (Digitize.Points.trans * [pointCoord 1]')'; PlotCoordinate(Digitize.Points.hpiL(iPoint,:), 'HPI-L', 'HPI', iPoint); else - % used to compute transform + % Used to compute transform Digitize.Points.hpiL(iPoint,:) = pointCoord; end SwitchToNewMode(3); @@ -1476,7 +2063,7 @@ function BytesAvailable_Callback(h, ev) %#ok Digitize.Points.hpiR(iPoint,:) = (Digitize.Points.trans * [pointCoord 1]')'; PlotCoordinate(Digitize.Points.hpiR(iPoint,:), 'HPI-R', 'HPI', iPoint); else - % used to compute transform + % Used to compute transform Digitize.Points.hpiR(iPoint,:) = pointCoord; end % Check for multiple fiducial measurements @@ -1547,43 +2134,75 @@ function BytesAvailable_Callback(h, ev) %#ok % === EEG === case 7 - % find the index for the current point - iPoint = str2double(ctrl.jTextFieldEEG.getText()); - % Transform coordinate - Digitize.Points.EEG(iPoint,:) = (Digitize.Points.trans * [pointCoord 1]')'; + % Find the index for the current point + % ADD A CONDITION HERE THAT EDITS EXISTING EEG POINTS + if Digitize.isEditPts + [~, iSelCoord] = GetSelectedCoord(); + iPoint = iSelCoord - 3; + else + % ELSE DOES THE BELOW OF ADDING NEW POINTS + iPoint = str2double(ctrl.jTextFieldEEG.getText()); + end + if strcmpi(Digitize.Type, '3DScanner') + Digitize.Points.EEG(iPoint,:) = pointCoord; + else + % Transform coordinate + Digitize.Points.EEG(iPoint,:) = (Digitize.Points.trans * [pointCoord 1]')'; + end % Add the point to the display % Get current montage [curMontage, nEEG] = GetCurrentMontage(); - PlotCoordinate(Digitize.Points.EEG(iPoint,:), curMontage.Labels{iPoint}, 'EEG', iPoint) - % update text field counter to the next point in the list - nextPoint = max(size(Digitize.Points.EEG,1)+1, 1); - if nextPoint > nEEG - % all EEG points have been collected, switch to next mode - ctrl.jTextFieldEEG.setText(java.lang.String.valueOf(int16(nEEG))); - SwitchToNewMode(8); + Digitize.Points.Label{iPoint} = curMontage.Labels{iPoint}; + PlotCoordinate(Digitize.Points.EEG(iPoint,:), Digitize.Points.Label{iPoint}, 'EEG', iPoint) + + if Digitize.isEditPts + Digitize.isEditPts = 0; else - ctrl.jTextFieldEEG.setText(java.lang.String.valueOf(int16(nextPoint))); + % Update text field counter to the next point in the list + nextPoint = max(size(Digitize.Points.EEG,1)+1, 1); + if nextPoint > nEEG + % All EEG points have been collected, switch to next mode + ctrl.jTextFieldEEG.setText(java.lang.String.valueOf(int16(nEEG))); + SwitchToNewMode(8); + else + ctrl.jTextFieldEEG.setText(java.lang.String.valueOf(int16(nextPoint))); + end end % === EXTRA === case 8 - % find the index for the current point in the headshape points + % Find the index for the current point in the headshape points iPoint = str2double(ctrl.jTextFieldExtra.getText()); - % Transformed points_pen from original points_pen - Digitize.Points.headshape(iPoint,:) = (Digitize.Points.trans * [pointCoord 1]')'; - % add the point to the display (in cm) + if strcmpi(Digitize.Type, '3DScanner') + Digitize.Points.headshape(iPoint,:) = pointCoord; + else + % Transformed points_pen from original points_pen + Digitize.Points.headshape(iPoint,:) = (Digitize.Points.trans * [pointCoord 1]')'; + end + % Add the point to the display (in cm) PlotCoordinate(Digitize.Points.headshape(iPoint,:), 'EXTRA', 'EXTRA', iPoint) - % update text field counter to the next point in the list + % Update text field counter to the next point in the list nextPoint = iPoint+1; ctrl.jTextFieldExtra.setText(java.lang.String.valueOf(int16(nextPoint))); end % Update coordinates list UpdateList(); + % Enable 'Auto' button IFF all landmark fiducials have been acquired + if strcmpi(Digitize.Type, '3DScanner') && (Digitize.Mode ~= 8) + curMontage = GetCurrentMontage(); + eegCapLandmarkLabels = channel_detect_eegcap_auto('GetEegCapLandmarkLabels', curMontage.Name); + if ~isempty(eegCapLandmarkLabels) && ~isempty(Digitize.Points.EEG) + acqPointLabels = Digitize.Points.Label(1 : size(Digitize.Points.EEG, 1)); + if all(ismember([eegCapLandmarkLabels], acqPointLabels)) + ctrl.jButtonEEGAutoDetectElectrodes.setEnabled(1); + end + end + end end %% ===== MOTION COMPENSATION ===== function newPT = DoMotionCompensation(sensors) - % use sensor one and its orientation vectors as the new coordinate system + % Use sensor one and its orientation vectors as the new coordinate system % Define the origin as the position of sensor attached to the glasses. WAND = 1; REMOTE1 = 2; @@ -1624,7 +2243,7 @@ function BytesAvailable_Callback(h, ev) %#ok rotMat(4, 1:4) = 0; - %Translate and rotate the WAND into new coordinate system + % Translate and rotate the WAND into new coordinate system pt(1) = sensors(WAND,2) - C(1); pt(2) = sensors(WAND,3) - C(2); pt(3) = sensors(WAND,4) - C(3); @@ -1634,6 +2253,3 @@ function BytesAvailable_Callback(h, ev) %#ok newPT(3) = pt(1) * rotMat(3, 1) + pt(2) * rotMat(3, 2) + pt(3) * rotMat(3, 3)'+ rotMat(3, 4); end - - - diff --git a/toolbox/sensors/panel_digitize_2024.m b/toolbox/sensors/panel_digitize_2024.m new file mode 100644 index 000000000..26db389b0 --- /dev/null +++ b/toolbox/sensors/panel_digitize_2024.m @@ -0,0 +1,1735 @@ +function varargout = panel_digitize_2024(varargin) +% PANEL_DIGITIZE_2024: Digitize EEG sensors and head shape. +% +% USAGE: panel_digitize_2024('Start') +% panel_digitize_2024('CreateSerialConnection') +% panel_digitize_2024('ResetDataCollection') +% bstPanelNew = panel_digitize_2024('CreatePanel') +% panel_digitize_2024('SetSimulate', isSimulate) Run this from command window BEFORE opening the Digitize panel. + +% @============================================================================= +% This function is part of the Brainstorm software: +% https://neuroimage.usc.edu/brainstorm +% +% Copyright (c) University of Southern California & McGill University +% This software is distributed under the terms of the GNU General Public License +% as published by the Free Software Foundation. Further details on the GPLv3 +% license can be found at http://www.gnu.org/copyleft/gpl.html. +% +% FOR RESEARCH PURPOSES ONLY. THE SOFTWARE IS PROVIDED "AS IS," AND THE +% UNIVERSITY OF SOUTHERN CALIFORNIA AND ITS COLLABORATORS DO NOT MAKE ANY +% WARRANTY, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO WARRANTIES OF +% MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE, NOR DO THEY ASSUME ANY +% LIABILITY OR RESPONSIBILITY FOR THE USE OF THIS SOFTWARE. +% +% For more information type "brainstorm license" at command prompt. +% =============================================================================@ +% +% Authors: Elizabeth Bock & Francois Tadel, 2012-2017 +% Marc Lalancette, 2024 +% Chinmay Chinara, 2024 + +eval(macro_method); +end + + +%% ======================================================================== +% ======= INITIALIZE ===================================================== +% ======================================================================== + +%% ===== START ===== +function Start(varargin) + global Digitize + % Intialize global variable + Digitize = struct(... + 'Options', bst_get('DigitizeOptions'), ... + 'Type' , [], ... + 'SerialConnection', [], ... + 'Mode', 0, ... + 'hFig', [], ... + 'iDS', [], ... + 'SubjectName', [], ... + 'ConditionName', [], ... + 'iStudy', [], ... + 'BeepWav', [], ... + 'isEditPts', 0, ... % for correcting a wrongly detected point manually + 'Points', struct(... + 'Label', [], ... + 'Type', [], ... + 'Loc', []), ... + 'iPoint', 0, ... + 'Transf', []); + + % Fix old structure (bef 2024) for Digitize.Options.Montages + if length(Digitize.Options.Montages) > 1 && ~isfield(Digitize.Options.Montages, 'ChannelFile') + Digitize.Options.Montages(end).ChannelFile = []; + end + + % ===== PARSE INPUT ===== + DigitizerType = 'Digitize'; + sSubject = []; + iSubject = []; + surfaceFile = []; + if nargin > 0 && ~isempty(varargin{1}) + DigitizerType = varargin{1}; + end + if nargin > 1 && ~isempty(varargin{2}) + sSubject = varargin{2}; + end + if nargin > 2 && ~isempty(varargin{3}) + iSubject = varargin{3}; + end + if nargin > 3 && ~isempty(varargin{4}) + surfaceFile = varargin{4}; + end + Digitize.Type = DigitizerType; + switch DigitizerType + case 'Digitize' + % Do nothing + case '3DScanner' + % Simulate + SetSimulate(1); + otherwise + bst_error(sprintf('DigitizerType : "%s" is not supported', DigitizerType)); + return + end + + % ===== PREPARE DATABASE ===== + % If no protocol: exit + if (bst_get('iProtocol') <= 0) + bst_error('Please create a protocol first.', Digitize.Type, 0); + return; + end + + % Ask for subject id + if isempty(sSubject) + Digitize.Options.PatientId = java_dialog('input', 'Please, enter subject ID:', Digitize.Type, [], Digitize.Options.PatientId); + if isempty(Digitize.Options.PatientId) + return; + end + % Save new ID + bst_set('DigitizeOptions', Digitize.Options); + + % ===== GET SUBJECT ===== + % Save the new SubjectName + if strcmpi(Digitize.Type, '3DScanner') + Digitize.SubjectName = [Digitize.Type, '_', Digitize.Options.PatientId]; + else + Digitize.SubjectName = Digitize.Type; + end + + [sSubject, iSubject] = bst_get('Subject', Digitize.SubjectName); + else + Digitize.SubjectName = sSubject.Name; + end + + % Create if subject doesnt exist + if isempty(iSubject) + % Default anat / one channel file per subject + if strcmpi(Digitize.Type, '3DScanner') + [sSubject, iSubject] = db_add_subject(Digitize.SubjectName, iSubject); + sTemplates = bst_get('AnatomyDefaults'); + db_set_template(iSubject, sTemplates(1), 1); + else + UseDefaultAnat = 1; + UseDefaultChannel = 0; + [sSubject, iSubject] = db_add_subject(Digitize.SubjectName, iSubject, UseDefaultAnat, UseDefaultChannel); + end + % Update tree + panel_protocols('UpdateTree'); + end + + % ===== INITIALIZE CONNECTION ===== + % Start Serial Connection + if ~CreateSerialConnection() + return; + end + + % ===== CREATE CONDITION ===== + % Get current date/time +% CurrentDate = char(datetime('now'), 'yyyyMMdd'); + c = clock; + % Condition name: PatientId_Date_Run + for i = 1:99 + % Generate new condition name + Digitize.ConditionName = sprintf('%s_%02d%02d%02d_%02d', Digitize.Options.PatientId, c(1), c(2), c(3), i); + % Get condition + sStudy = bst_get('StudyWithCondition', [Digitize.SubjectName '/' Digitize.ConditionName]); + % If condition doesn't exist: ok, keep this one + if isempty(sStudy) + break; + end + end + % Create condition + Digitize.iStudy = db_add_condition(Digitize.SubjectName, Digitize.ConditionName); + sStudy = bst_get('Study', Digitize.iStudy); + % Create an empty channel file in there + ChannelMat = db_template('channelmat'); + ChannelMat.Comment = Digitize.ConditionName; + % Save new channel file + ChannelFile = bst_fullfile(bst_fileparts(file_fullpath(sStudy.FileName)), ['channel_' Digitize.ConditionName '.mat']); + bst_save(ChannelFile, ChannelMat, 'v7'); + % Reload condition to update the functional nodes + db_reload_studies(Digitize.iStudy); + + if strcmpi(Digitize.Type, '3DScanner') + if isempty(surfaceFile) + % Import surface + iSurface = find(cellfun(@(x)~isempty(regexp(x, 'tess_textured', 'match')), {sSubject.Surface.FileName})); + if isempty(iSurface) + [~, surfaceFiles] = import_surfaces(iSubject); + if isempty(surfaceFiles) + return + end + surfaceFile = file_short(surfaceFiles{end}); + else + [res, isCancel] = java_dialog('question', ['There is already scanned mesh available for this subject.' 10 10 ... + 'What do you want to do?'], ... + 'Import surface', [], {'Use existing', 'Add new', 'Cancel'}, 'Use existing'); + if strcmpi(res, 'cancel') || isCancel + return + elseif strcmpi(res, 'use existing') + % If more than one surface present, user can choose + if length(iSurface) > 1 + texSurfComment = java_dialog('combo', 'Select the textured surface:

', 'Choose textured surface', [], {sSubject.Surface(iSurface).Comment}); + texSurfComment = strrep(texSurfComment, '_defaced', ''); + if isempty(texSurfComment) + return + end + iSurfFile = find(cellfun(@(x)~isempty(regexp(x, [texSurfComment '.mat'], 'match')), {sSubject.Surface.FileName})); + surfaceFile = sSubject.Surface(iSurfFile).FileName; + % If only one surface is present, then load it directly + else + surfaceFile = sSubject.Surface(iSurface(end)).FileName; + end + elseif strcmpi(res, 'add new') + % Import a new textured mesh and append it to the list + [~, surfaceFiles] = import_surfaces(iSubject); + if isempty(surfaceFiles) + return + end + surfaceFile = file_short(surfaceFiles{end}); + end + end + end + bst_progress('start', Digitize.Type, 'Loading surface file...'); + Digitize.surfaceFile = surfaceFile; + sSurf = bst_memory('LoadSurface', Digitize.surfaceFile); + % Display surface + view_surface_matrix(sSurf.Vertices, sSurf.Faces, [], sSurf.Color, [], [], Digitize.surfaceFile); + end + + % ===== DISPLAY DIGITIZE WINDOW ===== + % Display panel + % Set the window to the position of the main Bst window, which is then hidden + % Set window title to Digitize.Type + panelContainer = gui_show('panel_digitize_2024', 'JavaWindow', Digitize.Type, [], [], [], [], [0,0]); + + % Hide Brainstorm window + jBstFrame = bst_get('BstFrame'); + jBstFrame.setVisible(0); + drawnow; + + % Hard-coded window size for now + panelContainer.handle{1}.setSize(600, 600); + % Set the window to the left of the screen + loc = panelContainer.handle{1}.getLocation(); + loc.x = 0; + panelContainer.handle{1}.setLocation(loc); + + % Load beep sound + wavfile = bst_fullfile(bst_get('BrainstormHomeDir'), 'toolbox', 'sensors', 'private', 'bst_beep.wav'); + [Digitize.BeepWav.data, Digitize.BeepWav.fs] = audioread(wavfile); + + % Reset collection + ResetDataCollection(); +end + + +%% ======================================================================== +% ======= PANEL FUNCTIONS ================================================ +% ======================================================================== + +%% ===== CREATE PANEL ===== +function [bstPanelNew, panelName] = CreatePanel() + global Digitize + + % Constants + panelName = 'Digitize'; + % Java initializations + import java.awt.*; + import javax.swing.*; + import java.awt.event.KeyEvent; + import org.brainstorm.list.*; + import org.brainstorm.icon.*; + % Create new panel + jPanelNew = gui_component('Panel'); + % Font size for the lists + veryLargeFontSize = round(100 * bst_get('InterfaceScaling') / 100); + largeFontSize = round(20 * bst_get('InterfaceScaling') / 100); + fontSize = round(11 * bst_get('InterfaceScaling') / 100); + + % ===== MENU BAR ===== + jPanelMenu = gui_component('panel'); + jMenuBar = java_create('javax.swing.JMenuBar'); + jPanelMenu.add(jMenuBar, BorderLayout.NORTH); + jLabelNews = gui_component('label', jPanelMenu, BorderLayout.CENTER, ... + ['
Digitize version: "2024"
' ... + '&bull To go back to previous version: File > Switch to Digitize "legacy"   ' ... + '&bull More details: Help > Digitize tutorial'], [], [], [], fontSize); + jLabelNews.setHorizontalAlignment(SwingConstants.CENTER); + jLabelNews.setOpaque(true); + jLabelNews.setBackground(java.awt.Color.yellow); + + % ===== FILE MENU ===== + jMenu = gui_component('Menu', jMenuBar, [], 'File', [], [], [], []); + gui_component('MenuItem', jMenu, [], 'Start over', IconLoader.ICON_RELOAD, [], @(h,ev)bst_call(@ResetDataCollection, 1), []); + gui_component('MenuItem', jMenu, [], 'Edit settings...', IconLoader.ICON_EDIT, [], @(h,ev)bst_call(@EditSettings), []); + gui_component('MenuItem', jMenu, [], 'Switch to Digitize "legacy"', [], [], @(h,ev)bst_call(@SwitchVersion), []); + if ~strcmpi(Digitize.Type, '3DScanner') + gui_component('MenuItem', jMenu, [], 'Reset serial connection', IconLoader.ICON_FLIP, [], @(h,ev)bst_call(@CreateSerialConnection), []); + end + jMenu.addSeparator(); + if exist('bst_headtracking', 'file') && ~strcmpi(Digitize.Type, '3DScanner') + gui_component('MenuItem', jMenu, [], 'Start head tracking', IconLoader.ICON_ALIGN_CHANNELS, [], @(h,ev)bst_call(@(h,ev)bst_headtracking([],1,1)), []); + jMenu.addSeparator(); + end + gui_component('MenuItem', jMenu, [], 'Save as...', IconLoader.ICON_SAVE, [], @(h,ev)bst_call(@Save_Callback), []); + gui_component('MenuItem', jMenu, [], 'Save in database and exit', IconLoader.ICON_RESET, [], @(h,ev)bst_call(@Close_Callback), []); + % ===== EEG MONTAGE MENU ===== + jMenuEeg = gui_component('Menu', jMenuBar, [], 'EEG montage', [], [], [], []); + CreateMontageMenu(jMenuEeg); + % ===== HELP MENU ===== + jMenuHelp = gui_component('Menu', jMenuBar, [], 'Help', [], [], [], []); + gui_component('MenuItem', jMenuHelp, [], 'Digitize tutorial', [], [], @(h,ev)web('https://neuroimage.usc.edu/brainstorm/Tutorials/TutDigitize', '-browser'), []); + + jPanelNew.add(jPanelMenu, BorderLayout.NORTH); + + % ===== CONTROL PANEL ===== + jPanelControl = gui_component('panel'); + jPanelControl.setBorder(BorderFactory.createEmptyBorder(0,0,7,0)); + + % ===== NEXT POINT PANEL ===== + jPanelNext = gui_river([5,4], [4,4,4,4], 'Next point'); + % Next point label + jLabelNextPoint = gui_component('label', jPanelNext, [], '', [], [], [], veryLargeFontSize); + jButtonFids = gui_component('button', jPanelNext, 'br', 'Add fiducials', [], 'Add set of fiducials to digitize', @(h,ev)bst_call(@Fiducials_Callback)); + jButtonFids.setEnabled(0); + if strcmpi(Digitize.Type, '3DScanner') + jButtonEEGAutoDetectElectrodes = gui_component('button', jPanelNext, [], 'Auto', [], GenerateTooltipTextAuto(), @(h,ev)bst_call(@EEGAutoDetectElectrodes)); + else + % Separator + jButtonEEGAutoDetectElectrodes = gui_component('label', jPanelNext, 'hfill', ''); + end + jButtonEEGAutoDetectElectrodes.setEnabled(0); + jPanelControl.add(jPanelNext, BorderLayout.NORTH); + + % ===== INFO PANEL ===== + jPanelInfo = gui_river([5,4], [10,10,10,10], ''); + % Message label + jLabelWarning = gui_component('label', jPanelInfo, 'br', ' ', [], [], [], largeFontSize); + gui_component('label', jPanelInfo, 'br', ''); % spacing + % Number of head points collected + gui_component('label', jPanelInfo, 'br', 'Head shape points'); + jTextFieldExtra = gui_component('text', jPanelInfo, [], '0', [], 'Head shape points digitized', @(h,ev)bst_call(@ExtraChangePoint_Callback), largeFontSize); + initSize = jTextFieldExtra.getPreferredSize(); + jTextFieldExtra.setPreferredSize(Dimension(initSize.getWidth()*1.5, initSize.getHeight()*1.5)) + if strcmpi(Digitize.Type, '3DScanner') + % Add 150 random head shape points generation button + jButtonRandomHeadPts = gui_component('button', jPanelInfo, [], 'Random', [], 'Collect 150 head shape points from mesh', @(h,ev)bst_call(@CollectRandomHeadPts_Callback), largeFontSize); + jButtonRandomHeadPts.setPreferredSize(Dimension(initSize.getWidth()*2.2, initSize.getHeight()*1.7)); + else + % Separator + jButtonRandomHeadPts = gui_component('label', jPanelInfo, 'hfill', ''); + end + jButtonRandomHeadPts.setEnabled(0); + jPanelControl.add(jPanelInfo, BorderLayout.CENTER); + + % ===== OTHER BUTTONS ===== + jPanelMisc = gui_river([5,4], [10,4,4,4]); + if ~strcmpi(Digitize.Type, '3DScanner') + jButtonCollectPoint = gui_component('button', jPanelMisc, 'br', 'Collect point', [], [], @(h,ev)bst_call(@ManualCollect_Callback)); + else + jButtonCollectPoint = gui_component('label', jPanelMisc, 'hfill', ''); % spacing + end + % Until initial fids are collected and figure displayed, "delete" button is used to "restart". + jButtonDeletePoint = gui_component('button', jPanelMisc, [], 'Start over', [], [], @(h,ev)bst_call(@ResetDataCollection, 1)); + gui_component('label', jPanelMisc, 'hfill', ''); % spacing + gui_component('button', jPanelMisc, [], 'Save as...', [], [], @(h,ev)bst_call(@Save_Callback)); + jPanelControl.add(jPanelMisc, BorderLayout.SOUTH); + jPanelNew.add(jPanelControl, BorderLayout.WEST); + + % ===== COORDINATE DISPLAY PANEL ===== + jPanelDisplay = gui_component('Panel'); + jPanelDisplay.setBorder(java_scaled('titledborder', 'Coordinates (cm)')); + % List of coordinates + jListCoord = JList(fontSize); + jListCoord.setCellRenderer(BstStringListRenderer(fontSize)); + java_setcb(jListCoord, ... + 'KeyTypedCallback', @(h,ev)bst_call(@CoordListKeyTyped_Callback,h,ev), ... + 'MouseClickedCallback', @(h,ev)bst_call(@CoordListClick_Callback,h,ev)); + jPanelScrollList = JScrollPane(jListCoord); + jPanelScrollList.setViewportView(jListCoord); + jPanelScrollList.setHorizontalScrollBarPolicy(jPanelScrollList.HORIZONTAL_SCROLLBAR_NEVER); + jPanelScrollList.setVerticalScrollBarPolicy(jPanelScrollList.VERTICAL_SCROLLBAR_ALWAYS); + jPanelScrollList.setBorder(BorderFactory.createEmptyBorder(10,10,10,10)); + jPanelDisplay.add(jPanelScrollList, BorderLayout.CENTER); + jPanelNew.add(jPanelDisplay, BorderLayout.CENTER); + + % Create the controls structure + ctrl = struct('jMenuEeg', jMenuEeg, ... + 'jButtonFids', jButtonFids, ... + 'jLabelNextPoint', jLabelNextPoint, ... + 'jLabelWarning', jLabelWarning, ... + 'jListCoord', jListCoord, ... + 'jButtonEEGAutoDetectElectrodes', jButtonEEGAutoDetectElectrodes, ... + 'jButtonRandomHeadPts', jButtonRandomHeadPts, ... + 'jTextFieldExtra', jTextFieldExtra, ... + 'jButtonCollectPoint', jButtonCollectPoint, ... + 'jButtonDeletePoint', jButtonDeletePoint); + bstPanelNew = BstPanel(panelName, jPanelNew, ctrl); + + %% ================================================================================= + % === INTERNAL CALLBACKS ========================================================= + % ================================================================================= + %% ===== COORDINATE LIST KEY TYPED CALLBACK ===== + function CoordListKeyTyped_Callback(h, ev) + switch(uint8(ev.getKeyChar())) + % Delete + case {ev.VK_DELETE, ev.VK_BACK_SPACE} + ctrl = bst_get('PanelControls', 'Digitize'); + % If contact list rendering is blank in panel then dont't proceed + if ctrl.jListCoord.isSelectionEmpty() + return; + end + + [sCoordName, iSelCoord] = GetSelectedCoord(); + spl = regexp(sCoordName,'\s+','split'); + nameFinal = spl{1}; + if (~strcmpi(nameFinal, 'NAS') &&... + ~strcmpi(nameFinal, 'LPA') &&... + ~strcmpi(nameFinal, 'RPA')) + listModel = ctrl.jListCoord.getModel(); + listModel.setElementAt(nameFinal, iSelCoord-1); + Digitize.iPoint = iSelCoord; + Digitize.isEditPts = 1; + DeletePoint_Callback(); + end + end + end + + %% ===== COORDINATE LIST CLICK CALLBACK ===== + function CoordListClick_Callback(h, ev) + % If single click + if (ev.getClickCount() == 1) + ctrl = bst_get('PanelControls', 'Digitize'); + % If contact list rendering is blank in panel then dont't proceed + if ctrl.jListCoord.isSelectionEmpty() + return; + end + + [sCoordName, ~] = GetSelectedCoord(); + spl = regexp(sCoordName,'\s+','split'); + nameFinal = spl{1}; + bst_figures('SetSelectedRows', nameFinal); + end + end +end + +%% ===== GET SELECTED ELECTRODE ===== +function [sCoordName, iSelCoord] = GetSelectedCoord() + global Digitize + % Get panel handles + ctrl = bst_get('PanelControls', 'Digitize'); + if isempty(ctrl) + return; + end + + % Get JList selected indices + iSelCoord = uint16(ctrl.jListCoord.getSelectedIndices())' + 1; + listModel = ctrl.jListCoord.getModel(); + sCoordName = listModel.getElementAt(iSelCoord-1); +end + +%% ===== SWITCH TO OLD VERSION ===== +function SwitchVersion() + % Always confirm this switch. + if ~java_dialog('confirm', ['Switch to legacy version of the Digitize panel?
', ... + 'See Digitize tutorial (Digitize panel > Help menu).
', ... + 'This will close the window. Any unsaved points will be lost.'], 'Digitize version') + return; + end + % Close this panel + Close_Callback(); + % Save the preferred version. Must be after closing + DigitizeOptions = bst_get('DigitizeOptions'); + DigitizeOptions.Version = 'legacy'; + bst_set('DigitizeOptions', DigitizeOptions); +end + +%% ===== CLOSE ===== +function Close_Callback() + % Save channel file + SaveDigitizeChannelFile(); + % Close panel + gui_hide('Digitize'); +end + +%% ===== HIDING CALLBACK ===== +function isAccepted = PanelHidingCallback() + global Digitize; + % If Brainstorm window was hidden before showing the Digitizer + if bst_get('isGUI') + % Get Brainstorm frame + jBstFrame = bst_get('BstFrame'); + % Hide Brainstorm window + jBstFrame.setVisible(1); + end + % Get study + [sStudy, iStudy] = bst_get('StudyWithCondition', [Digitize.SubjectName '/' Digitize.ConditionName]); + % If nothing was clicked: delete the condition that was just created + if isempty(Digitize.Transf) + % Delete study + if ~isempty(iStudy) + db_delete_studies(iStudy); + panel_protocols('UpdateTree'); + end + % Else: reload to get access to the EEG type of sensors + else + db_reload_studies(iStudy); + end + % Close serial connection, in particular to avoid further callbacks if stylus is pressed. + if ~isempty(Digitize.SerialConnection) + delete(Digitize.SerialConnection); + Digitize.SerialConnection = []; % cleaner than "handle to deleted serialport". + end + % Could also: clear Digitize; + % Unload everything + bst_memory('UnloadAll', 'Forced'); + isAccepted = 1; +end + + +%% ===== EDIT SETTINGS ===== +function isOk = EditSettings() + global Digitize + + isOk = 0; + % Ask for new options + if isfield(Digitize.Options, 'Fids') && iscell(Digitize.Options.Fids) + FidsString = sprintf('%s, ', Digitize.Options.Fids{:}); + FidsString(end-1:end) = ''; + else + FidsString = 'NAS, LPA, RPA'; + end + if isfield(Digitize.Options, 'ConfigCommands') && iscell(Digitize.Options.ConfigCommands) && ... + ~isempty(Digitize.Options.ConfigCommands) + ConfigString = sprintf('%s; ', Digitize.Options.ConfigCommands{:}); + ConfigString(end-1:end) = ''; + else + ConfigString = ''; + end + + % GUI all potential options: {Description, default values} + options_all = {'Serial connection settings

Serial port name (COM1):', ... + Digitize.Options.ComPort; + 'Unit Type (Fastrak or Patriot):', ... + Digitize.Options.UnitType; ... + 'Additional device configuration commands, separated by ";"
(see device documentation, e.g. H1,0,0,-1;H2,0,0,-1):', ... + ConfigString; ... + '
Collection settings

List anatomy and possibly MEG fiducials, in desired order
(NAS, LPA, RPA, HPI-N, HPI-L, HPI-R, HPI-X):', ... + FidsString; ... + 'How many times do you want to localize
these fiducials at the start:', ... + num2str(Digitize.Options.nFidSets); ... + 'Distance threshold for repeated measure of fiducial locations (mm):', ... + num2str(Digitize.Options.DistThresh * 1000); ... % m to mm + 'Beep when collecting point (0=no, 1=yes):', ...; + num2str(Digitize.Options.isBeep)}; + + % Options to show for each type + switch lower(Digitize.Type) + case 'digitize' + iOptionsType = [1:7]; + case '3dscanner' + iOptionsType = [4:7]; + end + + % Ask options + [resType, isCancel] = java_dialog('input', options_all(iOptionsType,1), [Digitize.Type ' configuration'], [], options_all(iOptionsType,2)); + if isempty(resType) || isCancel + return + end + + % Results from options asked to user + options_all(iOptionsType, 3) = resType; + % Results from options not asked to user + iOptionsKeep = setdiff([1:length(options_all)], iOptionsType); + options_all(iOptionsKeep, 3) = options_all(iOptionsKeep,2); + res = options_all(:,3); + + % Check values + if length(resType) < length(iOptionsType) || (ismember(1, iOptionsType) && isempty(res{1})) || ... + (ismember(2, iOptionsType) && isempty(res{2})) || ... + (ismember(5, iOptionsType) && isnan(str2double(res{5}))) || ... + (ismember(6, iOptionsType) && isnan(str2double(res{6}))) || ... + (ismember(7, iOptionsType) && ~ismember(str2double(res{7}), [0 1])) + bst_error('Invalid values.', 'Digitize', 0); + return; + end + % Digitizer: COM port + Digitize.Options.ComPort = res{1}; + % Digitizer: Type + Digitize.Options.UnitType = lower(res{2}); + % Digitizer: COM properties + if isempty(Digitize.Options.UnitType) + % Do nothing + elseif strcmp(Digitize.Options.UnitType,'fastrak') + Digitize.Options.ComRate = 9600; + Digitize.Options.ComByteCount = 94; + elseif strcmp(Digitize.Options.UnitType,'patriot') + Digitize.Options.ComRate = 115200; + Digitize.Options.ComByteCount = 120; + else + bst_error('Incorrect unit type.', Digitize.Type, 0); + return; + end + % Digitizer: Parse device configuration commands. Remove all spaces, and split at ";" + Digitize.Options.ConfigCommands = str_split(strrep(res{3}, ' ', ''), ';', true); % remove empty + % Common: Parse fiducials. + Digitize.Options.Fids = str_split(res{4}, '()[],;"'' ', true); % remove empty + if isempty(Digitize.Options.Fids) || ~iscell(Digitize.Options.Fids) || numel(Digitize.Options.Fids) < 3 + bst_error('At least 3 anatomy fiducials are required, e.g. NAS, LPA, RPA.', Digitize.Type, 0); + Digitize.Options.Fids = {'NAS', 'LPA', 'RPA'}; + return; + end + % Common: Number of fiducial sets + if ~isempty(res{5}) + Digitize.Options.nFidSets = str2double(res{5}); + end + % Common: Threshold for distance in fiducial sets + if ~isempty(res{6}) + Digitize.Options.DistThresh = str2double(res{6}) / 1000; % mm to m + end + % Common: Beep + if ~isempty(res{7}) + Digitize.Options.isBeep = str2double(res{7}); + end + + for iFid = 1:numel(Digitize.Options.Fids) + switch lower(Digitize.Options.Fids{iFid}) + % Possible names copied from channel_detect_type + case {'nas', 'nasion', 'nz', 'fidnas', 'fidnz', 'n', 'na'} + Digitize.Options.Fids{iFid} = 'NAS'; + case {'lpa', 'pal', 'og', 'left', 'fidt9', 'leftear', 'l'} + Digitize.Options.Fids{iFid} = 'LPA'; + case {'rpa', 'par', 'od', 'right', 'fidt10', 'rightear', 'r'} + Digitize.Options.Fids{iFid} = 'RPA'; + otherwise + if ~strfind(lower(Digitize.Options.Fids{iFid}), 'hpi') + bst_error(sprintf('Unrecognized fiducial: %s', Digitize.Options.Fids{iFid}), Digitize.Type, 0); + return; + end + Digitize.Options.Fids{iFid} = upper(Digitize.Options.Fids{iFid}); + end + end + + % Save values + bst_set('DigitizeOptions', Digitize.Options); + isOk = 1; + + % If no points collected, reset. + if isempty(Digitize.Points) || ~isfield(Digitize.Points, 'Loc') || isempty(Digitize.Points(1).Loc) + ResetDataCollection(1); + end +end + + +%% ===== SET SIMULATION MODE ===== +% USAGE: panel_digitize_2024('SetSimulate', isSimulate) +% Run this from command line BEFORE opening the Digitize panel. +function SetSimulate(isSimulate) + DigitizeOptions = bst_get('DigitizeOptions'); + % Change value + DigitizeOptions.isSimulate = isSimulate; + bst_set('DigitizeOptions', DigitizeOptions); +end + + +%% ======================================================================== +% ======= ACQUISITION FUNCTIONS ========================================== +% ======================================================================== + +%% ===== RESET DATA COLLECTION ===== +function ResetDataCollection(isResetSerial) + global Digitize + bst_progress('start', Digitize.Type, 'Initializing...'); + % Reset serial? + if (nargin == 1) && isequal(isResetSerial, 1) + CreateSerialConnection(); + end + % Reset points structure + Digitize.Points = struct(... + 'Label', [], ... + 'Type', [], ... + 'Loc', []); + Digitize.iPoint = 0; + Digitize.Transf = []; + % Reset figure (also unloads in global data) + if ~isempty(Digitize.hFig) && ishandle(Digitize.hFig) + bst_figures('DeleteFigure', Digitize.hFig, []); + + % For 3D Scanner reload the surface + if strcmpi(Digitize.Type, '3DScanner') + % Load the surface + sSurf = bst_memory('LoadSurface', Digitize.surfaceFile); + % Display surface + view_surface_matrix(sSurf.Vertices, sSurf.Faces, [], sSurf.Color, [], [], Digitize.surfaceFile); + end + end + Digitize.iDS = []; + + % Get controls + ctrl = bst_get('PanelControls', 'Digitize'); + % Reset counters + ctrl.jTextFieldExtra.setText(num2str(0)); + ctrl.jTextFieldExtra.setEnabled(0); + java_setcb(ctrl.jButtonDeletePoint, 'ActionPerformedCallback', @(h,ev)bst_call(@ResetDataCollection, 1)); + ctrl.jButtonDeletePoint.setText('Start over'); + ctrl.jLabelWarning.setText(''); + ctrl.jLabelWarning.setBackground([]); + + % Generate list of labeled points + % Initial fiducials + for iP = 1:numel(Digitize.Options.Fids) + Digitize.Points(iP).Label = Digitize.Options.Fids{iP}; + Digitize.Points(iP).Type = 'CARDINAL'; + end + if Digitize.Options.nFidSets > 1 + Digitize.Points = repmat(Digitize.Points, 1, Digitize.Options.nFidSets); + end + % EEG + [curMontage, nEEG] = GetCurrentMontage(); + for iEEG = 1:nEEG + Digitize.Points(end+1).Label = curMontage.Labels{iEEG}; + Digitize.Points(end).Type = 'EEG'; + end + + % Display list in text box + UpdateList(); + + % Close progress bar + bst_progress('stop'); +end + + +%% ===== UPDATE LIST OF POINTS IN TEXT BOX ===== +function UpdateList() + global Digitize; + % Get controls + ctrl = bst_get('PanelControls', 'Digitize'); + % Define the model + listModel = javax.swing.DefaultListModel(); + % Add points to list + iHeadPoints = 0; + lastIndex = 0; + for iP = 1:numel(Digitize.Points) + if ~isempty(Digitize.Points(iP).Label) + listModel.addElement(sprintf('%s %3.3f %3.3f %3.3f', Digitize.Points(iP).Label, Digitize.Points(iP).Loc .* 100)); + else % Head points + iHeadPoints = iHeadPoints + 1; + listModel.addElement(sprintf('%03d %3.3f %3.3f %3.3f', iHeadPoints, Digitize.Points(iP).Loc .* 100)); + end + if ~isempty(Digitize.Points(iP).Loc) + lastIndex = iP; + end + end + % Set this list + ctrl.jListCoord.setModel(listModel); + % Scroll to last collected point (non-empty Loc), +1 if more points listed. + if listModel.getSize() > lastIndex + ctrl.jListCoord.ensureIndexIsVisible(lastIndex); % 0-indexed + else + ctrl.jListCoord.ensureIndexIsVisible(lastIndex-1); % 0-indexed, -1 works even if 0 + end + % Also update label of next point to digitize. + if numel(Digitize.Points) >= Digitize.iPoint + 1 && ~isempty(Digitize.Points(Digitize.iPoint + 1).Label) + ctrl.jLabelNextPoint.setText(Digitize.Points(Digitize.iPoint + 1).Label); + else + ctrl.jLabelNextPoint.setText(num2str(iHeadPoints + 1)); + end + + % Update tooltip text for 'Auto' button + if strcmpi(Digitize.Type, '3DScanner') + ctrl.jButtonEEGAutoDetectElectrodes.setToolTipText(GenerateTooltipTextAuto()); + end +end + +%% ===== 3DSCANNER: AUTOMATICALLY DETECT AND LABEL EEG CAP ELECTRODES ===== +function EEGAutoDetectElectrodes() + global Digitize GlobalData + + % Add disclaimer to users that 'Auto' feature is experimental + if ~java_dialog('confirm', [' Automatic detection of EEG sensors is an experimental feature.
' ... + 'Please verify the results carefully.

' ... + 'Do you want to continue?'], 'Auto detect EEG electrodes') + return + end + % Get controls + ctrl = bst_get('PanelControls', 'Digitize'); + % Disable Auto button + ctrl.jButtonEEGAutoDetectElectrodes.setEnabled(0); + % Progress bar + bst_progress('start', Digitize.Type, 'Automatic labelling of EEG sensors...'); + + % Get current montage + curMontage = GetCurrentMontage(); + isWhiteCap = 0; + % For white caps change the color space by inverting the colors + % NOTE: only 'Acticap' is the tested white cap (needs work on finding a better aprrooach) + if ~isempty(regexp(curMontage.Name, 'ActiCap', 'match')) + isWhiteCap = 1; + end + + % Get the cap surface from 3D scanner + hFig = bst_figures('GetCurrentFigure','3D'); + TessInfo = getappdata(hFig, 'Surface'); + sSurf = bst_memory('LoadSurface', TessInfo.SurfaceFile); + + % Automatically find electrodes locations on EEG cap + [capCenters2d, capImg2d, surface3dscannerUv] = channel_detect_eegcap_auto('FindElectrodesEegCap', sSurf, isWhiteCap); + if isempty(Digitize.Options.Montages(Digitize.Options.iMontage).ChannelFile) + bst_error('EEG cap layout not selected. Go to EEG', Digitize.Type, 1); + bst_progress('stop'); + return; + else + ChannelMat = in_bst_channel(Digitize.Options.Montages(Digitize.Options.iMontage).ChannelFile); + end + + % Get acquired EEG points + iEeg = and(cellfun(@(x) ~isempty(regexp(x, 'EEG', 'match')), {Digitize.Points.Type}), ~cellfun(@isempty, {Digitize.Points.Loc})); + pointsEEG = Digitize.Points(iEeg); + + % Warp points from layout to mesh + capPoints3d = channel_detect_eegcap_auto('WarpLayout2Mesh', capCenters2d, capImg2d, surface3dscannerUv, ChannelMat.Channel, pointsEEG); + + % Plot the electrodes and their labels + for iPoint= 1:length(capPoints3d) + % Find found point in current montage and set it in global + [~, Digitize.iPoint] = ismember(capPoints3d(iPoint).Label, {Digitize.Points.Label}); + Digitize.Points(Digitize.iPoint).Loc = capPoints3d(iPoint).Loc; + Digitize.Points(Digitize.iPoint).Type = 'EEG'; + % Add the point to the display (in cm) + PlotCoordinate(); + end + + UpdateList(); + % Enable Random button + ctrl.jButtonRandomHeadPts.setEnabled(1); + bst_progress('stop'); + +end + +%% ===== MANUAL COLLECT CALLBACK ====== +function ManualCollect_Callback() + global Digitize + ctrl = bst_get('PanelControls', 'Digitize'); + ctrl.jButtonCollectPoint.setEnabled(0); + % Simulation: call the callback directly + if Digitize.Options.isSimulate + BytesAvailable_Callback([], []); + % Else: Send a collection request to the Polhemus + else + % User clicked the button, collect a point + writeline(Digitize.SerialConnection,'P'); + pause(0.2); + end + ctrl.jButtonCollectPoint.setEnabled(1); +end + +%% ===== COLLECT RANDOM HEADPOINTS ===== +function CollectRandomHeadPts_Callback() + global Digitize + % Get controls + ctrl = bst_get('PanelControls', 'Digitize'); + % Disable Random button + ctrl.jButtonRandomHeadPts.setEnabled(0); + % Progress bar + bst_progress('start', Digitize.Type, 'Plotting 150 random head shape points...'); + + hFig = bst_figures('GetCurrentFigure','3D'); + TessInfo = getappdata(hFig, 'Surface'); + TessMat = bst_memory('LoadSurface', TessInfo.SurfaceFile); + + % Brainstorm recommends to collect approximately 100-150 points from the head + % 5-10 points from the boney part of the nose + PlotHeadShapePoints(TessMat.Vertices, 'nose', 10); + % 10-20 points across the left eyebrow + PlotHeadShapePoints(TessMat.Vertices, 'leyebrow', 20); + % 10-20 points across the right eyebrow + PlotHeadShapePoints(TessMat.Vertices, 'reyebrow', 20); + % 100 points on the scalp + PlotHeadShapePoints(TessMat.Vertices, 'scalp', 100); + + UpdateList(); + bst_progress('stop'); +end + +%% ===== PLOT HEAD SHAPE POINTS ===== +function PlotHeadShapePoints(Vertices, plotRegion, nPoints) + global Digitize + % Get controls + ctrl = bst_get('PanelControls', 'Digitize'); + + % Get the plotting parameters based on the region in the head + switch plotRegion + case 'nose' + nosePoint = Digitize.Points(1).Loc; + % Get 600 nearest points to the 'nosePoint' and choose 'nPoints' from it + nearPointsIdx = bst_nearest(Vertices, nosePoint, 600, 0, []); + range = length(nearPointsIdx); + stepFactor = range/nPoints; + case 'leyebrow' + lEyebrowPoint = (1.25 * Digitize.Points(1).Loc) + (0.5 * Digitize.Points(2).Loc); + % Get 400 nearest points to the 'lEyebrowPoint' and choose 'nPoints' from it + nearPointsIdx = bst_nearest(Vertices, lEyebrowPoint, 400, 0, []); + range = length(nearPointsIdx); + stepFactor = range/nPoints; + case 'reyebrow' + rEyebrowPoint = (1.25 * Digitize.Points(1).Loc) + (0.5 * Digitize.Points(3).Loc); + % Get 400 nearest points to the 'rEyebrowPoint' and choose 'nPoints' from it + nearPointsIdx = bst_nearest(Vertices, rEyebrowPoint, 400, 0, []); + range = length(nearPointsIdx); + stepFactor = range/nPoints; + case 'scalp' + range = length(Vertices); + stepFactor = ceil(range/nPoints); + otherwise + bst_error([plotRegion 'is invalid for plotting head shape'], Digitize.Type, 0); + bst_progress('stop'); + return + end + + % Plot the head shape points + for i= 1:stepFactor:range + % Increment current point index + Digitize.iPoint = Digitize.iPoint + 1; + % Update the coordinate and Type + if strcmpi(plotRegion, 'scalp') + Digitize.Points(Digitize.iPoint).Loc = Vertices(i, :); + else + Digitize.Points(Digitize.iPoint).Loc = Vertices(nearPointsIdx(i), :); + end + Digitize.Points(Digitize.iPoint).Type = 'EXTRA'; + % Add the point to the display (in cm) + PlotCoordinate(); + % Update text field counter to the next point in the list + iCount = str2double(ctrl.jTextFieldExtra.getText()); + ctrl.jTextFieldExtra.setText(num2str(iCount + 1)); + end +end + +%% ===== DELETE POINT CALLBACK ===== +function DeletePoint_Callback() + global Digitize + % Get controls + ctrl = bst_get('PanelControls', 'Digitize'); + + % If we're down to initial fids only, change delete button label and callback to "restart" instead of delete. + if Digitize.iPoint <= numel(Digitize.Options.Fids) * Digitize.Options.nFidSets + 1 + java_setcb(ctrl.jButtonDeletePoint, 'ActionPerformedCallback', @(h,ev)bst_call(@ResetDataCollection, 1)); + ctrl.jButtonDeletePoint.setText('Start over'); + % Safety check, but this should not happen. + if Digitize.iPoint <= numel(Digitize.Options.Fids) * Digitize.Options.nFidSets + error('Cannot delete initial fiducials.'); + end + end + + % Remove last point from figure. It must still be in the list. + PlotCoordinate(false); % isAdd = false: remove last point instead of adding one + + % Decrement head shape point count + if strcmpi(Digitize.Points(Digitize.iPoint).Type, 'EXTRA') + nShapePts = str2num(ctrl.jTextFieldExtra.getText()); + ctrl.jTextFieldExtra.setText(num2str(max(0, nShapePts - 1))); + end + + % Remove last point in list + if ~isempty(Digitize.Points(Digitize.iPoint).Label) + % Keep point in list, but remove location and decrease index to collect again + Digitize.Points(Digitize.iPoint).Loc = []; + else + % Delete the point from the list entirely + Digitize.Points(Digitize.iPoint) = []; + end + Digitize.iPoint = Digitize.iPoint - 1; + + % Update coordinates list + UpdateList(); +end + +%% ===== CHECK FIDUCIALS: ADD SET TO DIGITIZE NOW ===== +function Fiducials_Callback() + global Digitize + nRemaining = numel(Digitize.Points) - Digitize.iPoint; + nFids = numel(Digitize.Options.Fids); + if nRemaining > 0 + % Add space in points array. + Digitize.Points(Digitize.iPoint + nFids + (1:nRemaining)) = Digitize.Points(Digitize.iPoint + (1:nRemaining)); + end + for iP = 1:nFids + Digitize.Points(Digitize.iPoint + iP).Label = Digitize.Options.Fids{iP}; + Digitize.Points(Digitize.iPoint + iP).Type = 'CARDINAL'; + end + UpdateList(); +end + +%% ===== CREATE FIGURE ===== +function CreateHeadpointsFigure() + global Digitize + if isempty(Digitize.hFig) || ~ishandle(Digitize.hFig) || isempty(Digitize.iDS) + % Get study + sStudy = bst_get('StudyWithCondition', [Digitize.SubjectName '/' Digitize.ConditionName]); + % Plot head points and save handles in global variable + [Digitize.hFig, Digitize.iDS] = view_headpoints(file_fullpath(sStudy.Channel.FileName)); + % Hide head surface + panel_surface('SetSurfaceTransparency', Digitize.hFig, 1, 0.8); + % Get Digitizer JFrame + bstContainer = get(bst_get('Panel', 'Digitize'), 'container'); + % Get maximum figure position + decorationSize = bst_get('DecorationSize'); + [~, FigArea] = gui_layout('GetScreenBrainstormAreas', bstContainer.handle{1}); + FigPos = FigArea(1,:) + [decorationSize(1), decorationSize(4), - decorationSize(1) - decorationSize(3), - decorationSize(2) - decorationSize(4)]; + if (FigPos(3) > 0) && (FigPos(4) > 0) + set(Digitize.hFig, 'Position', FigPos); + end + % Remove the close handle function + set(Digitize.hFig, 'CloseRequestFcn', []); + else + % Hide figure + set(Digitize.hFig, 'Visible', 'off'); + % Get study + sStudy = bst_get('StudyWithCondition', [Digitize.SubjectName '/' Digitize.ConditionName]); + % Plot head points and save handles in global variable + [Digitize.hFig, Digitize.iDS] = view_headpoints(file_fullpath(sStudy.Channel.FileName)); + % Get the surface + sSurf = bst_memory('LoadSurface', Digitize.surfaceFile); + % Apply the transformation + sSurf.Vertices = [sSurf.Vertices ones(size(sSurf.Vertices,1),1)] * Digitize.Transf'; + % Remove the surface + panel_surface('RemoveSurface', Digitize.hFig, 1); + % Deface the surface + if isempty(regexp(sSurf.Comment, 'defaced', 'match')) + sSurf = tess_deface(sSurf); + end + % Save the surface and update the node + ProtocolInfo = bst_get('ProtocolInfo'); + surfaceFile = bst_fullfile(ProtocolInfo.SUBJECTS, Digitize.surfaceFile); + bst_save(surfaceFile, sSurf, 'v7'); + [~, iSubject] = bst_get('Subject', Digitize.SubjectName); + db_reload_subjects(iSubject); + % Get Digitizer JFrame + bstContainer = get(bst_get('Panel', 'Digitize'), 'container'); + % Get maximum figure position + decorationSize = bst_get('DecorationSize'); + [~, FigArea] = gui_layout('GetScreenBrainstormAreas', bstContainer.handle{1}); + FigPos = FigArea(1,:) + [decorationSize(1), decorationSize(4), - decorationSize(1) - decorationSize(3), - decorationSize(2) - decorationSize(4)]; + % Display updated surface + view_surface_matrix(sSurf.Vertices, sSurf.Faces, [], sSurf.Color, Digitize.hFig, [], Digitize.surfaceFile); + if (FigPos(3) > 0) && (FigPos(4) > 0) + set(Digitize.hFig, 'Position', FigPos); + end + % Remove the close handle function + set(Digitize.hFig, 'CloseRequestFcn', []); + end +end + +%% ===== PLOT NEXT POINT, OR REMOVE LAST OR REMOVE SELECTED POINT ===== +function PlotCoordinate(isAdd) + if nargin < 1 || isempty(isAdd) + isAdd = true; + end + global Digitize GlobalData + + % Add EEG sensor locations to channel stucture + if strcmpi(Digitize.Points(Digitize.iPoint).Type, 'EEG') + if ~isstruct(GlobalData.DataSet(Digitize.iDS).Channel) || ~isfield(GlobalData.DataSet(Digitize.iDS).Channel, 'Name') + % First point in the list. This creates one channel, with empty fields. + GlobalData.DataSet(Digitize.iDS).Channel = db_template('channeldesc'); + end + if numel(GlobalData.DataSet(Digitize.iDS).Channel) == 1 && isempty(GlobalData.DataSet(Digitize.iDS).Channel(1).Name) + % Overwrite empty channel created by template. + iP = 1; + else + if Digitize.isEditPts + % 'iP' points to the 'GlobalData's Channel' which just contains + % EEG data and not the fiducials so an offset is required + % from 'Digitize.iPoint' to exclude the fiducials + if isAdd + iP = Digitize.iPoint - 3; + else + iP = Digitize.iPoint - 2; + end + else + iP = numel(GlobalData.DataSet(Digitize.iDS).Channel) + 1; + end + end + + if isAdd + GlobalData.DataSet(Digitize.iDS).Channel(iP).Name = Digitize.Points(Digitize.iPoint).Label; + GlobalData.DataSet(Digitize.iDS).Channel(iP).Type = Digitize.Points(Digitize.iPoint).Type; % 'EEG' + GlobalData.DataSet(Digitize.iDS).Channel(iP).Loc = Digitize.Points(Digitize.iPoint).Loc'; + else % Remove last point or a selected point + iP = iP - 1; + if iP > 0 + if Digitize.isEditPts % remove selected point + % Keep point in list, but remove location + GlobalData.DataSet(Digitize.iDS).Channel(iP).Loc = []; + else % Remove last point + GlobalData.DataSet(Digitize.iDS).Channel(iP) = []; + end + end + end + else % FIDs or head points + iP = size(GlobalData.DataSet(Digitize.iDS).HeadPoints.Loc, 2) + 1; + if isAdd + GlobalData.DataSet(Digitize.iDS).HeadPoints.Label{iP} = Digitize.Points(Digitize.iPoint).Label; + GlobalData.DataSet(Digitize.iDS).HeadPoints.Type{iP} = Digitize.Points(Digitize.iPoint).Type; % 'CARDINAL' or 'EXTRA' + GlobalData.DataSet(Digitize.iDS).HeadPoints.Loc(:,iP) = Digitize.Points(Digitize.iPoint).Loc'; + else + iP = iP - 1; + if iP > 0 + GlobalData.DataSet(Digitize.iDS).HeadPoints.Label(iP) = []; + GlobalData.DataSet(Digitize.iDS).HeadPoints.Type(iP) = []; + GlobalData.DataSet(Digitize.iDS).HeadPoints.Loc(:,iP) = []; + end + end + end + + % Remove old HeadPoints + hAxes = findobj(Digitize.hFig, '-depth', 1, 'Tag', 'Axes3D'); + hHeadPointsMarkers = findobj(hAxes, 'Tag', 'HeadPointsMarkers'); + hHeadPointsLabels = findobj(hAxes, 'Tag', 'HeadPointsLabels'); + delete(hHeadPointsMarkers); + delete(hHeadPointsLabels); + % If all EEG were removed, ViewSensors won't remove the last remaining (first) EEG from the figure, so do it manually. + if isempty(GlobalData.DataSet(Digitize.iDS).Channel) + hSensorMarkers = findobj(hAxes, 'Tag', 'SensorsMarkers'); + hSensorLabels = findobj(hAxes, 'Tag', 'SensorsLabels'); + delete(hSensorMarkers); + delete(hSensorLabels); + end + % View all points in the channel file + figure_3d('ViewHeadPoints', Digitize.hFig, 1); + % This would give error if the channel structure is not truely empty: db_template creates effectively 1 channel with empty fields. + if ~isempty(GlobalData.DataSet(Digitize.iDS).Channel) && ~isempty(GlobalData.DataSet(Digitize.iDS).Channel(1).Name) + figure_3d('ViewSensors', Digitize.hFig, 1, 1, 0, 'EEG'); + end + % Hide template head surface + if ~strcmpi(Digitize.Type, '3DScanner') + panel_surface('SetSurfaceTransparency', Digitize.hFig, 1, 1); + end +end + +%% ===== SAVE CALLBACK ===== +% This saves a .pos file, which requires first saving the channel file. +function Save_Callback(OutFile) + global Digitize + % Do nothing if no points to save + if isempty(Digitize.Points) || ~isfield(Digitize.Points, 'Loc') || isempty(Digitize.Points(1).Loc) + java_dialog('msgbox', 'No points yet collected. Nothing to save.', 'Save as...', []); + return; + end + sStudy = bst_get('StudyWithCondition', [Digitize.SubjectName '/' Digitize.ConditionName]); + ChannelFile = file_fullpath(sStudy.Channel.FileName); + SaveDigitizeChannelFile(); + % Export + if nargin > 0 && ~isempty(OutFile) + export_channel(ChannelFile, OutFile, 'POLHEMUS', 0); + else + export_channel(ChannelFile); + end +end + +%% ===== SAVE CHANNEL FILE WITH CONTENTS OF POINTS LIST ===== +function SaveDigitizeChannelFile() + global Digitize + sStudy = bst_get('StudyWithCondition', [Digitize.SubjectName '/' Digitize.ConditionName]); + ChannelFile = file_fullpath(sStudy.Channel.FileName); + ChannelMat = load(ChannelFile); + % GlobalData may not exist here: before 3d figure is created or after it is closed. + % So fill in ChannelMat from Digitize.Points. + iHead = 0; + iChan = 0; + % Reset points + ChannelMat.Channel = db_template('channeldesc'); + ChannelMat.HeadPoints.Loc = []; + ChannelMat.HeadPoints.Label = []; + ChannelMat.HeadPoints.Type = []; + for iP = 1:numel(Digitize.Points) + % Skip uncollected points + if isempty(Digitize.Points(iP).Loc) + continue; + end + if ~isempty(Digitize.Points(iP).Label) && strcmpi(Digitize.Points(iP).Type, 'EEG') + % Add EEG sensor locations to channel stucture + iChan = iChan + 1; + ChannelMat.Channel(iChan).Name = Digitize.Points(iP).Label; + ChannelMat.Channel(iChan).Type = Digitize.Points(iP).Type; + ChannelMat.Channel(:,iChan).Loc = Digitize.Points(iP).Loc'; + else % Head points, including fiducials + iHead = iHead + 1; + ChannelMat.HeadPoints.Loc(:,iHead) = Digitize.Points(iP).Loc'; + ChannelMat.HeadPoints.Label{iHead} = Digitize.Points(iP).Label; + ChannelMat.HeadPoints.Type{iHead} = Digitize.Points(iP).Type; + end + end + bst_save(ChannelFile, ChannelMat, 'v7'); +end + +%% ===== CREATE MONTAGE MENU ===== +function CreateMontageMenu(jMenu) + import org.brainstorm.icon.*; + global Digitize + + % Get menu pointer if not in argument + if (nargin < 1) || isempty(jMenu) + ctrl = bst_get('PanelControls', 'Digitize'); + jMenu = ctrl.jMenuEeg; + end + % Empty menu + jMenu.removeAll(); + % Button group + buttonGroup = javax.swing.ButtonGroup(); + % Display all the montages + for i = 1:length(Digitize.Options.Montages) + jMenuMontage = gui_component('RadioMenuItem', jMenu, [], Digitize.Options.Montages(i).Name, buttonGroup, [], @(h,ev)bst_call(@SelectMontage, i), []); + if (i == 2) && (length(Digitize.Options.Montages) > 2) + jMenu.addSeparator(); + end + if (i == Digitize.Options.iMontage) + jMenuMontage.setSelected(1); + end + end + % Add new montage / reset list + jMenu.addSeparator(); + + if strcmpi(Digitize.Type, '3DScanner') + jMenuAddMontage = gui_component('Menu', jMenu, [], 'Add EEG montage...', [], [], [], []); + gui_component('MenuItem', jMenuAddMontage, [], 'From file...', [], [], @(h,ev)bst_call(@AddMontage), []); + % Creating montages from EEG cap layout mat files (only for 3DScanner) + jMenuEegCaps = gui_component('Menu', jMenuAddMontage, [], 'From default EEG cap', IconLoader.ICON_CHANNEL, [], [], []); + % Use default channel file + menu_default_eegcaps(jMenuEegCaps); + else % If not 3DScanner + gui_component('MenuItem', jMenu, [], 'Add EEG montage...', [], [], @(h,ev)bst_call(@AddMontage), []); + end + gui_component('MenuItem', jMenu, [], 'Unload all montages', [], [], @(h,ev)bst_call(@UnloadAllMontages), []); +end + +%% ===== SELECT MONTAGE ===== +function SelectMontage(iMontage) + global Digitize + % Default montage: ask for number of channels + if (iMontage == 2) + % Get previous number of electrodes + nEEG = length(Digitize.Options.Montages(iMontage).Labels); + if (nEEG == 0) + nEEG = 56; + end + % Ask user for the number of electrodes + res = java_dialog('input', 'Number of EEG channels in your montage:', 'Default EEG montage', [], num2str(nEEG)); + if isempty(res) || isnan(str2double(res)) + CreateMontageMenu(); + return; + end + nEEG = str2double(res); + % Create default montage + Digitize.Options.Montages(iMontage).Name = sprintf('Default (%d)', nEEG); + Digitize.Options.Montages(iMontage).Labels = {}; + for i = 1:nEEG + if (nEEG > 99) + strFormat = 'EEG%03d'; + else + strFormat = 'EEG%02d'; + end + Digitize.Options.Montages(iMontage).Labels{i} = sprintf(strFormat, i); + end + end + % Save currently selected montage + Digitize.Options.iMontage = iMontage; + % Save Digitize options + bst_set('DigitizeOptions', Digitize.Options); + % Update menu + CreateMontageMenu(); + % Restart acquisition + ResetDataCollection(); +end + +%% ===== TOOLTIP TEXT FOR AUTO BUTTON ===== +function autoButtonTooltip = GenerateTooltipTextAuto() + global Digitize + % Get cap landmark labels for selected montage + eegCapLandmarkLabels = channel_detect_eegcap_auto('GetEegCapLandmarkLabels', Digitize.Options.Montages(Digitize.Options.iMontage).Name); + autoButtonTooltip = 'Auto localization of EEG sensor is not suported for this cap.'; + if ~isempty(eegCapLandmarkLabels) + strSensors = sprintf('%s, ',eegCapLandmarkLabels{:}); + strSensors = strSensors(1:end-2); + autoButtonTooltip = ['Set at least sensors: [' strSensors '] to enable.']; + end +end + +%% ===== GET CURRENT MONTAGE ===== +function [curMontage, nEEG] = GetCurrentMontage() + global Digitize + % Return current montage + curMontage = Digitize.Options.Montages(Digitize.Options.iMontage); + nEEG = length(curMontage.Labels); +end + +%% ===== ADD EEG MONTAGE ===== +function AddMontage(ChannelFile) + global Digitize + % Add Montage from text file + if nargin<1 + % Get recently used folders + LastUsedDirs = bst_get('LastUsedDirs'); + % Open file + MontageFile = java_getfile('open', 'Select montage file...', LastUsedDirs.ImportChannel, 'single', 'files', ... + {{'*.txt'}, 'Text files', 'TXT'}, 0); + if isempty(MontageFile) + return; + end + % Get filename + [MontageDir, MontageName] = bst_fileparts(MontageFile); + % Intialize new montage + newMontage.Name = MontageName; + newMontage.Labels = {}; + + % Open file + fid = fopen(MontageFile,'r'); + if (fid == -1) + error('Cannot open file.'); + end + % Read file + while (1) + tline = fgetl(fid); + if ~ischar(tline) + break; + end + spl = regexp(tline,'\s+','split'); + if (length(spl) >= 2) + newMontage.Labels{end+1} = spl{2}; + end + end + % Close file + fclose(fid); + % If no labels were read: exit + if isempty(newMontage.Labels) + return + end + % Save last dir + LastUsedDirs.ImportChannel = MontageDir; + bst_set('LastUsedDirs', LastUsedDirs); + else % Add Montage from mat file of EEG caps + % Load existing file + ChannelMat = in_bst_channel(ChannelFile); + + % Intialize new montage + newMontage.Name = ChannelMat.Comment; + newMontage.Labels = {}; + newMontage.ChannelFile = ChannelFile; + + % Get cap landmark labels + eegCapLandmarkLabels = channel_detect_eegcap_auto('GetEegCapLandmarkLabels', newMontage.Name); + + % Sort as per the initialization landmark labels of EEG Cap + nonLandmarkLabelsIdx = find(~ismember({ChannelMat.Channel.Name},eegCapLandmarkLabels)); + allLabels = {ChannelMat.Channel.Name}; + newMontage.Labels = cat(2, eegCapLandmarkLabels, allLabels(nonLandmarkLabelsIdx)); + end + + % Get existing montage with the same name + iMontage = find(strcmpi({Digitize.Options.Montages.Name}, newMontage.Name)); + % If not found: create new montage entry + if isempty(iMontage) + iMontage = length(Digitize.Options.Montages) + 1; + else + iMontage = iMontage(1); + disp('DIGITIZER> Warning: Montage name already exists. Overwriting...'); + end + % Add new montage to registered montages + Digitize.Options.Montages(iMontage) = newMontage; + Digitize.Options.iMontage = iMontage; + % Save options + bst_set('DigitizeOptions', Digitize.Options); + % Reload Menu + CreateMontageMenu(); + % Restart acquisition + ResetDataCollection(); +end + +%% ===== UNLOAD ALL MONTAGES ===== +function UnloadAllMontages() + global Digitize + % Remove all montages + Digitize.Options.Montages = [... + struct('Name', 'No EEG', ... + 'Labels', [], ... + 'ChannelFile', []), ... + struct('Name', 'Default', ... + 'Labels', [], ... + 'ChannelFile', [])]; + % Reset to "No EEG" + Digitize.Options.iMontage = 1; + % Save Digitize options + bst_set('DigitizeOptions', Digitize.Options); + % Reload menu bar + CreateMontageMenu(); + % Reset list + ResetDataCollection(); +end + + +%% ======================================================================== +% ======= POLHEMUS COMMUNICATION ========================================= +% ======================================================================== + +%% ===== CREATE SERIAL COLLECTION ===== +function isOk = CreateSerialConnection() + global Digitize + isOk = 0; + while ~isOk + % Simulation: exit + if Digitize.Options.isSimulate + isOk = 1; + return; + end + try + % Delete previous connection. + if ~isempty(Digitize.SerialConnection) + delete(Digitize.SerialConnection); + end + % Create the serial port connection and store in global variable. + Digitize.SerialConnection = serialport(Digitize.Options.ComPort, Digitize.Options.ComRate); + if strcmp(Digitize.Options.UnitType,'patriot') + configureTerminator(Digitize.SerialConnection, 'CR'); + else + configureTerminator(Digitize.SerialConnection, 'LF'); + end + if Digitize.SerialConnection.NumBytesAvailable > 0 + flush(Digitize.SerialConnection); + end + + % Set up the Bytes Available function + configureCallback(Digitize.SerialConnection, 'byte', Digitize.Options.ComByteCount, @BytesAvailable_Callback); + if strcmp(Digitize.Options.UnitType, 'fastrak') + %'c' - Disable Continuous Printing + % Required for some configuration options. + writeline(Digitize.SerialConnection,'c'); + %'u' - Metric Conversion Units (set units to cm) + writeline(Digitize.SerialConnection,'u'); + %'F' - Enable ASCII Output Format + writeline(Digitize.SerialConnection,'F'); + %'R' - Reset Alignment Reference Frame + writeline(Digitize.SerialConnection,'R1'); + writeline(Digitize.SerialConnection,'R2'); + %'A' - Alignment Reference Frame + %'l' - Active Station State + % Could check here if 1 and 2 are active. + %'N' - Define Tip Offsets % Always factory default on power-up. + % writeline(Digitize.SerialConnection,'N1'); data = readline(Digitize.SerialConnection) + % data = '21N 6.344 0.013 0.059 + %'O' - Output Data List + writeline(Digitize.SerialConnection,'O1,2,4,1'); % default precision: position, Euler angles, CRLF + writeline(Digitize.SerialConnection,'O2,2,4,1'); % default precision: position, Euler angles, CRLF + %writeline(Digitize.SerialConnection,'O1,52,54,51'); % extended precision: position, Euler angles, CRLF + %writeline(Digitize.SerialConnection,'O2,52,54,51'); % extended precision: position, Euler angles, CRLF + %'x' - Position Filter Parameters + % The macro setting used here also applies to attitude filtering. + % 1=none, 2=low, 3=medium (default), 4=high + writeline(Digitize.SerialConnection,'x3'); + + %'e' - Define Stylus Button Function + writeline(Digitize.SerialConnection,'e1,1'); % Point mode + + % These should be set through the options panel, since they depend on the geometry of setup. + % e.g. 'H1,0,0,-1; H2,0,0,-1; Q1,180,90,180,-180,-90,-180; Q2,180,90,180,-180,-90,-180; V1,100,100,100,-100,-100,-100; V2,100,100,100,-100,-100,-100' + %'H' - Hemisphere of Operation + %writeline(Digitize.SerialConnection,'H1,0,0,-1'); % -Z hemisphere + %writeline(Digitize.SerialConnection,'H2,0,0,-1'); % -Z hemisphere + %'Q' - Angular Operational Envelope + %writeline(Digitize.SerialConnection,'Q1,180,90,180,-180,-90,-180'); + %writeline(Digitize.SerialConnection,'Q2,180,90,180,-180,-90,-180'); + %'V' - Position Operational Envelope + % Could use to warn if too far. + %writeline(Digitize.SerialConnection,'V1,100,100,100,-100,-100,-100'); + %writeline(Digitize.SerialConnection,'V2,100,100,100,-100,-100,-100'); + + %'^K' - *Save Operational Configuration + % 'ctrl+K' = char(11) + %'^Y' - *Reinitialize System + % 'ctrl+Y' = char(25) + + % Apply commands from options after, so they can overwride. + for iCmd = 1:numel(Digitize.Options.ConfigCommands) + writeline(Digitize.SerialConnection, Digitize.Options.ConfigCommands{iCmd}); + end + elseif strcmp(Digitize.Options.UnitType,'patriot') + % Request input from stylus + writeline(Digitize.SerialConnection,'L1,1\r'); + % Set units to centimeters + writeline(Digitize.SerialConnection,'U1\r'); + end + pause(0.2); + catch %#ok + % If the connection cannot be established: error message + bst_error(['Cannot open serial connection.' 10 10 'Please check the serial port configuration.' 10], Digitize.Type, 0); + % Ask user to edit the port options + isChanged = EditSettings(); + % If edit was canceled: exit + if ~isChanged + %Digitize.SerialConnection = []; + return; + % If not, try again + else + continue; + end + end + isOk = 1; + end +end + + +%% ===== BYTES AVAILABLE CALLBACK ===== +function BytesAvailable_Callback(h, ev) %#ok + global Digitize + % Get controls + ctrl = bst_get('PanelControls', 'Digitize'); + + % Simulate: Generate random points + if Digitize.Options.isSimulate + % Increment current point index + Digitize.iPoint = Digitize.iPoint + 1; + if Digitize.iPoint > numel(Digitize.Points) + Digitize.Points(Digitize.iPoint).Type = 'EXTRA'; + end + if strcmpi(Digitize.Type, '3DScanner') + % Get current 3D figure + [Digitize.hFig,~,Digitize.iDS] = bst_figures('GetCurrentFigure', '3D'); + if isempty(Digitize.hFig) + return + end + % Get current selected point + CoordinatesSelector = getappdata(Digitize.hFig, 'CoordinatesSelector'); + isSelectingCoordinates = getappdata(Digitize.hFig, 'isSelectingCoordinates'); + if isempty(CoordinatesSelector) || isempty(CoordinatesSelector.MRI) + return; + else + if isSelectingCoordinates + Digitize.Points(Digitize.iPoint).Loc = CoordinatesSelector.SCS; + end + end + else + Digitize.Points(Digitize.iPoint).Loc = rand(1,3) * .15 - .075; + end + + % Else: Get digitized point coordinates + else + vals = zeros(1,7); % header, x, y, z, azimuth, elevation, roll + rawpoints = zeros(2,7); % 2 receivers + data = []; + try + for j=1:2 % 1 point * 2 receivers + data = char(readline(Digitize.SerialConnection)); + if strcmp(Digitize.Options.UnitType, 'fastrak') + % This is fastrak + % The factory default ASCII output record x-y-z-azimuth-elevation-roll is composed of + % 47 bytes (3 status bytes, 6 data words each 7 bytes long, and a CR LF terminator) + vals(1) = str2double(data(1:3)); % header is first three char + for v=2:7 + % next 6 values are each 7 char + ind=(v-1)*7; + vals(v) = str2double(data((ind-6)+3:ind+3)); + end + elseif strcmp(Digitize.Options.UnitType, 'patriot') + % This is patriot + % The factory default ASCII output record x-y-z-azimuth-elevation-roll is composed of + % 60 bytes (4 status bytes, 6 data words each 9 bytes long, and a CR LF terminator) + vals(1) = str2double(data(1:4)); % header is first 5 char + for v=2:7 + % next 6 values are each 9 char + ind=(v-1)*9; + vals(v) = str2double(data((ind-8)+4:ind+4)); + end + end + rawpoints(j,:) = vals; + end + catch + disp(['Error reading data point. Try again.' 10, ... + 'If the problem persits, reset the serial connnection.' 10, ... + data]); + return; + end + % Increment current point index + Digitize.iPoint = Digitize.iPoint + 1; + if Digitize.iPoint > numel(Digitize.Points) + Digitize.Points(Digitize.iPoint).Type = 'EXTRA'; + end + % Motion compensation and conversion to meters + % This is not converting to SCS, but to another digitizer-specific head-fixed coordinate system. + Digitize.Points(Digitize.iPoint).Loc = DoMotionCompensation(rawpoints) ./100; % cm => meters + end + % Beep at each click + if Digitize.Options.isBeep + sound(Digitize.BeepWav.data, Digitize.BeepWav.fs); + end + + % Transform coordinates + if ~isempty(Digitize.Transf) && ~strcmpi(Digitize.Type, '3DScanner') + Digitize.Points(Digitize.iPoint).Loc = [Digitize.Points(Digitize.iPoint).Loc 1] * Digitize.Transf'; + end + % Update coordinates list only when there is no updating of selected point + % for which the updating happens at the end + if ~Digitize.isEditPts + UpdateList(); + end + + % Update counters + switch upper(Digitize.Points(Digitize.iPoint).Type) + case 'EXTRA' + iCount = str2double(ctrl.jTextFieldExtra.getText()); + ctrl.jTextFieldExtra.setText(num2str(iCount + 1)); + end + + if ~isempty(Digitize.hFig) && ishandle(Digitize.hFig) && ~strcmpi(Digitize.Points(Digitize.iPoint).Type, 'CARDINAL') + % Add this point to the figure + % Saves in GlobalData, but NOT in actual channel file + PlotCoordinate(); + end + + % Check distance for fiducials and warn if greater than threshold. + if strcmpi(Digitize.Points(Digitize.iPoint).Type, 'CARDINAL') && ... + Digitize.iPoint > numel(Digitize.Options.Fids) + iSameFid = find(strcmpi({Digitize.Points(1:(Digitize.iPoint-1)).Label}, Digitize.Points(Digitize.iPoint).Label)); + % Average location of this fiducial point initially, averaging those collected at start only. + InitLoc = mean(cat(1, Digitize.Points(iSameFid(1:min(numel(iSameFid),max(1,Digitize.Options.nFidSets)))).Loc), 1); + Distance = norm((InitLoc - Digitize.Points(Digitize.iPoint).Loc)); + if Distance > Digitize.Options.DistThresh + ctrl.jLabelWarning.setText(sprintf('%s distance exceeds %1.0f mm', Digitize.Points(Digitize.iPoint).Label, Digitize.Options.DistThresh * 1000)); + fprintf('%s distance %1.1f mm\n', Digitize.Points(Digitize.iPoint).Label, Distance * 1000); + ctrl.jLabelWarning.setOpaque(true); + ctrl.jLabelWarning.setBackground(java.awt.Color.red); + % Extra beep for large distances + pause(0.25); + sound(Digitize.BeepWav.data, Digitize.BeepWav.fs); + end + end + + % When initial fids are all collected + if Digitize.iPoint == numel(Digitize.Options.Fids) * Digitize.Options.nFidSets + % Save temp pos file + TmpDir = bst_get('BrainstormTmpDir'); + TmpPosFile = bst_fullfile(TmpDir, [Digitize.SubjectName '_' matlab.lang.makeValidName(Digitize.ConditionName) '.pos']); + Save_Callback(TmpPosFile); + % Re-import that .pos file. This converts to "Native" CTF coil-based coordinates. + HeadPointsMat = in_channel_pos(TmpPosFile); + % Delete temp file + file_delete(TmpPosFile, 1); + % Check for coordinate system transformation. There should be only 1, either to Native CTF or to SCS. + if ~isfield(HeadPointsMat, 'TransfMegLabels') || ~iscell(HeadPointsMat.TransfMegLabels) || numel(HeadPointsMat.TransfMegLabels) ~= 1 + error('Missing coordinate transformation'); + end + Digitize.Transf = HeadPointsMat.TransfMeg{1}(1:3,:); % 3x4 transform matrix + % Update coordinates in our list + for iP = 1:Digitize.iPoint % there could be EEG after, with empty Loc + Digitize.Points(iP).Loc = [Digitize.Points(iP).Loc, 1] * Digitize.Transf'; + end + UpdateList(); + % Update the channel file to save these essential points, and possibly needed for creating figure. + SaveDigitizeChannelFile(); + + % Create figure, store hFig & iDS + CreateHeadpointsFigure(); + % Enable fids button + ctrl.jButtonFids.setEnabled(1); + elseif Digitize.iPoint == numel(Digitize.Options.Fids) * Digitize.Options.nFidSets + 1 + % Change delete button label and callback such that we can delete the last point. + java_setcb(ctrl.jButtonDeletePoint, 'ActionPerformedCallback', @(h,ev)bst_call(@DeletePoint_Callback)); + ctrl.jButtonDeletePoint.setText('Delete last point'); + end + + % Update coordinate list after the updating the selected point + if Digitize.isEditPts + % Reset global variable required for updating + Digitize.isEditPts = 0; + % Update the Digitize.iPoint + iNotEmptyLoc = find(cellfun(@(x)~isempty(x), {Digitize.Points.Loc})); + Digitize.iPoint = length(iNotEmptyLoc); + % Update the coordinate list + UpdateList(); + end + % Enable 'Auto' button IFF all landmark fiducials have been acquired + if strcmpi(Digitize.Type, '3DScanner') && ~strcmpi(Digitize.Points(Digitize.iPoint).Type, 'EXTRA') + eegCapLandmarkLabels = channel_detect_eegcap_auto('GetEegCapLandmarkLabels', Digitize.Options.Montages(Digitize.Options.iMontage).Name); + if ~isempty(eegCapLandmarkLabels) + acqPoints = Digitize.Points(~cellfun(@isempty, {Digitize.Points.Loc})); + if all(ismember([eegCapLandmarkLabels], {acqPoints.Label})) + ctrl.jButtonEEGAutoDetectElectrodes.setEnabled(1); + end + end + end +end + + +%% ===== MOTION COMPENSATION ===== +function newPT = DoMotionCompensation(sensors) + % Use sensor one and its orientation vectors as the new coordinate system + % Define the origin as the position of sensor attached to the glasses + WAND = 1; + REMOTE1 = 2; + + C(1) = sensors(REMOTE1,2); + C(2) = sensors(REMOTE1,3); + C(3) = sensors(REMOTE1,4); + + % Deg2Rad = (angle / 180) * pi + % alpha = Deg2Rad(sensors(REMOTE1).o.Azimuth) + % beta = Deg2Rad(sensors(REMOTE1).o.Elevation) + % gamma = Deg2Rad(sensors(REMOTE1).o.Roll) + + alpha = (sensors(REMOTE1,5)/180) * pi; + beta = (sensors(REMOTE1,6)/180) * pi; + gamma = (sensors(REMOTE1,7)/180) * pi; + + SA = sin(alpha); + SE = sin(beta); + SR = sin(gamma); + CA = cos(alpha); + CE = cos(beta); + CR = cos(gamma); + + % Convert Euler angles to directional cosines using formulae in Polhemus manual + rotMat(1, 1) = CA * CE; + rotMat(1, 2) = SA * CE; + rotMat(1, 3) = -SE; + + rotMat(2, 1) = CA * SE * SR - SA * CR; + rotMat(2, 2) = CA * CR + SA * SE * SR; + rotMat(2, 3) = CE * SR; + + rotMat(3, 1) = CA * SE * CR + SA * SR; + rotMat(3, 2) = SA * SE * CR - CA * SR; + rotMat(3, 3) = CE * CR; + + rotMat(4, 1:4) = 0; + + % Translate and rotate the WAND into new coordinate system + pt(1) = sensors(WAND,2) - C(1); + pt(2) = sensors(WAND,3) - C(2); + pt(3) = sensors(WAND,4) - C(3); + + newPT(1) = pt(1) * rotMat(1, 1) + pt(2) * rotMat(1, 2) + pt(3) * rotMat(1, 3)'+ rotMat(1, 4); + newPT(2) = pt(1) * rotMat(2, 1) + pt(2) * rotMat(2, 2) + pt(3) * rotMat(2, 3)'+ rotMat(2, 4); + newPT(3) = pt(1) * rotMat(3, 1) + pt(2) * rotMat(3, 2) + pt(3) * rotMat(3, 3)'+ rotMat(3, 4); +end + diff --git a/toolbox/sensors/private/bst_beep.wav b/toolbox/sensors/private/bst_beep.wav new file mode 100644 index 000000000..946c0485c Binary files /dev/null and b/toolbox/sensors/private/bst_beep.wav differ diff --git a/toolbox/sensors/private/bst_beep_wav.mat b/toolbox/sensors/private/bst_beep_wav.mat deleted file mode 100644 index d6e8cbe9c..000000000 Binary files a/toolbox/sensors/private/bst_beep_wav.mat and /dev/null differ diff --git a/toolbox/timefreq/bst_psd.m b/toolbox/timefreq/bst_psd.m index e5423735a..385e06263 100644 --- a/toolbox/timefreq/bst_psd.m +++ b/toolbox/timefreq/bst_psd.m @@ -1,4 +1,4 @@ -function [TF, FreqVector, Nwin, Messages] = bst_psd( F, sfreq, WinLength, WinOverlap, BadSegments, ImagingKernel, isVariance, PowerUnits ) +function [TF, FreqVector, Nwin, Messages, TFbis] = bst_psd( F, sfreq, WinLength, WinOverlap, BadSegments, ImagingKernel, WinFunc, PowerUnits, IsRelative ) % BST_PSD: Compute the PSD of a set of signals using Welch method % @============================================================================= @@ -21,13 +21,17 @@ % % Authors: Francois Tadel, 2012-2017 % Marc Lalancette, 2020 +% Pauline Amrouche, 2024 % Parse inputs +if (nargin < 9) || isempty(IsRelative) + IsRelative = 0; +end if (nargin < 8) || isempty(PowerUnits) PowerUnits = 'physical'; end -if (nargin < 7) || isempty(isVariance) - isVariance = 0; +if (nargin < 7) || isempty(WinFunc) + WinFunc = 'mean'; end if (nargin < 6) || isempty(ImagingKernel) ImagingKernel = []; @@ -41,14 +45,26 @@ if (nargin < 3) || isempty(WinLength) || (WinLength == 0) WinLength = size(F,2) ./ sfreq; end + Messages = ''; % Get sampling frequency nTime = size(F,2); % Initialize returned values TF = []; +TFbis = []; +% Initialize frequency and number of windows FreqVector = []; Nwin = []; -Var = []; + +% ===== FUNCTION ACROSS WINDOWS ===== +% Backward compatibility with previous versions where winFunc could be 0 (mean) or 1 (std) +switch lower(WinFunc) + case {0, 'mean'}, WinFunc = 'mean'; + case {1, 'std'}, WinFunc = 'std'; + case {2, 'mean+std'}, WinFunc = 'mean+std'; + otherwise, bst_error(['Invalid window aggregating function: ' num2str(lower(WinFunc))]); return +end +computeStd = ~isempty(strfind(WinFunc,'std')); % ===== WINDOWING ===== Lwin = round(WinLength * sfreq); @@ -75,6 +91,18 @@ % Positive frequency bins spanned by FFT FreqVector = sfreq / 2 * linspace(0,1,NFFT/2+1); +% ===== INITIALIZE INTERMEDIATE SUM MATRICES ===== +if ~isempty(ImagingKernel) + nChannels = size(ImagingKernel,1); +else + nChannels = size(F,1); +end +% Sum of the FFTs for each channel and each frequency bin +S1 = zeros(nChannels, 1, NFFT/2+1); +% Sum of the squares of the FFTs for each channel and each frequency bin +if computeStd + S2 = zeros(nChannels, 1, NFFT/2+1); +end % ===== CALCULATE FFT FOR EACH WINDOW ===== Nbad = 0; @@ -133,46 +161,35 @@ TFwin = permute(TFwin, [1 3 2]); % Convert to power TFwin = process_tf_measure('Compute', TFwin, 'none', 'power'); - - -% %%%%% OLD VERSION: MEAN ONLY %%%%% -% % Add PSD of the window to the average -% if isempty(TF) -% TF = TFwin ./ Nwin; -% else -% TF = TF + TFwin ./ Nwin; -% end -% %%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% - - %%%%% NEW VERSION: MEAN AND STD %%%%% - % If file is first of the list: Initialize returned matrices - if isempty(TF) - TF = zeros(size(TFwin)); - if isVariance - Var = zeros(size(TFwin)); - end + % Convert to relative power + if IsRelative + TFwin = TFwin ./ sum(TFwin, 3); end - % Compute mean and standard deviation - TFwin = TFwin - TF; - R = TFwin ./ (iWin-Nbad); - if isVariance - Var = Var + TFwin .* R .* (iWin-Nbad-1); + % Compute sum and sum of squares + S1 = S1 + TFwin; + if computeStd + S2 = S2 + TFwin.^2; end - TF = TF + R; - %%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% -end - -% Convert variance to standard deviation -if isVariance - Var = Var ./ (Nwin-Nbad - 1); - TF = sqrt(Var); end % Correct the dividing factor if there are bad segments if (Nbad > 0) - % TF = TF .* (Nwin ./ (Nwin - Nbad)); % OLD VERSION Nwin = Nwin - Nbad; end +% Compute mean and standard deviation +TFmean = S1 ./ Nwin; +if computeStd + Var = S2 ./ Nwin - TFmean.^2; + TFstd = sqrt(Var); +end + +% Define the matrices to return +switch WinFunc + case 'mean', TF = TFmean; TFbis = []; + case 'std', TF = TFstd; TFbis = []; + case 'mean+std', TF = TFmean; TFbis = TFstd; +end + % Format message if isempty(Messages) Messages = [Messages, sprintf('Using %d windows of %d samples each', Nwin, Lwin)]; diff --git a/toolbox/timefreq/bst_sprint.m b/toolbox/timefreq/bst_sprint.m index 0da177fcf..01b658c25 100644 --- a/toolbox/timefreq/bst_sprint.m +++ b/toolbox/timefreq/bst_sprint.m @@ -1,7 +1,7 @@ function [TF, Messages, OPTIONS] = bst_sprint(F, sfreq, RowNames, OPTIONS) % BST_SPRiNT: Compute time-resolved specparam models for a set of signals using % an STFT approach. -% REFERENCE: Please cite the preprint for the SPRiNT algorithm: +% REFERENCE: Please cite the article for the SPRiNT algorithm: % Wilson, L. E., da Silva Castanheira, J., & Baillet, S. (2022). % Time-resolved parameterization of aperiodic and periodic brain % activity. eLife, 11, e77348. doi:10.7554/eLife.77348 @@ -25,7 +25,7 @@ % For more information type "brainstorm license" at command prompt. % =============================================================================@ % -% Authors: Luc Wilson (2021) +% Authors: Luc Wilson (2021-2024) % Fetch user settings opt = struct(); @@ -38,10 +38,11 @@ opt.min_peak_height = OPTIONS.SPRiNTopts.minpeakheight.Value{1} / 10; % convert from dB to B opt.aperiodic_mode = OPTIONS.SPRiNTopts.apermode.Value; opt.peak_threshold = 2; % 2 std dev: parameter for interface simplification -opt.peak_type = OPTIONS.SPRiNTopts.peaktype.Value; +opt.peak_type = 'gaussian'; % 'cauchy', for interface simplification opt.proximity_threshold = OPTIONS.SPRiNTopts.proxthresh.Value{1}; opt.guess_weight = OPTIONS.SPRiNTopts.guessweight.Value; opt.hOT = 0; +opt.optim_obj = OPTIONS.SPRiNTopts.optimobj.Value; opt.thresh_after = true; opt.rmoutliers = OPTIONS.SPRiNTopts.rmoutliers.Value; opt.maxfreq = OPTIONS.SPRiNTopts.maxfreq.Value{1}; @@ -60,6 +61,13 @@ disp('Using constrained optimization, Guess Weight ignored.') end +dct = 0; +if size(F,1) > 1 & license('test','distrib_computing_toolbox') % use distributed computing if more than 1 channel + dct = 1; % dct = 0; to force disable + % to force disable parallel processing, set above to 0; + disp('Using parallel processing.') +end + % Get sampling frequency nTime = size(F,2); % Initialize returned values @@ -86,6 +94,19 @@ Lwin = Lwin - mod(Lwin,2); % Make sure the number of samples is even Nwin = floor((nTime - Loverlap) ./ (Lwin - Loverlap)); end +% Finally, handle when aggregate window length exceeds recording when +% considering averaging across sliding window. +nAvgChanged = 0; +while (Lwin+Loverlap*(opt.nAverage-1) > nTime) + nAvgChanged = 1; + Messages = ['Time windows included in average exceed recording length, Reducing number of windows by 1' 10]; + opt.nAverage = opt.nAverage-1; +end +if nAvgChanged + disp('Time windows included in average exceed recording length') + disp(['Reduced number of windows used in average to: ' num2str(opt.nAverage)]) +end + % Next power of 2 from length of signal % NFFT = 2^nextpow2(Lwin); % Function fft() pads the signal with zeros before computing the FT NFFT = Lwin; % No zero-padding: Nfft = Ntime @@ -147,6 +168,16 @@ TF(:,indGood:end,:) = []; ts(indGood:end) = []; +switch opt.optim_obj + case 'leastsquare' % no knee + [TF, OPTIONS] = lse_sprint(TF, FreqVector, ts, opt, OPTIONS, RowNames, dct); + case 'negloglike' + [TF, OPTIONS] = nll_sprint(TF, FreqVector, ts, opt, OPTIONS, RowNames, dct); +end + +end + +function [TF, OPTIONS] = lse_sprint(TF, FreqVector, ts, opt, OPTIONS, RowNames, dct) % ===== GENERATE SPECPARAM MODELS FOR EACH WINDOW ===== % Find all frequency values within user limits fMask = (round(FreqVector.*10)./10 >= round(opt.freq_range(1).*10)./10) & (round(FreqVector.*10)./10 <= round(opt.freq_range(2).*10)./10); @@ -168,110 +199,623 @@ if isa(RowNames,'double') RowNames = cellstr(num2str(RowNames')); end - for chan = 1:nChan - channel(chan).name = RowNames{chan}; - bst_progress('text',['Standby: SPRiNTing sensor ' num2str(chan) ' of ' num2str(nChan)]); - channel(chan).data(nTimes) = struct(... - 'time', [],... - 'aperiodic_params', [],... - 'peak_params', [],... - 'peak_types', '',... - 'ap_fit', [],... - 'fooofed_spectrum', [],... - 'power_spectrum', [],... - 'peak_fit', [],... - 'error', [],... - 'r_squared', []); - channel(chan).peaks(nTimes*opt.max_peaks) = struct(... - 'time', [],... - 'center_frequency', [],... - 'amplitude', [],... - 'st_dev', []); - channel(chan).aperiodics(nTimes) = struct(... - 'time', [],... - 'offset', [],... - 'exponent', []); - channel(chan).stats(nTimes) = struct(... - 'MSE', [],... - 'r_squared', [],... - 'frequency_wise_error', []); - spec = log10(squeeze(TF(chan,:,:))); % extract log spectra for a given channel - % Iterate across time - i = 1; % For peak extraction - ag = -(spec(1,end)-spec(1,1))./log10(fs(end)./fs(1)); % aperiodic guess initialization - for time = 1:nTimes - bst_progress('set', bst_round(time / nTimes,2).*100); - % Fit aperiodic - aperiodic_pars = robust_ap_fit(fs, spec(time,:), opt.aperiodic_mode, ag); - % Remove aperiodic - flat_spec = flatten_spectrum(fs, spec(time,:), aperiodic_pars, opt.aperiodic_mode); - % Fit peaks - [peak_pars, peak_function] = fit_peaks(fs, flat_spec, opt.max_peaks, opt.peak_threshold, opt.min_peak_height, ... - opt.peak_width_limits/2, opt.proximity_threshold, opt.peak_type, opt.guess_weight,opt.hOT); - if opt.thresh_after && ~opt.hOT % Check thresholding requirements are met for unbounded optimization - peak_pars(peak_pars(:,2) < opt.min_peak_height,:) = []; % remove peaks shorter than limit - peak_pars(peak_pars(:,3) < opt.peak_width_limits(1)/2,:) = []; % remove peaks narrower than limit - peak_pars(peak_pars(:,3) > opt.peak_width_limits(2)/2,:) = []; % remove peaks broader than limit - peak_pars = drop_peak_cf(peak_pars, opt.proximity_threshold, opt.freq_range); % remove peaks outside frequency limits - peak_pars(peak_pars(:,1) < 0,:) = []; % remove peaks with a centre frequency less than zero (bypass drop_peak_cf) - peak_pars = drop_peak_overlap(peak_pars, opt.proximity_threshold); % remove smallest of two peaks fit too closely - end - % Refit aperiodic - aperiodic = spec(time,:); - for peak = 1:size(peak_pars,1) - aperiodic = aperiodic - peak_function(fs,peak_pars(peak,1), peak_pars(peak,2), peak_pars(peak,3)); + if ~dct + for chan = 1:nChan + channel(chan).name = RowNames{chan}; + bst_progress('text',['Standby: SPRiNTing sensor ' num2str(chan) ' of ' num2str(nChan)]); + channel(chan).data(nTimes) = struct(... + 'time', [],... + 'aperiodic_params', [],... + 'peak_params', [],... + 'peak_types', '',... + 'ap_fit', [],... + 'fooofed_spectrum', [],... + 'power_spectrum', [],... + 'peak_fit', [],... + 'error', [],... + 'r_squared', []); + channel(chan).peaks(nTimes*opt.max_peaks) = struct(... + 'time', [],... + 'center_frequency', [],... + 'amplitude', [],... + 'st_dev', []); + channel(chan).aperiodics(nTimes) = struct(... + 'time', [],... + 'offset', [],... + 'exponent', []); + channel(chan).stats(nTimes) = struct(... + 'MSE', [],... + 'r_squared', [],... + 'frequency_wise_error', []); + spec = log10(squeeze(TF(chan,:,:))); % extract log spectra for a given channel + % Iterate across time + i = 1; % For peak extraction + ag = -(spec(1,end)-spec(1,1))./log10(fs(end)./fs(1)); % aperiodic guess initialization + for time = 1:nTimes + bst_progress('set', bst_round(time / nTimes,2).*100); + % Fit aperiodic + aperiodic_pars = robust_ap_fit(fs, spec(time,:), opt.aperiodic_mode, ag); + % Remove aperiodic + flat_spec = flatten_spectrum(fs, spec(time,:), aperiodic_pars, opt.aperiodic_mode); + % Fit peaks + [peak_pars, pk_function] = fit_peaks(fs, flat_spec, opt.max_peaks, opt.peak_threshold, opt.min_peak_height, ... + opt.peak_width_limits/2, opt.proximity_threshold, opt.peak_type, opt.guess_weight,opt.hOT); + if opt.thresh_after && ~opt.hOT % Check thresholding requirements are met for unbounded optimization + peak_pars(peak_pars(:,2) < opt.min_peak_height,:) = []; % remove peaks shorter than limit + peak_pars(peak_pars(:,3) < opt.peak_width_limits(1)/2,:) = []; % remove peaks narrower than limit + peak_pars(peak_pars(:,3) > opt.peak_width_limits(2)/2,:) = []; % remove peaks broader than limit + peak_pars = drop_peak_cf(peak_pars, opt.proximity_threshold, opt.freq_range); % remove peaks outside frequency limits + peak_pars(peak_pars(:,1) < 0,:) = []; % remove peaks with a centre frequency less than zero (bypass drop_peak_cf) + peak_pars = drop_peak_overlap(peak_pars, opt.proximity_threshold); % remove smallest of two peaks fit too closely + end + % Refit aperiodic + aperiodic = spec(time,:); + for peak = 1:size(peak_pars,1) + aperiodic = aperiodic - pk_function(fs,peak_pars(peak,1), peak_pars(peak,2), peak_pars(peak,3)); + end + aperiodic_pars = simple_ap_fit(fs, aperiodic, opt.aperiodic_mode, aperiodic_pars(end)); + ag = aperiodic_pars(end); % save aperiodic estimate for next iteration + % Generate model fit + ap_fit = gen_aperiodic(fs, aperiodic_pars, opt.aperiodic_mode); + model_fit = ap_fit; + for peak = 1:size(peak_pars,1) + model_fit = model_fit + pk_function(fs,peak_pars(peak,1),... + peak_pars(peak,2),peak_pars(peak,3)); + end + % Calculate model error + MSE = sum((spec(time,:) - model_fit).^2)/length(model_fit); + rsq_tmp = corrcoef(spec(time,:),model_fit).^2; + % Return FOOOF results + aperiodic_pars(2) = abs(aperiodic_pars(2)); + channel(chan).data(time).time = ts(time); + channel(chan).data(time).aperiodic_params = aperiodic_pars; + channel(chan).data(time).peak_params = peak_pars; + channel(chan).data(time).peak_types = func2str(pk_function); + channel(chan).data(time).ap_fit = 10.^ap_fit; + aperiodic_models(chan,time,:) = 10.^ap_fit; + channel(chan).data(time).fooofed_spectrum = 10.^model_fit; + SPRiNT_models(chan,time,:) = 10.^model_fit; + channel(chan).data(time).power_spectrum = 10.^spec(time,:); + channel(chan).data(time).peak_fit = 10.^(model_fit-ap_fit); + peak_models(chan,time,:) = 10.^(model_fit-ap_fit); + channel(chan).data(time).error = MSE; + channel(chan).data(time).r_squared = rsq_tmp(2); + % Extract peaks + if ~isempty(peak_pars) & any(peak_pars) + for p = 1:size(peak_pars,1) + channel(chan).peaks(i).time = ts(time); + channel(chan).peaks(i).center_frequency = peak_pars(p,1); + channel(chan).peaks(i).amplitude = peak_pars(p,2); + channel(chan).peaks(i).st_dev = peak_pars(p,3); + i = i +1; + end + end + % Extract aperiodic + channel(chan).aperiodics(time).time = ts(time); + channel(chan).aperiodics(time).offset = aperiodic_pars(1); + if length(aperiodic_pars)>2 % Legacy FOOOF alters order of parameters + channel(chan).aperiodics(time).exponent = aperiodic_pars(3); + channel(chan).aperiodics(time).knee_frequency = aperiodic_pars(2); + else + channel(chan).aperiodics(time).exponent = aperiodic_pars(2); + end + channel(chan).stats(time).MSE = MSE; + channel(chan).stats(time).r_squared = rsq_tmp(2); + channel(chan).stats(time).frequency_wise_error = abs(spec(time,:)-model_fit); end - aperiodic_pars = simple_ap_fit(fs, aperiodic, opt.aperiodic_mode, aperiodic_pars(end)); - ag = aperiodic_pars(end); % save aperiodic estimate for next iteration - % Generate model fit - ap_fit = gen_aperiodic(fs, aperiodic_pars, opt.aperiodic_mode); - model_fit = ap_fit; - for peak = 1:size(peak_pars,1) - model_fit = model_fit + peak_function(fs,peak_pars(peak,1),... - peak_pars(peak,2),peak_pars(peak,3)); + channel(chan).peaks(i:end) = []; + end + else + bst_progress('text','Standby: Parallel SPRiNTing channels'); + parfor chan = 1:nChan + channel(chan).name = RowNames{chan}; + channel(chan).data(nTimes) = struct(... + 'time', [],... + 'aperiodic_params', [],... + 'peak_params', [],... + 'peak_types', '',... + 'ap_fit', [],... + 'fooofed_spectrum', [],... + 'power_spectrum', [],... + 'peak_fit', [],... + 'error', [],... + 'r_squared', []); + channel(chan).peaks(nTimes*opt.max_peaks) = struct(... + 'time', [],... + 'center_frequency', [],... + 'amplitude', [],... + 'st_dev', []); + channel(chan).aperiodics(nTimes) = struct(... + 'time', [],... + 'offset', [],... + 'exponent', []); + channel(chan).stats(nTimes) = struct(... + 'MSE', [],... + 'r_squared', [],... + 'frequency_wise_error', []); + spec = log10(squeeze(TF(chan,:,:))); % extract log spectra for a given channel + % Iterate across time + i = 1; % For peak extraction + ag = -(spec(1,end)-spec(1,1))./log10(fs(end)./fs(1)); % aperiodic guess initialization + for time = 1:nTimes + % Fit aperiodic + aperiodic_pars = robust_ap_fit(fs, spec(time,:), opt.aperiodic_mode, ag); + % Remove aperiodic + flat_spec = flatten_spectrum(fs, spec(time,:), aperiodic_pars, opt.aperiodic_mode); + % Fit peaks + [peak_pars, pk_function] = fit_peaks(fs, flat_spec, opt.max_peaks, opt.peak_threshold, opt.min_peak_height, ... + opt.peak_width_limits/2, opt.proximity_threshold, opt.peak_type, opt.guess_weight,opt.hOT); + if opt.thresh_after && ~opt.hOT % Check thresholding requirements are met for unbounded optimization + peak_pars(peak_pars(:,2) < opt.min_peak_height,:) = []; % remove peaks shorter than limit + peak_pars(peak_pars(:,3) < opt.peak_width_limits(1)/2,:) = []; % remove peaks narrower than limit + peak_pars(peak_pars(:,3) > opt.peak_width_limits(2)/2,:) = []; % remove peaks broader than limit + peak_pars = drop_peak_cf(peak_pars, opt.proximity_threshold, opt.freq_range); % remove peaks outside frequency limits + peak_pars(peak_pars(:,1) < 0,:) = []; % remove peaks with a centre frequency less than zero (bypass drop_peak_cf) + peak_pars = drop_peak_overlap(peak_pars, opt.proximity_threshold); % remove smallest of two peaks fit too closely + end + % Refit aperiodic + aperiodic = spec(time,:); + for peak = 1:size(peak_pars,1) + aperiodic = aperiodic - pk_function(fs,peak_pars(peak,1), peak_pars(peak,2), peak_pars(peak,3)); + end + aperiodic_pars = simple_ap_fit(fs, aperiodic, opt.aperiodic_mode, aperiodic_pars(end)); + ag = aperiodic_pars(end); % save aperiodic estimate for next iteration + % Generate model fit + ap_fit = gen_aperiodic(fs, aperiodic_pars, opt.aperiodic_mode); + model_fit = ap_fit; + for peak = 1:size(peak_pars,1) + model_fit = model_fit + pk_function(fs,peak_pars(peak,1),... + peak_pars(peak,2),peak_pars(peak,3)); + end + % Calculate model error + MSE = sum((spec(time,:) - model_fit).^2)/length(model_fit); + rsq_tmp = corrcoef(spec(time,:),model_fit).^2; + % Return FOOOF results + aperiodic_pars(2) = abs(aperiodic_pars(2)); + channel(chan).data(time).time = ts(time); + channel(chan).data(time).aperiodic_params = aperiodic_pars; + channel(chan).data(time).peak_params = peak_pars; + channel(chan).data(time).peak_types = func2str(pk_function); + channel(chan).data(time).ap_fit = 10.^ap_fit; + aperiodic_models(chan,time,:) = 10.^ap_fit; + channel(chan).data(time).fooofed_spectrum = 10.^model_fit; + SPRiNT_models(chan,time,:) = 10.^model_fit; + channel(chan).data(time).power_spectrum = 10.^spec(time,:); + channel(chan).data(time).peak_fit = 10.^(model_fit-ap_fit); + peak_models(chan,time,:) = 10.^(model_fit-ap_fit); + channel(chan).data(time).error = MSE; + channel(chan).data(time).r_squared = rsq_tmp(2); + % Extract peaks + if ~isempty(peak_pars) & any(peak_pars) + for p = 1:size(peak_pars,1) + channel(chan).peaks(i).time = ts(time); + channel(chan).peaks(i).center_frequency = peak_pars(p,1); + channel(chan).peaks(i).amplitude = peak_pars(p,2); + channel(chan).peaks(i).st_dev = peak_pars(p,3); + i = i +1; + end + end + % Extract aperiodic + channel(chan).aperiodics(time).time = ts(time); + channel(chan).aperiodics(time).offset = aperiodic_pars(1); + if length(aperiodic_pars)>2 % Legacy FOOOF alters order of parameters + channel(chan).aperiodics(time).exponent = aperiodic_pars(3); + channel(chan).aperiodics(time).knee_frequency = aperiodic_pars(2); + else + channel(chan).aperiodics(time).exponent = aperiodic_pars(2); + end + channel(chan).stats(time).MSE = MSE; + channel(chan).stats(time).r_squared = rsq_tmp(2); + channel(chan).stats(time).frequency_wise_error = abs(spec(time,:)-model_fit); end - % Calculate model error - MSE = sum((spec(time,:) - model_fit).^2)/length(model_fit); - rsq_tmp = corrcoef(spec(time,:),model_fit).^2; - % Return FOOOF results - aperiodic_pars(2) = abs(aperiodic_pars(2)); - channel(chan).data(time).time = ts(time); - channel(chan).data(time).aperiodic_params = aperiodic_pars; - channel(chan).data(time).peak_params = peak_pars; - channel(chan).data(time).peak_types = func2str(peak_function); - channel(chan).data(time).ap_fit = 10.^ap_fit; - aperiodic_models(chan,time,:) = 10.^ap_fit; - channel(chan).data(time).fooofed_spectrum = 10.^model_fit; - SPRiNT_models(chan,time,:) = 10.^model_fit; - channel(chan).data(time).power_spectrum = 10.^spec(time,:); - channel(chan).data(time).peak_fit = 10.^(model_fit-ap_fit); - peak_models(chan,time,:) = 10.^(model_fit-ap_fit); - channel(chan).data(time).error = MSE; - channel(chan).data(time).r_squared = rsq_tmp(2); - % Extract peaks - if ~isempty(peak_pars) & any(peak_pars) - for p = 1:size(peak_pars,1) - channel(chan).peaks(i).time = ts(time); - channel(chan).peaks(i).center_frequency = peak_pars(p,1); - channel(chan).peaks(i).amplitude = peak_pars(p,2); - channel(chan).peaks(i).st_dev = peak_pars(p,3); - i = i +1; + channel(chan).peaks(i:end) = []; + end + end + SPRiNT.channel = channel; + SPRiNT.aperiodic_models = aperiodic_models; + SPRiNT.SPRiNT_models = SPRiNT_models; + SPRiNT.peak_models = peak_models; + if strcmp(opt.rmoutliers,'yes') + bst_progress('text','Standby: Removing outlier peaks'); + SPRiNT = remove_outliers(SPRiNT,@gaussian,opt); + end + for chan = 1:nChan + tp_exponent(chan,:) = [SPRiNT.channel(chan).aperiodics(:).exponent]; + tp_offset(chan,:) = [SPRiNT.channel(chan).aperiodics(:).offset]; + end + SPRiNT.topography.exponent = tp_exponent; + SPRiNT.topography.offset = tp_offset; + bst_progress('text','Standby: Clustering modelled peaks'); + SPRiNT = cluster_peaks_dynamic(SPRiNT); % Cluster peaks + OPTIONS.TimeVector = ts'; % Reassign times by windows used + TF = sqrt(TF); % remove power transformation + OPTIONS.SPRiNT = SPRiNT; + +end + +function [TF, OPTIONS] = nll_sprint(TF, FreqVector, ts, opt, OPTIONS, RowNames, dct) + +% ===== GENERATE SPECPARAM MODELS FOR EACH WINDOW ===== +% Find all frequency values within user limits + fMask = (round(FreqVector.*10)./10 >= round(opt.freq_range(1).*10)./10) & (round(FreqVector.*10)./10 <= round(opt.freq_range(2).*10)./10); + fs = FreqVector(fMask); + lfdif = log10(fs(end)./fs(1)); + mp = opt.max_peaks; + am = opt.aperiodic_mode; + pet = opt.peak_threshold; + mph = opt.min_peak_height; + pwl = opt.peak_width_limits./2; + prt = opt.proximity_threshold; + pt = opt.peak_type; + gw = opt.guess_weight; + hOT = opt.hOT; + OPTIONS.Freqs = fs; + nChan = size(TF,1); + nTimes = size(TF,2); + % Adjust TF plots to only include modelled frequencies + TF = TF(:,:,fMask); + % Initalize FOOOF structs + channel(nChan) = struct('name',[]); + SPRiNT = struct('options',opt,'freqs',fs,'channel',channel,'SPRiNT_models',nan(size(TF)),'peak_models',nan(size(TF)),'aperiodic_models',nan(size(TF))); + % Iterate across channels + aperiodic_models = nan(nChan,nTimes,length(fs)); + peak_models = nan(nChan,nTimes,length(fs)); + SPRiNT_models = nan(nChan,nTimes,length(fs)); + tp_exponent = nan(nChan,nTimes); + tp_offset = nan(nChan,nTimes); + if isa(RowNames,'double') + RowNames = cellstr(num2str(RowNames')); + end + switch opt.peak_type + case 'gaussian' % gaussian only + peak_function = @gaussian; + case 'cauchy' + peak_function = @cauchy; + end + if ~dct + for chan = 1:nChan + channel(chan).name = RowNames{chan}; + bst_progress('text',['Standby: ms-SPRiNTing sensor ' num2str(chan) ' of ' num2str(nChan)]); + channel(chan).data(nTimes) = struct(... + 'time', [],... + 'aperiodic_params', [],... + 'peak_params', [],... + 'peak_types', '',... + 'ap_fit', [],... + 'fooofed_spectrum', [],... + 'power_spectrum', [],... + 'peak_fit', [],... + 'error', [],... + 'r_squared', []); + channel(chan).peaks(nTimes*mp) = struct(... + 'time', [],... + 'center_frequency', [],... + 'amplitude', [],... + 'st_dev', []); + channel(chan).aperiodics(nTimes) = struct(... + 'time', [],... + 'offset', [],... + 'exponent', []); + channel(chan).stats(nTimes) = struct(... + 'MSE', [],... + 'r_squared', [],... + 'frequency_wise_error', []); + spec = log10(squeeze(TF(chan,:,:))); % extract log spectra for a given channel + % Iterate across time + i = 1; % For peak extraction + ag = -(spec(1,end)-spec(1,1))./lfdif; % aperiodic guess initialization + for time = 1:nTimes + bst_progress('set', bst_round(time / nTimes,2).*100); + % Fit aperiodic + aperiodic_pars = robust_ap_fit(fs, spec(time,:), am, ag); + % Remove aperiodic + flat_spec = flatten_spectrum(fs, spec(time,:), aperiodic_pars, am); + try + [est_pars, pk_function] = est_peaks(fs, flat_spec, mp, pet, mph, ... + pwl, prt, pt); + catch + error(['Failure fitting peaks: channel ' num2str(chan) ', time index ' num2str(time)]) end + model = struct(); + for pk = 0:size(est_pars,1) + peak_pars = est_fit(est_pars(1:pk,:), fs, flat_spec, pwl, pt, gw, hOT); + % Refit aperiodic + aperiodic = spec(time,:); + for peak = 1:size(peak_pars,1) + aperiodic = aperiodic - pk_function(fs,peak_pars(peak,1), peak_pars(peak,2), peak_pars(peak,3)); + end + aperiodic_pars = simple_ap_fit(fs, aperiodic, am, aperiodic_pars(2)); + guess = peak_pars; + if ~isempty(guess) + lb = [max([ones(size(guess(1:pk,:),1),1).*fs(1) guess(1:pk,1)-guess(1:pk,3)*2],[],2),zeros(size(guess(1:pk,2))),ones(size(guess(1:pk,3)))*pwl(1)]'; + ub = [min([ones(size(guess(1:pk,:),1),1).*fs(end) guess(1:pk,1)+guess(1:pk,3)*2],[],2),inf(size(guess(1:pk,2))),ones(size(guess(1:pk,3)))*pwl(2)]'; + + else + lb = []; + ub = []; + end + switch am + case 'fixed' + lb = [-inf; 0; lb(:)]; + ub = [inf; inf; ub(:)]; + case 'knee' + lb = [-inf; 0; 0; lb(:)]; + ub = [inf; 100; inf; ub(:)]; + end + + guess = guess(1:pk,:)'; + guess = [aperiodic_pars'; guess(:)]; + options = optimset('Display', 'off', 'TolX', 1e-7, 'TolFun', 1e-9, ... + 'MaxFunEvals', 5000, 'MaxIter', 5000); % Tuned options + try + params = fmincon(@err_fm_constr, guess, [], [], [], [], ... + lb, ub, [], options, fs, spec(time,:), am, pt); + catch + error(['Optimization failed to converge: channel ' num2str(chan) ', time index ' num2str(time)]); + end + switch am + case 'fixed' + aperiodic_pars_tmp = params(1:2); + if length(params) > 3 + peak_pars_tmp = reshape(params(3:end),[3 length(params(3:end))./3])'; + end + case 'knee' + aperiodic_pars_tmp = params(1:3); + if length(params) > 3 + peak_pars_tmp = reshape(params(4:end),[3 length(params(4:end))./3])'; + end + end + % Generate model fit + ap_fit = gen_aperiodic(fs, aperiodic_pars_tmp, am); + model_fit = ap_fit; + if length(params) > 3 + for peak = 1:size(peak_pars_tmp,1) + model_fit = model_fit + peak_function(fs,peak_pars_tmp(peak,1),... + peak_pars_tmp(peak,2),peak_pars_tmp(peak,3)); + end + else + peak_pars_tmp = [0 0 0]; + end + % Calculate model error + MSE = sum((spec(time,:) - model_fit).^2)/length(model_fit); + rsq_tmp = corrcoef(spec(time,:),model_fit).^2; + loglik = -length(model_fit)/2.*(1+log(MSE)+log(2*pi)); + AIC = 2.*(length(params)-loglik); + BIC = length(params).*log(length(model_fit))-2.*loglik; + model(pk+1).aperiodic_params = aperiodic_pars_tmp; + model(pk+1).peak_params = peak_pars_tmp; + model(pk+1).MSE = MSE; + model(pk+1).r_squared = rsq_tmp(2); + model(pk+1).loglik = loglik; + model(pk+1).AIC = AIC; + model(pk+1).BIC = BIC; + model(pk+1).BF = exp((BIC-model(1).BIC)./2); + end + + % insert data from best model + [~,mi] = min([model.BIC]); + aperiodic_pars = model(mi).aperiodic_params; + peak_pars = model(mi).peak_params; + % Return FOOOF results + aperiodic_pars(2) = abs(aperiodic_pars(2)); + channel(chan).data(time).time = ts(time); + channel(chan).data(time).aperiodic_params = aperiodic_pars; + channel(chan).data(time).peak_params = peak_pars; + channel(chan).data(time).peak_types = func2str(peak_function); + channel(chan).data(time).ap_fit = 10.^gen_aperiodic(fs, aperiodic_pars, am); + aperiodic_models(chan,time,:) = channel(chan).data(time).ap_fit; + channel(chan).data(time).fooofed_spectrum = 10.^build_model(fs, aperiodic_pars, opt.aperiodic_mode, peak_pars, peak_function); + SPRiNT_models(chan,time,:) = channel(chan).data(time).fooofed_spectrum; + channel(chan).data(time).power_spectrum = 10.^spec(time,:); + channel(chan).data(time).peak_fit = 10.^(SPRiNT_models(chan,time,:)-aperiodic_models(chan,time,:)); + peak_models(chan,time,:) = 10.^(SPRiNT_models(chan,time,:)-aperiodic_models(chan,time,:)); + channel(chan).data(time).error = model(mi).MSE; + channel(chan).data(time).r_squared = model(mi).r_squared; + channel(chan).data(time).loglik = model(mi).loglik; % log-likelihood + channel(chan).data(time).AIC = model(mi).AIC; + channel(chan).data(time).BIC = model(mi).BIC; + channel(chan).data(time).models = model; + % Extract peaks + if ~isempty(peak_pars) & any(peak_pars) + for p = 1:size(peak_pars,1) + channel(chan).peaks(i).time = ts(time); + channel(chan).peaks(i).center_frequency = peak_pars(p,1); + channel(chan).peaks(i).amplitude = peak_pars(p,2); + channel(chan).peaks(i).st_dev = peak_pars(p,3); + i = i +1; + end + end + % Extract aperiodic + channel(chan).aperiodics(time).time = ts(time); + channel(chan).aperiodics(time).offset = aperiodic_pars(1); + if length(aperiodic_pars)>2 % Legacy FOOOF alters order of parameters + channel(chan).aperiodics(time).exponent = aperiodic_pars(3); + channel(chan).aperiodics(time).knee_frequency = aperiodic_pars(2); + else + channel(chan).aperiodics(time).exponent = aperiodic_pars(2); + end + channel(chan).stats(time).MSE = MSE; + channel(chan).stats(time).r_squared = rsq_tmp(2); + channel(chan).stats(time).frequency_wise_error = abs(spec(time,:)-log10(channel(chan).data(time).fooofed_spectrum)); end - % Extract aperiodic - channel(chan).aperiodics(time).time = ts(time); - channel(chan).aperiodics(time).offset = aperiodic_pars(1); - if length(aperiodic_pars)>2 % Legacy FOOOF alters order of parameters - channel(chan).aperiodics(time).exponent = aperiodic_pars(3); - channel(chan).aperiodics(time).knee_frequency = aperiodic_pars(2); - else - channel(chan).aperiodics(time).exponent = aperiodic_pars(2); + channel(chan).peaks(i:end) = []; + end + else + bst_progress('text','Standby: Parallel ms-SPRiNTing channels'); + parfor chan = 1:nChan + channel(chan).name = RowNames{chan}; + bst_progress('text',['Standby: ms-SPRiNTing sensor ' num2str(chan) ' of ' num2str(nChan)]); + channel(chan).data(nTimes) = struct(... + 'time', [],... + 'aperiodic_params', [],... + 'peak_params', [],... + 'peak_types', '',... + 'ap_fit', [],... + 'fooofed_spectrum', [],... + 'power_spectrum', [],... + 'peak_fit', [],... + 'error', [],... + 'r_squared', []); + channel(chan).peaks(nTimes*mp) = struct(... + 'time', [],... + 'center_frequency', [],... + 'amplitude', [],... + 'st_dev', []); + channel(chan).aperiodics(nTimes) = struct(... + 'time', [],... + 'offset', [],... + 'exponent', []); + channel(chan).stats(nTimes) = struct(... + 'MSE', [],... + 'r_squared', [],... + 'frequency_wise_error', []); + spec = log10(squeeze(TF(chan,:,:))); % extract log spectra for a given channel + % Iterate across time + i = 1; % For peak extraction + ag = -(spec(1,end)-spec(1,1))./lfdif; % aperiodic guess initialization + for time = 1:nTimes + bst_progress('set', bst_round(time / nTimes,2).*100); + est_pars = []; + pk_function = []; + MSE = []; + rsq_tmp = []; + % Fit aperiodic + aperiodic_pars = robust_ap_fit(fs, spec(time,:), am, ag); + % Remove aperiodic + flat_spec = flatten_spectrum(fs, spec(time,:), aperiodic_pars, am); + try + [est_pars, pk_function] = est_peaks(fs, flat_spec, mp, pet, mph, ... + pwl, prt, pt); + catch + error(['Failure fitting peaks: channel ' num2str(chan) ', time index ' num2str(time)]) + end + model = struct(); + for pk = 0:size(est_pars,1) + params = []; + aperiodic_pars_tmp = []; + peak_pars_tmp = []; + peak_pars = est_fit(est_pars(1:pk,:), fs, flat_spec, pwl, pt, gw, hOT); + % Refit aperiodic + aperiodic = spec(time,:); + for peak = 1:size(peak_pars,1) + aperiodic = aperiodic - pk_function(fs,peak_pars(peak,1), peak_pars(peak,2), peak_pars(peak,3)); + end + aperiodic_pars = simple_ap_fit(fs, aperiodic, am, aperiodic_pars(2)); + guess = peak_pars; + if ~isempty(guess) + lb = [max([ones(size(guess(1:pk,:),1),1).*fs(1) guess(1:pk,1)-guess(1:pk,3)*2],[],2),zeros(size(guess(1:pk,2))),ones(size(guess(1:pk,3)))*pwl(1)]'; + ub = [min([ones(size(guess(1:pk,:),1),1).*fs(end) guess(1:pk,1)+guess(1:pk,3)*2],[],2),inf(size(guess(1:pk,2))),ones(size(guess(1:pk,3)))*pwl(2)]'; + + else + lb = []; + ub = []; + end + switch am + case 'fixed' + lb = [-inf; 0; lb(:)]; + ub = [inf; inf; ub(:)]; + case 'knee' + lb = [-inf; 0; 0; lb(:)]; + ub = [inf; 100; inf; ub(:)]; + end + + guess = guess(1:pk,:)'; + guess = [aperiodic_pars'; guess(:)]; + options = optimset('Display', 'off', 'TolX', 1e-7, 'TolFun', 1e-9, ... + 'MaxFunEvals', 5000, 'MaxIter', 5000); % Tuned options + try + params = fmincon(@err_fm_constr, guess, [], [], [], [], ... + lb, ub, [], options, fs, spec(time,:), am, pt); + catch + error(['Optimization failed to converge: channel ' num2str(chan) ', time index ' num2str(time)]); + end + switch am + case 'fixed' + aperiodic_pars_tmp = params(1:2); + if length(params) > 3 + peak_pars_tmp = reshape(params(3:end),[3 length(params(3:end))./3])'; + end + case 'knee' + aperiodic_pars_tmp = params(1:3); + if length(params) > 3 + peak_pars_tmp = reshape(params(4:end),[3 length(params(4:end))./3])'; + end + end + % Generate model fit + ap_fit = gen_aperiodic(fs, aperiodic_pars_tmp, am); + model_fit = ap_fit; + if length(params) > 3 + for peak = 1:size(peak_pars_tmp,1) + model_fit = model_fit + peak_function(fs,peak_pars_tmp(peak,1),... + peak_pars_tmp(peak,2),peak_pars_tmp(peak,3)); + end + else + peak_pars_tmp = [0 0 0]; + end + % Calculate model error + MSE = sum((spec(time,:) - model_fit).^2)/length(model_fit); + rsq_tmp = corrcoef(spec(time,:),model_fit).^2; + loglik = -length(model_fit)/2.*(1+log(MSE)+log(2*pi)); + AIC = 2.*(length(params)-loglik); + BIC = length(params).*log(length(model_fit))-2.*loglik; + model(pk+1).aperiodic_params = aperiodic_pars_tmp; + model(pk+1).peak_params = peak_pars_tmp; + model(pk+1).MSE = MSE; + model(pk+1).r_squared = rsq_tmp(2); + model(pk+1).loglik = loglik; + model(pk+1).AIC = AIC; + model(pk+1).BIC = BIC; + model(pk+1).BF = exp((BIC-model(1).BIC)./2); + end + + % Insert data from best model + [~,mi] = min([model.BIC]); + aperiodic_pars = model(mi).aperiodic_params; + peak_pars = model(mi).peak_params; + % Return FOOOF results + aperiodic_pars(2) = abs(aperiodic_pars(2)); + channel(chan).data(time).time = ts(time); + channel(chan).data(time).aperiodic_params = aperiodic_pars; + channel(chan).data(time).peak_params = peak_pars; + channel(chan).data(time).peak_types = func2str(peak_function); + channel(chan).data(time).ap_fit = 10.^gen_aperiodic(fs, aperiodic_pars, am); + aperiodic_models(chan,time,:) = channel(chan).data(time).ap_fit; + channel(chan).data(time).fooofed_spectrum = 10.^build_model(fs, aperiodic_pars, opt.aperiodic_mode, peak_pars, peak_function); + SPRiNT_models(chan,time,:) = channel(chan).data(time).fooofed_spectrum; + channel(chan).data(time).power_spectrum = 10.^spec(time,:); + channel(chan).data(time).peak_fit = 10.^(SPRiNT_models(chan,time,:)-aperiodic_models(chan,time,:)); + peak_models(chan,time,:) = 10.^(SPRiNT_models(chan,time,:)-aperiodic_models(chan,time,:)); + channel(chan).data(time).error = model(mi).MSE; + channel(chan).data(time).r_squared = model(mi).r_squared; + channel(chan).data(time).loglik = model(mi).loglik; % log-likelihood + channel(chan).data(time).AIC = model(mi).AIC; + channel(chan).data(time).BIC = model(mi).BIC; + channel(chan).data(time).models = model; + % Extract peaks + if ~isempty(peak_pars) & any(peak_pars) + for p = 1:size(peak_pars,1) + channel(chan).peaks(i).time = ts(time); + channel(chan).peaks(i).center_frequency = peak_pars(p,1); + channel(chan).peaks(i).amplitude = peak_pars(p,2); + channel(chan).peaks(i).st_dev = peak_pars(p,3); + i = i +1; + end + end + % Extract aperiodic + channel(chan).aperiodics(time).time = ts(time); + channel(chan).aperiodics(time).offset = aperiodic_pars(1); + if length(aperiodic_pars)>2 % Legacy FOOOF alters order of parameters + channel(chan).aperiodics(time).exponent = aperiodic_pars(3); + channel(chan).aperiodics(time).knee_frequency = aperiodic_pars(2); + else + channel(chan).aperiodics(time).exponent = aperiodic_pars(2); + end + channel(chan).stats(time).MSE = MSE; + channel(chan).stats(time).r_squared = rsq_tmp(2); + channel(chan).stats(time).frequency_wise_error = abs(spec(time,:)-log10(channel(chan).data(time).fooofed_spectrum)); end - channel(chan).stats(time).MSE = MSE; - channel(chan).stats(time).r_squared = rsq_tmp(2); - channel(chan).stats(time).frequency_wise_error = abs(spec(time,:)-model_fit); + channel(chan).peaks(i:end) = []; end - channel(chan).peaks(i:end) = []; end SPRiNT.channel = channel; SPRiNT.aperiodic_models = aperiodic_models; @@ -282,8 +826,8 @@ SPRiNT = remove_outliers(SPRiNT,peak_function,opt); end for chan = 1:nChan - tp_exponent(chan,:) = [channel(chan).aperiodics(:).exponent]; - tp_offset(chan,:) = [channel(chan).aperiodics(:).offset]; + tp_exponent(chan,:) = [SPRiNT.channel(chan).aperiodics(:).exponent]; + tp_offset(chan,:) = [SPRiNT.channel(chan).aperiodics(:).offset]; end SPRiNT.topography.exponent = tp_exponent; SPRiNT.topography.offset = tp_offset; @@ -315,95 +859,105 @@ timeRange = opt.maxtime.*opt.winLen.*(1-opt.Ovrlp./100); nC = length(SPRiNT.channel); + channel = SPRiNT.channel; + freqs = SPRiNT.freqs; + aperiodic_models = SPRiNT.aperiodic_models; + SPRiNT_models = SPRiNT.SPRiNT_models; + peak_models = SPRiNT.peak_models; for c = 1:nC bst_progress('set', bst_round(c / nC,2).*100); - ts = [SPRiNT.channel(c).data.time]; + ts = [channel(c).data.time]; remove = 1; while any(remove) - remove = zeros(length([SPRiNT.channel(c).peaks]),1); - for p = 1:length([SPRiNT.channel(c).peaks]) - if sum((abs([SPRiNT.channel(c).peaks.time] - SPRiNT.channel(c).peaks(p).time) <= timeRange) &... - (abs([SPRiNT.channel(c).peaks.center_frequency] - SPRiNT.channel(c).peaks(p).center_frequency) <= opt.maxfreq)) < opt.minnear +1 % includes current peak + remove = zeros(length([channel(c).peaks]),1); + for p = 1:length([channel(c).peaks]) + if sum((abs([channel(c).peaks.time] - channel(c).peaks(p).time) <= timeRange) &... + (abs([channel(c).peaks.center_frequency] - channel(c).peaks(p).center_frequency) <= opt.maxfreq)) < opt.minnear +1 % includes current peak remove(p) = 1; end end - SPRiNT.channel(c).peaks(logical(remove)) = []; + channel(c).peaks(logical(remove)) = []; end for t = 1:length(ts) - if SPRiNT.channel(c).data(t).peak_params(1) == 0 + if channel(c).data(t).peak_params(1) == 0 continue % never any peaks to begin with end - p = [SPRiNT.channel(c).peaks.time] == ts(t); - if sum(p) == size(SPRiNT.channel(c).data(t).peak_params,1) + p = [channel(c).peaks.time] == ts(t); + if sum(p) == size(channel(c).data(t).peak_params,1) continue % number of peaks has not changed end - peak_fit = zeros(size(SPRiNT.freqs)); + peak_fit = zeros(size(freqs)); if any(p) - SPRiNT.channel(c).data(t).peak_params = [[SPRiNT.channel(c).peaks(p).center_frequency]' [SPRiNT.channel(c).peaks(p).amplitude]' [SPRiNT.channel(c).peaks(p).st_dev]']; - peak_pars = SPRiNT.channel(c).data(t).peak_params; + channel(c).data(t).peak_params = [[channel(c).peaks(p).center_frequency]' [channel(c).peaks(p).amplitude]' [channel(c).peaks(p).st_dev]']; + peak_pars = channel(c).data(t).peak_params; for peak = 1:size(peak_pars,1) - peak_fit = peak_fit + peak_function(SPRiNT.freqs,peak_pars(peak,1),... + peak_fit = peak_fit + peak_function(freqs,peak_pars(peak,1),... peak_pars(peak,2),peak_pars(peak,3)); end - ap_spec = log10(SPRiNT.channel(c).data(t).power_spectrum) - peak_fit; - ap_pars = simple_ap_fit(SPRiNT.freqs, ap_spec, opt.aperiodic_mode, SPRiNT.channel(c).data(t).aperiodic_params(end)); - ap_fit = gen_aperiodic(SPRiNT.freqs, ap_pars, opt.aperiodic_mode); - MSE = sum((ap_spec - ap_fit).^2)/length(SPRiNT.freqs); + ap_spec = log10(channel(c).data(t).power_spectrum) - peak_fit; + ap_pars = simple_ap_fit(freqs, ap_spec, opt.aperiodic_mode, channel(c).data(t).aperiodic_params(end)); + ap_fit = gen_aperiodic(freqs, ap_pars, opt.aperiodic_mode); + MSE = sum((ap_spec - ap_fit).^2)/length(freqs); rsq_tmp = corrcoef(ap_spec+peak_fit,ap_fit+peak_fit).^2; % Return FOOOF results ap_pars(2) = abs(ap_pars(2)); - SPRiNT.channel(c).data(t).ap_fit = 10.^(ap_fit); - SPRiNT.channel(c).data(t).fooofed_spectrum = 10.^(ap_fit+peak_fit); - SPRiNT.channel(c).data(t).peak_fit = 10.^(peak_fit); - SPRiNT.channel(c).data(t).error = MSE; - SPRiNT.channel(c).data(t).r_squared = rsq_tmp(2); - SPRiNT.aperiodic_models(c,t,:) = SPRiNT.channel(c).data(t).ap_fit; - SPRiNT.SPRiNT_models(c,t,:) = SPRiNT.channel(c).data(t).fooofed_spectrum; - SPRiNT.peak_models(c,t,:) = SPRiNT.channel(c).data(t).peak_fit; - SPRiNT.channel(c).aperiodics(t).offset = ap_pars(1); + channel(c).data(t).ap_fit = 10.^(ap_fit); + channel(c).data(t).fooofed_spectrum = 10.^(ap_fit+peak_fit); + channel(c).data(t).peak_fit = 10.^(peak_fit); + channel(c).data(t).error = MSE; + channel(c).data(t).r_squared = rsq_tmp(2); + aperiodic_models(c,t,:) = channel(c).data(t).ap_fit; + SPRiNT_models(c,t,:) = channel(c).data(t).fooofed_spectrum; + peak_models(c,t,:) = channel(c).data(t).peak_fit; + channel(c).aperiodics(t).offset = ap_pars(1); + channel(c).data(t).aperiodic_params = ap_pars; if length(ap_pars)>2 % Legacy FOOOF alters order of parameters - SPRiNT.channel(c).aperiodics(t).exponent = ap_pars(3); - SPRiNT.channel(c).aperiodics(t).knee_frequency = ap_pars(2); + channel(c).aperiodics(t).exponent = ap_pars(3); + channel(c).aperiodics(t).knee_frequency = ap_pars(2); else - SPRiNT.channel(c).aperiodics(t).exponent = ap_pars(2); + channel(c).aperiodics(t).exponent = ap_pars(2); end - SPRiNT.channel(c).stats(t).MSE = MSE; - SPRiNT.channel(c).stats(t).r_squared = rsq_tmp(2); - SPRiNT.channel(c).stats(t).frequency_wise_error = abs(ap_spec-ap_fit); + channel(c).stats(t).MSE = MSE; + channel(c).stats(t).r_squared = rsq_tmp(2); + channel(c).stats(t).frequency_wise_error = abs(ap_spec-ap_fit); else - SPRiNT.channel(c).data(t).peak_params = [0 0 0]; - ap_spec = log10(SPRiNT.channel(c).data(t).power_spectrum) - peak_fit; - ap_pars = simple_ap_fit(SPRiNT.freqs, ap_spec, opt.aperiodic_mode, SPRiNT.channel(c).data(t).aperiodic_params(end)); - ap_fit = gen_aperiodic(SPRiNT.freqs, ap_pars, opt.aperiodic_mode); - MSE = sum((ap_spec - ap_fit).^2)/length(SPRiNT.freqs); + channel(c).data(t).peak_params = [0 0 0]; + ap_spec = log10(channel(c).data(t).power_spectrum) - peak_fit; + ap_pars = simple_ap_fit(freqs, ap_spec, opt.aperiodic_mode, channel(c).data(t).aperiodic_params(end)); + ap_fit = gen_aperiodic(freqs, ap_pars, opt.aperiodic_mode); + MSE = sum((ap_spec - ap_fit).^2)/length(freqs); rsq_tmp = corrcoef(ap_spec+peak_fit,ap_fit+peak_fit).^2; % Return FOOOF results ap_pars(2) = abs(ap_pars(2)); - SPRiNT.channel(c).data(t).ap_fit = 10.^(ap_fit); - SPRiNT.channel(c).data(t).fooofed_spectrum = 10.^(ap_fit+peak_fit); - SPRiNT.channel(c).data(t).peak_fit = 10.^(peak_fit); - SPRiNT.aperiodic_models(c,t,:) = SPRiNT.channel(c).data(t).ap_fit; - SPRiNT.SPRiNT_models(c,t,:) = SPRiNT.channel(c).data(t).fooofed_spectrum; - SPRiNT.peak_models(c,t,:) = SPRiNT.channel(c).data(t).peak_fit; - SPRiNT.channel(c).aperiodics(t).offset = ap_pars(1); + channel(c).data(t).ap_fit = 10.^(ap_fit); + channel(c).data(t).fooofed_spectrum = 10.^(ap_fit+peak_fit); + channel(c).data(t).peak_fit = 10.^(peak_fit); + aperiodic_models(c,t,:) = channel(c).data(t).ap_fit; + SPRiNT_models(c,t,:) = channel(c).data(t).fooofed_spectrum; + peak_models(c,t,:) = channel(c).data(t).peak_fit; + channel(c).aperiodics(t).offset = ap_pars(1); if length(ap_pars)>2 % Legacy FOOOF alters order of parameters - SPRiNT.channel(c).aperiodics(t).exponent = ap_pars(3); - SPRiNT.channel(c).aperiodics(t).knee_frequency = ap_pars(2); + channel(c).aperiodics(t).exponent = ap_pars(3); + channel(c).aperiodics(t).knee_frequency = ap_pars(2); else - SPRiNT.channel(c).aperiodics(t).exponent = ap_pars(2); + channel(c).aperiodics(t).exponent = ap_pars(2); end - SPRiNT.channel(c).stats(t).MSE = MSE; - SPRiNT.channel(c).stats(t).r_squared = rsq_tmp(2); - SPRiNT.channel(c).stats(t).frequency_wise_error = abs(ap_spec-ap_fit); + channel(c).stats(t).MSE = MSE; + channel(c).stats(t).r_squared = rsq_tmp(2); + channel(c).stats(t).frequency_wise_error = abs(ap_spec-ap_fit); end end end + SPRiNT.channel = channel; + SPRiNT.aperiodic_models = aperiodic_models; + SPRiNT.SPRiNT_models = SPRiNT_models; + SPRiNT.peak_models = peak_models; end -function oS = cluster_peaks_dynamic(oS) +function SPRiNT = cluster_peaks_dynamic(SPRiNT) % Helper function to cluster peaks within sensors across time. % % Parameters @@ -418,21 +972,22 @@ % % Author: Luc Wilson - pthr = oS.options.proximity_threshold; - for chan = 1:length(oS.channel) + pthr = SPRiNT.options.proximity_threshold; + channel = SPRiNT.channel; + for chan = 1:length(channel) clustLead = []; nCl = 0; - oS.channel(chan).clustered_peaks = struct(); - times = unique([oS.channel(chan).peaks.time]); - all_peaks = oS.channel(chan).peaks; + channel(chan).clustered_peaks = struct(); + times = unique([channel(chan).peaks.time]); + all_peaks = channel(chan).peaks; for time = 1:length(times) time_peaks = all_peaks([all_peaks.time] == times(time)); % Initialize first clusters if time == 1 nCl = length(time_peaks); for Cl = 1:nCl - oS.channel(chan).clustered_peaks(Cl).cluster = Cl; - oS.channel(chan).clustered_peaks(Cl).peaks(Cl) = time_peaks(Cl); + channel(chan).clustered_peaks(Cl).cluster = Cl; + channel(chan).clustered_peaks(Cl).peaks(Cl) = time_peaks(Cl); clustLead(Cl,1) = time_peaks(Cl).time; clustLead(Cl,2) = time_peaks(Cl).center_frequency; clustLead(Cl,3) = time_peaks(Cl).amplitude; @@ -453,7 +1008,7 @@ [tmp,idx] = min(([time_peaks(match).center_frequency] - clustLead(Cl,2)).^2 +... ([time_peaks(match).amplitude] - clustLead(Cl,3)).^2 +... ([time_peaks(match).st_dev] - clustLead(Cl,4)).^2); - oS.channel(chan).clustered_peaks(clustLead(Cl,5)).peaks(length(oS.channel(chan).clustered_peaks(clustLead(Cl,5)).peaks)+1) = time_peaks(idx_tmp(idx)); + channel(chan).clustered_peaks(clustLead(Cl,5)).peaks(length(channel(chan).clustered_peaks(clustLead(Cl,5)).peaks)+1) = time_peaks(idx_tmp(idx)); clustLead(Cl,1) = time_peaks(idx_tmp(idx)).time; clustLead(Cl,2) = time_peaks(idx_tmp(idx)).center_frequency; clustLead(Cl,3) = time_peaks(idx_tmp(idx)).amplitude; @@ -472,14 +1027,15 @@ clustLead(Cl,3) = time_peaks(peak).amplitude; clustLead(Cl,4) = time_peaks(peak).st_dev; clustLead(Cl,5) = Cl; - oS.channel(chan).clustered_peaks(Cl).cluster = Cl; - oS.channel(chan).clustered_peaks(Cl).peaks(length(oS.channel(chan).clustered_peaks(clustLead(Cl,5)).peaks)+1) = time_peaks(peak); + channel(chan).clustered_peaks(Cl).cluster = Cl; + channel(chan).clustered_peaks(Cl).peaks(length(channel(chan).clustered_peaks(clustLead(Cl,5)).peaks)+1) = time_peaks(peak); end end % Sort clusters based on most recent clustLead = sortrows(clustLead,1,'descend'); end end + SPRiNT.channel = channel; end %% ===== GENERATE APERIODIC ===== @@ -594,10 +1150,38 @@ function ys = expo_fl_function(freqs, params) - ys = log10(f.^(params(1)) * 10^(params(2)) + params(3)); + ys = log10(freqs.^(params(1)) * 10^(params(2)) + params(3)); end +function model_fit = build_model(freqs, ap_pars, ap_type, pk_pars, peak_function) +% Builds a full spectral model from parameters. +% +% Parameters +% ---------- +% freqs : 1xn array +% Frequency values for the power spectrum, in linear scale. +% ap_pars : 1xm array +% Parameter estimates for aperiodic fit. +% pk_pars : kx3 array, where k = No. of peaks. +% Guess parameters for peak fits. +% pk_type : {'gaussian', 'cauchy', 'best'} +% Which types of peaks are being fitted. +% +% Returns +% ------- +% model_fit : 1xn array +% Model power spectrum, in log10-space + + ap_fit = gen_aperiodic(freqs, ap_pars, ap_type); + model_fit = ap_fit; + if length(pk_pars) > 1 + for peak = 1:size(pk_pars,1) + model_fit = model_fit + peak_function(freqs,pk_pars(peak,1),... + pk_pars(peak,2),pk_pars(peak,3)); + end + end +end %% ===== FITTING ALGORITHM ===== function aperiodic_params = simple_ap_fit(freqs, power_spectrum, aperiodic_mode, aperiodic_guess) @@ -709,6 +1293,208 @@ end +function [guess_params,peak_function] = est_peaks(freqs, flat_iter, max_n_peaks, peak_threshold, min_peak_height, gauss_std_limits, proxThresh, peakType) +% Iteratively fit peaks to flattened spectrum. +% +% Parameters +% ---------- +% freqs : 1xn array +% Frequency values for the power spectrum, in linear scale. +% flat_iter : 1xn array +% Flattened (aperiodic removed) power spectrum. +% max_n_peaks : double +% Maximum number of gaussians to fit within the spectrum. +% peak_threshold : double +% Threshold (in standard deviations of noise floor) to detect a peak. +% min_peak_height : double +% Minimum height of a peak (in log10). +% gauss_std_limits : 1x2 double +% Limits to gaussian (cauchy) standard deviation (gamma) when detecting a peak. +% proxThresh : double +% Minimum distance between two peaks, in st. dev. (gamma) of peaks. +% peakType : {'gaussian', 'cauchy', 'both'} +% Which types of peaks are being fitted +% guess_weight : {'none', 'weak', 'strong'} +% Parameter to weigh initial estimates during optimization (None, Weak, or Strong) +% hOT : 0 or 1 +% Defines whether to use constrained optimization, fmincon, or +% basic simplex, fminsearch. +% +% Returns +% ------- +% guess_params : mx3 array, where m = No. of peaks. +% Parameters that define the peak fit(s). Each row is a peak, as [mean, height, st. dev. (gamma)]. + + switch peakType + case 'gaussian' % gaussian only + peak_function = @gaussian; % Identify peaks as gaussian + % Initialize matrix of guess parameters for gaussian fitting. + guess_params = zeros(max_n_peaks, 3); + % Find peak: Loop through, finding a candidate peak, and fitting with a guess gaussian. + % Stopping procedure based on either the limit on # of peaks, + % or the relative or absolute height thresholds. + for guess = 1:max_n_peaks + % Find candidate peak - the maximum point of the flattened spectrum. + max_ind = find(flat_iter == max(flat_iter)); + max_height = flat_iter(max_ind); + + % Stop searching for peaks once max_height drops below height threshold. + if max_height <= peak_threshold * std(flat_iter) + break + end + + % Set the guess parameters for gaussian fitting - mean and height. + guess_freq = freqs(max_ind); + guess_height = max_height; + + % Halt fitting process if candidate peak drops below minimum height. + if guess_height <= min_peak_height + break + end + + % Data-driven first guess at standard deviation + % Find half height index on each side of the center frequency. + half_height = 0.5 * max_height; + + le_ind = sum(flat_iter(1:max_ind) <= half_height); + ri_ind = length(flat_iter) - sum(flat_iter(max_ind:end) <= half_height)+1; + + % Keep bandwidth estimation from the shortest side. + % We grab shortest to avoid estimating very large std from overalapping peaks. + % Grab the shortest side, ignoring a side if the half max was not found. + % Note: will fail if both le & ri ind's end up as None (probably shouldn't happen). + short_side = min(abs([le_ind,ri_ind]-max_ind)); + + % Estimate std from FWHM. Calculate FWHM, converting to Hz, get guess std from FWHM + fwhm = short_side * 2 * (freqs(2)-freqs(1)); + guess_std = fwhm / (2 * sqrt(2 * log(2))); + + % Check that guess std isn't outside preset std limits; restrict if so. + % Note: without this, curve_fitting fails if given guess > or < bounds. + if guess_std < gauss_std_limits(1) + guess_std = gauss_std_limits(1); + end + if guess_std > gauss_std_limits(2) + guess_std = gauss_std_limits(2); + end + + % Collect guess parameters. + guess_params(guess,:) = [guess_freq, guess_height, guess_std]; + + % Subtract best-guess gaussian. + peak_gauss = gaussian(freqs, guess_freq, guess_height, guess_std); + flat_iter = flat_iter - peak_gauss; + + end + % Remove unused guesses + guess_params(guess_params(:,1) == 0,:) = []; + + % Check peaks based on edges, and on overlap + % Drop any that violate requirements. + guess_params = drop_peak_cf(guess_params, proxThresh, [min(freqs) max(freqs)]); + guess_params = drop_peak_overlap(guess_params, proxThresh); + + case 'cauchy' % cauchy only + peak_function = @cauchy; % Identify peaks as cauchy + guess_params = zeros(max_n_peaks, 3); + flat_spec = flat_iter; + for guess = 1:max_n_peaks + max_ind = find(flat_iter == max(flat_iter)); + max_height = flat_iter(max_ind); + if max_height <= peak_threshold * std(flat_iter) + break + end + guess_freq = freqs(max_ind); + guess_height = max_height; + if guess_height <= min_peak_height + break + end + half_height = 0.5 * max_height; + le_ind = sum(flat_iter(1:max_ind) <= half_height); + ri_ind = length(flat_iter) - sum(flat_iter(max_ind:end) <= half_height); + short_side = min(abs([le_ind,ri_ind]-max_ind)); + + % Estimate gamma from FWHM. Calculate FWHM, converting to Hz, get guess gamma from FWHM + fwhm = short_side * 2 * (freqs(2)-freqs(1)); + guess_gamma = fwhm/2; + % Check that guess gamma isn't outside preset limits; restrict if so. + % Note: without this, curve_fitting fails if given guess > or < bounds. + if guess_gamma < gauss_std_limits(1) + guess_gamma = gauss_std_limits(1); + end + if guess_gamma > gauss_std_limits(2) + guess_gamma = gauss_std_limits(2); + end + + % Collect guess parameters. + guess_params(guess,:) = [guess_freq(1), guess_height, guess_gamma]; + + % Subtract best-guess cauchy. + peak_cauchy = cauchy(freqs, guess_freq(1), guess_height, guess_gamma); + flat_iter = flat_iter - peak_cauchy; + + end + guess_params(guess_params(:,1) == 0,:) = []; + guess_params = drop_peak_cf(guess_params, proxThresh, [min(freqs) max(freqs)]); + guess_params = drop_peak_overlap(guess_params, proxThresh); + + end +end + +function model_params = est_fit(guess_params, freqs, flat_spec, gauss_std_limits, peakType, guess_weight,hOT) +% Iteratively fit peaks to flattened spectrum. +% +% Parameters +% ---------- +% freqs : 1xn array +% Frequency values for the power spectrum, in linear scale. +% flat_iter : 1xn array +% Flattened (aperiodic removed) power spectrum. +% max_n_peaks : double +% Maximum number of gaussians to fit within the spectrum. +% peak_threshold : double +% Threshold (in standard deviations of noise floor) to detect a peak. +% min_peak_height : double +% Minimum height of a peak (in log10). +% gauss_std_limits : 1x2 double +% Limits to gaussian (cauchy) standard deviation (gamma) when detecting a peak. +% proxThresh : double +% Minimum distance between two peaks, in st. dev. (gamma) of peaks. +% peakType : {'gaussian', 'cauchy', 'both'} +% Which types of peaks are being fitted +% guess_weight : {'none', 'weak', 'strong'} +% Parameter to weigh initial estimates during optimization (None, Weak, or Strong) +% hOT : 0 or 1 +% Defines whether to use constrained optimization, fmincon, or +% basic simplex, fminsearch. +% +% Returns +% ------- +% guess_params : mx3 array, where m = No. of peaks. +% Parameters that define the peak fit(s). Each row is a peak, as [mean, height, st. dev. (gamma)]. + + switch peakType + case 'gaussian' % gaussian only + + % If there are peak guesses, fit the peaks, and sort results. + if ~isempty(guess_params) + model_params = fit_peak_guess(guess_params, freqs, flat_spec, 1, guess_weight, gauss_std_limits,hOT); + else + model_params = []; + end + + case 'cauchy' % cauchy only + + % If there are peak guesses, fit the peaks, and sort results. + if ~isempty(guess_params) + model_params = fit_peak_guess(guess_params, freqs, flat_spec, 2, guess_weight, gauss_std_limits,hOT); + else + model_params = []; + end + + end +end + function [model_params,peak_function] = fit_peaks(freqs, flat_iter, max_n_peaks, peak_threshold, min_peak_height, gauss_std_limits, proxThresh, peakType, guess_weight,hOT) % Iteratively fit peaks to flattened spectrum. % @@ -1038,3 +1824,23 @@ end err = sum((yVals - fitted_vals).^2); end + +function err = err_fm_constr(params, xVals, yVals, aperiodic_mode, peak_type) + switch (aperiodic_mode) + case 'fixed' % no knee + npk = (length(params)-2)/3; + fitted_vals = -log10(xVals.^params(2)) + params(1); + case 'knee' + npk = (length(params)-3)/3; + fitted_vals = params(1) - log10(abs(params(2)) +xVals.^params(3)); + end + for set = 1:npk + switch peak_type + case 'gaussian' % gaussian only + fitted_vals = fitted_vals + gaussian(xVals, params(3.*set), params(3*set+1), params(3*set+2)); + case 'cauchy' % Cauchy + fitted_vals = fitted_vals + cauchy(xVals, params(3.*set), params(3*set+1), params(3*set+2)); + end + end + err = sum((yVals - fitted_vals).^2); +end diff --git a/toolbox/timefreq/bst_timefreq.m b/toolbox/timefreq/bst_timefreq.m index b8a80f106..792475aad 100644 --- a/toolbox/timefreq/bst_timefreq.m +++ b/toolbox/timefreq/bst_timefreq.m @@ -69,7 +69,8 @@ Def_OPTIONS.MorletFwhmTc = 3; Def_OPTIONS.WinLength = []; Def_OPTIONS.WinOverlap = 50; -Def_OPTIONS.WinStd = 0; +Def_OPTIONS.IsRelative = 0; +Def_OPTIONS.WinFunc = 'mean'; Def_OPTIONS.isMirror = 0; Def_OPTIONS.SensorTypes = 'MEG, EEG'; Def_OPTIONS.Clusters = {}; @@ -139,6 +140,12 @@ isError = 1; return; end +% Cannot do average and compute several TF at same time +if isAverage && contains(OPTIONS.WinFunc, '+') + Messages = ['Incompatible options: 1)Use several functions for PSD and 2)average trials.']; + isError = 1; + return; +end % Progress bar switch(OPTIONS.Method) @@ -483,6 +490,7 @@ end % ===== COMPUTE TRANSFORM ===== + TFbis = []; isMeasureApplied = 0; switch (OPTIONS.Method) % Morlet wavelet transform (Dimitrios Pantazis) @@ -528,7 +536,7 @@ % PSD: Homemade computation based on Matlab's FFT case 'psd' % Calculate PSD/FFT - [TF, OPTIONS.Freqs, Nwin, Messages] = bst_psd(F, sfreq, OPTIONS.WinLength, OPTIONS.WinOverlap, BadSegments, ImagingKernel, OPTIONS.WinStd, OPTIONS.PowerUnits); + [TF, OPTIONS.Freqs, Nwin, Messages, TFbis] = bst_psd(F, sfreq, OPTIONS.WinLength, OPTIONS.WinOverlap, BadSegments, ImagingKernel, OPTIONS.WinFunc, OPTIONS.PowerUnits, OPTIONS.IsRelative); if isempty(TF) continue; end @@ -596,14 +604,14 @@ end % Correct the time step to the closest multiple of the sampling interval to keep the time axis uniform mt.timestep = round(fsample * mt.timestep) / fsample; - % Time axis + % Time-interval of interest timeoi = (OPTIONS.TimeVector(1) + mt.timeres/2) : mt.timestep : (OPTIONS.TimeVector(end) - mt.timeres/2 - 1/fsample); % Frequency resolution for each frequency freqres = mt.frequencies / mt.freqmod; freqres(find(freqres < 1/mt.timeres)) = 1/mt.timeres; % Call fieldtrip function - [TF, ntaper, OPTIONS.Freqs, OPTIONS.TimeVector] = ft_specest_mtmconvol(F, OPTIONS.TimeVector, ... + [TF, ntaper, OPTIONS.Freqs, TimeBins] = ft_specest_mtmconvol(F, OPTIONS.TimeVector, ... 'taper', mt.taper, ... 'timeoi', timeoi, ... 'freqoi', mt.frequencies,... @@ -611,130 +619,32 @@ 'tapsmofrq', freqres, ... 'pad', pad, ... 'verbose', 0); + % TimeBands + OPTIONS.TimeBands = cell(length(TimeBins), 3); + for iTimeBin = 1 : length(TimeBins) + OPTIONS.TimeBands{iTimeBin, 1} = sprintf('t%d', iTimeBin); + OPTIONS.TimeBands{iTimeBin, 2} = sprintf('%1.4f, %1.4f', TimeBins(iTimeBin) + [-mt.timeres/2, mt.timeres/2 - 1/fsample]); + OPTIONS.TimeBands{iTimeBin, 3} = 'mean'; + end + % Permute dimensions to get [nChannels x nTime x nFreq x nTapers] TF = permute(TF, [2 4 3 1]); end - bst_progress('inc', 1); - % Set to zero the bad channels - if ~isempty(iGoodChannels) - iBadChannels = setdiff(1:size(F,1), iGoodChannels); - if ~isempty(iBadChannels) - TF(iBadChannels, :, :, :) = 0; - end - end + nChannels = size(F,1); % Clean memory clear F; - - % ===== REBUILD FULL SOURCES ===== - % Kernel => Full results - if strcmpi(DataType, 'results') && ~isempty(ImagingKernel) && ~OPTIONS.SaveKernel - % Initialize full time-frequency matrix - TF_full = zeros(size(ImagingKernel,1), size(TF,2), size(TF,3), size(TF,4)); - % Loop on the frequencies and tapers - for itaper = 1:size(TF,4) - for ifreq = 1:size(TF,3) - TF_full(:,:,ifreq,itaper) = ImagingKernel * TF(:,:,ifreq,itaper); - end - end - % Replace previous values with new ones - TF = TF_full; - clear TF_full; - end - % Cannot save kernel when components > 1 - if strcmpi(DataType, 'results') && OPTIONS.SaveKernel && (nComponents ~= 1) - Messages = ['Cannot keep the inversion kernel when processing unconstrained sources.' 10 ... - 'Please selection the option "Optimize: No, save full sources."']; - isError = 1; - return; - end - - % ===== APPLY MEASURE ===== - if ~isMeasureApplied - % Multitaper: average power across tapers - if strcmpi(OPTIONS.Method, 'mtmconvol') - TF = nanmean(TF .* conj(TF), 4); - % Power or magnitude - if strcmpi(OPTIONS.Measure, 'magnitude') - TF = sqrt(TF); - end - % Other measures: Apply the expected measure - else - switch lower(OPTIONS.Measure) - case 'none' % Nothing to do - case 'power', TF = abs(TF) .^ 2; - case 'magnitude', TF = abs(TF); - otherwise, error('Unknown measure.'); - end - end - end - - % ===== PROCESS UNCONSTRAINED SOURCES ===== - % Unconstrained sources => SUM for each point (only if not complex) - if ismember(DataType, {'results','scout','matrix'}) && ~isempty(nComponents) && (nComponents ~= 1) - % This doesn't work for complex values: TODO - if strcmpi(OPTIONS.Measure, 'none') - Messages = ['Cannot keep the complex values when processing unconstrained sources.' 10 ... - 'Please selection the option "Optimize: No, save full sources."']; - isError = 1; - return; - end - % Apply orientation - [TF, GridAtlas, RowNames] = bst_source_orient([], nComponents, GridAtlas, TF, 'sum', DataType, RowNames); - end - - % ===== PROCESS POWER FOR SCOUTS ===== - % Get the lists of clusters - [tmp,I,J] = unique(RowNames); - ScoutNames = RowNames(sort(I)); - % If processing data blocks and if there are identical row names => Processing clusters / scouts - if ~isFile && ~isempty(OPTIONS.Clusters) && (length(ScoutNames) ~= length(RowNames)) - % If cluster function should be applied AFTER time-freq: we have now all the time series - if strcmpi(OPTIONS.ClusterFuncTime, 'after') - TF_cluster = zeros(length(ScoutNames), size(TF,2), size(TF,3)); - % For each unique row name: compute a measure over the clusters values - for iScout = 1:length(ScoutNames) - indClust = find(strcmpi(ScoutNames{iScout}, RowNames)); - % Compute cluster/scout measure - for iFreq = 1:size(TF,3) - TF_cluster(iScout,:,iFreq) = bst_scout_value(TF(indClust,:,iFreq), OPTIONS.ScoutFunc); - end - end - % Save only the requested rows - RowNames = ScoutNames; - TF = TF_cluster; - % Just make all RowNames unique - else - initRowNames = RowNames; - RowNames = cell(size(TF,1),1); - % For each row name: update name with the index of the row - for iScout = 1:length(ScoutNames) - indClust = find(strcmpi(ScoutNames{iScout}, initRowNames)); - % Process each cluster element: add an indice - for i = 1:length(indClust) - RowNames{indClust(i)} = sprintf('%s.%d', ScoutNames{iScout}, i); - end - end - end - end - - % ===== NORMALIZE VALUES ===== - if ~isempty(OPTIONS.NormalizeFunc) && ismember(OPTIONS.NormalizeFunc, {'multiply', 'multiply2020'}) - % Call normalization function - [TF, errorMsg] = process_tf_norm('Compute', TF, OPTIONS.Measure, OPTIONS.Freqs, OPTIONS.NormalizeFunc); - % Error handling - if ~isempty(errorMsg) - Messages = errorMsg; - isError = 1; - return; - end - % Add normalization comment - if ~isAddedCommentNorm - isAddedCommentNorm = 1; - OPTIONS.Comment = [OPTIONS.Comment ' | ' strrep(OPTIONS.NormalizeFunc, '2020', '')]; - end + % Set to zero the bad channels + TF=SetBadChannels(TF, nChannels, iGoodChannels); + % Apply post processing steps + [TF, GridAtlas, RowNames, isAddedCommentNorm, OPTIONS] = PostprocessTF(TF, DataType, ImagingKernel, OPTIONS, nComponents, GridAtlas, RowNames, isFile, isMeasureApplied, isAddedCommentNorm); + % For PSD_FEATURES, if TFbis (second TF) is returned + if ~isempty(TFbis) + % Set to zero the bad channels + TFbis = SetBadChannels(TFbis, nChannels, iGoodChannels); + % Apply post processing steps + TFbis = PostprocessTF(TFbis, DataType, ImagingKernel, OPTIONS, nComponents, GridAtlas, RowNames, isFile, isMeasureApplied, isAddedCommentNorm); end - % ===== SAVE FILE / COMPUTE AVERAGE ===== % Only save average if isAverage @@ -755,7 +665,7 @@ % Save all the time-frequency maps else % Save file - SaveFile(iTargetStudy, InitFile, DataType, RowNames, TF, OPTIONS, FreqBands, SurfaceFile, GridLoc, GridAtlas, HeadModelType, HeadModelFile, nAvg, Atlas, strHistory); + SaveFile(iTargetStudy, InitFile, DataType, RowNames, TF, TFbis, OPTIONS, FreqBands, SurfaceFile, GridLoc, GridAtlas, HeadModelType, HeadModelFile, nAvg, Atlas, strHistory); end bst_progress('inc', 1); end @@ -787,18 +697,141 @@ InitFile = ''; end % Save file - SaveFile(iTargetStudy, InitFile, DataType, RowNames, TF_avg, OPTIONS, FreqBands, SurfaceFile, GridLoc, GridAtlas, HeadModelType, HeadModelFile, nAvgTotal, Atlas, strHistory); + SaveFile(iTargetStudy, InitFile, DataType, RowNames, TF_avg, [], OPTIONS, FreqBands, SurfaceFile, GridLoc, GridAtlas, HeadModelType, HeadModelFile, nAvgTotal, Atlas, strHistory); end + %% ===== SET BAD CHANNELS ===== + function TF=SetBadChannels(TF, nChannels, iGoodChannels) + % Set to zero the bad channels + if ~isempty(iGoodChannels) + iBadChannels = setdiff(1:nChannels, iGoodChannels); + if ~isempty(iBadChannels) + TF(iBadChannels, :, :, :) = 0; + end + end + end + + %% ===== Post PROCESS TF ===== + function [TF, GridAtlas, RowNames, isAddedCommentNorm, OPTIONS] = PostprocessTF(TF, DataType, ImagingKernel, OPTIONS, nComponents, GridAtlas, RowNames, isFile, isMeasureApplied, isAddedCommentNorm) + % ===== REBUILD FULL SOURCES ===== + % Kernel => Full results + if strcmpi(DataType, 'results') && ~isempty(ImagingKernel) && ~OPTIONS.SaveKernel + % Initialize full time-frequency matrix + TF_full = zeros(size(ImagingKernel,1), size(TF,2), size(TF,3), size(TF,4)); + % Loop on the frequencies and tapers + for itaper = 1:size(TF,4) + for ifreq = 1:size(TF,3) + TF_full(:,:,ifreq,itaper) = ImagingKernel * TF(:,:,ifreq,itaper); + end + end + % Replace previous values with new ones + TF = TF_full; + clear TF_full; + end + % Cannot save kernel when components > 1 + if strcmpi(DataType, 'results') && OPTIONS.SaveKernel && (nComponents ~= 1) + Messages = ['Cannot keep the inversion kernel when processing unconstrained sources.' 10 ... + 'Please selection the option "Optimize: No, save full sources."']; + isError = 1; + return; + end + + % ===== APPLY MEASURE ===== + if ~isMeasureApplied + % Multitaper: average power across tapers + if strcmpi(OPTIONS.Method, 'mtmconvol') + TF = nanmean(TF .* conj(TF), 4); + % Power or magnitude + if strcmpi(OPTIONS.Measure, 'magnitude') + TF = sqrt(TF); + end + % Other measures: Apply the expected measure + else + switch lower(OPTIONS.Measure) + case 'none' % Nothing to do + case 'power', TF = abs(TF) .^ 2; + case 'magnitude', TF = abs(TF); + otherwise, error('Unknown measure.'); + end + end + end + + % ===== PROCESS UNCONSTRAINED SOURCES ===== + % Unconstrained sources => SUM for each point (only if not complex) + if ismember(DataType, {'results','scout','matrix'}) && ~isempty(nComponents) && (nComponents ~= 1) + % This doesn't work for complex values: TODO + if strcmpi(OPTIONS.Measure, 'none') + Messages = ['Cannot keep the complex values when processing unconstrained sources.' 10 ... + 'Please selection the option "Optimize: No, save full sources."']; + isError = 1; + return; + end + % Apply orientation + [TF, GridAtlas, RowNames] = bst_source_orient([], nComponents, GridAtlas, TF, 'sum', DataType, RowNames); + end + + % ===== PROCESS POWER FOR SCOUTS ===== + % Get the lists of clusters + [tmp,I,J] = unique(RowNames); + ScoutNames = RowNames(sort(I)); + % If processing data blocks and if there are identical row names => Processing clusters / scouts + if ~isFile && ~isempty(OPTIONS.Clusters) && (length(ScoutNames) ~= length(RowNames)) + % If cluster function should be applied AFTER time-freq: we have now all the time series + if strcmpi(OPTIONS.ClusterFuncTime, 'after') + TF_cluster = zeros(length(ScoutNames), size(TF,2), size(TF,3)); + % For each unique row name: compute a measure over the clusters values + for iScout = 1:length(ScoutNames) + indClust = find(strcmpi(ScoutNames{iScout}, RowNames)); + % Compute cluster/scout measure + for iFreq = 1:size(TF,3) + TF_cluster(iScout,:,iFreq) = bst_scout_value(TF(indClust,:,iFreq), OPTIONS.ScoutFunc); + end + end + % Save only the requested rows + RowNames = ScoutNames; + TF = TF_cluster; + % Just make all RowNames unique + else + initRowNames = RowNames; + RowNames = cell(size(TF,1),1); + % For each row name: update name with the index of the row + for iScout = 1:length(ScoutNames) + indClust = find(strcmpi(ScoutNames{iScout}, initRowNames)); + % Process each cluster element: add an indice + for idx = 1:length(indClust) + RowNames{indClust(idx)} = sprintf('%s.%d', ScoutNames{iScout}, idx); + end + end + end + end + + % ===== NORMALIZE VALUES ===== + if ~isempty(OPTIONS.NormalizeFunc) && ismember(OPTIONS.NormalizeFunc, {'multiply', 'multiply2020'}) + % Call normalization function + [TF, errorMsg] = process_tf_norm('Compute', TF, OPTIONS.Measure, OPTIONS.Freqs, OPTIONS.NormalizeFunc); + % Error handling + if ~isempty(errorMsg) + Messages = errorMsg; + isError = 1; + return; + end + % Add normalization comment + if ~isAddedCommentNorm + isAddedCommentNorm = 1; + OPTIONS.Comment = [OPTIONS.Comment ' | ' strrep(OPTIONS.NormalizeFunc, '2020', '')]; + end + end + end %% ===== SAVE FILE ===== - function SaveFile(iTargetStudy, DataFile, DataType, RowNames, TF, OPTIONS, FreqBands, SurfaceFile, GridLoc, GridAtlas, HeadModelType, HeadModelFile, nAvgFile, Atlas, strHistory) + function SaveFile(iTargetStudy, DataFile, DataType, RowNames, TF, TFbis, OPTIONS, FreqBands, SurfaceFile, GridLoc, GridAtlas, HeadModelType, HeadModelFile, nAvgFile, Atlas, strHistory) % Create file structure FileMat = db_template('timefreqmat'); FileMat.Comment = OPTIONS.Comment; FileMat.DataType = DataType; FileMat.TF = TF; + FileMat.Std = TFbis; FileMat.Time = OPTIONS.TimeVector; FileMat.TimeBands = []; FileMat.Freqs = OPTIONS.Freqs; @@ -850,8 +883,10 @@ function SaveFile(iTargetStudy, DataFile, DataType, RowNames, TF, OPTIONS, FreqB if ~isempty(FreqBands) || ~isempty(OPTIONS.TimeBands) if strcmpi(OPTIONS.Method, 'hilbert') && ~isempty(OPTIONS.TimeBands) [FileMat, Messages] = process_tf_bands('Compute', FileMat, [], OPTIONS.TimeBands); - elseif strcmpi(OPTIONS.Method, 'morlet') || strcmpi(OPTIONS.Method, 'psd') + elseif strcmpi(OPTIONS.Method, 'morlet') || strcmpi(OPTIONS.Method, 'psd') [FileMat, Messages] = process_tf_bands('Compute', FileMat, FreqBands, OPTIONS.TimeBands); + elseif strcmpi(OPTIONS.Method, 'mtmconvol') && ~isempty(OPTIONS.TimeBands) + FileMat.TimeBands = OPTIONS.TimeBands; end if isempty(FileMat) if ~isempty(Messages) @@ -861,6 +896,21 @@ function SaveFile(iTargetStudy, DataFile, DataType, RowNames, TF, OPTIONS, FreqB end end end + + % Add extra PSD options + if strcmpi(OPTIONS.Method, 'psd') + FileMat.Options.isRelativePSD = OPTIONS.IsRelative; + FileMat.Options.WindowFunction = OPTIONS.WinFunc; + % Apply time and frequency bands on TFbis + if (~isempty(FreqBands) || ~isempty(OPTIONS.TimeBands)) && ~isempty(FileMat.Std) + FileMat2 = FileMat; + FileMat2.TF = FileMat.Std; + FileMat2.Freqs = OPTIONS.Freqs; + [FileMat2, Messages] = process_tf_bands('Compute', FileMat2, FreqBands, OPTIONS.TimeBands); + FileMat.Std = FileMat2.TF; + clear FileMat2; + end + end % Save the file if ~isempty(iTargetStudy) diff --git a/toolbox/timefreq/panel_timefreq_options.m b/toolbox/timefreq/panel_timefreq_options.m index d1aa56771..f181a0838 100644 --- a/toolbox/timefreq/panel_timefreq_options.m +++ b/toolbox/timefreq/panel_timefreq_options.m @@ -76,13 +76,14 @@ end % Determine which function is calling this pannel - % Used by process_: hilbert, psd, timefreq, and connectivity: henv(1,1n,2), plv(1,1n,2) + % Used by process_: hilbert, psd, timefreq, psd_features, and connectivity: henv(1,1n,2), plv(1,1n,2), cohere(1,1n,2) % Connectivity processes have options.tfmeasure with value 'hilbert' or 'morlet' ('fourier' doesn't use this panel). isProcConnect = isfield(sProcess.options, 'tfmeasure'); % used multiple times if isProcConnect Method = sProcess.options.tfmeasure.Value; - else % hilbert, psd, timefreq - Method = strrep(strrep(func2str(sProcess.Function), 'process_', ''), 'timefreq', 'morlet'); + else % hilbert, psd, timefreq, psd_features + tmp = regexp(func2str(sProcess.Function), '(?<=process_)\w*?(?=_|$)', 'match'); + Method = strrep(tmp{1}, 'timefreq', 'morlet'); end hFigWavelet = []; diff --git a/toolbox/tree/node_create_subject.m b/toolbox/tree/node_create_subject.m index 86355dfa1..5e35e62ac 100644 --- a/toolbox/tree/node_create_subject.m +++ b/toolbox/tree/node_create_subject.m @@ -68,6 +68,7 @@ % Create list of anat files (put the default at the top) iAnatList = 1:length(sSubject.Anatomy); iAtlas = find(~cellfun(@(c)(isempty(strfind(char(c), '_volatlas')) && isempty(strfind(char(c), '_tissues'))), {sSubject.Anatomy.FileName})); + iCt = find(cellfun(@(c)(~isempty(strfind(char(c), '_volct'))), {sSubject.Anatomy.FileName})); if (length(sSubject.Anatomy) > 1) iAnatList = [sSubject.iAnatomy, setdiff(iAnatList,[iAtlas,sSubject.iAnatomy]), setdiff(iAtlas,sSubject.iAnatomy)]; end @@ -75,6 +76,8 @@ for iAnatomy = iAnatList if ismember(iAnatomy, iAtlas) nodeType = 'volatlas'; + elseif ismember(iAnatomy, iCt) + nodeType = 'volct'; else nodeType = 'anatomy'; end diff --git a/toolbox/tree/node_delete.m b/toolbox/tree/node_delete.m index 0fcc64d95..d8477b8fd 100644 --- a/toolbox/tree/node_delete.m +++ b/toolbox/tree/node_delete.m @@ -133,7 +133,7 @@ function node_delete(bstNodes, isUserConfirm) %% ===== ANATOMY ===== - case {'anatomy', 'volatlas'} + case {'anatomy', 'volatlas', 'volct'} bst_progress('start', 'Delete nodes', 'Deleting files...'); % Full file names FullFilesList = cellfun(@(f)fullfile(ProtocolInfo.SUBJECTS,f), FileName', 'UniformOutput',0); @@ -190,6 +190,16 @@ function node_delete(bstNodes, isUserConfirm) % Get indices iSubject = uniqueSubject(i); iSurfaces = iSubItem(iItem == iSubject); + % Current default surfaces + saveDefSurf = struct(); + SurfTypes = {'Scalp', 'Cortex', 'InnerSkull', 'OuterSkull', 'Fibers', 'FEM'}; + for ix = 1 : length(SurfTypes) + if (iSubject == 0) + saveDefSurf.(['i' SurfTypes{ix}]) = ['', ProtocolSubjects.DefaultSubject.Surface(ProtocolSubjects.DefaultSubject.(['i' SurfTypes{ix}])).FileName]; + else + saveDefSurf.(['i' SurfTypes{ix}]) = ['', ProtocolSubjects.Subject(iSubject).Surface(ProtocolSubjects.Subject(iSubject).(['i' SurfTypes{ix}])).FileName]; + end + end % Delete surface if (iSubject == 0) ProtocolSubjects.DefaultSubject.Surface(iSurfaces) = []; @@ -198,8 +208,14 @@ function node_delete(bstNodes, isUserConfirm) end % Update default surfaces bst_set('ProtocolSubjects', ProtocolSubjects); - for SurfType = {'Scalp', 'Cortex', 'InnerSkull', 'OuterSkull', 'Fibers', 'FEM'} - db_surface_default(iSubject, SurfType{1}, [], 0); + for ix = 1 : length(SurfTypes) + if (iSubject == 0) + iSurf = find(ismember({ProtocolSubjects.DefaultSubject.Surface.FileName}, saveDefSurf.(['i' SurfTypes{ix}]))); + else + iSurf = find(ismember({ProtocolSubjects.Subject(iSubject).Surface.FileName}, saveDefSurf.(['i' SurfTypes{ix}]))); + end + % Find in current surfaces + db_surface_default(iSubject, SurfTypes{ix}, iSurf, 0); end drawnow; ProtocolSubjects = bst_get('ProtocolSubjects'); diff --git a/toolbox/tree/node_rename.m b/toolbox/tree/node_rename.m index 7a33317c4..c0db84f12 100644 --- a/toolbox/tree/node_rename.m +++ b/toolbox/tree/node_rename.m @@ -116,7 +116,7 @@ function node_rename(bstNode, newComment) %% ===== ANATOMY (Comment) ===== - case {'anatomy', 'volatlas'} + case {'anatomy', 'volatlas', 'volct'} iSubject = iItem; iAnatomy = iSubItem; sSubject = bst_get('Subject', iSubject); diff --git a/toolbox/tree/tree_callbacks.m b/toolbox/tree/tree_callbacks.m index dcb844960..af704dbc3 100644 --- a/toolbox/tree/tree_callbacks.m +++ b/toolbox/tree/tree_callbacks.m @@ -31,6 +31,8 @@ % =============================================================================@ % % Authors: Francois Tadel, 2008-2023 +% Raymundo Cassani, 2023-2024 +% Chinmay Chinara, 2023-2024 import org.brainstorm.icon.*; import java.awt.event.KeyEvent; @@ -66,7 +68,7 @@ filenameRelative = char(bstNodes(1).getFileName()); % Build full filename (depends on the file type) switch lower(nodeType) - case {'surface', 'scalp', 'cortex', 'outerskull', 'innerskull', 'fibers', 'fem', 'other', 'subject', 'studysubject', 'anatomy', 'volatlas'} + case {'surface', 'scalp', 'cortex', 'outerskull', 'innerskull', 'fibers', 'fem', 'other', 'subject', 'studysubject', 'anatomy', 'volatlas', 'volct'} filenameFull = bst_fullfile(ProtocolInfo.SUBJECTS, filenameRelative); case {'study', 'condition', 'rawcondition', 'channel', 'headmodel', 'data','rawdata', 'datalist', 'results', 'kernel', 'pdata', 'presults', 'ptimefreq', 'pspectrum', 'image', 'video', 'videolink', 'noisecov', 'ndatacov', 'dipoles','timefreq', 'spectrum', 'matrix', 'matrixlist', 'pmatrix', 'spike'} filenameFull = bst_fullfile(ProtocolInfo.STUDIES, filenameRelative); @@ -164,19 +166,19 @@ % MRI: Display in MRI viewer view_mri(filenameRelative); - % ===== VOLUME ATLAS ===== - case 'volatlas' + % ===== VOLUME ATLAS AND VOLUME CT===== + case {'volatlas', 'volct'} % Get subject iSubject = bstNodes(1).getStudyIndex(); iAnatomy = bstNodes(1).getItemIndex(); sSubject = bst_get('Subject', iSubject); - % Atlas: display as overlay on the default MRI + % Atlas/CT: display as overlay on the default MRI if (iAnatomy ~= sSubject.iAnatomy) view_mri(sSubject.Anatomy(sSubject.iAnatomy).FileName, filenameRelative); else view_mri(filenameRelative); end - + % ===== SURFACE ===== % Mark/unmark (items selected : 1/category) case {'scalp', 'outerskull', 'innerskull', 'cortex', 'fibers', 'fem'} @@ -206,7 +208,18 @@ end % Other surface: display it case 'other' - view_surface(filenameRelative); + % Display mesh with 3D orthogonal slices of the default MRI only if it is an isosurface + if ~isempty(regexp(filenameRelative, 'isosurface', 'match')) + iSubject = bstNodes(1).getStudyIndex(); + sSubject = bst_get('Subject', iSubject); + MriFile = sSubject.Anatomy(1).FileName; + hFig = view_mri_3d(MriFile, [], 0.3, []); + view_surface(filenameRelative, [], [], hFig, []); + elseif ~isempty(regexp(filenameRelative, 'tess_textured', 'match')) + ViewTexturedSurface(filenameRelative); + else + view_surface(filenameRelative); + end % ===== CHANNEL ===== % If one and only one modality available : display sensors @@ -220,7 +233,7 @@ if strcmpi(DisplayMod{1}, 'ECOG+SEEG') || (length(DisplayMod) >= 2) && all(ismember({'SEEG','ECOG'}, DisplayMod)) DisplayChannels(bstNodes, 'ECOG+SEEG', 'cortex', 1); elseif strcmpi(DisplayMod{1}, 'SEEG') - DisplayChannels(bstNodes, DisplayMod{1}, 'anatomy', 1); + DisplayChannels(bstNodes, DisplayMod{1}, 'anatomy', 1, 0); elseif strcmpi(DisplayMod{1}, 'ECOG') DisplayChannels(bstNodes, DisplayMod{1}, 'cortex', 1); elseif ismember(DisplayMod{1}, {'MEG','MEG GRAD','MEG MAG'}) @@ -562,6 +575,7 @@ gui_component('MenuItem', jPopup, [], 'Import anatomy folder', IconLoader.ICON_ANATOMY, [], @(h,ev)bst_call(@import_anatomy, iSubject, 0)); gui_component('MenuItem', jPopup, [], 'Import anatomy folder (auto)', IconLoader.ICON_ANATOMY, [], @(h,ev)bst_call(@import_anatomy, iSubject, 1)); gui_component('MenuItem', jPopup, [], 'Import MRI', IconLoader.ICON_ANATOMY, [], @(h,ev)bst_call(@import_mri, iSubject, [], [], 1)); + gui_component('MenuItem', jPopup, [], 'Import CT', IconLoader.ICON_VOLCT, [], @(h,ev)bst_call(@import_mri, iSubject, [], [], 1, 1, 'Import CT')); gui_component('MenuItem', jPopup, [], 'Import surfaces', IconLoader.ICON_SURFACE, [], @(h,ev)bst_call(@import_surfaces, iSubject)); gui_component('MenuItem', jPopup, [], 'Import fibers', IconLoader.ICON_FIBERS, [], @(h,ev)bst_call(@import_fibers, iSubject)); gui_component('MenuItem', jPopup, [], 'Convert DWI to DTI', IconLoader.ICON_FIBERS, [], @(h,ev)bst_call(@process_dwi2dti, 'ComputeInteractive', iSubject)); @@ -571,11 +585,12 @@ % Get registered Brainstorm anatomy defaults sTemplates = bst_get('AnatomyDefaults'); % Create menus - jMenuDefaults = gui_component('Menu', jPopup, [], 'Use template', IconLoader.ICON_ANATOMY, [], []); - jMenuDefMni = gui_component('Menu', jMenuDefaults, [], 'MNI', IconLoader.ICON_ANATOMY, [], []); - jMenuDefUsc = gui_component('Menu', jMenuDefaults, [], 'USC', IconLoader.ICON_ANATOMY, [], []); - jMenuDefFs = gui_component('Menu', jMenuDefaults, [], 'FsAverage', IconLoader.ICON_ANATOMY, [], []); + jMenuDefaults = gui_component('Menu', jPopup, [], 'Use template', IconLoader.ICON_ANATOMY, [], []); + jMenuDefMni = gui_component('Menu', jMenuDefaults, [], 'MNI', IconLoader.ICON_ANATOMY, [], []); + jMenuDefUsc = gui_component('Menu', jMenuDefaults, [], 'USC', IconLoader.ICON_ANATOMY, [], []); + jMenuDefFs = gui_component('Menu', jMenuDefaults, [], 'FsAverage', IconLoader.ICON_ANATOMY, [], []); jMenuDefInfants = gui_component('Menu', jMenuDefaults, [], 'Infants', IconLoader.ICON_ANATOMY, [], []); + jMenuDefOthers = gui_component('Menu', jMenuDefaults, [], 'Others', IconLoader.ICON_ANATOMY, [], []); % Add an item per Template available for i = 1:length(sTemplates) % Local or download? @@ -593,6 +608,8 @@ jParent = jMenuDefFs; elseif ~isempty(strfind(lower(sTemplates(i).Name), 'oreilly')) || ~isempty(strfind(lower(sTemplates(i).Name), 'kabdebon')) || ~isempty(strfind(lower(sTemplates(i).Name), 'infant')) jParent = jMenuDefInfants; + else + jParent = jMenuDefOthers; end % Create item gui_component('MenuItem', jParent, [], Comment, IconLoader.ICON_ANATOMY, [], @(h,ev)db_set_template(iSubject, sTemplates(i), 1)); @@ -631,7 +648,7 @@ gui_component('MenuItem', jMenuMniVol, [], 'Import from file', IconLoader.ICON_ANATOMY, [], @(h,ev)bst_call(@import_mniatlas, iSubject)); % === MRI SEGMENTATION === - fcnMriSegment(jPopup, sSubject, iSubject, [], 0); + fcnMriSegment(jPopup, sSubject, iSubject, [], 0, 0); % Export menu (added later) if (iSubject ~= 0) jMenuExport{1} = gui_component('MenuItem', [], [], 'Export subject', IconLoader.ICON_SAVE, [], @(h,ev)export_protocol(bst_get('iProtocol'), iSubject)); @@ -685,6 +702,11 @@ % fcnPopupProjectSources(0); end fcnPopupScoutTimeSeries(jPopup); + AddSeparator(jPopup); + % === SEEG IMPLANTATION === + if ~isempty(sSubject.Anatomy) && ~strcmpi(sSubject.Name, bst_get('NormalizedSubjectName')) + gui_component('MenuItem', jPopup, [], 'SEEG/ECOG implantation', IconLoader.ICON_SEEG_DEPTH, [], @(h,ev)bst_call(@panel_ieeg, 'CreateImplantation', sSubject)); + end % Export menu (added later) jMenuExport = gui_component('MenuItem', [], [], 'Export subject', IconLoader.ICON_SAVE, [], @(h,ev)export_protocol(bst_get('iProtocol'), iSubject)); @@ -884,8 +906,8 @@ end end elseif ismember('NIRS', DisplayMod{iType}) - gui_component('MenuItem', jMenuDisplay, [], 'NIRS (scalp)', IconLoader.ICON_CHANNEL, [], @(h,ev)DisplayChannels(bstNodes, Device, 'scalp', 0, 0)); - gui_component('MenuItem', jMenuDisplay, [], 'NIRS (pairs)', IconLoader.ICON_CHANNEL, [], @(h,ev)DisplayChannels(bstNodes, Device, 'scalp', 0, 1)); + gui_component('MenuItem', jMenuDisplay, [], 'NIRS (scalp)', IconLoader.ICON_CHANNEL, [], @(h,ev)DisplayChannels(bstNodes, 'NIRS-BRS', 'scalp', 0, 0)); + gui_component('MenuItem', jMenuDisplay, [], 'NIRS (pairs)', IconLoader.ICON_CHANNEL, [], @(h,ev)DisplayChannels(bstNodes, 'NIRS-BRS', 'scalp', 0, 1)); else gui_component('MenuItem', jMenuDisplay, [], channelTypeDisplay, IconLoader.ICON_CHANNEL, [], @(h,ev)DisplayChannels(bstNodes, DisplayMod{iType}, 'scalp')); end @@ -896,7 +918,7 @@ gui_component('MenuItem', jPopup, [], 'Edit channel file', IconLoader.ICON_EDIT, [], @(h,ev)gui_edit_channel(filenameRelative)); end % === RENAME CHANNELS BIOSEMI === - if ~isempty(regexp(char(bstNodes(1).getComment()), 'BDF')) + if ~isempty(regexp(lower(char(bstNodes(1).getComment())), 'bdf')) || ~isempty(regexp(lower(char(bstNodes(1).getComment())), 'biosemi')) gui_component('MenuItem', jPopup, [], 'BioSemi channels names to 10-10 system', IconLoader.ICON_EDIT, [], @(h,ev)process_channel_biosemi('ComputeInteractive', filenameRelative)); end @@ -907,12 +929,12 @@ fcnPopupImportChannel(bstNodes, jPopup, 1); end % === SEEG CONTACT LABELLING === - if (length(bstNodes) == 1) && any(ismember({'SEEG','ECOG','ECOG+SEEG'}, AllMod)) + if (length(bstNodes) == 1) && ~isempty(AllMod) && any(ismember({'SEEG','ECOG','ECOG+SEEG'}, AllMod)) gui_component('MenuItem', jPopup, [], 'iEEG atlas labels', IconLoader.ICON_VOLATLAS, [], @(h,ev)bst_call(@export_channel_atlas, filenameRelative, 'ECOG+SEEG')); end % === NIRS CHANNEL LABELLING === - if (length(bstNodes) == 1) && any(ismember({'NIRS'}, AllMod)) + if (length(bstNodes) == 1) && ~isempty(AllMod) && any(ismember({'NIRS'}, AllMod)) gui_component('MenuItem', jPopup, [], 'NIRS atlas labels', IconLoader.ICON_VOLATLAS, [], @(h,ev)bst_call(@export_channel_nirs_atlas, filenameRelative)); end @@ -975,6 +997,10 @@ if ~bst_get('ReadOnly') gui_component('MenuItem', jMenuAlign, [], 'Refine using head points', IconLoader.ICON_ALIGN_CHANNELS, [], @(h,ev)channel_align_auto(filenameRelative, [], 1, 0)); end + if ~bst_get('ReadOnly') && ~isempty(DisplayMod) && ~any(ismember(DisplayMod, {'MEG', 'MEG MAG', 'MEG GRAD'})) && any(ismember(DisplayMod,{'EEG','NIRS'})) + AddSeparator(jMenuAlign); + gui_component('MenuItem', jMenuAlign, [], 'Scalp scouts from scalp sensors', IconLoader.ICON_PROJECT_ELECTRODES, [], @(h,ev)bst_scout_channels(filenameRelative, 'Scalp', {'EEG', 'NIRS'})); + end % === MENU: EXTRA HEAD POINTS === jMenuHeadPoints = gui_component('Menu', jPopup, [], 'Digitized head points', IconLoader.ICON_CHANNEL, [], []); @@ -988,6 +1014,8 @@ gui_component('MenuItem', jMenuHeadPoints, [], 'Remove all points', IconLoader.ICON_DELETE, [], @(h,ev)ChannelRemoveHeadpoints(filenameRelative)); % Remove points below the nasion gui_component('MenuItem', jMenuHeadPoints, [], 'Remove points below nasion', IconLoader.ICON_DELETE, [], @(h,ev)ChannelRemoveHeadpoints(filenameRelative, 0)); + % Remove points manually + gui_component('MenuItem', jMenuHeadPoints, [], 'Remove points manually', IconLoader.ICON_DELETE, [], @(h,ev)channel_align_manual(filenameRelative, 'HeadPoints', 1)); % WARP AddSeparator(jMenuHeadPoints); jMenuWarp = gui_component('Menu', jMenuHeadPoints, [], 'Warp', IconLoader.ICON_ALIGN_CHANNELS, [], []); @@ -1003,7 +1031,7 @@ end % ===== PROJECT SENSORS ===== - if ~bst_get('ReadOnly') && ~any(ismember(DisplayMod, {'MEG', 'MEG MAG', 'MEG GRAD'})) + if ~bst_get('ReadOnly') && ~isempty(DisplayMod) && ~any(ismember(DisplayMod, {'MEG', 'MEG MAG', 'MEG GRAD'})) AddSeparator(jPopup); if (iSubject == 0) || sSubject.UseDefaultAnat gui_component('MenuItem', jPopup, [], 'Project to subject...', IconLoader.ICON_PROJECT_ELECTRODES, [], @(h,ev)bst_project_channel(filenameRelative, [])); @@ -1018,7 +1046,7 @@ end %% ===== POPUP: ANATOMY ===== - case {'anatomy', 'volatlas'} + case {'anatomy', 'volatlas', 'volct'} iSubject = bstNodes(1).getStudyIndex(); sSubject = bst_get('Subject', iSubject); iAnatomy = []; @@ -1027,6 +1055,7 @@ end mriComment = lower(char(bstNodes(1).getComment())); isAtlas = strcmpi(nodeType, 'volatlas') || ~isempty(strfind(mriComment, 'tissues')) || ~isempty(strfind(mriComment, 'aseg')) || ~isempty(strfind(mriComment, 'atlas')); + isCt = strcmpi(nodeType, 'volct'); if (length(bstNodes) == 1) % MENU : DISPLAY @@ -1041,7 +1070,7 @@ end AddSeparator(jMenuDisplay); % Display as overlay - if ~bstNodes(1).isMarked() + if ~bstNodes(1).isMarked() && ~isempty(sSubject.iAnatomy) % Get subject structure sSubject = bst_get('MriFile', filenameRelative); MriFile = sSubject.Anatomy(sSubject.iAnatomy).FileName; @@ -1058,11 +1087,11 @@ gui_component('MenuItem', jMenuDisplay, [], 'Histogram', IconLoader.ICON_HISTOGRAM, [], @(h,ev)view_mri_histogram(filenameFull)); end % === MENU: EDIT MRI === - if ~bst_get('ReadOnly') && ~isAtlas + if ~bst_get('ReadOnly') && ~isAtlas && ~isCt gui_component('MenuItem', jPopup, [], 'Edit MRI...', IconLoader.ICON_ANATOMY, [], @(h,ev)view_mri(filenameRelative, 'EditMri')); end % === MENU: SET AS DEFAULT === - if ~bst_get('ReadOnly') && (~ismember(iAnatomy, sSubject.iAnatomy) || ~bstNodes(1).isMarked()) && ~isAtlas + if ~bst_get('ReadOnly') && (~ismember(iAnatomy, sSubject.iAnatomy) || ~bstNodes(1).isMarked()) && ~isAtlas && ~isCt gui_component('MenuItem', jPopup, [], 'Set as default MRI', IconLoader.ICON_GOOD, [], @(h,ev)SetDefaultSurf(iSubject, 'Anatomy', iAnatomy)); end % === MENU: CREATE SURFACES === @@ -1078,10 +1107,14 @@ AddSeparator(jPopup); gui_component('MenuItem', jPopup, [], 'MNI normalization', IconLoader.ICON_ANATOMY, [], @(h,ev)process_mni_normalize('ComputeInteractive', filenameRelative)); gui_component('MenuItem', jPopup, [], 'Resample volume...', IconLoader.ICON_ANATOMY, [], @(h,ev)ResampleMri(filenameRelative)); - if ~bstNodes(1).isMarked() + if ~bstNodes(1).isMarked() && ~isempty(sSubject.iAnatomy) jMenuRegister = gui_component('Menu', jPopup, [], 'Register with default MRI', IconLoader.ICON_ANATOMY); - gui_component('MenuItem', jMenuRegister, [], 'SPM: Register + reslice', IconLoader.ICON_ANATOMY, [], @(h,ev)MriCoregister(filenameRelative, [], 'spm', 1)); - gui_component('MenuItem', jMenuRegister, [], 'SPM: Register only', IconLoader.ICON_ANATOMY, [], @(h,ev)MriCoregister(filenameRelative, [], 'spm', 0)); + gui_component('MenuItem', jMenuRegister, [], 'SPM: Register + reslice', IconLoader.ICON_ANATOMY, [], @(h,ev)MriCoregister(filenameRelative, [], 'SPM', 1)); + gui_component('MenuItem', jMenuRegister, [], 'SPM: Register only', IconLoader.ICON_ANATOMY, [], @(h,ev)MriCoregister(filenameRelative, [], 'SPM', 0)); + if isCt + gui_component('MenuItem', jMenuRegister, [], 'CT2MRI: Register + reslice', IconLoader.ICON_ANATOMY, [], @(h,ev)MriCoregister(filenameRelative, [], 'CT2MRI', 1)); + gui_component('MenuItem', jMenuRegister, [], 'CT2MRI: Register only', IconLoader.ICON_ANATOMY, [], @(h,ev)MriCoregister(filenameRelative, [], 'CT2MRI', 0)); + end AddSeparator(jMenuRegister); gui_component('MenuItem', jMenuRegister, [], 'Reslice / normalized coordinates (MNI)', IconLoader.ICON_ANATOMY, [], @(h,ev)MriReslice(filenameRelative, [], 'ncs', 'ncs')); gui_component('MenuItem', jMenuRegister, [], 'Reslice / subject coordinates (SCS)', IconLoader.ICON_ANATOMY, [], @(h,ev)MriReslice(filenameRelative, [], 'scs', 'scs')); @@ -1091,7 +1124,7 @@ end end % === MRI SEGMENTATION === - fcnMriSegment(jPopup, sSubject, iSubject, iAnatomy, isAtlas); + fcnMriSegment(jPopup, sSubject, iSubject, iAnatomy, isAtlas, isCt); end % === MENU: EXPORT === % Export menu (added later) @@ -1106,7 +1139,11 @@ sSubject = bst_get('Subject', iSubject); % === DISPLAY === - gui_component('MenuItem', jPopup, [], 'Display', IconLoader.ICON_DISPLAY, [], @(h,ev)view_surface(filenameRelative)); + if strcmpi(nodeType, 'other') && ~isempty(regexp(filenameRelative, 'tess_textured', 'match')) + gui_component('MenuItem', jPopup, [], 'Display', IconLoader.ICON_DISPLAY, [], @(h,ev)ViewTexturedSurface(filenameRelative)); + else + gui_component('MenuItem', jPopup, [], 'Display', IconLoader.ICON_DISPLAY, [], @(h,ev)view_surface(filenameRelative)); + end % === SET SURFACE TYPE === if ~bst_get('ReadOnly') && (length(bstNodes) == 1) @@ -1133,7 +1170,7 @@ jItemSetSurfTypeOther.setSelected(1); end end - + % SET AS DEFAULT SURFACE if ~bst_get('ReadOnly') && (length(bstNodes) == 1) iSurface = bstNodes(1).getItemIndex(); @@ -1150,6 +1187,14 @@ % Separator AddSeparator(jPopup); end + + % === DIGITIZE (3D SCANNER) OPTION === + if strcmpi(nodeType, 'other') && ~isempty(regexp(filenameRelative, 'tess_textured', 'match')) + gui_component('MenuItem', jPopup, [], 'Digitize (3D scanner)', IconLoader.ICON_SNAPSHOT, [], @(h,ev)bst_call(@panel_digitize, 'Start', '3DScanner', sSubject, iSubject, filenameRelative)); + % Separator + AddSeparator(jPopup); + end + % NUMBER OF SELECTED FILES if (length(bstNodes) >= 2) if ~bst_get('ReadOnly') @@ -1345,6 +1390,8 @@ end gui_component('MenuItem', jPopup, [], ['View ' mod{1} ' leadfield sensitivity (MRI 3D)'], IconLoader.ICON_ANATOMY, [], @(h,ev)bst_call(@view_leadfield_sensitivity, filenameRelative, mod{1}, 'Mri3D')); gui_component('MenuItem', jPopup, [], ['View ' mod{1} ' leadfield sensitivity (MRI Viewer)'], IconLoader.ICON_ANATOMY, [], @(h,ev)bst_call(@view_leadfield_sensitivity, filenameRelative, mod{1}, 'MriViewer')); + AddSeparator(jPopup); + gui_component('MenuItem', jPopup, [], ['Apply ' mod{1} ' leadfield exclusion zone'], IconLoader.ICON_HEADMODEL, [], @(h,ev)process_headmodel_exclusionzone('ComputeInteractive', filenameRelative, mod{1}, iStudy)); elseif strcmpi(sStudy.HeadModel(iHeadModel).HeadModelType, 'surface') gui_component('MenuItem', jPopup, [], ['View ' mod{1} ' leadfield sensitivity'], IconLoader.ICON_ANATOMY, [], @(h,ev)bst_call(@view_leadfield_sensitivity, filenameRelative, mod{1}, 'Surface')); end @@ -1910,7 +1957,7 @@ % Get data type isStat = strcmpi(char(bstNodes(1).getType()), 'ptimefreq'); if isStat - TimefreqMat = in_bst_timefreq(filenameRelative, 0, 'DataType'); + TimefreqMat = in_bst_timefreq(filenameRelative, 0, 'DataType', 'Time'); if ~isempty(TimefreqMat.DataType) DataType = TimefreqMat.DataType; else @@ -1950,7 +1997,12 @@ if (length(bstNodes) == 1) % ===== CONNECTIVITY ===== if ~isempty(strfind(filenameRelative, '_connectn')) || ~isempty(strfind(filenameRelative, '_connect1')) - + % Time defined Connectivity file or Stat Connectivity file + if isStat + cnxTimeDef = length(TimefreqMat.Time) > 1; + else + cnxTimeDef = ~isempty(strfind(sStudy.Timefreq(iTimefreq).Comment, '-time')); + end % [NxN] only if ~isempty(strfind(filenameRelative, '_connectn')) gui_component('MenuItem', jPopup, [], 'Display as graph [NxN]', IconLoader.ICON_CONNECTN, [], @(h,ev)view_connect(filenameRelative, 'GraphFull')); @@ -1971,7 +2023,8 @@ gui_component('MenuItem', jPopup, [], 'Power spectrum', IconLoader.ICON_SPECTRUM, [], @(h,ev)view_spectrum(filenameRelative, 'Spectrum')); end if ~isempty(strfind(filenameRelative, '_plvt')) || ~isempty(strfind(filenameRelative, '_corr_time')) || ~isempty(strfind(filenameRelative, '_cohere_time')) ... - || ~isempty(strfind(filenameRelative, '_wplit')) || ~isempty(strfind(filenameRelative, '_ciplvt')) + || ~isempty(strfind(filenameRelative, '_wplit')) || ~isempty(strfind(filenameRelative, '_ciplvt')) ... + || (cnxTimeDef && (~isempty(strfind(filenameRelative, '_corr')) || ~isempty(strfind(filenameRelative, '_cohere')))) gui_component('MenuItem', jPopup, [], 'Time series', IconLoader.ICON_DATA, [], @(h,ev)view_spectrum(filenameRelative, 'TimeSeries')); gui_component('MenuItem', jMenuConn1, [], 'One row', IconLoader.ICON_TIMEFREQ, [], @(h,ev)view_timefreq(filenameFull, 'SingleSensor')); gui_component('MenuItem', jMenuConn1, [], 'All rows', IconLoader.ICON_TIMEFREQ, [], @(h,ev)view_timefreq(filenameFull, 'AllSensors')); @@ -1989,7 +2042,8 @@ case 'results' % One channel if ~isempty(strfind(filenameRelative, '_plvt')) || ~isempty(strfind(filenameRelative, '_corr_time')) || ~isempty(strfind(filenameRelative, '_cohere_time'))... - || ~isempty(strfind(filenameRelative, '_wplit')) || ~isempty(strfind(filenameRelative, '_ciplvt')) + || ~isempty(strfind(filenameRelative, '_wplit')) || ~isempty(strfind(filenameRelative, '_ciplvt')) ... + || (cnxTimeDef && (~isempty(strfind(filenameRelative, '_corr')) || ~isempty(strfind(filenameRelative, '_cohere')))) gui_component('MenuItem', jMenuConn1, [], 'One channel', IconLoader.ICON_TIMEFREQ, [], @(h,ev)view_timefreq(filenameFull, 'SingleSensor')); AddSeparator(jMenuConn1); end @@ -2010,7 +2064,8 @@ gui_component('MenuItem', jPopup, [], 'Power spectrum', IconLoader.ICON_SPECTRUM, [], @(h,ev)view_spectrum(filenameRelative, 'Spectrum')); end if ~isempty(strfind(filenameRelative, '_plvt')) || ~isempty(strfind(filenameRelative, '_corr_time')) || ~isempty(strfind(filenameRelative, '_cohere_time')) ... - || ~isempty(strfind(filenameRelative, '_wplit')) || ~isempty(strfind(filenameRelative, '_ciplvt')) + || ~isempty(strfind(filenameRelative, '_wplit')) || ~isempty(strfind(filenameRelative, '_ciplvt')) ... + || (cnxTimeDef && (~isempty(strfind(filenameRelative, '_corr')) || ~isempty(strfind(filenameRelative, '_cohere')))) gui_component('MenuItem', jPopup, [], 'Time series', IconLoader.ICON_DATA, [], @(h,ev)view_spectrum(filenameRelative, 'TimeSeries')); gui_component('MenuItem', jMenuConn1, [], 'One row', IconLoader.ICON_TIMEFREQ, [], @(h,ev)view_timefreq(filenameFull, 'SingleSensor')); gui_component('MenuItem', jMenuConn1, [], 'All rows', IconLoader.ICON_TIMEFREQ, [], @(h,ev)view_timefreq(filenameFull, 'AllSensors')); @@ -2246,22 +2301,24 @@ DataType = sStudy.Timefreq(iTimefreq).DataType; DataFile = sStudy.Timefreq(iTimefreq).DataFile; end + if strcmpi(DataType, 'data') + % Get avaible modalities for this data file + DisplayMod = bst_get('TimefreqDisplayModalities', filenameRelative); + % Add SEEG+ECOG + if ~isempty(DisplayMod) && all(ismember({'SEEG','ECOG'}, DisplayMod)) + DisplayMod = cat(2, {'ECOG+SEEG'}, DisplayMod); + end + end % One file selected if (length(bstNodes) == 1) % ===== RECORDINGS ===== if strcmpi(DataType, 'data') - % Get avaible modalities for this data file - DisplayMod = bst_get('TimefreqDisplayModalities', filenameRelative); - % Add SEEG+ECOG - if all(ismember({'SEEG','ECOG'}, DisplayMod)) - DisplayMod = cat(2, {'ECOG+SEEG'}, DisplayMod); - end % Power spectrum gui_component('MenuItem', jPopup, [], 'Power spectrum', IconLoader.ICON_SPECTRUM, [], @(h,ev)view_spectrum(filenameRelative, 'Spectrum')); AddSeparator(jPopup); % Topography isGradNorm = strcmpi(nodeType, 'spectrum'); - jSubMenus = fcnPopupTopoNoInterp(jPopup, filenameRelative, DisplayMod, 0, isGradNorm, 0); + jSubMenus = fcnPopupTopoNoInterp(jPopup, filenameRelative, DisplayMod, 1, isGradNorm, 0); % Interpolate SEEG/ECOG on the anatomy for iMod = 1:length(DisplayMod) % Create submenu if there are multiple modalities @@ -2279,7 +2336,7 @@ AddSeparator(jPopup); % EEG: Display on scalp if strcmpi(DisplayMod{iMod}, 'EEG') && ~isempty(sSubject) && ~isempty(sSubject.iScalp) - gui_component('MenuItem', jPopup, [], 'Display on scalp', IconLoader.ICON_SURFACE_SCALP, [], @(h,ev)view_surface_data(sSubject.Surface(sSubject.iScalp).FileName, filenameRelative, 'EEG')); + gui_component('MenuItem', jMenuModality, [], 'Display on scalp', IconLoader.ICON_SURFACE_SCALP, [], @(h,ev)view_surface_data(sSubject.Surface(sSubject.iScalp).FileName, filenameRelative, 'EEG')); % SEEG/ECOG: Display on cortex or MRI elseif ismember(DisplayMod{iMod}, {'SEEG', 'ECOG', 'ECOG+SEEG'}) && ~isempty(sSubject) if ~isempty(sSubject.iCortex) @@ -2335,6 +2392,20 @@ else gui_component('MenuItem', jPopup, [], 'Power spectrum', IconLoader.ICON_SPECTRUM, [], @(h,ev)view_spectrum(filenameRelative, 'Spectrum')); end + else + % Display of multiple files + if ~isempty(DisplayMod) + % === 2DLAYOUT === + mod2D = intersect(DisplayMod, {'EEG', 'MEG', 'MEG MAG', 'MEG GRAD', 'ECOG', 'SEEG', 'ECOG+SEEG', 'NIRS'}); + if (length(mod2D) == 1) + gui_component('MenuItem', jPopup, [], ['2D Layout: ' mod2D{1}], IconLoader.ICON_2DLAYOUT, [], @(h,ev)bst_call(@view_topography, GetAllFilenames(bstNodes), mod2D{1}, '2DLayout')); + elseif (length(mod2D) > 1) + jMenu2d = gui_component('Menu', jPopup, [], '2D Layout', IconLoader.ICON_2DLAYOUT, [], []); + for iMod = 1:length(mod2D) + gui_component('MenuItem', jMenu2d, [], mod2D{iMod}, IconLoader.ICON_2DLAYOUT, [], @(h,ev)bst_call(@view_topography, GetAllFilenames(bstNodes), mod2D{iMod}, '2DLayout')); + end + end + end end % Project sources if strcmpi(DataType, 'results') && ~strcmpi(nodeType, 'ptimefreq') && isempty(strfind(filenameRelative, '_KERNEL_')) @@ -2814,7 +2885,7 @@ function fcnPopupAlign() jMenuAlignManual = gui_component('Menu', jPopup, [], 'Align manually on...', IconLoader.ICON_ALIGN_SURFACES, [], []); % ADD ANATOMIES for iAnat = 1:length(sSubject.Anatomy) - if isempty(strfind(sSubject.Anatomy(iAnat).FileName, '_volatlas')) + if isempty(strfind(sSubject.Anatomy(iAnat).FileName, '_volatlas')) && isempty(strfind(sSubject.Anatomy(iAnat).FileName, '_volct')) fullAnatFile = bst_fullfile(ProtocolInfo.SUBJECTS, sSubject.Anatomy(iAnat).FileName); gui_component('MenuItem', jMenuAlignManual, [], sSubject.Anatomy(iAnat).Comment, IconLoader.ICON_ANATOMY, [], @(h,ev)tess_align_manual(fullAnatFile, filenameFull)); end @@ -2846,6 +2917,36 @@ function fcnPopupImportChannel(bstNodes, jMenu, isAddLoc) jMenu = gui_component('Menu', jMenu, [], 'Add EEG positions', IconLoader.ICON_CHANNEL, [], []); % Import from file gui_component('MenuItem', jMenu, [], 'Import from file', IconLoader.ICON_CHANNEL, [], @(h,ev)channel_add_loc(iAllStudies, [], 1)); + % From other Studies within same Subject + sStudies = bst_get('Study', iAllStudies); + % If adding locations to multiple channel files, they must be from the same subject + if length(unique({sStudies.BrainStormSubject})) == 1 + sSubject = bst_get('Subject', sStudies(1).BrainStormSubject); + if sSubject.UseDefaultChannel == 0 + % Only consider Studies with ChannelFile + [sStudies, iStudies] = bst_get('StudyWithSubject', sSubject.FileName); + iChStudies = ~cellfun(@isempty, {sStudies.Channel}); + sStudies = sStudies(iChStudies); + iStudies = iStudies(iChStudies); + [~, ixDiff] = setdiff(iStudies, iAllStudies); + if ~isempty(ixDiff) + % Create menu and entries + AddSeparator(jMenu); + jMenuStudy = gui_component('Menu', jMenu, [], 'From other studies', IconLoader.ICON_CHANNEL, [], []); + for ix = 1 : length(ixDiff) + conditionName = sStudies(ixDiff(ix)).Condition{1}; + if length(conditionName) > 4 && strcmpi(conditionName(1:4), '@raw') + iconLoader = IconLoader.ICON_RAW_FOLDER_CLOSE; + conditionName(1:4) = ''; + else + iconLoader = IconLoader.ICON_FOLDER_CLOSE; + end + % Menu entry + gui_component('MenuItem', jMenuStudy, [], conditionName, iconLoader, [], @(h,ev)channel_add_loc(iAllStudies, sStudies(ixDiff(ix)).Channel.FileName, 1)); + end + end + end + end % If only SEEG/ECOG, stop here (we do not want to offer the standard EEG caps, it doesn't make sense) if (isAddLoc < 2) return; @@ -2856,72 +2957,8 @@ function fcnPopupImportChannel(bstNodes, jMenu, isAddLoc) gui_component('MenuItem', jMenu, [], 'Import channel file', IconLoader.ICON_CHANNEL, [], @(h,ev)bst_call(@ImportChannelCheck, iAllStudies)); jMenu = gui_component('Menu', jMenu, [], 'Use default EEG cap', IconLoader.ICON_CHANNEL, [], []); end - % === USE DEFAULT CHANNEL FILE === - % Get registered Brainstorm EEG defaults - bstDefaults = bst_get('EegDefaults'); - if ~isempty(bstDefaults) - % Add a directory per template block available - for iDir = 1:length(bstDefaults) - jMenuDir = gui_component('Menu', jMenu, [], bstDefaults(iDir).name, IconLoader.ICON_FOLDER_CLOSE, [], []); - isMni = strcmpi(bstDefaults(iDir).name, 'ICBM152'); - % Create subfolder for cap manufacturer - jMenuOther = gui_component('Menu', [], [], 'Generic', IconLoader.ICON_FOLDER_CLOSE, [], []); - jMenuAnt = gui_component('Menu', [], [], 'ANT', IconLoader.ICON_FOLDER_CLOSE, [], []); - jMenuBs = gui_component('Menu', [], [], 'BioSemi', IconLoader.ICON_FOLDER_CLOSE, [], []); - jMenuBp = gui_component('Menu', [], [], 'BrainProducts', IconLoader.ICON_FOLDER_CLOSE, [], []); - jMenuEgi = gui_component('Menu', [], [], 'EGI', IconLoader.ICON_FOLDER_CLOSE, [], []); - jMenuNs = gui_component('Menu', [], [], 'NeuroScan', IconLoader.ICON_FOLDER_CLOSE, [], []); - % Add an item per Template available - fList = bstDefaults(iDir).contents; - % Sort in natural order - [tmp,I] = sort_nat({fList.name}); - fList = fList(I); - % Create an entry for each default - for iFile = 1:length(fList) - % Define callback function - if isAddLoc - fcnCallback = @(h,ev)channel_add_loc(iAllStudies, fList(iFile).fullpath, 1, isMni); - else - fcnCallback = @(h,ev)db_set_channel(iAllStudies, fList(iFile).fullpath, 1, 0); - end - % Find corresponding submenu - if ~isempty(strfind(fList(iFile).name, 'ANT')) - jMenuType = jMenuAnt; - elseif ~isempty(strfind(fList(iFile).name, 'BioSemi')) - jMenuType = jMenuBs; - elseif ~isempty(strfind(fList(iFile).name, 'BrainProducts')) - jMenuType = jMenuBp; - elseif ~isempty(strfind(fList(iFile).name, 'GSN')) || ~isempty(strfind(fList(iFile).name, 'U562')) - jMenuType = jMenuEgi; - elseif ~isempty(strfind(fList(iFile).name, 'Neuroscan')) - jMenuType = jMenuNs; - else - jMenuType = jMenuOther; - end - % Create item - gui_component('MenuItem', jMenuType, [], fList(iFile).name, IconLoader.ICON_CHANNEL, [], fcnCallback); - end - % Add if not empty - if (jMenuOther.getMenuComponentCount() > 0) - jMenuDir.add(jMenuOther); - end - if (jMenuAnt.getMenuComponentCount() > 0) - jMenuDir.add(jMenuAnt); - end - if (jMenuBs.getMenuComponentCount() > 0) - jMenuDir.add(jMenuBs); - end - if (jMenuBp.getMenuComponentCount() > 0) - jMenuDir.add(jMenuBp); - end - if (jMenuEgi.getMenuComponentCount() > 0) - jMenuDir.add(jMenuEgi); - end - if (jMenuNs.getMenuComponentCount() > 0) - jMenuDir.add(jMenuNs); - end - end - end + % Use default channel file + menu_default_eegcaps(jMenu, iAllStudies, isAddLoc); end %% ===== EDIT NODE ===== @@ -3060,7 +3097,7 @@ function fcnPopupDisplayTopography(jMenu, FileName, AllMod, Modality, isStat) %% ===== MRI SEGMENTATION ===== -function fcnMriSegment(jPopup, sSubject, iSubject, iAnatomy, isAtlas) +function fcnMriSegment(jPopup, sSubject, iSubject, iAnatomy, isAtlas, isCt) import org.brainstorm.icon.*; % No anatomy: nothing to do if isempty(sSubject.Anatomy) @@ -3078,24 +3115,39 @@ function fcnMriSegment(jPopup, sSubject, iSubject, iAnatomy, isAtlas) else MriFile = {sSubject.Anatomy(iAnatomy).FileName}; end + % Menu label + volType = 'MRI'; + volIcon = 'ICON_ANATOMY'; + if isCt + volType = 'CT'; + volIcon = 'ICON_VOLCT'; + end % Add menu separator AddSeparator(jPopup); % === MRI/CT === if ~isAtlas % Create sub-menu - jMenu = gui_component('Menu', jPopup, [], 'MRI segmentation', IconLoader.ICON_ANATOMY); + jMenu = gui_component('Menu', jPopup, [], [volType, ' segmentation'], IconLoader.(volIcon)); + % === MESH FROM THRESHOLD CT === + if (length(iAnatomy) <= 1) && isCt + if ~isempty(sSubject.iAnatomy) + gui_component('MenuItem', jMenu, [], 'SPM: Skull stripping', IconLoader.(volIcon), [], @(h,ev)MriSkullStrip(MriFile, sSubject.Anatomy(iAnatomy).FileName, 'SPM')); + gui_component('MenuItem', jMenu, [], 'BrainSuite: Skull stripping', IconLoader.(volIcon), [], @(h,ev)MriSkullStrip(MriFile, sSubject.Anatomy(iAnatomy).FileName, 'BrainSuite')); + end + gui_component('MenuItem', jMenu, [], 'Generate threshold mesh from CT', IconLoader.ICON_SURFACE_SCALP, [], @(h,ev)tess_isosurface(MriFile)); + end % === GENERATE HEAD/BEM === - if (length(iAnatomy) <= 1) + if (length(iAnatomy) <= 1) && ~isCt gui_component('MenuItem', jMenu, [], 'Generate head surface', IconLoader.ICON_SURFACE_SCALP, [], @(h,ev)tess_isohead(MriFile)); gui_component('MenuItem', jMenu, [], 'Generate BEM surfaces', IconLoader.ICON_FEM, [], @(h,ev)bst_call(@process_generate_bem, 'ComputeInteractive', iSubject, iAnatomy)); end % === GENERATE FEM === - if (length(iAnatomy) <= 2) % T1 + optional T2 + if (length(iAnatomy) <= 2) && ~isCt % T1 + optional T2 jItemFem = gui_component('MenuItem', jMenu, [], 'Generate FEM mesh', IconLoader.ICON_FEM, [], @(h,ev)bst_call(@process_fem_mesh, 'ComputeInteractive', iSubject, iAnatomy)); end - % === SEGMENTATION === - if (length(iAnatomy) <= 1) + % === MRI SEGMENTATION === + if (length(iAnatomy) <= 1) && ~isCt AddSeparator(jMenu); % gui_component('MenuItem', jMenu, [], 'SPM12 canonical surfaces', IconLoader.ICON_FEM, [], @(h,ev)bst_call(@process_generate_canonical, 'ComputeInteractive', iSubject, iAnatomy)); gui_component('MenuItem', jMenu, [], 'CAT12: Cortex, atlases, tissues', IconLoader.ICON_FEM, [], @(h,ev)bst_call(@process_segment_cat12, 'ComputeInteractive', iSubject, iAnatomy)); @@ -3119,11 +3171,15 @@ function fcnMriSegment(jPopup, sSubject, iSubject, iAnatomy, isAtlas) if isempty(iAnatomy) gui_component('MenuItem', jPopup, [], 'Deface anatomy', IconLoader.ICON_ANATOMY, [], @(h,ev)process_mri_deface('Compute', iSubject, struct('isDefaceHead', 1))); else - gui_component('MenuItem', jPopup, [], 'Deface volume', IconLoader.ICON_ANATOMY, [], @(h,ev)process_mri_deface('Compute', MriFile, struct('isDefaceHead', 0))); + gui_component('MenuItem', jPopup, [], 'Deface volume', IconLoader.(volIcon), [], @(h,ev)process_mri_deface('Compute', MriFile, struct('isDefaceHead', 0))); end % === SEEG/ECOG === - if (length(iAnatomy) <= 1) - gui_component('MenuItem', jPopup, [], 'SEEG/ECOG implantation', IconLoader.ICON_SEEG_DEPTH, [], @(h,ev)bst_call(@panel_ieeg, 'CreateNewImplantation', MriFile)); + % Right click on the subject only + if isempty(iAnatomy) && iSubject ~=0 + gui_component('MenuItem', jPopup, [], 'SEEG/ECOG implantation', IconLoader.ICON_SEEG_DEPTH, [], @(h,ev)bst_call(@panel_ieeg, 'CreateImplantation', sSubject)); + % Right click on a desired volume (MRI/CT) in a subject + elseif (length(iAnatomy) == 1) && iSubject ~=0 + gui_component('MenuItem', jPopup, [], 'SEEG/ECOG implantation', IconLoader.ICON_SEEG_DEPTH, [], @(h,ev)bst_call(@panel_ieeg, 'CreateImplantation', MriFile)); end % === TISSUE SEGMENTATION === @@ -3337,7 +3393,7 @@ function SurfaceFillHoles_Callback(TessFile) sHead = in_tess_bst(TessFile, 0); % Get subject [sSubject, iSubject] = bst_get('SurfaceFile', TessFile); - if isempty(sSubject.Anatomy) + if isempty(sSubject.Anatomy) || isempty(sSubject.iAnatomy) bst_error('No MRI available.', 'Remove surface holes'); return; end @@ -3764,5 +3820,16 @@ function MriReslice(MriFileSrc, MriFileRef, TransfSrc, TransfRef) end end +function MriSkullStrip(MriFileSrc, MriFileRef, Method) + [MriFileMask, errMsg] = bst_call(@mri_skullstrip, MriFileSrc, MriFileRef, Method); + if isempty(MriFileMask) || ~isempty(errMsg) + bst_error(['Could not perform skull stripping.', 10, 10, errMsg], 'MRI skull stripping', 0); + end +end + - +%% ===== DISPLAY TEXTURED SURFACE ===== +function ViewTexturedSurface(filenameRelative) + sSurf = bst_memory('LoadSurface', filenameRelative); + view_surface_matrix(sSurf.Vertices, sSurf.Faces, [], sSurf.Color, [], [], filenameRelative); +end diff --git a/toolbox/tree/tree_set_noisecov.m b/toolbox/tree/tree_set_noisecov.m index 134cbaeba..3de0bf7ab 100644 --- a/toolbox/tree/tree_set_noisecov.m +++ b/toolbox/tree/tree_set_noisecov.m @@ -73,16 +73,20 @@ function tree_set_noisecov(bstNodes, NoiseCovFile, isDataCov) % === IMPORT FROM MATLAB === elseif strcmpi(NoiseCovFile, 'MatlabVar') % Get matlab variable - NoiseCovMat.Comment = 'Noise covariance (Matlab)'; [NoiseCovMat.NoiseCov, varname] = in_matlab_var(); % Check if import was cancelled if isempty(NoiseCovMat.NoiseCov) return end - % Check if input was already a structure + % Check if input was already a Brainstorm structure if isstruct(NoiseCovMat.NoiseCov) && isfield(NoiseCovMat.NoiseCov, 'NoiseCov') - NoiseCovMat.NoiseCov = NoiseCovMat.NoiseCov.NoiseCov; + NoiseCovMat = struct_copy_fields(db_template('noisecovmat'), NoiseCovMat.NoiseCov); + if ~isempty(NoiseCovMat.History) + NoiseCovMat.History = []; + end end + % Update comment + NoiseCovMat.Comment = 'Noise covariance (Matlab)'; % History: Import from Matlab NoiseCovMat = bst_history('add', NoiseCovMat, 'import', ['Import from Matlab variable: ' varname]); % Save in database