From 8e6d19e58fbc68c43d6a0a006622bbf03d1e7bd2 Mon Sep 17 00:00:00 2001 From: Marc Lalancette <31040756+Moo-Marc@users.noreply.github.com> Date: Sat, 19 Feb 2022 22:45:25 -0500 Subject: [PATCH 01/47] Option to change MRI fids to match digitized ones --- toolbox/db/db_set_channel.m | 14 ++- toolbox/gui/figure_mri.m | 20 +++- .../functions/process_adjust_coordinates.m | 42 ++++++-- .../process/functions/process_import_bids.m | 10 +- toolbox/sensors/channel_align_auto.m | 97 ++++++++++++++----- 5 files changed, 139 insertions(+), 44 deletions(-) diff --git a/toolbox/db/db_set_channel.m b/toolbox/db/db_set_channel.m index 447af48c1..ddd35767e 100644 --- a/toolbox/db/db_set_channel.m +++ b/toolbox/db/db_set_channel.m @@ -16,6 +16,7 @@ % - ChannelAlign : 0, do not perform automatic headpoints-based alignment % 1, perform automatic alignment after user confirmation % 2, perform automatic alignment without user confirmation +% 3, as 2, but also updating MRI SCS from digitized points % OUTPUT: % - OutputFile: Newly created channel file (empty is no file created) @@ -175,15 +176,18 @@ end % Call automatic registration for MEG - [ChannelMat, R, T, isSkip, isUserCancel] = channel_align_auto(OutputFile, [], 0, isConfirm); + if ChannelAlign >= 3 + % Also adjust MRI SCS from digitized points. + [ChannelMat, R, T, isSkip, isUserCancel] = channel_align_auto(OutputFile, [], 0, isConfirm, [], 1); + else + [ChannelMat, R, T, isSkip, isUserCancel] = channel_align_auto(OutputFile, [], 0, isConfirm); + end % 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/gui/figure_mri.m b/toolbox/gui/figure_mri.m index 9d46eeebb..c5fe40fbc 100644 --- a/toolbox/gui/figure_mri.m +++ b/toolbox/gui/figure_mri.m @@ -2413,10 +2413,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 @@ -2448,7 +2460,11 @@ function ButtonSave_Callback(hFig, varargin) % === 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/process/functions/process_adjust_coordinates.m b/toolbox/process/functions/process_adjust_coordinates.m index bf861a247..17cfa566e 100644 --- a/toolbox/process/functions/process_adjust_coordinates.m +++ b/toolbox/process/functions/process_adjust_coordinates.m @@ -3,7 +3,7 @@ % % 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. +% points set on the MRI. % @============================================================================= % This function is part of the Brainstorm software: @@ -23,14 +23,14 @@ % 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'; @@ -64,6 +64,11 @@ 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.scs.Type = 'checkbox'; + sProcess.options.scs.Comment = 'Also ajust MRI nasion and ear points.'; + sProcess.options.scs.Value = 0; + sProcess.options.scs.Class = 'Refine'; sProcess.options.remove.Type = 'checkbox'; sProcess.options.remove.Comment = 'Remove selected adjustments (if present) instead of adding them.'; sProcess.options.remove.Value = 0; @@ -101,6 +106,18 @@ bst_report('Info', sProcess, sInputs, ... 'Multiple inputs were found for a single channel file. They will be concatenated for adjusting the head position.'); end + + if ~sProcess.options.remove.Value && sProcess.options.points.Value && sProcess.options.scs.Value + % Warning and confirmation dialog. + isConfirmed = java_dialog('confirm', 'Ajusting MRI nasion and ear points will break previous alignment with head points for files not included here. Proceed?', ... + 'Adjust MRI nasion and ear points?'); + if ~isConfirmed + bst_report('User cancelled.'); + OutputFiles = {}; + return; + end + end + bst_progress('start', 'Adjust coordinate system', ... ' ', 0, nFiles); % If resetting, in case the original data moved, and because the same @@ -159,14 +176,14 @@ Which = {}; if sProcess.options.head.Value - Which{end+1} = 'AdjustedNative'; + Which{end+1} = 'AdjustedNative'; %#ok<*AGROW> end if sProcess.options.points.Value Which{end+1} = 'refine registration: head points'; end for TransfLabel = Which - TransfLabel = TransfLabel{1}; + TransfLabel = TransfLabel{1}; %#ok ChannelMat = RemoveTransformation(ChannelMat, TransfLabel, sInputs(iFile), sProcess); end % TransfLabel loop @@ -187,8 +204,13 @@ % 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 + if sProcess.options.scs.Value + [ChannelMat, R, T, isSkip] = ... + channel_align_auto(sInputs(iFile).ChannelFile, ChannelMat, 0, 0, [], 1); % No warning or confirmation, adjust scs + else + [ChannelMat, R, T, isSkip] = ... + channel_align_auto(sInputs(iFile).ChannelFile, ChannelMat, 0, 0); % No warning or confirmation + end % ChannelFile needed to find subject and scalp surface, but not % used otherwise when ChannelMat is provided. if isSkip @@ -266,8 +288,8 @@ % end -function [ChannelMat, NewChannelFiles, Failed] = ... - ResetChannelFile(ChannelMat, NewChannelFiles, sInput, sProcess) +function [ChannelMat, NewChannelFiles, Failed] = ResetChannelFile(... + ChannelMat, NewChannelFiles, sInput, sProcess) if nargin < 4 sProcess = []; end @@ -506,7 +528,7 @@ iHeadSamples = 1 + ((1:(nHeadSamples*nEpochs)) - 1) * HeadSamplePeriod; % first is 1 iBad = []; for iSeg = 1:size(BadSegments, 2) - iBad = [iBad, nSamplesPerEpoch * (BadEpoch(1,iSeg) - 1) + (BadSegments(1,iSeg):BadSegments(2,iSeg))]; %#ok + iBad = [iBad, nSamplesPerEpoch * (BadEpoch(1,iSeg) - 1) + (BadSegments(1,iSeg):BadSegments(2,iSeg))]; % iBad = [iBad, find((DataMat.Time >= badTimes(1,iSeg)) & (DataMat.Time <= badTimes(2,iSeg)))]; end % Exclude bad samples. diff --git a/toolbox/process/functions/process_import_bids.m b/toolbox/process/functions/process_import_bids.m index ba8bf1318..2d8a57854 100644 --- a/toolbox/process/functions/process_import_bids.m +++ b/toolbox/process/functions/process_import_bids.m @@ -68,6 +68,11 @@ sProcess.options.channelalign.Comment = 'Align sensors using headpoints'; sProcess.options.channelalign.Type = 'checkbox'; sProcess.options.channelalign.Value = 1; + sProcess.options.channelalign.Controller = 'Align'; + sProcess.options.scs.Type = 'checkbox'; + sProcess.options.scs.Comment = 'Also ajust MRI nasion and ear points from digitized points.'; + sProcess.options.scs.Value = 1; + sProcess.options.scs.Class = 'Align'; % Group sessions sProcess.options.groupsessions.Comment = 'Import multiple anat sessions to the same subject'; sProcess.options.groupsessions.Type = 'checkbox'; @@ -109,7 +114,8 @@ end % Other options OPTIONS.isInteractive = 0; - OPTIONS.ChannelAlign = 2 * double(sProcess.options.channelalign.Value); + % 2=align without confirmation, 3=also adjust MRI SCS from digitized points + OPTIONS.ChannelAlign = (2 + double(sProcess.options.scs.Value)) * double(sProcess.options.channelalign.Value); OPTIONS.SelectedSubjects = strtrim(str_split(sProcess.options.selectsubj.Value, ',')); OPTIONS.isGroupSessions = sProcess.options.groupsessions.Value; OPTIONS.isGenerateBem = sProcess.options.bem.Value; @@ -488,7 +494,7 @@ % Import options ImportOptions = db_template('ImportOptions'); ImportOptions.ChannelReplace = 1; - ImportOptions.ChannelAlign = 2 * (OPTIONS.ChannelAlign >= 1) * ~sSubject.UseDefaultAnat; + ImportOptions.ChannelAlign = OPTIONS.ChannelAlign * ~sSubject.UseDefaultAnat; ImportOptions.DisplayMessages = OPTIONS.isInteractive; ImportOptions.EventsMode = 'ignore'; ImportOptions.EventsTrackMode = 'value'; diff --git a/toolbox/sensors/channel_align_auto.m b/toolbox/sensors/channel_align_auto.m index cbfa9dc96..926d7eb51 100644 --- a/toolbox/sensors/channel_align_auto.m +++ b/toolbox/sensors/channel_align_auto.m @@ -1,7 +1,7 @@ -function [ChannelMat, R, T, isSkip, isUserCancel, strReport] = channel_align_auto(ChannelFile, ChannelMat, isWarning, isConfirm, tolerance) +function [ChannelMat, R, T, isSkip, isUserCancel, strReport] = channel_align_auto(ChannelFile, ChannelMat, isWarning, isConfirm, tolerance, isAdjustScs) % CHANNEL_ALIGN_AUTO: Aligns the channels to the scalp using Polhemus points. % -% USAGE: [ChannelMat, R, T, isSkip, isUserCancel, strReport] = channel_align_auto(ChannelFile, ChannelMat=[], isWarning=1, isConfirm=1, tolerance=0) +% USAGE: [ChannelMat, R, T, isSkip, isUserCancel, strReport] = channel_align_auto(ChannelFile, ChannelMat=[], isWarning=1, isConfirm=1, tolerance=0, isAdjustScs=0) % % DESCRIPTION: % Aligns the channels to the scalp using Polhemus points stored in channel structure. @@ -14,10 +14,11 @@ % % 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 +% - isAdjustScs : If 1 and not already done for this subject, update MRI to use digitized nasion and ear points. % % OUTPUTS: % - ChannelMat : The same ChannelMat structure input in, with the head points and sensors rotated and translated to match the head points to the scalp. @@ -46,10 +47,12 @@ % For more information type "brainstorm license" at command prompt. % =============================================================================@ % -% Authors: Syed Ashrafulla, 2009 -% Francois Tadel, 2009-2021 +% Authors: Syed Ashrafulla, 2009, Francois Tadel, 2009-2021, Marc Lalancette 2022 %% ===== PARSE INPUTS ===== +if (nargin < 6) || isempty(isAdjustScs) + isAdjustScs = 0; +end if (nargin < 5) || isempty(tolerance) tolerance = 0; end @@ -69,6 +72,7 @@ T = []; isSkip = 0; isUserCancel = 0; +strReport = ''; %% ===== LOAD CHANNELS ===== @@ -96,10 +100,18 @@ % Get study sStudy = bst_get('ChannelFile', ChannelFile); % Get subject -sSubject = bst_get('Subject', sStudy.BrainStormSubject); +[sSubject, iSubject] = bst_get('Subject', sStudy.BrainStormSubject); +% Check if default anatomy. (Usually also checked before calling this function.) +if iSubject == 0 + if isWarning + bst_error('Digitized nasion and ear points cannot be applied to default anatomy.', 'Automatic EEG-MEG/MRI registration', 0); + end + bst_progress('stop'); + return +end 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 @@ -185,24 +197,63 @@ ' | 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; + + +%% ===== ADJUST MRI FIDUCIALS AND SCS ===== +if isAdjustScs + % Check if already adjusted, in which case the transformation above is correct (identity if same head points). + sMriOld = in_mri_bst(sSubject.Anatomy(sSubject.iAnatomy).FileName); + % History string is set in figure_mri SaveMri. + if isfield(sMriOld, 'History') && ~isempty(sMriOld.History) && any(strcmpi(sMriOld.History(:,2), 'Applied digitized anatomical fiducials')) + if isWarning + bst_warning('Nasion and ear points already adjusted.', 'Automatic EEG-MEG/MRI registration', 0); + end + % 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). + elseif all(isfield(ChannelMat.SCS, {'NAS','LPA','RPA'})) && (length(ChannelMat.SCS.NAS) == 3) && (length(ChannelMat.SCS.LPA) == 3) && (length(ChannelMat.SCS.RPA) == 3) + % Convert to MRI SCS coordinates. + % To do this we need to apply the transformation computed above. + sMri = sMriOld; + sMri.SCS.NAS = DigToMriTransf(1:3,:) * [ChannelMat.SCS.NAS; 1]; + sMri.SCS.LPA = DigToMriTransf(1:3,:) * [ChannelMat.SCS.LPA; 1]; + sMri.SCS.RPA = DigToMriTransf(1:3,:) * [ChannelMat.SCS.RPA; 1]; + + % Compare with existing MRI fids, replace if changed, and update surfaces. + figure_mri('SaveMri', sMri); + + % Adjust transformation from fit above. MRI SCS now matches Digitized SCS. + DigToMriTransf = eye(4); + R = eye(3); + T = zeros(3,1); + end +end + + %% ===== 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 = {}; @@ -216,13 +267,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'; From c40c8fe426d4bb6d800b757b4cc90c23b97a7cdb Mon Sep 17 00:00:00 2001 From: Marc Lalancette <31040756+Moo-Marc@users.noreply.github.com> Date: Sat, 19 Feb 2022 23:48:54 -0500 Subject: [PATCH 02/47] can repeat new option by using remove first --- .../functions/process_adjust_coordinates.m | 128 ++++++++++-------- .../functions/process_headpoints_refine.m | 2 +- toolbox/sensors/channel_align_auto.m | 2 +- 3 files changed, 73 insertions(+), 59 deletions(-) diff --git a/toolbox/process/functions/process_adjust_coordinates.m b/toolbox/process/functions/process_adjust_coordinates.m index 17cfa566e..1239665d5 100644 --- a/toolbox/process/functions/process_adjust_coordinates.m +++ b/toolbox/process/functions/process_adjust_coordinates.m @@ -3,7 +3,9 @@ % % Native coordinates are based on system fiducials (e.g. MEG head coils), % whereas Brainstorm's SCS coordinates are based on the anatomical fiducial -% points set on the MRI. +% 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: @@ -65,6 +67,10 @@ 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 = 'Also ajust MRI nasion and ear points.'; sProcess.options.scs.Value = 0; @@ -121,8 +127,8 @@ 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. + % 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. @@ -151,11 +157,11 @@ 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. + % 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. [ChannelMat, NewChannelFiles, Failed] = ... ResetChannelFile(ChannelMat, NewChannelFiles, sInputs(iFile), sProcess); @@ -166,13 +172,12 @@ % ---------------------------------------------------------------- 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. + % 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 @@ -186,6 +191,26 @@ 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, we must edit the MRI + % history. + if sProcess.options.points.Value && sProcess.options.scs.Value + % Get subject in database, with subject directory + sSubject = bst_get('Subject', sInputs(iFile).FileName); + sMri = in_mri_bst(sSubject.Anatomy(sSubject.iAnatomy).FileName); + % Slightly change the string we use to verify if it was done: append " (hidden)". + for iH = find(strcmpi(sMri.History(:,3), 'Applied digitized anatomical fiducials')) + sMri.History{iH,3} = [sMri.History{iH,3}, ' (hidden)']; + end + try + bst_save(file_fullpath(sMri.FileName), sMri, 'v7'); + catch + bst_report('Error', sProcess, sInputs(iFile), ... + sprintf('Unable to save MRI file %s.', sMri.FileName)); + continue; + end + end end % reset channel file or remove transformations @@ -204,13 +229,8 @@ % Redundant, but makes sense to have it here also. bst_progress('text', 'Fitting head surface to points...'); - if sProcess.options.scs.Value - [ChannelMat, R, T, isSkip] = ... - channel_align_auto(sInputs(iFile).ChannelFile, ChannelMat, 0, 0, [], 1); % No warning or confirmation, adjust scs - else - [ChannelMat, R, T, isSkip] = ... - channel_align_auto(sInputs(iFile).ChannelFile, ChannelMat, 0, 0); % No warning or confirmation - end + [ChannelMat, R, T, isSkip] = channel_align_auto(sInputs(iFile).ChannelFile, ... + ChannelMat, 0, 0, sProcess.options.tolerance.Value, sProcess.options.scs.Value); % No warning or confirmation % ChannelFile needed to find subject and scalp surface, but not % used otherwise when ChannelMat is provided. if isSkip @@ -234,14 +254,14 @@ 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 -% if ~sProcess.options.remove.Value && sProcess.options.scs.Value +% if ~sProcess.options.remove.Value && sProcess.options.newpoints.Value % % This not yet implemented option could apply the Native to SCS % % transformation for head points loaded after the raw data was % % imported. @@ -402,9 +422,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) @@ -479,9 +498,9 @@ 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, ... @@ -554,9 +573,9 @@ 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) @@ -567,28 +586,24 @@ % 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 @@ -627,10 +642,9 @@ 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 = []; diff --git a/toolbox/process/functions/process_headpoints_refine.m b/toolbox/process/functions/process_headpoints_refine.m index 9cf995a6f..5c8622455 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):'; diff --git a/toolbox/sensors/channel_align_auto.m b/toolbox/sensors/channel_align_auto.m index 25e05ddff..cb465e946 100644 --- a/toolbox/sensors/channel_align_auto.m +++ b/toolbox/sensors/channel_align_auto.m @@ -207,7 +207,7 @@ % Check if already adjusted, in which case the transformation above is correct (identity if same head points). sMriOld = in_mri_bst(sSubject.Anatomy(sSubject.iAnatomy).FileName); % History string is set in figure_mri SaveMri. - if isfield(sMriOld, 'History') && ~isempty(sMriOld.History) && any(strcmpi(sMriOld.History(:,2), 'Applied digitized anatomical fiducials')) + if isfield(sMriOld, 'History') && ~isempty(sMriOld.History) && any(strcmpi(sMriOld.History(:,3), 'Applied digitized anatomical fiducials')) if isWarning bst_warning('Nasion and ear points already adjusted.', 'Automatic EEG-MEG/MRI registration', 0); end From c3894baf05445f2b750961368efd5b860b65eaee Mon Sep 17 00:00:00 2001 From: Marc Lalancette <31040756+Moo-Marc@users.noreply.github.com> Date: Sun, 20 Feb 2022 15:12:25 -0500 Subject: [PATCH 03/47] debugging --- toolbox/gui/figure_mri.m | 7 +-- .../functions/process_adjust_coordinates.m | 27 +++++----- toolbox/sensors/channel_align_auto.m | 53 +++++++++++-------- 3 files changed, 49 insertions(+), 38 deletions(-) diff --git a/toolbox/gui/figure_mri.m b/toolbox/gui/figure_mri.m index c5fe40fbc..bb53b3f38 100644 --- a/toolbox/gui/figure_mri.m +++ b/toolbox/gui/figure_mri.m @@ -2449,11 +2449,12 @@ function ButtonSave_Callback(hFig, varargin) % If the fiducials were modified 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 diff --git a/toolbox/process/functions/process_adjust_coordinates.m b/toolbox/process/functions/process_adjust_coordinates.m index 1239665d5..c3bd836ed 100644 --- a/toolbox/process/functions/process_adjust_coordinates.m +++ b/toolbox/process/functions/process_adjust_coordinates.m @@ -31,7 +31,6 @@ end - function sProcess = GetDescription() % Description of the process sProcess.Comment = 'Adjust coordinate system'; @@ -197,18 +196,21 @@ % history. if sProcess.options.points.Value && sProcess.options.scs.Value % Get subject in database, with subject directory - sSubject = bst_get('Subject', sInputs(iFile).FileName); + sSubject = bst_get('Subject', sInputs(iFile).SubjectFile); sMri = in_mri_bst(sSubject.Anatomy(sSubject.iAnatomy).FileName); % Slightly change the string we use to verify if it was done: append " (hidden)". - for iH = find(strcmpi(sMri.History(:,3), 'Applied digitized anatomical fiducials')) - sMri.History{iH,3} = [sMri.History{iH,3}, ' (hidden)']; - end - try - bst_save(file_fullpath(sMri.FileName), sMri, 'v7'); - catch - bst_report('Error', sProcess, sInputs(iFile), ... - sprintf('Unable to save MRI file %s.', sMri.FileName)); - continue; + 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(sMri.FileName), sMri, 'v7'); + catch + bst_report('Error', sProcess, sInputs(iFile), ... + sprintf('Unable to save MRI file %s.', sMri.FileName)); + continue; + end end end @@ -228,9 +230,10 @@ if ~sProcess.options.remove.Value && sProcess.options.points.Value % Redundant, but makes sense to have it here also. + Tolerance = sProcess.options.tolerance.Value{1} / 100; bst_progress('text', 'Fitting head surface to points...'); [ChannelMat, R, T, isSkip] = channel_align_auto(sInputs(iFile).ChannelFile, ... - ChannelMat, 0, 0, sProcess.options.tolerance.Value, sProcess.options.scs.Value); % No warning or confirmation + ChannelMat, 0, 0, Tolerance, sProcess.options.scs.Value); % No warning or confirmation % ChannelFile needed to find subject and scalp surface, but not % used otherwise when ChannelMat is provided. if isSkip diff --git a/toolbox/sensors/channel_align_auto.m b/toolbox/sensors/channel_align_auto.m index cb465e946..cc173ae77 100644 --- a/toolbox/sensors/channel_align_auto.m +++ b/toolbox/sensors/channel_align_auto.m @@ -101,7 +101,7 @@ % Get subject [sSubject, iSubject] = bst_get('Subject', sStudy.BrainStormSubject); % Check if default anatomy. (Usually also checked before calling this function.) -if iSubject == 0 +if sSubject.UseDefaultAnat if isWarning bst_error('Digitized nasion and ear points cannot be applied to default anatomy.', 'Automatic EEG-MEG/MRI registration', 0); end @@ -204,31 +204,38 @@ %% ===== ADJUST MRI FIDUCIALS AND SCS ===== if isAdjustScs - % Check if already adjusted, in which case the transformation above is correct (identity if same head points). - sMriOld = in_mri_bst(sSubject.Anatomy(sSubject.iAnatomy).FileName); - % History string is set in figure_mri SaveMri. - if isfield(sMriOld, 'History') && ~isempty(sMriOld.History) && any(strcmpi(sMriOld.History(:,3), 'Applied digitized anatomical fiducials')) - if isWarning - bst_warning('Nasion and ear points already adjusted.', 'Automatic EEG-MEG/MRI registration', 0); - end + % Check if already adjusted, in which case the transformation above is correct (identity if same head points). + sMriOld = in_mri_bst(sSubject.Anatomy(sSubject.iAnatomy).FileName); + % History string is set in figure_mri SaveMri. + if isfield(sMriOld, 'History') && ~isempty(sMriOld.History) && any(strcmpi(sMriOld.History(:,3), 'Applied digitized anatomical fiducials')) + if isWarning + bst_warning('Nasion and ear points already adjusted.', 'Automatic EEG-MEG/MRI registration', 0); + end % 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). - elseif all(isfield(ChannelMat.SCS, {'NAS','LPA','RPA'})) && (length(ChannelMat.SCS.NAS) == 3) && (length(ChannelMat.SCS.LPA) == 3) && (length(ChannelMat.SCS.RPA) == 3) - % Convert to MRI SCS coordinates. - % To do this we need to apply the transformation computed above. - sMri = sMriOld; - sMri.SCS.NAS = DigToMriTransf(1:3,:) * [ChannelMat.SCS.NAS; 1]; - sMri.SCS.LPA = DigToMriTransf(1:3,:) * [ChannelMat.SCS.LPA; 1]; - sMri.SCS.RPA = DigToMriTransf(1:3,:) * [ChannelMat.SCS.RPA; 1]; - - % Compare with existing MRI fids, replace if changed, and update surfaces. - figure_mri('SaveMri', sMri); + elseif all(isfield(ChannelMat.SCS, {'NAS','LPA','RPA'})) && (length(ChannelMat.SCS.NAS) == 3) && (length(ChannelMat.SCS.LPA) == 3) && (length(ChannelMat.SCS.RPA) == 3) + % Convert to MRI SCS coordinates. + % To do this we need to apply the transformation computed above. + sMri = sMriOld; + sMri.SCS.NAS = (DigToMriTransf(1:3,:) * [ChannelMat.SCS.NAS'; 1])'; + sMri.SCS.LPA = (DigToMriTransf(1:3,:) * [ChannelMat.SCS.LPA'; 1])'; + sMri.SCS.RPA = (DigToMriTransf(1:3,:) * [ChannelMat.SCS.RPA'; 1])'; + % Then convert to MRI coordinates (mm), this is how sMri.SCS is saved. + 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 + [unused, sMri] = cs_compute(sMri, 'scs'); - % Adjust transformation from fit above. MRI SCS now matches Digitized SCS. - DigToMriTransf = eye(4); - R = eye(3); - T = zeros(3,1); - end + % Compare with existing MRI fids, replace if changed, and update surfaces. + sMri.FileName = sSubject.Anatomy(sSubject.iAnatomy).FileName; + figure_mri('SaveMri', sMri); + + % Adjust transformation from headpoints fit above. MRI SCS now matches digitized SCS (defined from same points). + DigToMriTransf = eye(4); + R = eye(3); + T = zeros(3,1); + end end From b95846ea88ab256cf600e866acbf7f5e0bd8e459 Mon Sep 17 00:00:00 2001 From: Marc Lalancette <31040756+Moo-Marc@users.noreply.github.com> Date: Sun, 20 Feb 2022 15:20:59 -0500 Subject: [PATCH 04/47] add option to process_refine --- toolbox/process/functions/process_headpoints_refine.m | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/toolbox/process/functions/process_headpoints_refine.m b/toolbox/process/functions/process_headpoints_refine.m index 5c8622455..0e9d85a8a 100644 --- a/toolbox/process/functions/process_headpoints_refine.m +++ b/toolbox/process/functions/process_headpoints_refine.m @@ -47,6 +47,9 @@ sProcess.options.tolerance.Comment = 'Tolerance (outlier points to ignore):'; sProcess.options.tolerance.Type = 'value'; sProcess.options.tolerance.Value = {0, '%', 0}; + sProcess.options.scs.Type = 'checkbox'; + sProcess.options.scs.Comment = 'Also ajust MRI nasion and ear points.'; + sProcess.options.scs.Value = 0; end @@ -65,7 +68,7 @@ % 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); + [ChannelMat, R, T, isSkip, isUserCancel, strReport] = channel_align_auto(uniqueChan{i}, [], 0, 0, tolerance, sProcess.options.scs.Value); if ~isempty(strReport) bst_report('Info', sProcess, sInputs, strReport); end From cf5a1410011dec1171bc780f61d5b212d5ae9c15 Mon Sep 17 00:00:00 2001 From: Marc Lalancette <31040756+Moo-Marc@users.noreply.github.com> Date: Sun, 20 Feb 2022 16:09:17 -0500 Subject: [PATCH 05/47] improved report --- toolbox/process/functions/process_adjust_coordinates.m | 10 ++++++---- toolbox/process/functions/process_headpoints_refine.m | 4 ++-- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/toolbox/process/functions/process_adjust_coordinates.m b/toolbox/process/functions/process_adjust_coordinates.m index c3bd836ed..c6594c320 100644 --- a/toolbox/process/functions/process_adjust_coordinates.m +++ b/toolbox/process/functions/process_adjust_coordinates.m @@ -232,13 +232,15 @@ Tolerance = sProcess.options.tolerance.Value{1} / 100; bst_progress('text', 'Fitting head surface to points...'); - [ChannelMat, R, T, isSkip] = channel_align_auto(sInputs(iFile).ChannelFile, ... + [ChannelMat, R, T, isSkip, isUserCancel, strReport] = channel_align_auto(sInputs(iFile).ChannelFile, ... ChannelMat, 0, 0, Tolerance, sProcess.options.scs.Value); % 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 ~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; end diff --git a/toolbox/process/functions/process_headpoints_refine.m b/toolbox/process/functions/process_headpoints_refine.m index 0e9d85a8a..5d862fbc8 100644 --- a/toolbox/process/functions/process_headpoints_refine.m +++ b/toolbox/process/functions/process_headpoints_refine.m @@ -64,13 +64,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, sProcess.options.scs.Value); 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 From 19fa381bbd96f64397f281008c4bb0f8e9c51fe5 Mon Sep 17 00:00:00 2001 From: Marc Lalancette <31040756+Moo-Marc@users.noreply.github.com> Date: Fri, 25 Feb 2022 11:23:31 -0500 Subject: [PATCH 06/47] improved scalp surface fit --- toolbox/math/bst_meshfit.m | 222 +++++++++++------- .../functions/process_adjust_coordinates.m | 5 +- toolbox/sensors/channel_align_auto.m | 24 +- 3 files changed, 151 insertions(+), 100 deletions(-) diff --git a/toolbox/math/bst_meshfit.m b/toolbox/math/bst_meshfit.m index 5ad40a256..5868809f4 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,63 @@ % % Authors: Qianqian Fang, 2008 % Francois Tadel, 2013-2021 +% Marc Lalancette, 2022 + +% Coordinates are in m. + +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 = normr(cross(EdgesV(:,:,1), EdgesV(:,:,2))); +%FaceArea = sqrt(sum(FaceNormalsA.^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.1 mm displacement. +OptimOptions = optimoptions(@fminunc, 'MaxFunctionEvaluations', 1000, 'MaxIterations', 200, ... + 'FiniteDifferenceStepSize', 1e-3, ... + 'FunctionTolerance', 1e-4, 'StepTolerance', 5e-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 +105,94 @@ 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); + 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" + Cost = sum(Dist); + for iP = 1:Outliers + [MaxD, iMaxD] = max(Dist); + Dist(iMaxD) = 0; + 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 + % 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 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 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 - % 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); + + 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/process/functions/process_adjust_coordinates.m b/toolbox/process/functions/process_adjust_coordinates.m index c6594c320..5e4214b97 100644 --- a/toolbox/process/functions/process_adjust_coordinates.m +++ b/toolbox/process/functions/process_adjust_coordinates.m @@ -197,6 +197,7 @@ if sProcess.options.points.Value && 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')); @@ -205,10 +206,10 @@ sMri.History{iHist(iH),3} = [sMri.History{iHist(iH),3}, ' (hidden)']; end try - bst_save(file_fullpath(sMri.FileName), sMri, 'v7'); + bst_save(file_fullpath(MriFile), sMri, 'v7'); catch bst_report('Error', sProcess, sInputs(iFile), ... - sprintf('Unable to save MRI file %s.', sMri.FileName)); + sprintf('Unable to save MRI file %s.', MriFile)); continue; end end diff --git a/toolbox/sensors/channel_align_auto.m b/toolbox/sensors/channel_align_auto.m index cc173ae77..5e70469c9 100644 --- a/toolbox/sensors/channel_align_auto.m +++ b/toolbox/sensors/channel_align_auto.m @@ -169,19 +169,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'); From 02f02d45afcdd38d8dc3f622e6ef56bfb6901612 Mon Sep 17 00:00:00 2001 From: Marc Lalancette <31040756+Moo-Marc@users.noreply.github.com> Date: Wed, 2 Mar 2022 16:05:11 -0500 Subject: [PATCH 07/47] wip head surface & fit head points --- toolbox/anatomy/tess_isohead.m | 213 ++++++++++++++++++++++----- toolbox/math/bst_meshfit.m | 31 ++-- toolbox/sensors/channel_align_auto.m | 8 + 3 files changed, 202 insertions(+), 50 deletions(-) diff --git a/toolbox/anatomy/tess_isohead.m b/toolbox/anatomy/tess_isohead.m index 5c5343eb5..25c0c560c 100644 --- a/toolbox/anatomy/tess_isohead.m +++ b/toolbox/anatomy/tess_isohead.m @@ -7,12 +7,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 @@ -43,7 +43,7 @@ end %% ===== LOAD MRI ===== -% Load MRI +% Load MRI bst_progress('start', 'Generate head surface', 'Loading MRI...'); sMri = bst_memory('LoadMri', MriFile); bst_progress('stop'); @@ -94,24 +94,62 @@ % 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); +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); +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); +% if (erodeFactor > 0) +% headmask = headmask & ~mri_dilate(~headmask, erodeFactor); +% headmask = mri_dilate(headmask, erodeFactor); +% end +% bst_progress('inc', 10); + +% Remove isolated voxels (dots or holes) from 5 out of 6 sides +% isFill = false(size(headmask)); +% isFill(2:end-1,2:end-1,2:end-1) = (headmask(1:end-2,2:end-1,2:end-1) + headmask(3:end,2:end-1,2:end-1) + ... +% headmask(2:end-1,1:end-2,2:end-1) + headmask(2:end-1,3:end,2:end-1) + ... +% headmask(2:end-1,2:end-1,1:end-2) + headmask(2:end-1,2:end-1,3:end)) >= 5 & ... +% ~headmask(2:end-1,2:end-1,2:end-1); +% headmask(isFill) = 1; +% isFill = false(size(headmask)); +% isFill(2:end-1,2:end-1,2:end-1) = (headmask(1:end-2,2:end-1,2:end-1) + headmask(3:end,2:end-1,2:end-1) + ... +% headmask(2:end-1,1:end-2,2:end-1) + headmask(2:end-1,3:end,2:end-1) + ... +% headmask(2:end-1,2:end-1,1:end-2) + headmask(2:end-1,2:end-1,3:end)) <= 1 & ... +% headmask(2:end-1,2:end-1,2:end-1); +% headmask(isFill) = 0; + +% Fill neck holes (bones, etc.) where it is cut at edge of volume. +bst_progress('text', 'Filling holes and removing disconnected parts...'); +for iDim = 1:3 + % Swap slice dimension into first position. + 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 + Slice = TempMask(2, :, :); + TempMask(2, :, :) = Slice | (Fill(Slice, 2) & Fill(Slice, 3)); + Slice = TempMask(end-1, :, :); + TempMask(end-1, :, :) = Slice | (Fill(Slice, 2) & Fill(Slice, 3)); + % Permute back + headmask = permute(TempMask, Perm); 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); +InsideMask = (Fill(headmask, 1) & Fill(headmask, 2) & Fill(headmask, 3)); +headmask = InsideMask | (Dilate(InsideMask) & headmask); +% Keep only central connected volume (trim "beard" or bubbles) +headmask = CenterSpread(headmask); +bst_progress('inc', 20); % view_mri_slices(headmask, 'x', 20) @@ -119,45 +157,66 @@ %% ===== CREATE SURFACE ===== % Compute isosurface bst_progress('text', 'Creating isosurface...'); +% Could have avoided x-y flip by specifying XYZ in isosurface... [sHead.Faces, sHead.Vertices] = mri_isosurface(headmask, 0.5); -bst_progress('inc', 10); +% Flip x-y back to our voxel coordinates. +sHead.Vertices = sHead.Vertices(:, [2, 1, 3]); +bst_progress('inc', 20); % 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)); - bst_progress('inc', 10); -end +% 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)); +% bst_progress('inc', 10); +% 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); +bst_progress('inc', 20); + +% Clean final surface +% This is very strange, it doesn't look at face locations, only the normals. +% After isosurface, many many faces are parallel. +% bst_progress('text', 'Fill: Cleaning surface...'); +% [sHead.Vertices, sHead.Faces] = tess_clean(sHead.Vertices, sHead.Faces); + +% 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. +sHead.Vertices = SurfaceSmooth(sHead.Vertices, sHead.Faces, 2, [], 50, [], false); % voxel/smoothing size, iterations, verbose % 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); + bst_progress('inc', 20); 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); +sHead.Vertices = cs_convert(sMri, 'voxel', 'scs', sHead.Vertices); +% Flip face order to Brainstorm convention +sHead.Faces = sHead.Faces(:,[2,1,3]); + +% % Smooth isosurface +% bst_progress('text', 'Fill: Smoothing surface...'); +% VertConn = tess_vertconn(Vertices, Faces); +% Vertices = tess_smooth(Vertices, 1, 10, VertConn, 0); +% % One final round of smoothing +% VertConn = tess_vertconn(Vertices, Faces); +% Vertices = tess_smooth(Vertices, 0.2, 3, VertConn, 0); +% +% % 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); % end %% ===== SAVE FILES ===== bst_progress('text', 'Saving new file...'); +bst_progress('inc', 15); % Create output filenames ProtocolInfo = bst_get('ProtocolInfo'); SurfaceDir = bst_fullfile(ProtocolInfo.SUBJECTS, bst_fileparts(MriFile)); @@ -174,7 +233,83 @@ % Close, success bst_progress('stop'); +end +%% ===== Subfunctions ===== +function mask = Fill(mask, dim) +% Modified to exclude boundaries, so we can get rid of external junk as well as +% internal holes easily. +% Initialize two accumulators, for the two directions +acc1 = false(size(mask)); +acc2 = false(size(mask)); +n = size(mask,dim); +% Process in required direction +switch dim + case 1 + for i = 2:n + acc1(i,:,:) = acc1(i-1,:,:) | mask(i-1,:,:); + end + for i = n-1:-1:1 + acc2(i,:,:) = acc2(i+1,:,:) | mask(i+1,:,:); + end + case 2 + for i = 2:n + acc1(:,i,:) = acc1(:,i-1,:) | mask(:,i-1,:); + end + for i = n-1:-1:1 + acc2(:,i,:) = acc2(:,i+1,:) | mask(:,i+1,:); + end + case 3 + for i = 2:n + acc1(:,:,i) = acc1(:,:,i-1) | mask(:,:,i-1); + end + for i = n-1:-1:1 + acc2(:,:,i) = acc2(:,:,i+1) | mask(:,:,i+1); + end +end +% Combine two accumulators +mask = acc1 & acc2; +end + +function mask = Dilate(mask) +% Dilate by 1 voxel in 6 directions, except at volume edges +mask(2:end-1,2:end-1,2:end-1) = mask(1:end-2,2:end-1,2:end-1) | mask(3:end,2:end-1,2:end-1) | ... + mask(2:end-1,1:end-2,2:end-1) | mask(2:end-1,3:end,2:end-1) | ... + mask(2:end-1,2:end-1,1:end-2) | mask(2:end-1,2:end-1,3:end); +end +function OutMask = CenterSpread(InMask) +% Similar to Fill, but from a central starting point and intersecting with the input "reference" mask. +OutMask = false(size(InMask)); +iStart = round(size(OutMask)/2); +nVox = size(OutMask); +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); + 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 +end diff --git a/toolbox/math/bst_meshfit.m b/toolbox/math/bst_meshfit.m index 5868809f4..0ec577892 100644 --- a/toolbox/math/bst_meshfit.m +++ b/toolbox/math/bst_meshfit.m @@ -44,7 +44,7 @@ % Marc Lalancette, 2022 % Coordinates are in m. - +PenalizeInside = true; if nargin < 4 || isempty(Outliers) Outliers = 0; end @@ -84,10 +84,10 @@ % Fit points % [R,T,newP] = fit_points(Vertices, VertNorm, P, dt); % Do optimization -% Stop at 0.1 mm total distance, or 0.1 mm displacement. +% 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', 5e-2, 'Display', 'none'); % 'OptimalityTolerance', 1e-15, 'final-detailed' + 'FunctionTolerance', 1e-4, 'StepTolerance', 2e-2, 'Display', 'none'); % 'OptimalityTolerance', 1e-15, 'final-detailed' BestParams = fminunc(@CostFunction, InitParams, OptimOptions); [R,T,newP] = Transform(BestParams, P); @@ -110,16 +110,25 @@ function [Cost, Dist] = CostFunction(Params) [~,~,Points] = Transform(Params, P); Dist = PointSurfDistance(Points); - 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" + 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 - [MaxD, iMaxD] = max(Dist); - Dist(iMaxD) = 0; + 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 diff --git a/toolbox/sensors/channel_align_auto.m b/toolbox/sensors/channel_align_auto.m index 5e70469c9..176f749aa 100644 --- a/toolbox/sensors/channel_align_auto.m +++ b/toolbox/sensors/channel_align_auto.m @@ -94,6 +94,13 @@ end % M x 3 matrix of head points HP = double(HeadPoints.Loc'); +% % Add anatomical points. +% 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) +% HP(end+1,:) = ChannelMat.SCS.NAS; +% HP(end+1,:) = ChannelMat.SCS.LPA; +% HP(end+1,:) = ChannelMat.SCS.RPA; +% end %% ===== LOAD SCALP SURFACE ===== % Get study @@ -213,6 +220,7 @@ end % 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. elseif all(isfield(ChannelMat.SCS, {'NAS','LPA','RPA'})) && (length(ChannelMat.SCS.NAS) == 3) && (length(ChannelMat.SCS.LPA) == 3) && (length(ChannelMat.SCS.RPA) == 3) % Convert to MRI SCS coordinates. % To do this we need to apply the transformation computed above. From f09944cb32523cfab024f1dd85f756af2bb2b84b Mon Sep 17 00:00:00 2001 From: Marc Lalancette <31040756+Moo-Marc@users.noreply.github.com> Date: Thu, 2 Jun 2022 11:44:52 -0400 Subject: [PATCH 08/47] threshold input for tess_isohead --- toolbox/anatomy/tess_isohead.m | 27 +++++++++++++++++++-------- 1 file changed, 19 insertions(+), 8 deletions(-) diff --git a/toolbox/anatomy/tess_isohead.m b/toolbox/anatomy/tess_isohead.m index 25c0c560c..21a8bc5f4 100644 --- a/toolbox/anatomy/tess_isohead.m +++ b/toolbox/anatomy/tess_isohead.m @@ -1,4 +1,4 @@ -function [HeadFile, iSurface] = tess_isohead(iSubject, nVertices, erodeFactor, fillFactor, Comment) +function [HeadFile, iSurface] = tess_isohead(iSubject, nVertices, erodeFactor, fillFactor, Comment, bgLevel) % 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) @@ -29,6 +29,9 @@ HeadFile = []; iSurface = []; % Parse inputs +if (nargin < 6) || isempty(bgLevel) + bgLevel = []; +end if (nargin < 5) || isempty(Comment) Comment = []; end @@ -80,7 +83,7 @@ if isempty(bgLevel) bgLevel = sMri.Histogram.bgLevel; end -else +elseif isempty(bgLevel) bgLevel = sMri.Histogram.bgLevel; end % Check parameters values @@ -149,7 +152,7 @@ headmask = InsideMask | (Dilate(InsideMask) & headmask); % Keep only central connected volume (trim "beard" or bubbles) headmask = CenterSpread(headmask); -bst_progress('inc', 20); +bst_progress('inc', 15); % view_mri_slices(headmask, 'x', 20) @@ -161,7 +164,7 @@ [sHead.Faces, sHead.Vertices] = mri_isosurface(headmask, 0.5); % Flip x-y back to our voxel coordinates. sHead.Vertices = sHead.Vertices(:, [2, 1, 3]); -bst_progress('inc', 20); +bst_progress('inc', 10); % Downsample to a maximum number of vertices % maxIsoVert = 60000; % if (length(sHead.Vertices) > maxIsoVert) @@ -172,7 +175,7 @@ % Remove small objects bst_progress('text', 'Removing small patches...'); [sHead.Vertices, sHead.Faces] = tess_remove_small(sHead.Vertices, sHead.Faces); -bst_progress('inc', 20); +bst_progress('inc', 15); % Clean final surface % This is very strange, it doesn't look at face locations, only the normals. @@ -183,14 +186,22 @@ % 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. -sHead.Vertices = SurfaceSmooth(sHead.Vertices, sHead.Faces, 2, [], 50, [], false); % voxel/smoothing size, iterations, verbose +% 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 +bst_progress('inc', 20); % 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', 20); + bst_progress('inc', 15); end + +bst_progress('text', 'Smoothing...'); +sHead.Vertices = SurfaceSmooth(sHead.Vertices, sHead.Faces, 2, [], 45, 0, false); % voxel/smoothing size, iterations, freedom (normal), verbose +bst_progress('inc', 10); + % Convert to SCS sHead.Vertices = cs_convert(sMri, 'voxel', 'scs', sHead.Vertices); % Flip face order to Brainstorm convention @@ -216,7 +227,7 @@ %% ===== SAVE FILES ===== bst_progress('text', 'Saving new file...'); -bst_progress('inc', 15); +bst_progress('inc', 10); % Create output filenames ProtocolInfo = bst_get('ProtocolInfo'); SurfaceDir = bst_fullfile(ProtocolInfo.SUBJECTS, bst_fileparts(MriFile)); From c778b8a2b5d4400e70d15a9cc25e2bf0fe3641ad Mon Sep 17 00:00:00 2001 From: Marc Lalancette <31040756+Moo-Marc@users.noreply.github.com> Date: Tue, 9 Aug 2022 15:08:08 -0400 Subject: [PATCH 09/47] head shape from spatial gradient threshold --- toolbox/anatomy/mri_histogram.m | 32 +++++++++++++------- toolbox/anatomy/tess_isohead.m | 51 +++++++++++++++++++++++++++++--- toolbox/gui/view_mri_histogram.m | 25 ++++++++++------ 3 files changed, 85 insertions(+), 23 deletions(-) diff --git a/toolbox/anatomy/mri_histogram.m b/toolbox/anatomy/mri_histogram.m index 6d985006f..1badf8695 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 @@ -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 @@ -237,12 +237,12 @@ 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 exist) [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 +251,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 +341,4 @@ end - \ No newline at end of file diff --git a/toolbox/anatomy/tess_isohead.m b/toolbox/anatomy/tess_isohead.m index 4e59e93c0..53f8876e5 100644 --- a/toolbox/anatomy/tess_isohead.m +++ b/toolbox/anatomy/tess_isohead.m @@ -1,4 +1,4 @@ -function [HeadFile, iSurface] = tess_isohead(iSubject, nVertices, erodeFactor, fillFactor, Comment, bgLevel) +function [HeadFile, iSurface] = tess_isohead(iSubject, nVertices, erodeFactor, fillFactor, Comment, bgLevel, isGradient) % 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) @@ -33,6 +33,9 @@ iSurface = []; isSave = true; % Parse inputs +if (nargin < 7) || isempty(isGradient) + isGradient = false; +end if (nargin < 6) || isempty(bgLevel) bgLevel = []; end @@ -85,7 +88,7 @@ %% ===== 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)}); + 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(sMri.Histogram.bgLevel)}); % If user cancelled: return if isempty(res) return @@ -98,7 +101,7 @@ if isempty(bgLevel) bgLevel = sMri.Histogram.bgLevel; end -elseif isempty(bgLevel) +elseif isempty(bgLevel) && ~isGradient bgLevel = sMri.Histogram.bgLevel; end % Check parameters values @@ -112,7 +115,39 @@ % 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; +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)); + 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(1,:,:); headmask(end,:,:) = 0; %*headmask(1,:,:); @@ -344,3 +379,11 @@ nOut = sum(OutMask(:)); 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/gui/view_mri_histogram.m b/toolbox/gui/view_mri_histogram.m index b2a13ff58..daa8b4f78 100644 --- a/toolbox/gui/view_mri_histogram.m +++ b/toolbox/gui/view_mri_histogram.m @@ -27,17 +27,24 @@ % % Authors: Francois Tadel, 2006-2020 -%% ===== COMPUTE HISTOGRAM ===== +%% ===== 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); + else + Histogram = MRI.Histogram; + end +end %% ===== DISPLAY HISTOGRAM ===== % Create figure From 529fb4431404852c46f7bdb727e045972f086fd0 Mon Sep 17 00:00:00 2001 From: Marc Lalancette <31040756+Moo-Marc@users.noreply.github.com> Date: Tue, 9 Aug 2022 15:10:09 -0400 Subject: [PATCH 10/47] small fix for old pos files --- toolbox/gui/figure_3d.m | 30 ++++++++++++++++++++++++++-- toolbox/gui/view_headpoints.m | 2 ++ toolbox/io/in_channel_pos.m | 4 ++-- toolbox/sensors/channel_align_auto.m | 1 + 4 files changed, 33 insertions(+), 4 deletions(-) diff --git a/toolbox/gui/figure_3d.m b/toolbox/gui/figure_3d.m index 28b757236..275ca9945 100644 --- a/toolbox/gui/figure_3d.m +++ b/toolbox/gui/figure_3d.m @@ -3721,9 +3721,9 @@ function ViewHeadPoints(hFig, isVisible) % If head points graphic objects already exist: set the "Visible" property if ~isempty(hHeadPointsMarkers) if isVisible - set([hHeadPointsMarkers hHeadPointsLabels], 'Visible', 'on'); + set([hHeadPointsMarkers(:)' hHeadPointsLabels(:)'], 'Visible', 'on'); else - set([hHeadPointsMarkers hHeadPointsLabels], 'Visible', 'off'); + set([hHeadPointsMarkers(:)' hHeadPointsLabels(:)'], 'Visible', 'off'); end % If head points objects were not created yet: create them elseif isVisible @@ -3801,6 +3801,31 @@ function ViewHeadPoints(hFig, isVisible) end % Plot extra head points if ~isempty(iExtra) + % If distances, color code points. + if isfield(HeadPoints, 'Dist') + patch(digLoc(iExtra,1), digLoc(iExtra,2), digLoc(iExtra,3), ... + HeadPoints.Dist(iExtra)*1000, ... % mm + 'Marker', 'o', ... + 'MarkerSize', 6, ... + 'FaceColor', 'none', ... + 'EdgeColor', 'none', ... + 'MarkerFaceColor', 'flat', ... + 'MarkerEdgeColor', 'flat', ... + 'Parent', hAxes, ... + 'UserData', iExtra, ... + 'Tag', 'HeadPointsMarkers'); + ColormapType = 'stat1'; + set(hAxes, 'CLim', [0, 10]); + bst_colormaps('AddColormapToFigure', hFig, ColormapType); + bst_colormaps('ConfigureColorbar', hFig, ColormapType, 'stat', 'mm'); +% hColorbar = findobj(hFig, '-depth', 1, 'Tag', 'Colorbar'); +% set(hColorbar, 'YTick', 0:64:256, 'YTickLabel', {'0', '2.5', '5', '7.5', '>10'}); +% xlabel(hColorbar, 'mm'); + %sColormap = bst_colormaps('GetColormap', hFig); + bst_colormaps('SetColorbarVisible', hFig, 1); + + else + % Display markers line(digLoc(iExtra,1), digLoc(iExtra,2), digLoc(iExtra,3), ... 'Parent', hAxes, ... @@ -3812,6 +3837,7 @@ function ViewHeadPoints(hFig, isVisible) 'Marker', 'o', ... 'UserData', iExtra, ... 'Tag', 'HeadPointsMarkers'); + end end end end diff --git a/toolbox/gui/view_headpoints.m b/toolbox/gui/view_headpoints.m index 09f0168e7..9a97adf2d 100644 --- a/toolbox/gui/view_headpoints.m +++ b/toolbox/gui/view_headpoints.m @@ -58,6 +58,7 @@ HeadPoints = channel_get_headpoints(ChannelFile, 1); if isempty(HeadPoints) bst_error('No digitized head points to display for this file.', 'Add head points', 0); + hFig = []; iDS = []; iFig = []; return; end % Load full channel file @@ -65,6 +66,7 @@ % View scalp surface if available [hFig, iDS, iFig] = view_surface(ScalpFile, .2); +figure_3d('SetStandardView', hFig, 'front'); % Extend figure and dataset for this particular channel file GlobalData.DataSet(iDS).StudyFile = sStudy.FileName; diff --git a/toolbox/io/in_channel_pos.m b/toolbox/io/in_channel_pos.m index 5cff8bffd..00441f657 100644 --- a/toolbox/io/in_channel_pos.m +++ b/toolbox/io/in_channel_pos.m @@ -53,7 +53,7 @@ ChannelMat.HeadPoints.Type{end+1} = 'EXTRA'; case {4,7} % Name X Y Z ... => Headpoint or fiducial - if ~isnan(str2double(ss{1})) + if ~isnan(str2double(ss{1})) || strcmpi(ss{1}, 'EXTRA') ChannelMat.HeadPoints.Label{end+1} = 'EXTRA'; ChannelMat.HeadPoints.Type{end+1} = 'EXTRA'; else @@ -66,7 +66,7 @@ end ChannelMat.HeadPoints.Loc(:,end+1) = cellfun(@str2num, ss(2:4))' ./ 100; case 5 - % Indice Name X Y Z => EEG + % Index Name X Y Z => EEG i = length(ChannelMat.Channel) + 1; ChannelMat.Channel(i).Type = 'EEG'; ChannelMat.Channel(i).Name = ss{2}; diff --git a/toolbox/sensors/channel_align_auto.m b/toolbox/sensors/channel_align_auto.m index 86671ea89..910ae54a8 100644 --- a/toolbox/sensors/channel_align_auto.m +++ b/toolbox/sensors/channel_align_auto.m @@ -176,6 +176,7 @@ nRemove = 0; end % Current position cannot be optimized +ChannelMat.HeadPoints.Dist = dist'; if isempty(R) bst_progress('stop'); isSkip = 1; From 59317182e9d2f9f01c68aaca73da60be48327cb0 Mon Sep 17 00:00:00 2001 From: Marc Lalancette <31040756+Moo-Marc@users.noreply.github.com> Date: Tue, 9 Aug 2022 15:16:07 -0400 Subject: [PATCH 11/47] cleanup --- toolbox/gui/figure_3d.m | 6 +----- toolbox/sensors/channel_align_auto.m | 3 ++- 2 files changed, 3 insertions(+), 6 deletions(-) diff --git a/toolbox/gui/figure_3d.m b/toolbox/gui/figure_3d.m index 275ca9945..ebb935f1a 100644 --- a/toolbox/gui/figure_3d.m +++ b/toolbox/gui/figure_3d.m @@ -3814,14 +3814,10 @@ function ViewHeadPoints(hFig, isVisible) 'Parent', hAxes, ... 'UserData', iExtra, ... 'Tag', 'HeadPointsMarkers'); - ColormapType = 'stat1'; set(hAxes, 'CLim', [0, 10]); + ColormapType = 'stat1'; bst_colormaps('AddColormapToFigure', hFig, ColormapType); bst_colormaps('ConfigureColorbar', hFig, ColormapType, 'stat', 'mm'); -% hColorbar = findobj(hFig, '-depth', 1, 'Tag', 'Colorbar'); -% set(hColorbar, 'YTick', 0:64:256, 'YTickLabel', {'0', '2.5', '5', '7.5', '>10'}); -% xlabel(hColorbar, 'mm'); - %sColormap = bst_colormaps('GetColormap', hFig); bst_colormaps('SetColorbarVisible', hFig, 1); else diff --git a/toolbox/sensors/channel_align_auto.m b/toolbox/sensors/channel_align_auto.m index 910ae54a8..a2a74a150 100644 --- a/toolbox/sensors/channel_align_auto.m +++ b/toolbox/sensors/channel_align_auto.m @@ -175,8 +175,9 @@ else nRemove = 0; end -% Current position cannot be optimized +% Save point-to-scalp distances for display and quality control ChannelMat.HeadPoints.Dist = dist'; +% Current position cannot be optimized if isempty(R) bst_progress('stop'); isSkip = 1; From 6133b7ff0e9c5f153bcbbb3975598a87e93c618a Mon Sep 17 00:00:00 2001 From: Francois Date: Wed, 10 Aug 2022 15:09:34 +0200 Subject: [PATCH 12/47] Fix indentation --- toolbox/gui/figure_3d.m | 50 ++++++++++++++++++++--------------------- 1 file changed, 24 insertions(+), 26 deletions(-) diff --git a/toolbox/gui/figure_3d.m b/toolbox/gui/figure_3d.m index ebb935f1a..45d5bba30 100644 --- a/toolbox/gui/figure_3d.m +++ b/toolbox/gui/figure_3d.m @@ -3801,38 +3801,36 @@ function ViewHeadPoints(hFig, isVisible) end % Plot extra head points if ~isempty(iExtra) - % If distances, color code points. - if isfield(HeadPoints, 'Dist') + % If distances are available, color-code the points + if isfield(HeadPoints, 'Dist') && ~isempty(HeadPoints.Dist) patch(digLoc(iExtra,1), digLoc(iExtra,2), digLoc(iExtra,3), ... - HeadPoints.Dist(iExtra)*1000, ... % mm - 'Marker', 'o', ... - 'MarkerSize', 6, ... - 'FaceColor', 'none', ... - 'EdgeColor', 'none', ... - 'MarkerFaceColor', 'flat', ... - 'MarkerEdgeColor', 'flat', ... - 'Parent', hAxes, ... - 'UserData', iExtra, ... - 'Tag', 'HeadPointsMarkers'); + HeadPoints.Dist(iExtra)*1000, ... % mm + 'Marker', 'o', ... + 'MarkerSize', 6, ... + 'FaceColor', 'none', ... + 'EdgeColor', 'none', ... + 'MarkerFaceColor', 'flat', ... + 'MarkerEdgeColor', 'flat', ... + 'Parent', hAxes, ... + 'UserData', iExtra, ... + 'Tag', 'HeadPointsMarkers'); set(hAxes, 'CLim', [0, 10]); ColormapType = 'stat1'; bst_colormaps('AddColormapToFigure', hFig, ColormapType); bst_colormaps('ConfigureColorbar', hFig, ColormapType, 'stat', 'mm'); bst_colormaps('SetColorbarVisible', hFig, 1); - - else - - % Display markers - line(digLoc(iExtra,1), digLoc(iExtra,2), digLoc(iExtra,3), ... - 'Parent', hAxes, ... - 'LineWidth', 2, ... - 'LineStyle', 'none', ... - 'MarkerFaceColor', [.3 1 .3], ... - 'MarkerEdgeColor', [.4 .7 .4], ... - 'MarkerSize', 6, ... - 'Marker', 'o', ... - 'UserData', iExtra, ... - 'Tag', 'HeadPointsMarkers'); + else + % Display markers + line(digLoc(iExtra,1), digLoc(iExtra,2), digLoc(iExtra,3), ... + 'Parent', hAxes, ... + 'LineWidth', 2, ... + 'LineStyle', 'none', ... + 'MarkerFaceColor', [.3 1 .3], ... + 'MarkerEdgeColor', [.4 .7 .4], ... + 'MarkerSize', 6, ... + 'Marker', 'o', ... + 'UserData', iExtra, ... + 'Tag', 'HeadPointsMarkers'); end end end From 8f967a9107b53da6c927fd309409ebcb3e2694f1 Mon Sep 17 00:00:00 2001 From: ftadel Date: Wed, 10 Aug 2022 16:51:17 +0200 Subject: [PATCH 13/47] Recompute distance dynamically --- toolbox/gui/figure_3d.m | 29 +++++++++++++++++++--------- toolbox/gui/view_headpoints.m | 12 ++++++++---- toolbox/sensors/channel_align_auto.m | 2 -- 3 files changed, 28 insertions(+), 15 deletions(-) diff --git a/toolbox/gui/figure_3d.m b/toolbox/gui/figure_3d.m index 45d5bba30..fd3f380dc 100644 --- a/toolbox/gui/figure_3d.m +++ b/toolbox/gui/figure_3d.m @@ -3685,8 +3685,12 @@ function ViewSensors(hFig, isMarkers, isLabels, isMesh, Modality) %% ===== VIEW HEAD POINTS ===== -function ViewHeadPoints(hFig, isVisible) +function ViewHeadPoints(hFig, isVisible, isColorDist) global GlobalData; + % Parse inputs + if (nargin < 3) || isempty(isColorDist) + isColorDist = 0; + end % Get figure description [hFig, iFig, iDS] = bst_figures('GetFigure', hFig); if isempty(iDS) @@ -3801,10 +3805,22 @@ function ViewHeadPoints(hFig, isVisible) end % Plot extra head points if ~isempty(iExtra) - % If distances are available, color-code the points - if isfield(HeadPoints, 'Dist') && ~isempty(HeadPoints.Dist) + % Color-code the points according to the distance to the displayed surface + if isColorDist + % Get selected surface + [iTess, TessInfo, hFig, sSurf] = panel_surface('GetSelectedSurface', hFig); + % Compute the distance as in bst_meshfit + VertNorm = tess_normals(sSurf.Vertices, sSurf.Faces); + iNearest = bst_nearest(sSurf.Vertices, digLoc(iExtra,:), 1, 0, []); + dist = abs(sum(VertNorm(iNearest,:) .* (digLoc(iExtra,:) - sSurf.Vertices(iNearest,:)),2)); + % Compute color array + iColor = round((dist - min(dist)) / (max(dist) - min(dist)) * 255) + 1; + CMap = jet(512); + CData = CMap(iColor+256,:); + % Plot colored dots patch(digLoc(iExtra,1), digLoc(iExtra,2), digLoc(iExtra,3), ... - HeadPoints.Dist(iExtra)*1000, ... % mm + dist, ... + 'FaceVertexCData', CData, ... 'Marker', 'o', ... 'MarkerSize', 6, ... 'FaceColor', 'none', ... @@ -3814,11 +3830,6 @@ function ViewHeadPoints(hFig, isVisible) 'Parent', hAxes, ... 'UserData', iExtra, ... 'Tag', 'HeadPointsMarkers'); - set(hAxes, 'CLim', [0, 10]); - ColormapType = 'stat1'; - bst_colormaps('AddColormapToFigure', hFig, ColormapType); - bst_colormaps('ConfigureColorbar', hFig, ColormapType, 'stat', 'mm'); - bst_colormaps('SetColorbarVisible', hFig, 1); else % Display markers line(digLoc(iExtra,1), digLoc(iExtra,2), digLoc(iExtra,3), ... diff --git a/toolbox/gui/view_headpoints.m b/toolbox/gui/view_headpoints.m index 9a97adf2d..99bdb5c0a 100644 --- a/toolbox/gui/view_headpoints.m +++ b/toolbox/gui/view_headpoints.m @@ -1,8 +1,8 @@ -function [hFig, iDS, iFig] = view_headpoints(ChannelFile, ScalpFile, isInterp) +function [hFig, iDS, iFig] = view_headpoints(ChannelFile, ScalpFile, isInterp, isColorDist) % VIEW_HEADPOINTS: View surface file and head points. % % USAGE: view_headpoints(ChannelFile) -% view_headpoints(ChannelFile, ScalpFile) +% view_headpoints(ChannelFile, ScalpFile=[], isInterp=0, isColorDist=0) % % OUTPUT: % - hFig : Matlab handle to the 3DViz figure that was created or updated @@ -28,10 +28,14 @@ % For more information type "brainstorm license" at command prompt. % =============================================================================@ % -% Authors: Francois Tadel, 2010 +% Authors: Francois Tadel, 2010-2022 global GlobalData; +% Default: no color for the distance between the scalp and the points +if (nargin < 4) || isempty(isColorDist) + isColorDist = 0; +end % Default: no spherical harmonics if (nargin < 3) || isempty(isInterp) isInterp = 0; @@ -78,7 +82,7 @@ GlobalData.DataSet(iDS).HeadPoints = ChannelMat.HeadPoints; % View HeadPoints -figure_3d('ViewHeadPoints', hFig, 1); +figure_3d('ViewHeadPoints', hFig, 1, isColorDist); % Show a spherical harmonic fit to the landmark data if isInterp diff --git a/toolbox/sensors/channel_align_auto.m b/toolbox/sensors/channel_align_auto.m index a2a74a150..86671ea89 100644 --- a/toolbox/sensors/channel_align_auto.m +++ b/toolbox/sensors/channel_align_auto.m @@ -175,8 +175,6 @@ else nRemove = 0; end -% Save point-to-scalp distances for display and quality control -ChannelMat.HeadPoints.Dist = dist'; % Current position cannot be optimized if isempty(R) bst_progress('stop'); From 31e3177a021888156e57d93d39c38fe2078f05be Mon Sep 17 00:00:00 2001 From: ftadel Date: Wed, 10 Aug 2022 16:58:01 +0200 Subject: [PATCH 14/47] Added popup menu for color-coded dots --- toolbox/tree/tree_callbacks.m | 1 + 1 file changed, 1 insertion(+) diff --git a/toolbox/tree/tree_callbacks.m b/toolbox/tree/tree_callbacks.m index 584441f7c..b3b34a4cf 100644 --- a/toolbox/tree/tree_callbacks.m +++ b/toolbox/tree/tree_callbacks.m @@ -969,6 +969,7 @@ jMenuHeadPoints = gui_component('Menu', jPopup, [], 'Digitized head points', IconLoader.ICON_CHANNEL, [], []); % View head points gui_component('MenuItem', jMenuHeadPoints, [], 'View head points', IconLoader.ICON_SURFACE_SCALP, [], @(h,ev)view_headpoints(filenameFull, [], 0)); + gui_component('MenuItem', jMenuHeadPoints, [], 'View head points (color=distance)', IconLoader.ICON_SURFACE_SCALP, [], @(h,ev)view_headpoints(filenameFull, [], 0, 1)); % Edit head points if ~bst_get('ReadOnly') % Add head points From b2b4a7a9c9dc083776f8e79fedefac7ed57da9dc Mon Sep 17 00:00:00 2001 From: Marc Lalancette <31040756+Moo-Marc@users.noreply.github.com> Date: Wed, 10 Aug 2022 15:30:40 -0400 Subject: [PATCH 15/47] wip, head points as patch --- toolbox/gui/figure_3d.m | 46 +++++------ toolbox/math/bst_surfdist.m | 105 +++++++++++++++++++++++++ toolbox/sensors/channel_align_auto.m | 2 - toolbox/sensors/channel_align_manual.m | 47 ++++++++--- 4 files changed, 165 insertions(+), 35 deletions(-) create mode 100644 toolbox/math/bst_surfdist.m diff --git a/toolbox/gui/figure_3d.m b/toolbox/gui/figure_3d.m index ebb935f1a..fc29494a4 100644 --- a/toolbox/gui/figure_3d.m +++ b/toolbox/gui/figure_3d.m @@ -1242,7 +1242,9 @@ function ResetView(hFig) % Get axes hAxes = findobj(hFig, 'Tag', 'Axes3D'); % Reset zoom - zoom(hAxes, 'out'); + zoom(hAxes, 'out'); + % Enforce camera target at (0,0,0) + camtarget(hAxes, [0 0 0]); % 2D LAYOUT: separate function if strcmpi(GlobalData.DataSet(iDS).Figure(iFig).Id.SubType, '2DLayout') GlobalData.DataSet(iDS).Figure(iFig).Handles.DisplayFactor = 1; @@ -1333,6 +1335,8 @@ function SetStandardView(hFig, viewNames) end % Update camera position view(hAxes, newView * R); + % view() changes the camera target. Enforce (0,0,0). + camtarget(hAxes, [0,0,0]); camup(hAxes, double(newCamup * R)); % Update head light position camlight(findobj(hAxes, '-depth', 1, 'Tag', 'FrontLight'), 'headlight'); @@ -3802,38 +3806,36 @@ function ViewHeadPoints(hFig, isVisible) % Plot extra head points if ~isempty(iExtra) % If distances, color code points. - if isfield(HeadPoints, 'Dist') - patch(digLoc(iExtra,1), digLoc(iExtra,2), digLoc(iExtra,3), ... - HeadPoints.Dist(iExtra)*1000, ... % mm - 'Marker', 'o', ... - 'MarkerSize', 6, ... - 'FaceColor', 'none', ... - 'EdgeColor', 'none', ... - 'MarkerFaceColor', 'flat', ... - 'MarkerEdgeColor', 'flat', ... - 'Parent', hAxes, ... - 'UserData', iExtra, ... - 'Tag', 'HeadPointsMarkers'); - set(hAxes, 'CLim', [0, 10]); + if isColorDist + % Get selected surface + [iTess, TessInfo, hFig, sSurf] = panel_surface('GetSelectedSurface', hFig); + % Compute the distance + Dist = bst_surfdist(digLoc(iExtra, :), sSurf.Vertices, sSurf.Faces); + CData = Dist * 1000; % mm + MarkerFaceColor = 'flat'; + MarkerEdgeColor = 'flat'; + % TBD if we can use colormaps here... ColormapType = 'stat1'; bst_colormaps('AddColormapToFigure', hFig, ColormapType); + % How can the units be retained when changing colormap through the GUI? bst_colormaps('ConfigureColorbar', hFig, ColormapType, 'stat', 'mm'); bst_colormaps('SetColorbarVisible', hFig, 1); - else - - % Display markers - line(digLoc(iExtra,1), digLoc(iExtra,2), digLoc(iExtra,3), ... + CData = 'w'; % any color, not displayed + MarkerFaceColor = [.3 1 .3]; + MarkerEdgeColor = [.4 .7 .4]; + end + patch(digLoc(iExtra,1), digLoc(iExtra,2), digLoc(iExtra,3), CData, ... 'Parent', hAxes, ... 'LineWidth', 2, ... - 'LineStyle', 'none', ... - 'MarkerFaceColor', [.3 1 .3], ... - 'MarkerEdgeColor', [.4 .7 .4], ... + 'FaceColor', 'none', ... + 'EdgeColor', 'none', ... + 'MarkerFaceColor', MarkerFaceColor, ... + 'MarkerEdgeColor', MarkerEdgeColor, ... 'MarkerSize', 6, ... 'Marker', 'o', ... 'UserData', iExtra, ... 'Tag', 'HeadPointsMarkers'); - end end end end diff --git a/toolbox/math/bst_surfdist.m b/toolbox/math/bst_surfdist.m new file mode 100644 index 000000000..06550e4e3 --- /dev/null +++ b/toolbox/math/bst_surfdist.m @@ -0,0 +1,105 @@ +function Dist = bst_surfdist(Points, Vertices, Faces) +% BST_SURFDIST: Compute the distances between points and a surface. +% +% USAGE: Dist = bst_surfdist(Points, Vertices, Faces) +% +% DESCRIPTION: +% Exact distance computation, which checks all 3 sets of distances: points +% to vertices, points to edges, and points to faces, keeping the smallest +% for each point. +% +% INPUTS: +% - Points : [Qx3] double matrix, points to compare to the mesh defined by Vertices/Faces +% - Vertices : [Mx3] double matrix +% - Faces : [Nx3] double matrix +% +% OUTPUTS: +% - Dist : [Qx1] final distance between points and mesh + +% @============================================================================= +% 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, 2022 + +% TODO: A bit slow, look for alternatives +% This seems similar: https://www.mathworks.com/matlabcentral/fileexchange/52882-point2trimesh-distance-between-point-and-triangulated-surface + Epsilon = 1e-9; % nanometer + nP = size(Points, 1); + + % Prepare surface quantities, independent of points + % (In bst_meshfit, this can be done only once before iterative fitting.) + % 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 = normr(cross(EdgesV(:,:,1), EdgesV(:,:,2))); + %FaceArea = sqrt(sum(FaceNormalsA.^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),:); + + + % 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 + % 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 + end + % 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 diff --git a/toolbox/sensors/channel_align_auto.m b/toolbox/sensors/channel_align_auto.m index a2a74a150..86671ea89 100644 --- a/toolbox/sensors/channel_align_auto.m +++ b/toolbox/sensors/channel_align_auto.m @@ -175,8 +175,6 @@ else nRemove = 0; end -% Save point-to-scalp distances for display and quality control -ChannelMat.HeadPoints.Dist = dist'; % Current position cannot be optimized if isempty(R) bst_progress('stop'); diff --git a/toolbox/sensors/channel_align_manual.m b/toolbox/sensors/channel_align_manual.m index 02d7522c5..626a2bb4a 100644 --- a/toolbox/sensors/channel_align_manual.m +++ b/toolbox/sensors/channel_align_manual.m @@ -202,10 +202,14 @@ HeadPointsFidLoc = []; HeadPointsHpiLoc = []; if isHeadPoints + % More transparency to view points inside. + panel_surface('SetSurfaceTransparency', hFig, 1, 0.5); + % Hide helmet by default to align with points. + if ~isempty(hHelmetPatch) + set(hHelmetPatch, 'Visible', 'off'); + end % Get markers positions - HeadPointsMarkersLoc = [get(hHeadPointsMarkers, 'XData')', ... - get(hHeadPointsMarkers, 'YData')', ... - get(hHeadPointsMarkers, 'ZData')']; + HeadPointsMarkersLoc = get(hHeadPointsMarkers, 'Vertices'); % Hide HeadPoints when looking at EEG and number of EEG channels is the same as headpoints if isEeg && ~isempty(HeadPointsMarkersLoc) && ~isempty(SensorsVertices) && (length(SensorsVertices) == length(HeadPointsMarkersLoc)) && (max(abs(SensorsVertices(:) - HeadPointsMarkersLoc(:))) < 0.001) set(hHeadPointsMarkers, 'Visible', 'off'); @@ -343,11 +347,12 @@ gChanAlign.hButtonEditLabel = []; gChanAlign.hButtonHelmet = []; if gChanAlign.isMeg - gChanAlign.hButtonHelmet = uitoggletool(hToolbar, 'CData', java_geticon('ICON_DISPLAY'), 'TooltipString', 'Show/Hide MEG helmet', 'ClickedCallback', @ToggleHelmet, 'State', 'on'); + gChanAlign.hButtonHelmet = uitoggletool(hToolbar, 'CData', java_geticon('ICON_DISPLAY'), 'TooltipString', 'Show/Hide MEG helmet', 'ClickedCallback', @ToggleHelmet, 'State', get(hHelmetPatch, 'Visible')); elseif gChanAlign.isEeg gChanAlign.hButtonLabels = uitoggletool(hToolbar, 'CData', java_geticon('ICON_LABELS'), 'TooltipString', 'Show/Hide electrodes labels', 'ClickedCallback', @ToggleLabels); gChanAlign.hButtonEditLabel = uipushtool( hToolbar, 'CData', java_geticon('ICON_EDIT'), 'TooltipString', 'Edit selected channel label', 'ClickedCallback', @EditLabel); end +gChanAlign.hButtonColorDist = uitoggletool(hToolbar, 'CData', java_geticon('ICON_CHANNEL'), 'TooltipString', 'Color head points by distance', 'ClickedCallback', @ToggleColorDist, 'State', 'on'); gChanAlign.hButtonTransX = uitoggletool(hToolbar, 'CData', java_geticon('ICON_TRANSLATION_X'), 'TooltipString', 'Translation/X: Press right button and move mouse up/down', 'ClickedCallback', @SelectOperation, 'separator', 'on'); gChanAlign.hButtonTransY = uitoggletool(hToolbar, 'CData', java_geticon('ICON_TRANSLATION_Y'), 'TooltipString', 'Translation/Y: Press right button and move mouse up/down', 'ClickedCallback', @SelectOperation); gChanAlign.hButtonTransZ = uitoggletool(hToolbar, 'CData', java_geticon('ICON_TRANSLATION_Z'), 'TooltipString', 'Translation/Z: Press right button and move mouse up/down', 'ClickedCallback', @SelectOperation); @@ -640,10 +645,13 @@ function UpdatePoints(iSelChan) % Update headpoints markers and labels if gChanAlign.isHeadPoints % Extra head points - set(gChanAlign.hHeadPointsMarkers, ... - 'XData', gChanAlign.HeadPointsMarkersLoc(:,1), ... - 'YData', gChanAlign.HeadPointsMarkersLoc(:,2), ... - 'ZData', gChanAlign.HeadPointsMarkersLoc(:,3)); + set(gChanAlign.hHeadPointsMarkers, 'Vertices', gChanAlign.HeadPointsMarkersLoc); + if strcmpi(get(gChanAlign.hButtonColorDist, 'State'), 'on') + % Update distance color + Dist = bst_surfdist(gChanAlign.HeadPointsMarkersLoc, ... + get(gChanAlign.hSurfacePatch, 'Vertices'), get(gChanAlign.hSurfacePatch, 'Faces')); + set(gChanAlign.hHeadPointsMarkers, 'CData', Dist * 1000); + end % Fiducials if ~isempty(gChanAlign.hHeadPointsFid) set(gChanAlign.hHeadPointsFid, ... @@ -986,6 +994,25 @@ function ToggleHelmet(varargin) end +%% ===== COLOR HEAD POINTS ===== +function ToggleColorDist(varargin) + global gChanAlign; + % Update button color + gui_update_toggle(gChanAlign.hButtonColorDist); + if strcmpi(get(gChanAlign.hButtonColorDist, 'State'), 'on') + % Color points according to distance to surface + Dist = bst_surfdist(gChanAlign.HeadPointsMarkersLoc, ... + get(gChanAlign.hSurfacePatch, 'Vertices'), get(gChanAlign.hSurfacePatch, 'Faces')); + set(gChanAlign.hHeadPointsMarkers, 'CData', Dist * 1000, ... + 'MarkerFaceColor', 'flat', 'MarkerEdgeColor', 'flat'); + else + % Conventional fixed point color + set(gChanAlign.hHeadPointsMarkers, ...% 'CData', 'w', ... + 'MarkerFaceColor', [.3 1 .3], 'MarkerEdgeColor', [.4 .7 .4]); + end +end + + %% ===== EDIT LABEL ===== function EditLabel(varargin) global GlobalData gChanAlign; @@ -1107,9 +1134,7 @@ function ProjectElectrodesOnSurface(varargin) % Copy modification to the head points if gChanAlign.isEeg && ~isempty(gChanAlign.SensorsVertices) && ~isempty(gChanAlign.HeadPointsMarkersLoc) && (length(gChanAlign.SensorsVertices) == length(gChanAlign.HeadPointsMarkersLoc)) gChanAlign.HeadPointsMarkersLoc = gChanAlign.SensorsVertices; - set(gChanAlign.hHeadPointsMarkers, 'XData', gChanAlign.HeadPointsMarkersLoc(:,1), ... - 'YData', gChanAlign.HeadPointsMarkersLoc(:,2), ... - 'ZData', gChanAlign.HeadPointsMarkersLoc(:,3)); + set(gChanAlign.hHeadPointsMarkers, 'Vertices', gChanAlign.HeadPointsMarkersLoc); end % Mark current channel file as modified gChanAlign.isChanged = 1; From 2e8bc0946c20a24fc0be8c49b94cfb24d22c59c8 Mon Sep 17 00:00:00 2001 From: Marc Lalancette <31040756+Moo-Marc@users.noreply.github.com> Date: Wed, 10 Aug 2022 19:43:34 -0400 Subject: [PATCH 16/47] cleanup --- toolbox/gui/figure_3d.m | 3 ++- toolbox/sensors/channel_align_manual.m | 11 ++--------- 2 files changed, 4 insertions(+), 10 deletions(-) diff --git a/toolbox/gui/figure_3d.m b/toolbox/gui/figure_3d.m index ed33bccee..2a8c9331d 100644 --- a/toolbox/gui/figure_3d.m +++ b/toolbox/gui/figure_3d.m @@ -3768,9 +3768,10 @@ function ViewHeadPoints(hFig, isVisible, isColorDist) Dist = bst_surfdist(get(hHeadPointsMarkers, 'Vertices'), sSurf.Vertices, sSurf.Faces); set(hHeadPointsMarkers, 'CData', Dist * 1000, ... 'MarkerFaceColor', 'flat', 'MarkerEdgeColor', 'flat'); - if ~ismember(ColormapInfo.AllTypes, ColormapType) + if ~ismember(ColormapType, ColormapInfo.AllTypes) % Add missing colormap (color was toggled after points were displayed) bst_colormaps('AddColormapToFigure', hFig, ColormapType, 'mm'); + ColormapInfo = getappdata(hFig, 'Colormap'); end if strcmpi(ColormapInfo.Type, ColormapType) bst_colormaps('SetColorbarVisible', hFig, 1); diff --git a/toolbox/sensors/channel_align_manual.m b/toolbox/sensors/channel_align_manual.m index 994758635..01103b1b5 100644 --- a/toolbox/sensors/channel_align_manual.m +++ b/toolbox/sensors/channel_align_manual.m @@ -1,4 +1,4 @@ -function hFig = channel_align_manual( ChannelFile, Modality, isEdit, SurfaceType, isColorDist ) +function hFig = channel_align_manual( ChannelFile, Modality, isEdit, SurfaceType ) % CHANNEL_ALIGN_MANUAL: Align manually an electrodes net on the scalp surface of the subject. % % USAGE: hFig = channel_align_manual( ChannelFile, Modality, isEdit, SurfaceType='cortex') @@ -34,9 +34,6 @@ % Parse inputs hFig = []; -if (nargin < 5) || isempty(isColorDist) - isColorDist = 1; -end if (nargin < 4) || isempty(SurfaceType) if ismember(Modality, {'SEEG'}) SurfaceType = 'cortex'; @@ -193,7 +190,7 @@ % ===== DISPLAY HEAD POINTS ===== % Display head points -figure_3d('ViewHeadPoints', hFig, 1, isColorDist); +figure_3d('ViewHeadPoints', hFig, 1); % Get patch and vertices hHeadPointsMarkers = findobj(hFig, 'Tag', 'HeadPointsMarkers'); hHeadPointsLabels = findobj(hFig, 'Tag', 'HeadPointsLabels'); @@ -207,10 +204,6 @@ if isHeadPoints % More transparency to view points inside. panel_surface('SetSurfaceTransparency', hFig, 1, 0.5); - % Hide helmet by default to align with points. - if ~isempty(hHelmetPatch) - set(hHelmetPatch, 'Visible', 'off'); - end % Get markers positions HeadPointsMarkersLoc = get(hHeadPointsMarkers, 'Vertices'); % Hide HeadPoints when looking at EEG and number of EEG channels is the same as headpoints From 74e02daede898998d527fd3c01ffa1c82d7e6127 Mon Sep 17 00:00:00 2001 From: ftadel Date: Fri, 12 Aug 2022 13:27:31 +0200 Subject: [PATCH 17/47] Enforce target (0,0,0) only when the axes are visible --- toolbox/gui/figure_3d.m | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/toolbox/gui/figure_3d.m b/toolbox/gui/figure_3d.m index 2a8c9331d..f1cdc85cb 100644 --- a/toolbox/gui/figure_3d.m +++ b/toolbox/gui/figure_3d.m @@ -1261,8 +1261,10 @@ function ResetView(hFig) hAxes = findobj(hFig, 'Tag', 'Axes3D'); % Reset zoom zoom(hAxes, 'out'); - % Enforce camera target at (0,0,0) - camtarget(hAxes, [0 0 0]); + % Enforce camera target at (0,0,0) when the axes are visible + if ~isempty(findobj(hAxes, 'Tag', 'AxisXYZ')) + camtarget(hAxes, [0 0 0]); + end % 2D LAYOUT: separate function if strcmpi(GlobalData.DataSet(iDS).Figure(iFig).Id.SubType, '2DLayout') GlobalData.DataSet(iDS).Figure(iFig).Handles.DisplayFactor = 1; @@ -1353,8 +1355,10 @@ function SetStandardView(hFig, viewNames) end % Update camera position view(hAxes, newView * R); - % view() changes the camera target. Enforce (0,0,0). - camtarget(hAxes, [0,0,0]); + % Enforce camera target at (0,0,0) when the axes are visible + if ~isempty(findobj(hAxes, 'Tag', 'AxisXYZ')) + camtarget(hAxes, [0 0 0]); + end camup(hAxes, double(newCamup * R)); % Update head light position camlight(findobj(hAxes, '-depth', 1, 'Tag', 'FrontLight'), 'headlight'); From 89a9b98a90fd087e7ce347ccea358aebc27c18ec Mon Sep 17 00:00:00 2001 From: ftadel Date: Fri, 12 Aug 2022 15:10:47 +0200 Subject: [PATCH 18/47] Disabling camera target at (0,0,0) --- toolbox/gui/figure_3d.m | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/toolbox/gui/figure_3d.m b/toolbox/gui/figure_3d.m index f1cdc85cb..f8eb9d648 100644 --- a/toolbox/gui/figure_3d.m +++ b/toolbox/gui/figure_3d.m @@ -1261,10 +1261,6 @@ function ResetView(hFig) hAxes = findobj(hFig, 'Tag', 'Axes3D'); % Reset zoom zoom(hAxes, 'out'); - % Enforce camera target at (0,0,0) when the axes are visible - if ~isempty(findobj(hAxes, 'Tag', 'AxisXYZ')) - camtarget(hAxes, [0 0 0]); - end % 2D LAYOUT: separate function if strcmpi(GlobalData.DataSet(iDS).Figure(iFig).Id.SubType, '2DLayout') GlobalData.DataSet(iDS).Figure(iFig).Handles.DisplayFactor = 1; @@ -1355,10 +1351,6 @@ function SetStandardView(hFig, viewNames) end % Update camera position view(hAxes, newView * R); - % Enforce camera target at (0,0,0) when the axes are visible - if ~isempty(findobj(hAxes, 'Tag', 'AxisXYZ')) - camtarget(hAxes, [0 0 0]); - end camup(hAxes, double(newCamup * R)); % Update head light position camlight(findobj(hAxes, '-depth', 1, 'Tag', 'FrontLight'), 'headlight'); @@ -3921,7 +3913,7 @@ function ViewAxis(hFig, isVisible) 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'); % Enforce camera target at (0,0,0) - camtarget(hAxes, [0,0,0]); + % camtarget(hAxes, [0,0,0]); else hAxisXYZ = findobj(hAxes, 'Tag', 'AxisXYZ'); if ~isempty(hAxisXYZ) From f510e909aa73b8a4787763dfff856b8142cb0557 Mon Sep 17 00:00:00 2001 From: ftadel Date: Fri, 12 Aug 2022 15:22:23 +0200 Subject: [PATCH 19/47] Get rid of normr --- toolbox/math/bst_surfdist.m | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/toolbox/math/bst_surfdist.m b/toolbox/math/bst_surfdist.m index db24d0944..47787de41 100644 --- a/toolbox/math/bst_surfdist.m +++ b/toolbox/math/bst_surfdist.m @@ -56,7 +56,8 @@ 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 = normr(cross(EdgesV(:,:,1), EdgesV(:,:,2))); +m = cross(EdgesV(:,:,1), EdgesV(:,:,2)); +FaceNormals = sqrt(ones./(sum((m.*m)')))'*ones(1,size(m,2)).*m; %FaceArea = sqrt(sum(FaceNormalsA.^2, 2)); % Perpendicular vectors to edges, pointing inside triangular face. for e = 3:-1:1 @@ -104,3 +105,4 @@ Dist = min([DistVert, DistEdge, DistFace], [], 2); end + From 382ca4485417891d5958ced6ee2152099283a470 Mon Sep 17 00:00:00 2001 From: ftadel Date: Fri, 12 Aug 2022 15:42:13 +0200 Subject: [PATCH 20/47] Moved popup menu to Channel submenu (Figure is independent from the data) --- toolbox/gui/figure_3d.m | 60 ++++++++++++++++++++++------------------- 1 file changed, 32 insertions(+), 28 deletions(-) diff --git a/toolbox/gui/figure_3d.m b/toolbox/gui/figure_3d.m index f8eb9d648..b90c43124 100644 --- a/toolbox/gui/figure_3d.m +++ b/toolbox/gui/figure_3d.m @@ -1754,6 +1754,23 @@ function DisplayFigurePopup(hFig) gui_component('MenuItem', jMenuChannels, [], 'SEEG contacts', IconLoader.ICON_CHANNEL, [], @(h,ev)view_channels(ChannelFile, 'SEEG', 1, 0, hFig, 1)); end end + + % Show Head points + isHeadPoints = ~isempty(GlobalData.DataSet(iDS).HeadPoints) && ~isempty(GlobalData.DataSet(iDS).HeadPoints.Loc); + if isHeadPoints && ~strcmpi(FigureType, 'Topography') + jMenuChannels.addSeparator(); + % Are head points visible + hHeadPointsMarkers = findobj(GlobalData.DataSet(iDS).Figure(iFig).hFigure, 'Tag', 'HeadPointsMarkers'); + isVisible = ~isempty(hHeadPointsMarkers) && strcmpi(get(hHeadPointsMarkers, 'Visible'), 'on'); + jItem = gui_component('CheckBoxMenuItem', jMenuChannels, [], 'View head points', IconLoader.ICON_CHANNEL, [], @(h,ev)ViewHeadPoints(hFig, ~isVisible)); + jItem.setSelected(isVisible); + jItem.setAccelerator(KeyStroke.getKeyStroke(KeyEvent.VK_H, KeyEvent.CTRL_MASK)); + % Are head points color coded by distance + isColorDist = ~isempty(hHeadPointsMarkers) && strcmpi(get(hHeadPointsMarkers, 'MarkerFaceColor'), 'flat'); + jItem = gui_component('CheckBoxMenuItem', jMenuChannels, [], 'Color head points by distance', IconLoader.ICON_CHANNEL, [], @(h,ev)ViewHeadPoints(hFig, isVisible, ~isColorDist)); + jItem.setSelected(isColorDist); + jItem.setAccelerator(KeyStroke.getKeyStroke(KeyEvent.VK_H, KeyEvent.CTRL_MASK)); + end end % ==== MENU: MONTAGE ==== @@ -1958,22 +1975,7 @@ function DisplayFigurePopup(hFig) isAxis = ~isempty(findobj(hFig, 'Tag', 'AxisXYZ')); jItem = gui_component('CheckBoxMenuItem', jMenuFigure, [], 'View axis', IconLoader.ICON_AXES, [], @(h,ev)ViewAxis(hFig, ~isAxis)); jItem.setSelected(isAxis); - jItem.setAccelerator(KeyStroke.getKeyStroke(KeyEvent.VK_A, KeyEvent.CTRL_MASK)); - % Show Head points - isHeadPoints = ~isempty(GlobalData.DataSet(iDS).HeadPoints) && ~isempty(GlobalData.DataSet(iDS).HeadPoints.Loc); - if isHeadPoints && ~strcmpi(FigureType, 'Topography') - % Are head points visible - hHeadPointsMarkers = findobj(GlobalData.DataSet(iDS).Figure(iFig).hFigure, 'Tag', 'HeadPointsMarkers'); - isVisible = ~isempty(hHeadPointsMarkers) && strcmpi(get(hHeadPointsMarkers, 'Visible'), 'on'); - jItem = gui_component('CheckBoxMenuItem', jMenuFigure, [], 'View head points', IconLoader.ICON_CHANNEL, [], @(h,ev)ViewHeadPoints(hFig, ~isVisible)); - jItem.setSelected(isVisible); - jItem.setAccelerator(KeyStroke.getKeyStroke(KeyEvent.VK_H, KeyEvent.CTRL_MASK)); - % Are head points color coded by distance - isColorDist = ~isempty(hHeadPointsMarkers) && strcmpi(get(hHeadPointsMarkers, 'MarkerFaceColor'), 'flat'); - jItem = gui_component('CheckBoxMenuItem', jMenuFigure, [], 'Color head points by distance', IconLoader.ICON_CHANNEL, [], @(h,ev)ViewHeadPoints(hFig, isVisible, ~isColorDist)); - jItem.setSelected(isColorDist); - jItem.setAccelerator(KeyStroke.getKeyStroke(KeyEvent.VK_H, KeyEvent.CTRL_MASK)); - end + jItem.setAccelerator(KeyStroke.getKeyStroke(KeyEvent.VK_A, KeyEvent.CTRL_MASK)); jMenuFigure.addSeparator(); % Change background color gui_component('MenuItem', jMenuFigure, [], 'Change background color', IconLoader.ICON_COLOR_SELECTION, [], @(h,ev)bst_figures('SetBackgroundColor', hFig)); @@ -3760,18 +3762,20 @@ function ViewHeadPoints(hFig, isVisible, isColorDist) % Color points according to distance to surface % Get selected surface [iTess, TessInfo, hFig, sSurf] = panel_surface('GetSelectedSurface', hFig); - % Compute the distance - Dist = bst_surfdist(get(hHeadPointsMarkers, 'Vertices'), sSurf.Vertices, sSurf.Faces); - set(hHeadPointsMarkers, 'CData', Dist * 1000, ... - 'MarkerFaceColor', 'flat', 'MarkerEdgeColor', 'flat'); - if ~ismember(ColormapType, ColormapInfo.AllTypes) - % Add missing colormap (color was toggled after points were displayed) - bst_colormaps('AddColormapToFigure', hFig, ColormapType, 'mm'); - ColormapInfo = getappdata(hFig, 'Colormap'); - end - if strcmpi(ColormapInfo.Type, ColormapType) - bst_colormaps('SetColorbarVisible', hFig, 1); - bst_colormaps('ConfigureColorbar', hFig, ColormapType, 'stat', 'mm'); + if ~isempty(sSurf) && isfield(sSurf, 'Vertices') && ~isempty(sSurf.Vertices) + % Compute the distance + Dist = bst_surfdist(get(hHeadPointsMarkers, 'Vertices'), sSurf.Vertices, sSurf.Faces); + set(hHeadPointsMarkers, 'CData', Dist * 1000, ... + 'MarkerFaceColor', 'flat', 'MarkerEdgeColor', 'flat'); + if ~ismember(ColormapType, ColormapInfo.AllTypes) + % Add missing colormap (color was toggled after points were displayed) + bst_colormaps('AddColormapToFigure', hFig, ColormapType, 'mm'); + ColormapInfo = getappdata(hFig, 'Colormap'); + end + if strcmpi(ColormapInfo.Type, ColormapType) + bst_colormaps('SetColorbarVisible', hFig, 1); + bst_colormaps('ConfigureColorbar', hFig, ColormapType, 'stat', 'mm'); + end end elseif ~isColorDist && strcmpi(get(hHeadPointsMarkers, 'MarkerFaceColor'), 'flat') % Conventional fixed color From 9faac455aec8bf503b2c56e8e08a09bbd80906d8 Mon Sep 17 00:00:00 2001 From: Marc Lalancette <31040756+Moo-Marc@users.noreply.github.com> Date: Wed, 31 Aug 2022 17:26:20 -0400 Subject: [PATCH 21/47] colorbar fix --- toolbox/gui/figure_3d.m | 19 ++++++++++--------- toolbox/math/bst_surfdist.m | 6 +++--- 2 files changed, 13 insertions(+), 12 deletions(-) diff --git a/toolbox/gui/figure_3d.m b/toolbox/gui/figure_3d.m index b90c43124..c4b196e12 100644 --- a/toolbox/gui/figure_3d.m +++ b/toolbox/gui/figure_3d.m @@ -3763,15 +3763,16 @@ function ViewHeadPoints(hFig, isVisible, isColorDist) % Get selected surface [iTess, TessInfo, hFig, sSurf] = panel_surface('GetSelectedSurface', hFig); if ~isempty(sSurf) && isfield(sSurf, 'Vertices') && ~isempty(sSurf.Vertices) - % Compute the distance - Dist = bst_surfdist(get(hHeadPointsMarkers, 'Vertices'), sSurf.Vertices, sSurf.Faces); - set(hHeadPointsMarkers, 'CData', Dist * 1000, ... - 'MarkerFaceColor', 'flat', 'MarkerEdgeColor', 'flat'); if ~ismember(ColormapType, ColormapInfo.AllTypes) % Add missing colormap (color was toggled after points were displayed) bst_colormaps('AddColormapToFigure', hFig, ColormapType, 'mm'); ColormapInfo = getappdata(hFig, 'Colormap'); + ColormapChangedCallback(iDS, iFig); end + % Compute the distance + Dist = bst_surfdist(get(hHeadPointsMarkers, 'Vertices'), sSurf.Vertices, sSurf.Faces); + set(hHeadPointsMarkers, 'CData', Dist * 1000, ... + 'MarkerFaceColor', 'flat', 'MarkerEdgeColor', 'flat'); if strcmpi(ColormapInfo.Type, ColormapType) bst_colormaps('SetColorbarVisible', hFig, 1); bst_colormaps('ConfigureColorbar', hFig, ColormapType, 'stat', 'mm'); @@ -3869,6 +3870,8 @@ function ViewHeadPoints(hFig, isVisible, isColorDist) CData = Dist * 1000; % mm MarkerFaceColor = 'flat'; MarkerEdgeColor = 'flat'; + ColormapType = 'stat1'; + bst_colormaps('AddColormapToFigure', hFig, ColormapType, 'mm'); else CData = 'w'; % any color, not displayed MarkerFaceColor = [.3 1 .3]; @@ -3886,11 +3889,9 @@ function ViewHeadPoints(hFig, isVisible, isColorDist) 'UserData', iExtra, ... 'Tag', 'HeadPointsMarkers'); if isColorDist - % TBD if we should use colormaps or not here. - ColormapType = 'stat1'; - bst_colormaps('AddColormapToFigure', hFig, ColormapType, 'mm'); - bst_colormaps('SetColorbarVisible', hFig, 1); - bst_colormaps('ConfigureColorbar', hFig, ColormapType, 'stat', 'mm'); + ColormapChangedCallback(iDS, iFig); +% bst_colormaps('SetColorbarVisible', hFig, 1); +% bst_colormaps('ConfigureColorbar', hFig, ColormapType, 'stat', 'mm'); end end end diff --git a/toolbox/math/bst_surfdist.m b/toolbox/math/bst_surfdist.m index 47787de41..4dab27212 100644 --- a/toolbox/math/bst_surfdist.m +++ b/toolbox/math/bst_surfdist.m @@ -56,9 +56,9 @@ EdgesV(:,:,2) = Vertices(Faces(:,3),:) - Vertices(Faces(:,2),:); EdgesV(:,:,3) = Vertices(Faces(:,1),:) - Vertices(Faces(:,3),:); % First edge to second edge: counter clockwise = up -m = cross(EdgesV(:,:,1), EdgesV(:,:,2)); -FaceNormals = sqrt(ones./(sum((m.*m)')))'*ones(1,size(m,2)).*m; -%FaceArea = sqrt(sum(FaceNormalsA.^2, 2)); +FaceNormals = cross(EdgesV(:,:,1), EdgesV(:,:,2)); +%FaceArea = sqrt(sum(FaceNormals.^2, 2)); +FaceNormals = bsxfun(@rdivide, FaceNormals, sqrt(sum(FaceNormals.^2, 2))); % Perpendicular vectors to edges, pointing inside triangular face. for e = 3:-1:1 EdgeTriNormals(:,:,e) = cross(FaceNormals, EdgesV(:,:,e)); From 90b4dacdeaf8dcf0f63466b58b15a0c0fde6bac6 Mon Sep 17 00:00:00 2001 From: Marc Lalancette <31040756+Moo-Marc@users.noreply.github.com> Date: Fri, 18 Nov 2022 16:50:19 -0500 Subject: [PATCH 22/47] tweaks to manual registration figure, head points distance coloring --- toolbox/core/bst_colormaps.m | 24 +- toolbox/gui/figure_3d.m | 42 ++-- toolbox/gui/figure_mri.m | 2 +- toolbox/gui/panel_surface.m | 87 ++++++- toolbox/math/bst_surfdist.m | 8 - .../functions/process_adjust_coordinates.m | 217 ++++++++---------- toolbox/sensors/channel_align_auto.m | 36 +-- toolbox/sensors/channel_align_manual.m | 132 +++++++++-- toolbox/sensors/channel_align_scs.m | 122 ++++++++++ toolbox/tree/tree_callbacks.m | 2 + 10 files changed, 446 insertions(+), 226 deletions(-) create mode 100644 toolbox/sensors/channel_align_scs.m diff --git a/toolbox/core/bst_colormaps.m b/toolbox/core/bst_colormaps.m index d48956357..d3f626c87 100644 --- a/toolbox/core/bst_colormaps.m +++ b/toolbox/core/bst_colormaps.m @@ -376,12 +376,20 @@ function SetMaxCustom(ColormapType, DisplayUnits, newMin, newMax) case {'3DViz', 'MriViewer'} % Get surfaces defined in this figure TessInfo = getappdata(sFigure.hFigure, 'Surface'); - DataFig = TessInfo.DataMinMax; - if ~isempty(TessInfo.DataSource.Type) - DataType = TessInfo.DataSource.Type; - isSLORETA = strcmpi(DataType, 'Source') && ~isempty(strfind(lower(TessInfo.DataSource.FileName), 'sloreta')); - if isSLORETA - DataType = 'sLORETA'; + % Find surface(s) that matches this ColormapType + iSurfaces = find(strcmpi({TessInfo.ColormapType}, ColormapType)); + DataFig = []; + for i = 1:length(iSurfaces) + iTess = iSurfaces(i); + DataFig = [min([DataFig(:); TessInfo(iTess).DataMinMax(:)]), ... + max([DataFig(:); TessInfo(iTess).DataMinMax(:)])]; + if ~isempty(TessInfo(iTess).DataSource.Type) + % We'll keep the last non-empty DataType + DataType = TessInfo(iTess).DataSource.Type; + isSLORETA = strcmpi(DataType, 'Source') && ~isempty(strfind(lower(TessInfo(iTess).DataSource.FileName), 'sloreta')); + if isSLORETA + DataType = 'sLORETA'; + end end end @@ -443,10 +451,10 @@ function SetMaxCustom(ColormapType, DisplayUnits, newMin, newMax) if isinf(amplitudeMax) fFactor = 1; fUnits = 'Inf'; - elseif isequal(DisplayUnits, '%') + elseif isequal(DisplayUnits, '%') || isequal(DisplayUnits, 'mm') fFactor = 1; fUnits = DisplayUnits; - else + else % Guess the display units [tmp, fFactor, fUnits ] = bst_getunits(amplitudeMax, DataType); % For readability: replace '\sigma' with 'no units' diff --git a/toolbox/gui/figure_3d.m b/toolbox/gui/figure_3d.m index 42c32b246..7fafa8733 100644 --- a/toolbox/gui/figure_3d.m +++ b/toolbox/gui/figure_3d.m @@ -1755,10 +1755,17 @@ function DisplayFigurePopup(hFig) end end + end + + if ~isempty(GlobalData.DataSet(iDS).ChannelFile) % Show Head points isHeadPoints = ~isempty(GlobalData.DataSet(iDS).HeadPoints) && ~isempty(GlobalData.DataSet(iDS).HeadPoints.Loc); if isHeadPoints && ~strcmpi(FigureType, 'Topography') - jMenuChannels.addSeparator(); + if isAlignFig + jMenuChannels = gui_component('Menu', jPopup, [], 'Channels', IconLoader.ICON_CHANNEL); + else + jMenuChannels.addSeparator(); + end % Are head points visible hHeadPointsMarkers = findobj(GlobalData.DataSet(iDS).Figure(iFig).hFigure, 'Tag', 'HeadPointsMarkers'); isVisible = ~isempty(hHeadPointsMarkers) && strcmpi(get(hHeadPointsMarkers, 'Visible'), 'on'); @@ -3746,7 +3753,7 @@ function ViewHeadPoints(hFig, isVisible, isColorDist) [HeadPoints.Loc(1,iDupli), HeadPoints.Loc(2,iDupli), HeadPoints.Loc(3,iDupli)] = sph2cart(th, phi, r - 0.0001); end - % Else, get previous head points + % Look for previous head points hHeadPointsMarkers = findobj(hAxes, 'Tag', 'HeadPointsMarkers'); hHeadPointsLabels = findobj(hAxes, 'Tag', 'HeadPointsLabels'); % If head points graphic objects already exist: set the "Visible" property @@ -3761,22 +3768,19 @@ function ViewHeadPoints(hFig, isVisible, isColorDist) ColormapType = 'stat1'; if isColorDist && ~strcmpi(get(hHeadPointsMarkers, 'MarkerFaceColor'), 'flat') % Color points according to distance to surface - % Get selected surface - [iTess, TessInfo, hFig, sSurf] = panel_surface('GetSelectedSurface', hFig); + % Get scalp surface + [~, ~, hFig, sSurf] = panel_surface('GetSurface', hFig, '', 'Scalp'); if ~isempty(sSurf) && isfield(sSurf, 'Vertices') && ~isempty(sSurf.Vertices) - if ~ismember(ColormapType, ColormapInfo.AllTypes) - % Add missing colormap (color was toggled after points were displayed) - bst_colormaps('AddColormapToFigure', hFig, ColormapType, 'mm'); - ColormapInfo = getappdata(hFig, 'Colormap'); - ColormapChangedCallback(iDS, iFig); - end % Compute the distance Dist = bst_surfdist(get(hHeadPointsMarkers, 'Vertices'), sSurf.Vertices, sSurf.Faces); set(hHeadPointsMarkers, 'CData', Dist * 1000, ... 'MarkerFaceColor', 'flat', 'MarkerEdgeColor', 'flat'); - if strcmpi(ColormapInfo.Type, ColormapType) - bst_colormaps('SetColorbarVisible', hFig, 1); - bst_colormaps('ConfigureColorbar', hFig, ColormapType, 'stat', 'mm'); + if ~ismember(ColormapType, ColormapInfo.AllTypes) + iTess = panel_surface('GetSurface', hFig, '', 'HeadPoints'); + if isempty(iTess) + error('HeadPoints surface not found.'); + end + panel_surface('SetSurfaceData', hFig, iTess, 'HeadPointsDistance', '', 1); % isStat=1 end end elseif ~isColorDist && strcmpi(get(hHeadPointsMarkers, 'MarkerFaceColor'), 'flat') @@ -3864,15 +3868,13 @@ function ViewHeadPoints(hFig, isVisible, isColorDist) if ~isempty(iExtra) % Color code points by distance if isColorDist - % Get selected surface - [iTess, TessInfo, hFig, sSurf] = panel_surface('GetSelectedSurface', hFig); + % Get scalp surface + [~, ~, hFig, sSurf] = panel_surface('GetSurface', hFig, '', 'Scalp'); % Compute the distance Dist = bst_surfdist(digLoc(iExtra, :), sSurf.Vertices, sSurf.Faces); CData = Dist * 1000; % mm MarkerFaceColor = 'flat'; MarkerEdgeColor = 'flat'; - ColormapType = 'stat1'; - bst_colormaps('AddColormapToFigure', hFig, ColormapType, 'mm'); else CData = 'w'; % any color, not displayed MarkerFaceColor = [.3 1 .3]; @@ -3889,10 +3891,10 @@ function ViewHeadPoints(hFig, isVisible, isColorDist) 'Marker', 'o', ... 'UserData', iExtra, ... 'Tag', 'HeadPointsMarkers'); + % Add points patch to figure surfaces so all color bar functionality work. + iTess = panel_surface('AddSurface', hFig, '', 'HeadPoints'); if isColorDist - ColormapChangedCallback(iDS, iFig); -% bst_colormaps('SetColorbarVisible', hFig, 1); -% bst_colormaps('ConfigureColorbar', hFig, ColormapType, 'stat', 'mm'); + panel_surface('SetSurfaceData', hFig, iTess, 'HeadPointsDistance', '', 1); % isStat=1 end end end diff --git a/toolbox/gui/figure_mri.m b/toolbox/gui/figure_mri.m index d702baad4..b4c565ed8 100644 --- a/toolbox/gui/figure_mri.m +++ b/toolbox/gui/figure_mri.m @@ -2583,7 +2583,7 @@ function ButtonSave_Callback(hFig, varargin) warning('off', 'MATLAB:load:variableNotFound'); sMriOld = load(MriFileFull, 'SCS'); warning('on', 'MATLAB:load:variableNotFound'); - % If the fiducials were modified + % If the fiducials were modified (> 1um) if isfield(sMriOld, 'SCS') && all(isfield(sMriOld.SCS,{'NAS','LPA','RPA'})) ... && ~isempty(sMriOld.SCS.NAS) && ~isempty(sMriOld.SCS.LPA) && ~isempty(sMriOld.SCS.RPA) ... && ((max(abs(sMri.SCS.NAS - sMriOld.SCS.NAS)) > 1e-3) || ... diff --git a/toolbox/gui/panel_surface.m b/toolbox/gui/panel_surface.m index f775e50eb..12537050c 100644 --- a/toolbox/gui/panel_surface.m +++ b/toolbox/gui/panel_surface.m @@ -1130,15 +1130,20 @@ function UpdateSurfaceProperties() %% ===== ADD A SURFACE ===== % Add a surface to a given 3DViz figure % USAGE : [iTess, TessInfo] = panel_surface('AddSurface', hFig, surfaceFile) +% [iTess, TessInfo] = panel_surface('AddSurface', hFig, [], surfaceType) % OUTPUT: Indice of the surface in the figure's surface array -function [iTess, TessInfo] = AddSurface(hFig, surfaceFile) +function [iTess, TessInfo] = AddSurface(hFig, surfaceFile, surfaceType) % ===== CHECK EXISTENCE ===== - % Check whether filename is an absolute or relative path - surfaceFile = file_short(surfaceFile); % Get figure appdata (surfaces configuration) TessInfo = getappdata(hFig, 'Surface'); % Check that this surface is not already displayed in 3DViz figure - iTess = find(file_compare({TessInfo.SurfaceFile}, surfaceFile)); + if isempty(surfaceFile) && nargin > 2 + iTess = find(file_compare({TessInfo.Name}, surfaceType)); + else + % Check whether filename is an absolute or relative path + surfaceFile = file_short(surfaceFile); + iTess = find(file_compare({TessInfo.SurfaceFile}, surfaceFile)); + end if ~isempty(iTess) disp('BST> This surface is already displayed. Ignoring...'); return @@ -1161,6 +1166,9 @@ function UpdateSurfaceProperties() % ===== PLOT OBJECT ===== % Get file type (tessalation or MRI) fileType = file_gettype(surfaceFile); + if strcmpi(fileType, 'unknown') && nargin > 2 + fileType = surfaceType; + end % === TESSELATION === if any(strcmpi(fileType, {'cortex','scalp','innerskull','outerskull','tess'})) % === LOAD SURFACE === @@ -1291,8 +1299,18 @@ function UpdateSurfaceProperties() setappdata(hFig, 'Surface', TessInfo); end + % === NO FILE: HeadPoints === + elseif strcmpi(fileType, 'HeadPoints') + % Points were already displayed; just add the patch to the figure surfaces. + TessInfo(iTess).DataSource.Type = 'HeadPointsDistance'; + TessInfo(iTess).Name = 'HeadPoints'; + TessInfo(iTess).ColormapType = 'stat1'; + TessInfo(iTess).hPatch = findobj(hFig, 'Tag', 'HeadPointsMarkers'); + % Update figure's surfaces list and current surface pointer + setappdata(hFig, 'Surface', TessInfo); + % === FEM === - else + else % TODO: Check for FEM fileType explicitly view_surface_fem(surfaceFile, [], [], [], hFig); end % Update default surface @@ -1496,6 +1514,11 @@ function UpdateSurfaceProperties() DisplayUnits = []; TessInfo(iTess).Data = []; TessInfo(iTess).DataWmat = []; + + case 'HeadPointsDistance' + ColormapType = 'stat1'; + DisplayUnits = 'mm'; + % Data is actually added in UpdateSurfaceData below. otherwise ColormapType = ''; @@ -1878,6 +1901,15 @@ function UpdateSurfaceProperties() figure_callback(hFig, 'UpdateSurfaceColor', hFig, iTess); % Get updated surface definition TessInfo = getappdata(hFig, 'Surface'); + + case 'HeadPointsDistance' + TessInfo(iTess).Data = get(TessInfo(iTess).hPatch, 'CData'); + if isempty(TessInfo(iTess).Data) || ~isnumeric(TessInfo(iTess).Data) + isOk = 0; + return; + end + TessInfo(iTess).DataMinMax = [min(TessInfo(iTess).Data(:)), max(TessInfo(iTess).Data(:))]; + otherwise % Nothing to do end @@ -2001,7 +2033,7 @@ function UpdateSurfaceColormap(hFig, iSurfaces) TessInfo(iTess).Data = abs(TessInfo(iTess).Data); end % If current colormap is the default colormap for this figure (for colorbar) - if strcmpi(ColormapInfo.Type, TessInfo(iTess).ColormapType) && ~isempty(TessInfo(iTess).DataSource.FileName) + if strcmpi(ColormapInfo.Type, TessInfo(iTess).ColormapType) && ~isempty(TessInfo(iTess).DataSource.Type) if all(~isnan(TessInfo(iTess).DataLimitValue)) && (TessInfo(iTess).DataLimitValue(1) < TessInfo(iTess).DataLimitValue(2)) set(hAxes, 'CLim', TessInfo(iTess).DataLimitValue); else @@ -2017,7 +2049,8 @@ function UpdateSurfaceColormap(hFig, iSurfaces) end end % === DISPLAY ON MRI === - if strcmpi(TessInfo(iTess).Name, 'Anatomy') && ~isempty(TessInfo(iTess).DataSource.Type) && ~strcmpi(TessInfo(iTess).DataSource.Type, 'MriTime') && isempty(TessInfo(iTess).OverlayCube) + if strcmpi(TessInfo(iTess).Name, 'Anatomy') && ~isempty(TessInfo(iTess).DataSource.Type) && ... + ~strcmpi(TessInfo(iTess).DataSource.Type, 'MriTime') && isempty(TessInfo(iTess).OverlayCube) % Progress bar isProgressBar = bst_progress('isVisible'); bst_progress('start', 'Display MRI', 'Updating values...'); @@ -2029,6 +2062,10 @@ function UpdateSurfaceColormap(hFig, iSurfaces) if ~isProgressBar bst_progress('stop'); end + elseif strcmpi(TessInfo(iTess).Name, 'HeadPoints') + % No need to update surface color here, data already updated. + % Update figure's appdata (surface list) + setappdata(hFig, 'Surface', TessInfo); else % Update figure's appdata (surface list) setappdata(hFig, 'Surface', TessInfo); @@ -2047,13 +2084,39 @@ function UpdateSurfaceColormap(hFig, iSurfaces) %% ===== GET SURFACE ===== % Find a surface in a given 3DViz figure -function iTess = GetSurface(hFig, SurfaceFile) - % Check whether filename is an absolute or relative path - SurfaceFile = file_short(SurfaceFile); +% Usage: [iTess, TessInfo, hFig, sSurf] = GetSurface(hFig, SurfaceFile) +% [iTess, TessInfo, hFig, sSurf] = GetSurface(hFig, [], SurfaceType) +function [iTess, TessInfo, hFig, sSurf] = GetSurface(hFig, SurfaceFile, SurfaceType) + iTess = []; + sSurf = []; % Get figure appdata (surfaces configuration) TessInfo = getappdata(hFig, 'Surface'); - % Find the surface in the 3DViz figure - iTess = find(file_compare({TessInfo.SurfaceFile}, SurfaceFile)); + if nargin < 3 || (isempty(SurfaceType) && isempty(SurfaceFile)) + return; + end + if isempty(SurfaceFile) + % Search by type. + iTess = find(strcmpi({TessInfo.Name}, SurfaceType)); + if isempty(iTess) + return; + elseif numel(iTess) > 1 + % See if selected is one of them, otherwise return last. + iTessSel = getappdata(hFig, 'iSurface'); + if ismember(iTessSel, iTess) + iTess = iTessSel; + else + iTess = iTess(end); + end + end + else + % Check whether filename is an absolute or relative path + SurfaceFile = file_short(SurfaceFile); + % Find the surface in the 3DViz figure + iTess = find(file_compare({TessInfo.SurfaceFile}, SurfaceFile)); + end + if (nargout >= 4) && ~isempty(TessInfo) && ~isempty(iTess) + sSurf = bst_memory('GetSurface', TessInfo(iTess).SurfaceFile); + end end diff --git a/toolbox/math/bst_surfdist.m b/toolbox/math/bst_surfdist.m index 432a5fdfc..2db5580a5 100644 --- a/toolbox/math/bst_surfdist.m +++ b/toolbox/math/bst_surfdist.m @@ -19,20 +19,12 @@ % @============================================================================= % This function is part of the Brainstorm software: % https://neuroimage.usc.edu/brainstorm -<<<<<<< HEAD -% -======= % ->>>>>>> upstream/master % 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. -<<<<<<< HEAD -% -======= % ->>>>>>> upstream/master % 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_adjust_coordinates.m b/toolbox/process/functions/process_adjust_coordinates.m index 5e4214b97..e41dc8cee 100644 --- a/toolbox/process/functions/process_adjust_coordinates.m +++ b/toolbox/process/functions/process_adjust_coordinates.m @@ -1,11 +1,10 @@ function varargout = process_adjust_coordinates(varargin) % PROCESS_ADJUST_COORDINATES: Adjust, recompute, or remove various coordinate transformations. % -% 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. +% 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: @@ -125,9 +124,8 @@ 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. + % 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. @@ -154,13 +152,11 @@ % ---------------------------------------------------------------- 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 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. [ChannelMat, NewChannelFiles, Failed] = ... ResetChannelFile(ChannelMat, NewChannelFiles, sInputs(iFile), sProcess); @@ -170,13 +166,12 @@ % ---------------------------------------------------------------- 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 @@ -191,9 +186,8 @@ 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, we must edit the MRI - % history. + % We cannot change back the MRI fiducials, but in order to be able to update it again + % from digitized fids, we must edit the MRI history. if sProcess.options.points.Value && sProcess.options.scs.Value % Get subject in database, with subject directory sSubject = bst_get('Subject', sInputs(iFile).SubjectFile); @@ -231,12 +225,19 @@ if ~sProcess.options.remove.Value && sProcess.options.points.Value % Redundant, but makes sense to have it here also. - Tolerance = sProcess.options.tolerance.Value{1} / 100; bst_progress('text', 'Fitting head surface to 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, R, T, isSkip, isUserCancel, strReport] = channel_align_auto(sInputs(iFile).ChannelFile, ... - ChannelMat, 0, 0, Tolerance, sProcess.options.scs.Value); % No warning or confirmation - % ChannelFile needed to find subject and scalp surface, but not - % used otherwise when ChannelMat is provided. + ChannelMat, isWarning, 0, Tolerance, sProcess.options.scs.Value); % 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 @@ -260,10 +261,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 @@ -504,9 +504,8 @@ 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, ... @@ -579,9 +578,8 @@ 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) @@ -603,13 +601,11 @@ 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 @@ -644,13 +640,12 @@ 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 = []; @@ -659,9 +654,9 @@ 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. + % 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. @@ -685,11 +680,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, :) = []; @@ -699,23 +692,21 @@ 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 + % 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. + % 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 = []; @@ -766,11 +757,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 @@ -778,10 +767,9 @@ 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. @@ -801,14 +789,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 @@ -838,34 +824,17 @@ % % 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 - % - % 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. - % - % This program is distributed WITHOUT ANY WARRANTY. - % See the LICENSE file, or for details. + % 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. % - % 2012-05 + % 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). nDims = ndims(X); XSize = size(X); @@ -892,17 +861,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 @@ -910,8 +877,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 @@ -925,10 +891,9 @@ 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 diff --git a/toolbox/sensors/channel_align_auto.m b/toolbox/sensors/channel_align_auto.m index 77de94ab1..aa0f7931d 100644 --- a/toolbox/sensors/channel_align_auto.m +++ b/toolbox/sensors/channel_align_auto.m @@ -216,39 +216,9 @@ %% ===== ADJUST MRI FIDUCIALS AND SCS ===== if isAdjustScs - % Check if already adjusted, in which case the transformation above is correct (identity if same head points). - sMriOld = in_mri_bst(sSubject.Anatomy(sSubject.iAnatomy).FileName); - % History string is set in figure_mri SaveMri. - if isfield(sMriOld, 'History') && ~isempty(sMriOld.History) && any(strcmpi(sMriOld.History(:,3), 'Applied digitized anatomical fiducials')) - if isWarning - bst_warning('Nasion and ear points already adjusted.', 'Automatic EEG-MEG/MRI registration', 0); - end - % 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. - elseif all(isfield(ChannelMat.SCS, {'NAS','LPA','RPA'})) && (length(ChannelMat.SCS.NAS) == 3) && (length(ChannelMat.SCS.LPA) == 3) && (length(ChannelMat.SCS.RPA) == 3) - % Convert to MRI SCS coordinates. - % To do this we need to apply the transformation computed above. - sMri = sMriOld; - sMri.SCS.NAS = (DigToMriTransf(1:3,:) * [ChannelMat.SCS.NAS'; 1])'; - sMri.SCS.LPA = (DigToMriTransf(1:3,:) * [ChannelMat.SCS.LPA'; 1])'; - sMri.SCS.RPA = (DigToMriTransf(1:3,:) * [ChannelMat.SCS.RPA'; 1])'; - % Then convert to MRI coordinates (mm), this is how sMri.SCS is saved. - 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 - [unused, sMri] = cs_compute(sMri, 'scs'); - - % Compare with existing MRI fids, replace if changed, and update surfaces. - sMri.FileName = sSubject.Anatomy(sSubject.iAnatomy).FileName; - figure_mri('SaveMri', sMri); - - % Adjust transformation from headpoints fit above. MRI SCS now matches digitized SCS (defined from same points). - DigToMriTransf = eye(4); - R = eye(3); - T = zeros(3,1); - end + DigToMriTransf = channel_align_scs(ChannelFile, DigToMriTransf, isWarning, isConfirm); + R = DigToMriTransf(1:3,1:3); + T = DigToMriTransf(1:3,4); end diff --git a/toolbox/sensors/channel_align_manual.m b/toolbox/sensors/channel_align_manual.m index 01103b1b5..99426d66e 100644 --- a/toolbox/sensors/channel_align_manual.m +++ b/toolbox/sensors/channel_align_manual.m @@ -231,7 +231,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); ... @@ -364,7 +364,7 @@ gChanAlign.hButtonRefine = uipushtool(hToolbar, 'CData', java_geticon('ICON_ALIGN_CHANNELS'), 'TooltipString', 'Refine registration using head points', 'ClickedCallback', @RefineWithHeadPoints, 'separator', 'on'); gChanAlign.hButtonMoveChan = []; gChanAlign.hButtonProject = []; -else +else % isEeg gChanAlign.hButtonResizeX = uitoggletool(hToolbar, 'CData', java_geticon('ICON_RESIZE_X'), 'TooltipString', 'Resize/X: Press right button and move mouse up/down', 'ClickedCallback', @SelectOperation, 'separator', 'on'); gChanAlign.hButtonResizeY = uitoggletool(hToolbar, 'CData', java_geticon('ICON_RESIZE_Y'), 'TooltipString', 'Resize/Y: Press right button and move mouse up/down', 'ClickedCallback', @SelectOperation); gChanAlign.hButtonResizeZ = uitoggletool(hToolbar, 'CData', java_geticon('ICON_RESIZE_Z'), 'TooltipString', 'Resize/Z: Press right button and move mouse up/down', 'ClickedCallback', @SelectOperation); @@ -392,7 +392,8 @@ % else gChanAlign.hButtonAlign = []; % end -gChanAlign.hButtonOk = uipushtool( hToolbar, 'CData', java_geticon( 'ICON_OK'), 'separator', 'on', 'ClickedCallback', @buttonOk_Callback);% Update figure localization +gChanAlign.hButtonReset = uipushtool( hToolbar, 'CData', java_geticon('ICON_RELOAD'), 'separator', 'on', 'TooltipString', 'Reset: discard all changes', 'ClickedCallback', @buttonReset_Callback); +gChanAlign.hButtonOk = uipushtool( hToolbar, 'CData', java_geticon('ICON_OK'), 'TooltipString', 'Save & close', 'ClickedCallback', @buttonOk_Callback);% Update figure localization gui_layout('Update'); % Move a bit the figure to refresh it on all systems pos = get(gChanAlign.hFig, 'Position'); @@ -404,6 +405,60 @@ bst_progress('stop'); end +% Flag if auto or manual registration performed, and if MRI fids updated. Print to command +% window for now. +ChannelMat = in_bst_channel(ChannelFile); +iMriHist = find(strcmpi(sMri.History(:,3), 'Applied digitized anatomical fiducials'), 1, 'last'); +% Can also be reset, so check for 'import' action and ignore previous alignments. +iImport = find(strcmpi(ChannelMat.History(:,2), 'import'), 1, 'last'); +iAlign = find(strcmpi(ChannelMat.History(:,2), 'align')); +iAlign(iAlign < iImport) = []; +AlignType = 'none'; +while ~isempty(iAlign) + % Check which adjustment was done last. + switch lower(ChannelMat.History{iAlign(end),3}(1:5)) + case 'remov' + % Removed a previous step. Ignore corresponding adjustment and look again. + iAlign(end) = []; + if contains(ChannelMat.History{iAlign(end),3}, 'AdjustedNative') + iAlignRemoved = find(contains(ChannelMat.History(iAlign,3), 'AdjustedNative'), 1, 'last'); + elseif contains(ChannelMat.History{iAlign(end),3}, 'refine registration: head points') + iAlignRemoved = find(contains(ChannelMat.History(iAlign,3), 'AdjustedNative'), 1, 'last'); + elseif contains(ChannelMat.History{iAlign(end),3}, 'manual correction') + iAlignRemoved = find(contains(ChannelMat.History(iAlign,3), 'AdjustedNative'), 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' + % This alignment is between points and functional dataset, ignore here. + iAlign(end) = []; + case 'refin' + % Automatic MRI-points alignment + AlignType = 'auto'; + break; + case 'align' + % Manual MRI-points alignment + AlignType = 'manual'; + break; + end +end +disp(['BST> Previous registration adjustment: ' AlignType]); +if ~isempty(iMriHist) + % Compare digitized fids to MRI fids (in MRI coordinates, mm). + 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) + disp('BST> MRI fiducials updated, but different than digitized fiducials.'); + else + disp('BST> MRI fiducials updated, and match digitized fiducials.'); + end +end + end @@ -646,12 +701,10 @@ function UpdatePoints(iSelChan) Dist = bst_surfdist(gChanAlign.HeadPointsMarkersLoc, ... get(gChanAlign.hSurfacePatch, 'Vertices'), get(gChanAlign.hSurfacePatch, 'Faces')); set(gChanAlign.hHeadPointsMarkers, 'CData', Dist * 1000); - % Update colorbar scale - ColormapInfo = getappdata(gChanAlign.hFig, 'Colormap'); - ColormapType = 'stat1'; - if strcmpi(ColormapInfo.Type, ColormapType) - bst_colormaps('ConfigureColorbar', gChanAlign.hFig, ColormapType, 'stat', 'mm'); - end + % Update surface data and colorbar + TessInfo = getappdata(gChanAlign.hFig, 'Surface'); + iTessPoints = find(strcmpi({TessInfo.Name}, 'HeadPoints')); + panel_surface('UpdateSurfaceData', gChanAlign.hFig, iTessPoints); end % Fiducials if ~isempty(gChanAlign.hHeadPointsFid) @@ -854,17 +907,52 @@ function AlignKeyPress_Callback(hFig, keyEvent) function AlignClose_Callback(varargin) global gChanAlign; if gChanAlign.isChanged + isCancel = false; % Ask user to save changes (only if called as a callback) if (nargin == 3) SaveChanged = 1; else - SaveChanged = java_dialog('confirm', ['The sensors locations changed.' 10 10 ... - 'Would you like to save changes? ' 10 10], 'Align sensors'); + % If head points present, offer to update MRI anat fids to match digitized ones. + if gChanAlign.isHeadPoints + [Choice, isCancel] = java_dialog('question', ['The sensors locations changed.' 10 ... + 'Would you like to save changes?' 10 10], 'Align sensors', [], {'Yes', 'Update MRI', 'No'}, 'Yes'); + if strcmpi(Choice, 'Yes') + SaveChanged = 1; + else + SaveChanged = 0; + end + if strcmpi(Choice, 'Update MRI') + % If EEG, warn that only linear transformation would be saved this way. + if gChanAlign.isEeg + [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; + end + end + if ~isCancel + % Get final transformation matrix + Transform = eye(4); + Transform(1:3,1:3) = gChanAlign.FinalTransf(1:3,1:3); + Transform(1:3,4) = gChanAlign.FinalTransf(1:3,4); + % Update MRI (and surfaces) + [~, isCancel] = channel_align_scs(gChanAlign.ChannelFile, Transform, 1, 1); % warn & confirm + end + end + else % no head points + [SaveChanged, isCancel] = java_dialog('confirm', ['The sensors locations changed.' 10 10 ... + 'Would you like to save changes? ' 10 10], 'Align sensors'); + end end - % Progress bar - bst_progress('start', 'Align sensors', 'Updating channel file...'); - % Save changes and close figure + % Don't close figure if cancelled. + if isCancel + return; + end + % Save changes to channel file and close figure if SaveChanged + % 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; @@ -878,14 +966,14 @@ function AlignClose_Callback(varargin) [sStudy, iStudy] = bst_get('ChannelFile', gChanAlign.ChannelFile); % Reload study file db_reload_studies(iStudy); + bst_progress('stop'); end - bst_progress('stop'); else SaveChanged = 0; end % Only close figure gChanAlign.Figure3DCloseRequest_Bak(varargin{1:2}); - % Apply to other recordings in the same subject + % Apply to other recordings with same sensor locations in the same subject if SaveChanged CopyToOtherFolders(ChannelMatOrig, iStudy, Transf, iChannels); end @@ -930,7 +1018,7 @@ function CopyToOtherFolders(ChannelMatSrc, iStudySrc, Transf, iChannels) end % Check if the positions of the sensors are similar distLoc = sqrt((locDest(1,:) - locSrc(1,:)).^2 + (locDest(2,:) - locSrc(2,:)).^2 + (locDest(3,:) - locSrc(3,:)).^2); - % If the sensors are more than 5mm apart in average: skip + % If any sensors are more than 5mm apart: skip if any(distLoc > 0.005) continue; end @@ -1153,13 +1241,21 @@ function RefineWithHeadPoints(varargin) end -%% ===== VALIDATION BUTTONS ===== +%% ===== VALIDATION BUTTON ===== function buttonOk_Callback(varargin) global gChanAlign; % Close 3DViz figure close(gChanAlign.hFig); end +%% ===== RESET BUTTON ===== +function buttonReset_Callback(varargin) + global gChanAlign; + % Close figure + gChanAlign.Figure3DCloseRequest_Bak(gChanAlign.hFig, []); + % Call function again, which resets gChanAlign. + channel_align_manual(gChanAlign.ChannelFile, gChanAlign.Modality, 1); +end %% ===== REMOVE ELECTRODES ===== function RemoveElectrodes(varargin) diff --git a/toolbox/sensors/channel_align_scs.m b/toolbox/sensors/channel_align_scs.m new file mode 100644 index 000000000..14a439079 --- /dev/null +++ b/toolbox/sensors/channel_align_scs.m @@ -0,0 +1,122 @@ +function [Transform, isCancel] = channel_align_scs(ChannelFile, Transform, isWarning, isConfirm) +% CHANNEL_ALIGN_SCS: Saves new MRI anatomical points after manual or auto registration adjustment. +% +% USAGE: Transform = channel_align_scs(ChannelFile, isWarning=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), instead of saving a +% registration adjustment transformation for a single functional dataset. 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 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 isWarning and isConfirm are false). In that case, the 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. +% - isWarning : If 1, display warning in case of errors, or if this was already done +% previously for this MRI. +% - isConfirm : If 1, 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 not, it is the same as the input Transform. + +% @============================================================================= +% 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 2022 + +isCancel = false; +% Get study +sStudy = bst_get('ChannelFile', ChannelFile); +% Get subject +[sSubject, iSubject] = bst_get('Subject', sStudy.BrainStormSubject); +% 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) + if isWarning + bst_error('Digitized nasion and ear points not found.', 'Apply digitized anatomical fiducials to MRI', 0); + else + disp('BST> Digitized nasion and ear points not found.'); + end + isCancel = true; + return; +end + +% Check if already adjusted. + sMriOld = in_mri_bst(sSubject.Anatomy(sSubject.iAnatomy).FileName); + % History string is set in figure_mri SaveMri. + if isfield(sMriOld, 'History') && ~isempty(sMriOld.History) && any(strcmpi(sMriOld.History(:,3), 'Applied digitized anatomical fiducials')) + % Already done previously. + if isWarning || 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. + disp('BST> Digitized nasion and ear points previously applied to this MRI. Not applying again.'); + return; + end + elseif isConfirm + % Request confirmation. + [Proceed, isCancel] = java_dialog('confirm', ['Updating the MRI fiducial points NAS/LPA/RPA will break any' 10 ... + 'previous alignment with functional datasets.' 10 10 ... + 'Proceed and update MRI now?' 10], 'Head points/anatomy registration'); + if ~Proceed || isCancel + isCancel = true; + return; + end + end + % Convert to MRI SCS coordinates. + % To do this we need to apply the transformation provided. + sMri = sMriOld; + 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. + 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 + [unused, 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); + + % Adjust transformation. MRI SCS now matches digitized SCS (defined from same points). + Transform = eye(4); +end diff --git a/toolbox/tree/tree_callbacks.m b/toolbox/tree/tree_callbacks.m index 5d3fd6e87..fc75049fa 100644 --- a/toolbox/tree/tree_callbacks.m +++ b/toolbox/tree/tree_callbacks.m @@ -224,6 +224,8 @@ DisplayChannels(bstNodes, DisplayMod{1}, 'anatomy', 1); elseif strcmpi(DisplayMod{1}, 'ECOG') DisplayChannels(bstNodes, DisplayMod{1}, 'cortex', 1); + elseif ismember(DisplayMod{1}, {'MEG','MEG GRAD','MEG MAG'}) + channel_align_manual(filenameRelative, DisplayMod{1}, 0) elseif strcmpi(DisplayMod{1}, 'NIRS') DisplayChannels(bstNodes, 'NIRS-BRS', 'scalp', [], 1); else From a91ac60d390fc93670d386aa137a461f4cf058a8 Mon Sep 17 00:00:00 2001 From: Marc Lalancette <31040756+Moo-Marc@users.noreply.github.com> Date: Fri, 18 Nov 2022 17:14:43 -0500 Subject: [PATCH 23/47] fix head point distance coloring & add manual registration reset button --- toolbox/core/bst_colormaps.m | 28 +++---- toolbox/gui/figure_3d.m | 58 ++++---------- toolbox/gui/panel_surface.m | 87 ++++++++++++++++++--- toolbox/sensors/channel_align_manual.m | 104 ++++++++++++++++++++----- 4 files changed, 188 insertions(+), 89 deletions(-) diff --git a/toolbox/core/bst_colormaps.m b/toolbox/core/bst_colormaps.m index ce73922b9..47d27ef65 100644 --- a/toolbox/core/bst_colormaps.m +++ b/toolbox/core/bst_colormaps.m @@ -376,18 +376,20 @@ function SetMaxCustom(ColormapType, DisplayUnits, newMin, newMax) case {'3DViz', 'MriViewer'} % Get surfaces defined in this figure TessInfo = getappdata(sFigure.hFigure, 'Surface'); - DataFig = TessInfo.DataMinMax; - if ~isempty(TessInfo.DataSource.Type) - DataType = TessInfo.DataSource.Type; - isSLORETA = strcmpi(DataType, 'Source') && ~isempty(strfind(lower(TessInfo.DataSource.FileName), 'sloreta')); - if isSLORETA - DataType = 'sLORETA'; - end - elseif isempty(DataFig) - % If displaying color-coded head points (see channel_align_manual) - HeadpointsDistMax = getappdata(sFigure.hFigure, 'HeadpointsDistMax'); - if ~isempty(HeadpointsDistMax) - DataFig = [0, HeadpointsDistMax * 1000]; + % Find surface(s) that matches this ColormapType + iSurfaces = find(strcmpi({TessInfo.ColormapType}, ColormapType)); + DataFig = []; + for i = 1:length(iSurfaces) + iTess = iSurfaces(i); + DataFig = [min([DataFig(:); TessInfo(iTess).DataMinMax(:)]), ... + max([DataFig(:); TessInfo(iTess).DataMinMax(:)])]; + if ~isempty(TessInfo(iTess).DataSource.Type) + % We'll keep the last non-empty DataType + DataType = TessInfo(iTess).DataSource.Type; + isSLORETA = strcmpi(DataType, 'Source') && ~isempty(strfind(lower(TessInfo(iTess).DataSource.FileName), 'sloreta')); + if isSLORETA + DataType = 'sLORETA'; + end end end @@ -452,7 +454,7 @@ function SetMaxCustom(ColormapType, DisplayUnits, newMin, newMax) elseif isequal(DisplayUnits, '%') || isequal(DisplayUnits, 'mm') fFactor = 1; fUnits = DisplayUnits; - else + else % Guess the display units [tmp, fFactor, fUnits ] = bst_getunits(amplitudeMax, DataType); % For readability: replace '\sigma' with 'no units' diff --git a/toolbox/gui/figure_3d.m b/toolbox/gui/figure_3d.m index eeb1c3baf..19184d41f 100644 --- a/toolbox/gui/figure_3d.m +++ b/toolbox/gui/figure_3d.m @@ -169,11 +169,6 @@ function ColormapChangedCallback(iDS, iFig) %#ok if ~isempty(getappdata(hFig, 'Dipoles')) && gui_brainstorm('isTabVisible', 'Dipoles') panel_dipoles('PlotSelectedDipoles', hFig); end - % If displaying color-coded head points (see channel_align_manual) - HeadpointsDistMax = getappdata(hFig, 'HeadpointsDistMax'); - if ~isempty(HeadpointsDistMax) - UpdateHeadPointsColormap(hFig); - end end @@ -3772,7 +3767,7 @@ function ViewHeadPoints(hFig, isVisible, isColorDist) [HeadPoints.Loc(1,iDupli), HeadPoints.Loc(2,iDupli), HeadPoints.Loc(3,iDupli)] = sph2cart(th, phi, r - 0.0001); end - % Else, get previous head points + % Look for previous head points hHeadPointsMarkers = findobj(hAxes, 'Tag', 'HeadPointsMarkers'); hHeadPointsLabels = findobj(hAxes, 'Tag', 'HeadPointsLabels'); % If head points graphic objects already exist: set the "Visible" property @@ -3787,22 +3782,19 @@ function ViewHeadPoints(hFig, isVisible, isColorDist) ColormapType = 'stat1'; if isColorDist && ~strcmpi(get(hHeadPointsMarkers, 'MarkerFaceColor'), 'flat') % Color points according to distance to surface - % Get selected surface - [iTess, TessInfo, hFig, sSurf] = panel_surface('GetSelectedSurface', hFig); + % Get scalp surface + [~, ~, hFig, sSurf] = panel_surface('GetSurface', hFig, '', 'Scalp'); if ~isempty(sSurf) && isfield(sSurf, 'Vertices') && ~isempty(sSurf.Vertices) - if ~ismember(ColormapType, ColormapInfo.AllTypes) - % Add missing colormap (color was toggled after points were displayed) - bst_colormaps('AddColormapToFigure', hFig, ColormapType, 'mm'); - ColormapInfo = getappdata(hFig, 'Colormap'); - end % Compute the distance Dist = bst_surfdist(get(hHeadPointsMarkers, 'Vertices'), sSurf.Vertices, sSurf.Faces); set(hHeadPointsMarkers, 'CData', Dist * 1000, ... 'MarkerFaceColor', 'flat', 'MarkerEdgeColor', 'flat'); - setappdata(hFig, 'HeadpointsDistMax', max(Dist)); - if strcmpi(ColormapInfo.Type, ColormapType) - ColormapChangedCallback(iDS, iFig); - bst_colormaps('SetColorbarVisible', hFig, 1); + if ~ismember(ColormapType, ColormapInfo.AllTypes) + iTess = panel_surface('GetSurface', hFig, '', 'HeadPoints'); + if isempty(iTess) + error('HeadPoints surface not found.'); + end + panel_surface('SetSurfaceData', hFig, iTess, 'HeadPointsDistance', '', 1); % isStat=1 end end elseif ~isColorDist && strcmpi(get(hHeadPointsMarkers, 'MarkerFaceColor'), 'flat') @@ -3890,15 +3882,13 @@ function ViewHeadPoints(hFig, isVisible, isColorDist) if ~isempty(iExtra) % Color code points by distance if isColorDist - % Get selected surface - [iTess, TessInfo, hFig, sSurf] = panel_surface('GetSelectedSurface', hFig); + % Get scalp surface + [~, ~, hFig, sSurf] = panel_surface('GetSurface', hFig, '', 'Scalp'); % Compute the distance Dist = bst_surfdist(digLoc(iExtra, :), sSurf.Vertices, sSurf.Faces); CData = Dist * 1000; % mm - setappdata(hFig, 'HeadpointsDistMax', max(Dist)); MarkerFaceColor = 'flat'; MarkerEdgeColor = 'flat'; - bst_colormaps('AddColormapToFigure', hFig, 'stat1', 'mm'); else CData = 'w'; % any color, not displayed MarkerFaceColor = [.3 1 .3]; @@ -3915,36 +3905,16 @@ function ViewHeadPoints(hFig, isVisible, isColorDist) 'Marker', 'o', ... 'UserData', iExtra, ... 'Tag', 'HeadPointsMarkers'); + % Add points patch to figure surfaces so all color bar functionality work. + iTess = panel_surface('AddSurface', hFig, '', 'HeadPoints'); if isColorDist - ColormapChangedCallback(iDS, iFig); + panel_surface('SetSurfaceData', hFig, iTess, 'HeadPointsDistance', '', 1); % isStat=1 end end end end -%% ===== UPDATE HEADPOINTS COLORMAP ===== -function UpdateHeadPointsColormap(hFig) - % If not using color-coded display - hHeadPointsMarkers = findobj(hFig, 'Tag', 'HeadPointsMarkers'); - if ~strcmpi(get(hHeadPointsMarkers, 'MarkerFaceColor'), 'flat') - return; - end - % Get colormap configuration - sColormap = bst_colormaps('GetColormap', 'stat1'); - % Update axes color limits, which will update de colorbar - hAxes = get(hHeadPointsMarkers, 'Parent'); - if strcmpi(sColormap.MaxMode, 'custom') - set(hAxes, 'CLim', [sColormap.MinValue, sColormap.MaxValue]); - else - HeadpointsDistMax = getappdata(hFig, 'HeadpointsDistMax'); - set(hAxes, 'CLim', [0, HeadpointsDistMax * 1000]); - end - % Update colorbar - bst_colormaps('ConfigureColorbar', hFig, 'stat1', 'stat', 'mm'); -end - - %% ===== VIEW AXIS ===== function ViewAxis(hFig, isVisible) hAxes = findobj(hFig, '-depth', 1, 'Tag', 'Axes3D'); diff --git a/toolbox/gui/panel_surface.m b/toolbox/gui/panel_surface.m index 7460f5c24..36adc98ca 100644 --- a/toolbox/gui/panel_surface.m +++ b/toolbox/gui/panel_surface.m @@ -1130,15 +1130,20 @@ function UpdateSurfaceProperties() %% ===== ADD A SURFACE ===== % Add a surface to a given 3DViz figure % USAGE : [iTess, TessInfo] = panel_surface('AddSurface', hFig, surfaceFile) +% [iTess, TessInfo] = panel_surface('AddSurface', hFig, [], surfaceType) % OUTPUT: Indice of the surface in the figure's surface array -function [iTess, TessInfo] = AddSurface(hFig, surfaceFile) +function [iTess, TessInfo] = AddSurface(hFig, surfaceFile, surfaceType) % ===== CHECK EXISTENCE ===== - % Check whether filename is an absolute or relative path - surfaceFile = file_short(surfaceFile); % Get figure appdata (surfaces configuration) TessInfo = getappdata(hFig, 'Surface'); % Check that this surface is not already displayed in 3DViz figure - iTess = find(file_compare({TessInfo.SurfaceFile}, surfaceFile)); + if isempty(surfaceFile) && nargin > 2 + iTess = find(file_compare({TessInfo.Name}, surfaceType)); + else + % Check whether filename is an absolute or relative path + surfaceFile = file_short(surfaceFile); + iTess = find(file_compare({TessInfo.SurfaceFile}, surfaceFile)); + end if ~isempty(iTess) disp('BST> This surface is already displayed. Ignoring...'); return @@ -1161,6 +1166,9 @@ function UpdateSurfaceProperties() % ===== PLOT OBJECT ===== % Get file type (tessalation or MRI) fileType = file_gettype(surfaceFile); + if strcmpi(fileType, 'unknown') && nargin > 2 + fileType = surfaceType; + end % === TESSELATION === if any(strcmpi(fileType, {'cortex','scalp','innerskull','outerskull','tess'})) % === LOAD SURFACE === @@ -1291,8 +1299,18 @@ function UpdateSurfaceProperties() setappdata(hFig, 'Surface', TessInfo); end + % === NO FILE: HeadPoints === + elseif strcmpi(fileType, 'HeadPoints') + % Points were already displayed; just add the patch to the figure surfaces. + TessInfo(iTess).DataSource.Type = 'HeadPointsDistance'; + TessInfo(iTess).Name = 'HeadPoints'; + TessInfo(iTess).ColormapType = 'stat1'; + TessInfo(iTess).hPatch = findobj(hFig, 'Tag', 'HeadPointsMarkers'); + % Update figure's surfaces list and current surface pointer + setappdata(hFig, 'Surface', TessInfo); + % === FEM === - else + else % TODO: Check for FEM fileType explicitly view_surface_fem(surfaceFile, [], [], [], hFig); end % Update default surface @@ -1496,6 +1514,11 @@ function UpdateSurfaceProperties() DisplayUnits = []; TessInfo(iTess).Data = []; TessInfo(iTess).DataWmat = []; + + case 'HeadPointsDistance' + ColormapType = 'stat1'; + DisplayUnits = 'mm'; + % Data is actually added in UpdateSurfaceData below. otherwise ColormapType = ''; @@ -1878,6 +1901,15 @@ function UpdateSurfaceProperties() figure_callback(hFig, 'UpdateSurfaceColor', hFig, iTess); % Get updated surface definition TessInfo = getappdata(hFig, 'Surface'); + + case 'HeadPointsDistance' + TessInfo(iTess).Data = get(TessInfo(iTess).hPatch, 'CData'); + if isempty(TessInfo(iTess).Data) || ~isnumeric(TessInfo(iTess).Data) + isOk = 0; + return; + end + TessInfo(iTess).DataMinMax = [min(TessInfo(iTess).Data(:)), max(TessInfo(iTess).Data(:))]; + otherwise % Nothing to do end @@ -2001,7 +2033,7 @@ function UpdateSurfaceColormap(hFig, iSurfaces) TessInfo(iTess).Data = abs(TessInfo(iTess).Data); end % If current colormap is the default colormap for this figure (for colorbar) - if strcmpi(ColormapInfo.Type, TessInfo(iTess).ColormapType) && ~isempty(TessInfo(iTess).DataSource.FileName) + if strcmpi(ColormapInfo.Type, TessInfo(iTess).ColormapType) && ~isempty(TessInfo(iTess).DataSource.Type) if all(~isnan(TessInfo(iTess).DataLimitValue)) && (TessInfo(iTess).DataLimitValue(1) < TessInfo(iTess).DataLimitValue(2)) set(hAxes, 'CLim', TessInfo(iTess).DataLimitValue); else @@ -2017,7 +2049,8 @@ function UpdateSurfaceColormap(hFig, iSurfaces) end end % === DISPLAY ON MRI === - if strcmpi(TessInfo(iTess).Name, 'Anatomy') && ~isempty(TessInfo(iTess).DataSource.Type) && ~strcmpi(TessInfo(iTess).DataSource.Type, 'MriTime') && isempty(TessInfo(iTess).OverlayCube) + if strcmpi(TessInfo(iTess).Name, 'Anatomy') && ~isempty(TessInfo(iTess).DataSource.Type) && ... + ~strcmpi(TessInfo(iTess).DataSource.Type, 'MriTime') && isempty(TessInfo(iTess).OverlayCube) % Progress bar isProgressBar = bst_progress('isVisible'); bst_progress('start', 'Display MRI', 'Updating values...'); @@ -2029,6 +2062,10 @@ function UpdateSurfaceColormap(hFig, iSurfaces) if ~isProgressBar bst_progress('stop'); end + elseif strcmpi(TessInfo(iTess).Name, 'HeadPoints') + % No need to update surface color here, data already updated. + % Update figure's appdata (surface list) + setappdata(hFig, 'Surface', TessInfo); else % Update figure's appdata (surface list) setappdata(hFig, 'Surface', TessInfo); @@ -2047,13 +2084,39 @@ function UpdateSurfaceColormap(hFig, iSurfaces) %% ===== GET SURFACE ===== % Find a surface in a given 3DViz figure -function iTess = GetSurface(hFig, SurfaceFile) - % Check whether filename is an absolute or relative path - SurfaceFile = file_short(SurfaceFile); +% Usage: [iTess, TessInfo, hFig, sSurf] = GetSurface(hFig, SurfaceFile) +% [iTess, TessInfo, hFig, sSurf] = GetSurface(hFig, [], SurfaceType) +function [iTess, TessInfo, hFig, sSurf] = GetSurface(hFig, SurfaceFile, SurfaceType) + iTess = []; + sSurf = []; % Get figure appdata (surfaces configuration) TessInfo = getappdata(hFig, 'Surface'); - % Find the surface in the 3DViz figure - iTess = find(file_compare({TessInfo.SurfaceFile}, SurfaceFile)); + if nargin < 3 || (isempty(SurfaceType) && isempty(SurfaceFile)) + return; + end + if isempty(SurfaceFile) + % Search by type. + iTess = find(strcmpi({TessInfo.Name}, SurfaceType)); + if isempty(iTess) + return; + elseif numel(iTess) > 1 + % See if selected is one of them, otherwise return last. + iTessSel = getappdata(hFig, 'iSurface'); + if ismember(iTessSel, iTess) + iTess = iTessSel; + else + iTess = iTess(end); + end + end + else + % Check whether filename is an absolute or relative path + SurfaceFile = file_short(SurfaceFile); + % Find the surface in the 3DViz figure + iTess = find(file_compare({TessInfo.SurfaceFile}, SurfaceFile)); + end + if (nargout >= 4) && ~isempty(TessInfo) && ~isempty(iTess) + sSurf = bst_memory('GetSurface', TessInfo(iTess).SurfaceFile); + end end diff --git a/toolbox/sensors/channel_align_manual.m b/toolbox/sensors/channel_align_manual.m index c4b5e5cef..0b1fce4d0 100644 --- a/toolbox/sensors/channel_align_manual.m +++ b/toolbox/sensors/channel_align_manual.m @@ -231,7 +231,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); ... @@ -364,7 +364,7 @@ gChanAlign.hButtonRefine = uipushtool(hToolbar, 'CData', java_geticon('ICON_ALIGN_CHANNELS'), 'TooltipString', 'Refine registration using head points', 'ClickedCallback', @RefineWithHeadPoints, 'separator', 'on'); gChanAlign.hButtonMoveChan = []; gChanAlign.hButtonProject = []; -else +else % isEeg gChanAlign.hButtonResizeX = uitoggletool(hToolbar, 'CData', java_geticon('ICON_RESIZE_X'), 'TooltipString', 'Resize/X: Press right button and move mouse up/down', 'ClickedCallback', @SelectOperation, 'separator', 'on'); gChanAlign.hButtonResizeY = uitoggletool(hToolbar, 'CData', java_geticon('ICON_RESIZE_Y'), 'TooltipString', 'Resize/Y: Press right button and move mouse up/down', 'ClickedCallback', @SelectOperation); gChanAlign.hButtonResizeZ = uitoggletool(hToolbar, 'CData', java_geticon('ICON_RESIZE_Z'), 'TooltipString', 'Resize/Z: Press right button and move mouse up/down', 'ClickedCallback', @SelectOperation); @@ -392,7 +392,8 @@ % else gChanAlign.hButtonAlign = []; % end -gChanAlign.hButtonOk = uipushtool( hToolbar, 'CData', java_geticon( 'ICON_OK'), 'separator', 'on', 'ClickedCallback', @buttonOk_Callback);% Update figure localization +gChanAlign.hButtonReset = uipushtool( hToolbar, 'CData', java_geticon('ICON_RELOAD'), 'separator', 'on', 'TooltipString', 'Reset: discard all changes', 'ClickedCallback', @buttonReset_Callback); +gChanAlign.hButtonOk = uipushtool( hToolbar, 'CData', java_geticon('ICON_OK'), 'TooltipString', 'Save & close', 'ClickedCallback', @buttonOk_Callback);% Update figure localization gui_layout('Update'); % Move a bit the figure to refresh it on all systems pos = get(gChanAlign.hFig, 'Position'); @@ -404,6 +405,60 @@ bst_progress('stop'); end +% Flag if auto or manual registration performed, and if MRI fids updated. Print to command +% window for now. +ChannelMat = in_bst_channel(ChannelFile); +iMriHist = find(strcmpi(sMri.History(:,3), 'Applied digitized anatomical fiducials'), 1, 'last'); +% Can also be reset, so check for 'import' action and ignore previous alignments. +iImport = find(strcmpi(ChannelMat.History(:,2), 'import'), 1, 'last'); +iAlign = find(strcmpi(ChannelMat.History(:,2), 'align')); +iAlign(iAlign < iImport) = []; +AlignType = 'none'; +while ~isempty(iAlign) + % Check which adjustment was done last. + switch lower(ChannelMat.History{iAlign(end),3}(1:5)) + case 'remov' + % Removed a previous step. Ignore corresponding adjustment and look again. + iAlign(end) = []; + if contains(ChannelMat.History{iAlign(end),3}, 'AdjustedNative') + iAlignRemoved = find(contains(ChannelMat.History(iAlign,3), 'AdjustedNative'), 1, 'last'); + elseif contains(ChannelMat.History{iAlign(end),3}, 'refine registration: head points') + iAlignRemoved = find(contains(ChannelMat.History(iAlign,3), 'AdjustedNative'), 1, 'last'); + elseif contains(ChannelMat.History{iAlign(end),3}, 'manual correction') + iAlignRemoved = find(contains(ChannelMat.History(iAlign,3), 'AdjustedNative'), 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' + % This alignment is between points and functional dataset, ignore here. + iAlign(end) = []; + case 'refin' + % Automatic MRI-points alignment + AlignType = 'auto'; + break; + case 'align' + % Manual MRI-points alignment + AlignType = 'manual'; + break; + end +end +disp(['BST> Previous registration adjustment: ' AlignType]); +if ~isempty(iMriHist) + % Compare digitized fids to MRI fids (in MRI coordinates, mm). + 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) + disp('BST> MRI fiducials updated, but different than digitized fiducials.'); + else + disp('BST> MRI fiducials updated, and match digitized fiducials.'); + end +end + end @@ -646,15 +701,10 @@ function UpdatePoints(iSelChan) Dist = bst_surfdist(gChanAlign.HeadPointsMarkersLoc, ... get(gChanAlign.hSurfacePatch, 'Vertices'), get(gChanAlign.hSurfacePatch, 'Faces')); set(gChanAlign.hHeadPointsMarkers, 'CData', Dist * 1000); - % Update axes maximum - setappdata(gChanAlign.hFig, 'HeadpointsDistMax', max(Dist)); - figure_3d('UpdateHeadPointsColormap', gChanAlign.hFig); - % Update colorbar scale - ColormapInfo = getappdata(gChanAlign.hFig, 'Colormap'); - ColormapType = 'stat1'; - if strcmpi(ColormapInfo.Type, ColormapType) - bst_colormaps('ConfigureColorbar', gChanAlign.hFig, ColormapType, 'stat', 'mm'); - end + % Update surface data and colorbar + TessInfo = getappdata(gChanAlign.hFig, 'Surface'); + iTessPoints = find(strcmpi({TessInfo.Name}, 'HeadPoints')); + panel_surface('UpdateSurfaceData', gChanAlign.hFig, iTessPoints); end % Fiducials if ~isempty(gChanAlign.hHeadPointsFid) @@ -857,17 +907,23 @@ function AlignKeyPress_Callback(hFig, keyEvent) function AlignClose_Callback(varargin) global gChanAlign; if gChanAlign.isChanged + isCancel = false; % Ask user to save changes (only if called as a callback) if (nargin == 3) SaveChanged = 1; else SaveChanged = java_dialog('confirm', ['The sensors locations changed.' 10 10 ... - 'Would you like to save changes? ' 10 10], 'Align sensors'); + 'Would you like to save changes? ' 10 10], 'Align sensors'); + end end - % Progress bar - bst_progress('start', 'Align sensors', 'Updating channel file...'); - % Save changes and close figure + % Don't close figure if cancelled. + if isCancel + return; + end + % Save changes to channel file and close figure if SaveChanged + % 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; @@ -881,14 +937,14 @@ function AlignClose_Callback(varargin) [sStudy, iStudy] = bst_get('ChannelFile', gChanAlign.ChannelFile); % Reload study file db_reload_studies(iStudy); + bst_progress('stop'); end - bst_progress('stop'); else SaveChanged = 0; end % Only close figure gChanAlign.Figure3DCloseRequest_Bak(varargin{1:2}); - % Apply to other recordings in the same subject + % Apply to other recordings with same sensor locations in the same subject if SaveChanged CopyToOtherFolders(ChannelMatOrig, iStudy, Transf, iChannels); end @@ -933,7 +989,7 @@ function CopyToOtherFolders(ChannelMatSrc, iStudySrc, Transf, iChannels) end % Check if the positions of the sensors are similar distLoc = sqrt((locDest(1,:) - locSrc(1,:)).^2 + (locDest(2,:) - locSrc(2,:)).^2 + (locDest(3,:) - locSrc(3,:)).^2); - % If the sensors are more than 5mm apart in average: skip + % If any sensors are more than 5mm apart: skip if any(distLoc > 0.005) continue; end @@ -1156,13 +1212,21 @@ function RefineWithHeadPoints(varargin) end -%% ===== VALIDATION BUTTONS ===== +%% ===== VALIDATION BUTTON ===== function buttonOk_Callback(varargin) global gChanAlign; % Close 3DViz figure close(gChanAlign.hFig); end +%% ===== RESET BUTTON ===== +function buttonReset_Callback(varargin) + global gChanAlign; + % Close figure + gChanAlign.Figure3DCloseRequest_Bak(gChanAlign.hFig, []); + % Call function again, which resets gChanAlign. + channel_align_manual(gChanAlign.ChannelFile, gChanAlign.Modality, 1); +end %% ===== REMOVE ELECTRODES ===== function RemoveElectrodes(varargin) From ea3ea6d46b4043dfaa84ea5d5c09e8292aaa3628 Mon Sep 17 00:00:00 2001 From: Marc Lalancette <31040756+Moo-Marc@users.noreply.github.com> Date: Fri, 18 Nov 2022 17:42:15 -0500 Subject: [PATCH 24/47] small code cleaning --- toolbox/core/bst_colormaps.m | 13 ++++++++----- toolbox/gui/panel_surface.m | 2 +- toolbox/sensors/channel_align_manual.m | 1 - 3 files changed, 9 insertions(+), 7 deletions(-) diff --git a/toolbox/core/bst_colormaps.m b/toolbox/core/bst_colormaps.m index 47d27ef65..24ad47480 100644 --- a/toolbox/core/bst_colormaps.m +++ b/toolbox/core/bst_colormaps.m @@ -381,13 +381,16 @@ function SetMaxCustom(ColormapType, DisplayUnits, newMin, newMax) DataFig = []; for i = 1:length(iSurfaces) iTess = iSurfaces(i); - DataFig = [min([DataFig(:); TessInfo(iTess).DataMinMax(:)]), ... - max([DataFig(:); TessInfo(iTess).DataMinMax(:)])]; 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; - isSLORETA = strcmpi(DataType, 'Source') && ~isempty(strfind(lower(TessInfo(iTess).DataSource.FileName), 'sloreta')); - if isSLORETA + % 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'; end end @@ -454,7 +457,7 @@ function SetMaxCustom(ColormapType, DisplayUnits, newMin, newMax) elseif isequal(DisplayUnits, '%') || isequal(DisplayUnits, 'mm') fFactor = 1; fUnits = DisplayUnits; - else + else % Guess the display units [tmp, fFactor, fUnits ] = bst_getunits(amplitudeMax, DataType); % For readability: replace '\sigma' with 'no units' diff --git a/toolbox/gui/panel_surface.m b/toolbox/gui/panel_surface.m index 36adc98ca..48c3eee15 100644 --- a/toolbox/gui/panel_surface.m +++ b/toolbox/gui/panel_surface.m @@ -1302,7 +1302,7 @@ function UpdateSurfaceProperties() % === NO FILE: HeadPoints === elseif strcmpi(fileType, 'HeadPoints') % Points were already displayed; just add the patch to the figure surfaces. - TessInfo(iTess).DataSource.Type = 'HeadPointsDistance'; + %TessInfo(iTess).DataSource.Type = 'HeadPointsDistance'; TessInfo(iTess).Name = 'HeadPoints'; TessInfo(iTess).ColormapType = 'stat1'; TessInfo(iTess).hPatch = findobj(hFig, 'Tag', 'HeadPointsMarkers'); diff --git a/toolbox/sensors/channel_align_manual.m b/toolbox/sensors/channel_align_manual.m index 0b1fce4d0..f1e820db0 100644 --- a/toolbox/sensors/channel_align_manual.m +++ b/toolbox/sensors/channel_align_manual.m @@ -914,7 +914,6 @@ function AlignClose_Callback(varargin) else SaveChanged = java_dialog('confirm', ['The sensors locations changed.' 10 10 ... 'Would you like to save changes? ' 10 10], 'Align sensors'); - end end % Don't close figure if cancelled. if isCancel From e8ceedb21721202492d67ab51b7c2623e2eee966 Mon Sep 17 00:00:00 2001 From: Marc Lalancette <31040756+Moo-Marc@users.noreply.github.com> Date: Tue, 22 Nov 2022 15:47:31 -0500 Subject: [PATCH 25/47] undo adding head points as figure surface --- toolbox/core/bst_colormaps.m | 9 +- toolbox/gui/figure_3d.m | 54 ++- toolbox/gui/panel_surface.m | 49 +-- .../functions/process_adjust_coordinates.m | 352 ++++++++++-------- toolbox/sensors/channel_align_manual.m | 70 +--- 5 files changed, 252 insertions(+), 282 deletions(-) diff --git a/toolbox/core/bst_colormaps.m b/toolbox/core/bst_colormaps.m index 24ad47480..aefe462aa 100644 --- a/toolbox/core/bst_colormaps.m +++ b/toolbox/core/bst_colormaps.m @@ -376,7 +376,7 @@ function SetMaxCustom(ColormapType, DisplayUnits, newMin, newMax) case {'3DViz', 'MriViewer'} % Get surfaces defined in this figure TessInfo = getappdata(sFigure.hFigure, 'Surface'); - % Find surface(s) that matches this ColormapType + % Find surfaces that match this ColormapType iSurfaces = find(strcmpi({TessInfo.ColormapType}, ColormapType)); DataFig = []; for i = 1:length(iSurfaces) @@ -395,6 +395,13 @@ function SetMaxCustom(ColormapType, DisplayUnits, newMin, newMax) end end end + if isempty(DataFig) + % If displaying color-coded head points (see channel_align_manual) + HeadpointsDistMax = getappdata(sFigure.hFigure, 'HeadpointsDistMax'); + if ~isempty(HeadpointsDistMax) + DataFig = [0, HeadpointsDistMax * 1000]; + end + end case 'Pac' DataFig = GlobalData.DataSet(iDS).Figure(iFig).Handles.DataMinMax; diff --git a/toolbox/gui/figure_3d.m b/toolbox/gui/figure_3d.m index 19184d41f..82f890f9f 100644 --- a/toolbox/gui/figure_3d.m +++ b/toolbox/gui/figure_3d.m @@ -169,6 +169,11 @@ function ColormapChangedCallback(iDS, iFig) %#ok if ~isempty(getappdata(hFig, 'Dipoles')) && gui_brainstorm('isTabVisible', 'Dipoles') panel_dipoles('PlotSelectedDipoles', hFig); end + % If displaying color-coded head points (see channel_align_manual) + HeadpointsDistMax = getappdata(hFig, 'HeadpointsDistMax'); + if ~isempty(HeadpointsDistMax) + UpdateHeadPointsColormap(hFig); + end end @@ -3738,7 +3743,15 @@ function ViewHeadPoints(hFig, isVisible, isColorDist) % Parse inputs if (nargin < 3) || isempty(isColorDist) isColorDist = 0; + elseif isColorDist + % Find scalp surface + [iTess, ~, hFig, sSurf] = panel_surface('GetSurface', hFig, '', 'Scalp'); + if isempty(iTess) + % Can't use color distance without scalp surface. + isColorDist = 0; + end end + % Get figure description [hFig, iFig, iDS] = bst_figures('GetFigure', hFig); if isempty(iDS) @@ -3783,19 +3796,18 @@ function ViewHeadPoints(hFig, isVisible, isColorDist) if isColorDist && ~strcmpi(get(hHeadPointsMarkers, 'MarkerFaceColor'), 'flat') % Color points according to distance to surface % Get scalp surface - [~, ~, hFig, sSurf] = panel_surface('GetSurface', hFig, '', 'Scalp'); if ~isempty(sSurf) && isfield(sSurf, 'Vertices') && ~isempty(sSurf.Vertices) % Compute the distance Dist = bst_surfdist(get(hHeadPointsMarkers, 'Vertices'), sSurf.Vertices, sSurf.Faces); set(hHeadPointsMarkers, 'CData', Dist * 1000, ... 'MarkerFaceColor', 'flat', 'MarkerEdgeColor', 'flat'); + setappdata(hFig, 'HeadpointsDistMax', max(Dist)); if ~ismember(ColormapType, ColormapInfo.AllTypes) - iTess = panel_surface('GetSurface', hFig, '', 'HeadPoints'); - if isempty(iTess) - error('HeadPoints surface not found.'); - end - panel_surface('SetSurfaceData', hFig, iTess, 'HeadPointsDistance', '', 1); % isStat=1 + % Add missing colormap (color was toggled after points were displayed) + bst_colormaps('AddColormapToFigure', hFig, ColormapType, 'mm'); end + ColormapChangedCallback(iDS, iFig); + bst_colormaps('SetColorbarVisible', hFig, 1); end elseif ~isColorDist && strcmpi(get(hHeadPointsMarkers, 'MarkerFaceColor'), 'flat') % Conventional fixed color @@ -3882,13 +3894,13 @@ function ViewHeadPoints(hFig, isVisible, isColorDist) if ~isempty(iExtra) % Color code points by distance if isColorDist - % Get scalp surface - [~, ~, hFig, sSurf] = panel_surface('GetSurface', hFig, '', 'Scalp'); % Compute the distance Dist = bst_surfdist(digLoc(iExtra, :), sSurf.Vertices, sSurf.Faces); CData = Dist * 1000; % mm + setappdata(hFig, 'HeadpointsDistMax', max(Dist)); MarkerFaceColor = 'flat'; MarkerEdgeColor = 'flat'; + bst_colormaps('AddColormapToFigure', hFig, 'stat1', 'mm'); else CData = 'w'; % any color, not displayed MarkerFaceColor = [.3 1 .3]; @@ -3905,16 +3917,36 @@ function ViewHeadPoints(hFig, isVisible, isColorDist) 'Marker', 'o', ... 'UserData', iExtra, ... 'Tag', 'HeadPointsMarkers'); - % Add points patch to figure surfaces so all color bar functionality work. - iTess = panel_surface('AddSurface', hFig, '', 'HeadPoints'); if isColorDist - panel_surface('SetSurfaceData', hFig, iTess, 'HeadPointsDistance', '', 1); % isStat=1 + ColormapChangedCallback(iDS, iFig); end end end end +%% ===== UPDATE HEADPOINTS COLORMAP ===== +function UpdateHeadPointsColormap(hFig) + % If not using color-coded display + hHeadPointsMarkers = findobj(hFig, 'Tag', 'HeadPointsMarkers'); + if ~strcmpi(get(hHeadPointsMarkers, 'MarkerFaceColor'), 'flat') + return; + end + % Get colormap configuration + sColormap = bst_colormaps('GetColormap', 'stat1'); + % Update axes color limits, which will update de colorbar + hAxes = get(hHeadPointsMarkers, 'Parent'); + if strcmpi(sColormap.MaxMode, 'custom') + set(hAxes, 'CLim', [sColormap.MinValue, sColormap.MaxValue]); + else + HeadpointsDistMax = getappdata(hFig, 'HeadpointsDistMax'); + set(hAxes, 'CLim', [0, HeadpointsDistMax * 1000]); + end + % Update colorbar + bst_colormaps('ConfigureColorbar', hFig, 'stat1', 'stat', 'mm'); +end + + %% ===== VIEW AXIS ===== function ViewAxis(hFig, isVisible) hAxes = findobj(hFig, '-depth', 1, 'Tag', 'Axes3D'); diff --git a/toolbox/gui/panel_surface.m b/toolbox/gui/panel_surface.m index 48c3eee15..32b8dbf4a 100644 --- a/toolbox/gui/panel_surface.m +++ b/toolbox/gui/panel_surface.m @@ -1130,20 +1130,15 @@ function UpdateSurfaceProperties() %% ===== ADD A SURFACE ===== % Add a surface to a given 3DViz figure % USAGE : [iTess, TessInfo] = panel_surface('AddSurface', hFig, surfaceFile) -% [iTess, TessInfo] = panel_surface('AddSurface', hFig, [], surfaceType) % OUTPUT: Indice of the surface in the figure's surface array -function [iTess, TessInfo] = AddSurface(hFig, surfaceFile, surfaceType) +function [iTess, TessInfo] = AddSurface(hFig, surfaceFile) % ===== CHECK EXISTENCE ===== + % Check whether filename is an absolute or relative path + surfaceFile = file_short(surfaceFile); % Get figure appdata (surfaces configuration) TessInfo = getappdata(hFig, 'Surface'); % Check that this surface is not already displayed in 3DViz figure - if isempty(surfaceFile) && nargin > 2 - iTess = find(file_compare({TessInfo.Name}, surfaceType)); - else - % Check whether filename is an absolute or relative path - surfaceFile = file_short(surfaceFile); - iTess = find(file_compare({TessInfo.SurfaceFile}, surfaceFile)); - end + iTess = find(file_compare({TessInfo.SurfaceFile}, surfaceFile)); if ~isempty(iTess) disp('BST> This surface is already displayed. Ignoring...'); return @@ -1166,9 +1161,6 @@ function UpdateSurfaceProperties() % ===== PLOT OBJECT ===== % Get file type (tessalation or MRI) fileType = file_gettype(surfaceFile); - if strcmpi(fileType, 'unknown') && nargin > 2 - fileType = surfaceType; - end % === TESSELATION === if any(strcmpi(fileType, {'cortex','scalp','innerskull','outerskull','tess'})) % === LOAD SURFACE === @@ -1299,16 +1291,6 @@ function UpdateSurfaceProperties() setappdata(hFig, 'Surface', TessInfo); end - % === NO FILE: HeadPoints === - elseif strcmpi(fileType, 'HeadPoints') - % Points were already displayed; just add the patch to the figure surfaces. - %TessInfo(iTess).DataSource.Type = 'HeadPointsDistance'; - TessInfo(iTess).Name = 'HeadPoints'; - TessInfo(iTess).ColormapType = 'stat1'; - TessInfo(iTess).hPatch = findobj(hFig, 'Tag', 'HeadPointsMarkers'); - % Update figure's surfaces list and current surface pointer - setappdata(hFig, 'Surface', TessInfo); - % === FEM === else % TODO: Check for FEM fileType explicitly view_surface_fem(surfaceFile, [], [], [], hFig); @@ -1515,11 +1497,6 @@ function UpdateSurfaceProperties() TessInfo(iTess).Data = []; TessInfo(iTess).DataWmat = []; - case 'HeadPointsDistance' - ColormapType = 'stat1'; - DisplayUnits = 'mm'; - % Data is actually added in UpdateSurfaceData below. - otherwise ColormapType = ''; DisplayUnits = []; @@ -1901,15 +1878,6 @@ function UpdateSurfaceProperties() figure_callback(hFig, 'UpdateSurfaceColor', hFig, iTess); % Get updated surface definition TessInfo = getappdata(hFig, 'Surface'); - - case 'HeadPointsDistance' - TessInfo(iTess).Data = get(TessInfo(iTess).hPatch, 'CData'); - if isempty(TessInfo(iTess).Data) || ~isnumeric(TessInfo(iTess).Data) - isOk = 0; - return; - end - TessInfo(iTess).DataMinMax = [min(TessInfo(iTess).Data(:)), max(TessInfo(iTess).Data(:))]; - otherwise % Nothing to do end @@ -2033,7 +2001,7 @@ function UpdateSurfaceColormap(hFig, iSurfaces) TessInfo(iTess).Data = abs(TessInfo(iTess).Data); end % If current colormap is the default colormap for this figure (for colorbar) - if strcmpi(ColormapInfo.Type, TessInfo(iTess).ColormapType) && ~isempty(TessInfo(iTess).DataSource.Type) + if strcmpi(ColormapInfo.Type, TessInfo(iTess).ColormapType) && ~isempty(TessInfo(iTess).DataSource.FileName) if all(~isnan(TessInfo(iTess).DataLimitValue)) && (TessInfo(iTess).DataLimitValue(1) < TessInfo(iTess).DataLimitValue(2)) set(hAxes, 'CLim', TessInfo(iTess).DataLimitValue); else @@ -2049,8 +2017,7 @@ function UpdateSurfaceColormap(hFig, iSurfaces) end end % === DISPLAY ON MRI === - if strcmpi(TessInfo(iTess).Name, 'Anatomy') && ~isempty(TessInfo(iTess).DataSource.Type) && ... - ~strcmpi(TessInfo(iTess).DataSource.Type, 'MriTime') && isempty(TessInfo(iTess).OverlayCube) + if strcmpi(TessInfo(iTess).Name, 'Anatomy') && ~isempty(TessInfo(iTess).DataSource.Type) && ~strcmpi(TessInfo(iTess).DataSource.Type, 'MriTime') && isempty(TessInfo(iTess).OverlayCube) % Progress bar isProgressBar = bst_progress('isVisible'); bst_progress('start', 'Display MRI', 'Updating values...'); @@ -2062,10 +2029,6 @@ function UpdateSurfaceColormap(hFig, iSurfaces) if ~isProgressBar bst_progress('stop'); end - elseif strcmpi(TessInfo(iTess).Name, 'HeadPoints') - % No need to update surface color here, data already updated. - % Update figure's appdata (surface list) - setappdata(hFig, 'Surface', TessInfo); else % Update figure's appdata (surface list) setappdata(hFig, 'Surface', TessInfo); diff --git a/toolbox/process/functions/process_adjust_coordinates.m b/toolbox/process/functions/process_adjust_coordinates.m index bf861a247..4e9f0d0eb 100644 --- a/toolbox/process/functions/process_adjust_coordinates.m +++ b/toolbox/process/functions/process_adjust_coordinates.m @@ -1,9 +1,8 @@ function varargout = process_adjust_coordinates(varargin) % PROCESS_ADJUST_COORDINATES: Adjust, recompute, or remove various coordinate transformations. % -% 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 from the .pos file. % @============================================================================= % This function is part of the Brainstorm software: @@ -30,7 +29,7 @@ -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 +41,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,7 +59,7 @@ 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; @@ -103,9 +102,8 @@ 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. + % 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. @@ -132,13 +130,11 @@ % ---------------------------------------------------------------- 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 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. [ChannelMat, NewChannelFiles, Failed] = ... ResetChannelFile(ChannelMat, NewChannelFiles, sInputs(iFile), sProcess); @@ -148,25 +144,23 @@ % ---------------------------------------------------------------- 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 @@ -189,8 +183,8 @@ 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. + % 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.'); @@ -212,10 +206,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,8 +259,7 @@ % end -function [ChannelMat, NewChannelFiles, Failed] = ... - ResetChannelFile(ChannelMat, NewChannelFiles, sInput, sProcess) +function [ChannelMat, NewChannelFiles, Failed] = ResetChannelFile(ChannelMat, NewChannelFiles, sInput, sProcess) if nargin < 4 sProcess = []; end @@ -307,10 +299,9 @@ 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). + % 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). [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), ... @@ -380,9 +371,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) @@ -457,9 +447,8 @@ 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, ... @@ -513,7 +502,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,8 +512,7 @@ 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. @@ -532,9 +520,8 @@ 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) @@ -545,33 +532,26 @@ % 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); @@ -601,14 +581,12 @@ 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 +594,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 +622,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 +632,22 @@ 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. + % 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 +698,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 +708,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 +731,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 +766,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). - % + % 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. % - % (c) Copyright 2018 Marc Lalancette - % The Hospital for Sick Children, Toronto, Canada + % 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 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. - % - % 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 +805,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 +821,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 +835,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 +855,79 @@ end % GeoMedian +function CheckPrevAdjustments(ChannelMat, sMri) + % Flag if auto or manual registration performed, and if MRI fids updated. Print to command + % window for now. + if any(~isfield(ChannelMat, {'History', 'HeadPoints'})) + % Nothing to check. + return; + end + if nargin < 2 || isempty(sMri) || ~isfield(sMri, 'History') + iMriHist = []; + else + 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'), 1, 'last'); + iAlign = find(strcmpi(ChannelMat.History(:,2), 'align')); + iAlign(iAlign < iImport) = []; + AlignType = 'none'; + while ~isempty(iAlign) + % Check which adjustment was done last. + switch lower(ChannelMat.History{iAlign(end),3}(1:5)) + case 'remov' + % Removed a previous step. Ignore corresponding adjustment and look again. + iAlign(end) = []; + if contains(ChannelMat.History{iAlign(end),3}, 'AdjustedNative') + iAlignRemoved = find(contains(ChannelMat.History(iAlign,3), 'AdjustedNative'), 1, 'last'); + elseif contains(ChannelMat.History{iAlign(end),3}, 'refine registration: head points') + iAlignRemoved = find(contains(ChannelMat.History(iAlign,3), 'AdjustedNative'), 1, 'last'); + elseif contains(ChannelMat.History{iAlign(end),3}, 'manual correction') + iAlignRemoved = find(contains(ChannelMat.History(iAlign,3), 'AdjustedNative'), 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' + % This alignment is between points and functional dataset, ignore here. + iAlign(end) = []; + case 'refin' + % Automatic MRI-points alignment + AlignType = 'auto'; + break; + case 'align' + % Manual MRI-points alignment + AlignType = 'manual'; + break; + end + end + disp(['BST> Previous registration adjustment: ' AlignType]); + if ~isempty(iMriHist) + % 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 + 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); + ChannelMat.SCS.LPA = mean(ChannelMat.HeadPoints.Loc(:,iLpa)', 1); + ChannelMat.SCS.RPA = mean(ChannelMat.HeadPoints.Loc(:,iRpa)', 1); + end + 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) + disp('BST> MRI fiducials previously updated, but different than current digitized fiducials.'); + else + disp('BST> MRI fiducials previously updated, and match current digitized fiducials.'); + end + end + +end + + diff --git a/toolbox/sensors/channel_align_manual.m b/toolbox/sensors/channel_align_manual.m index f1e820db0..672e710e7 100644 --- a/toolbox/sensors/channel_align_manual.m +++ b/toolbox/sensors/channel_align_manual.m @@ -404,65 +404,11 @@ if isProgress bst_progress('stop'); end - -% Flag if auto or manual registration performed, and if MRI fids updated. Print to command -% window for now. -ChannelMat = in_bst_channel(ChannelFile); -iMriHist = find(strcmpi(sMri.History(:,3), 'Applied digitized anatomical fiducials'), 1, 'last'); -% Can also be reset, so check for 'import' action and ignore previous alignments. -iImport = find(strcmpi(ChannelMat.History(:,2), 'import'), 1, 'last'); -iAlign = find(strcmpi(ChannelMat.History(:,2), 'align')); -iAlign(iAlign < iImport) = []; -AlignType = 'none'; -while ~isempty(iAlign) - % Check which adjustment was done last. - switch lower(ChannelMat.History{iAlign(end),3}(1:5)) - case 'remov' - % Removed a previous step. Ignore corresponding adjustment and look again. - iAlign(end) = []; - if contains(ChannelMat.History{iAlign(end),3}, 'AdjustedNative') - iAlignRemoved = find(contains(ChannelMat.History(iAlign,3), 'AdjustedNative'), 1, 'last'); - elseif contains(ChannelMat.History{iAlign(end),3}, 'refine registration: head points') - iAlignRemoved = find(contains(ChannelMat.History(iAlign,3), 'AdjustedNative'), 1, 'last'); - elseif contains(ChannelMat.History{iAlign(end),3}, 'manual correction') - iAlignRemoved = find(contains(ChannelMat.History(iAlign,3), 'AdjustedNative'), 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' - % This alignment is between points and functional dataset, ignore here. - iAlign(end) = []; - case 'refin' - % Automatic MRI-points alignment - AlignType = 'auto'; - break; - case 'align' - % Manual MRI-points alignment - AlignType = 'manual'; - break; - end -end -disp(['BST> Previous registration adjustment: ' AlignType]); -if ~isempty(iMriHist) - % Compare digitized fids to MRI fids (in MRI coordinates, mm). - 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) - disp('BST> MRI fiducials updated, but different than digitized fiducials.'); - else - disp('BST> MRI fiducials updated, and match digitized fiducials.'); - end -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 ===== %% ===== MOUSE DOWN ===== function AlignButtonDown_Callback(hObject, ev) @@ -701,10 +647,9 @@ function UpdatePoints(iSelChan) Dist = bst_surfdist(gChanAlign.HeadPointsMarkersLoc, ... get(gChanAlign.hSurfacePatch, 'Vertices'), get(gChanAlign.hSurfacePatch, 'Faces')); set(gChanAlign.hHeadPointsMarkers, 'CData', Dist * 1000); - % Update surface data and colorbar - TessInfo = getappdata(gChanAlign.hFig, 'Surface'); - iTessPoints = find(strcmpi({TessInfo.Name}, 'HeadPoints')); - panel_surface('UpdateSurfaceData', gChanAlign.hFig, iTessPoints); + % Update axes maximum + setappdata(gChanAlign.hFig, 'HeadpointsDistMax', max(Dist)); + figure_3d('UpdateHeadPointsColormap', gChanAlign.hFig); end % Fiducials if ~isempty(gChanAlign.hHeadPointsFid) @@ -907,7 +852,6 @@ function AlignKeyPress_Callback(hFig, keyEvent) function AlignClose_Callback(varargin) global gChanAlign; if gChanAlign.isChanged - isCancel = false; % Ask user to save changes (only if called as a callback) if (nargin == 3) SaveChanged = 1; @@ -915,10 +859,6 @@ function AlignClose_Callback(varargin) SaveChanged = 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 % Save changes to channel file and close figure if SaveChanged % Progress bar From af062b384931efa30b67a2a4f1962f8df6f6cefc Mon Sep 17 00:00:00 2001 From: Marc Lalancette <31040756+Moo-Marc@users.noreply.github.com> Date: Mon, 28 Nov 2022 12:04:52 -0500 Subject: [PATCH 26/47] minor fix to adjust coordinates scs option --- .../functions/process_adjust_coordinates.m | 16 ++-------------- 1 file changed, 2 insertions(+), 14 deletions(-) diff --git a/toolbox/process/functions/process_adjust_coordinates.m b/toolbox/process/functions/process_adjust_coordinates.m index 0f55b9da0..58aef299c 100644 --- a/toolbox/process/functions/process_adjust_coordinates.m +++ b/toolbox/process/functions/process_adjust_coordinates.m @@ -110,18 +110,6 @@ bst_report('Info', sProcess, sInputs, ... 'Multiple inputs were found for a single channel file. They will be concatenated for adjusting the head position.'); end - - if ~sProcess.options.remove.Value && sProcess.options.points.Value && sProcess.options.scs.Value - % Warning and confirmation dialog. - isConfirmed = java_dialog('confirm', 'Ajusting MRI nasion and ear points will break previous alignment with head points for files not included here. Proceed?', ... - 'Adjust MRI nasion and ear points?'); - if ~isConfirmed - bst_report('User cancelled.'); - OutputFiles = {}; - return; - end - 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 @@ -265,7 +253,7 @@ OutputFiles = {sInputs(isFileOk(iUniqInputs(iUniqFiles))).FileName}; end -% if ~sProcess.options.remove.Value && sProcess.options.newpoints.Value +% if ~sProcess.options.remove.Value && sProcess.options.scs.Value % % This not yet implemented option could apply the Native to SCS % % transformation for head points loaded after the raw data was % % imported. @@ -1054,4 +1042,4 @@ ChannelMat.SCS.LPA = mean(ChannelMat.HeadPoints.Loc(:,iLpa)', 1); ChannelMat.SCS.RPA = mean(ChannelMat.HeadPoints.Loc(:,iRpa)', 1); end -end \ No newline at end of file +end From 242f9d5fa4aaa79e8093a2f48ddd64fd481305cd Mon Sep 17 00:00:00 2001 From: Marc Lalancette <31040756+Moo-Marc@users.noreply.github.com> Date: Mon, 28 Nov 2022 12:50:00 -0500 Subject: [PATCH 27/47] compat fix --- .../functions/process_adjust_coordinates.m | 26 ++++++++++++------- toolbox/tree/tree_callbacks.m | 2 +- 2 files changed, 17 insertions(+), 11 deletions(-) diff --git a/toolbox/process/functions/process_adjust_coordinates.m b/toolbox/process/functions/process_adjust_coordinates.m index 58aef299c..e88c8a76e 100644 --- a/toolbox/process/functions/process_adjust_coordinates.m +++ b/toolbox/process/functions/process_adjust_coordinates.m @@ -930,15 +930,15 @@ while ~isempty(iAlign) % Check which adjustment was done last. switch lower(ChannelMat.History{iAlign(end),3}(1:5)) - case 'remov' + case 'remov' % ['Removed transform: ' TransfLabel] % Removed a previous step. Ignore corresponding adjustment and look again. iAlign(end) = []; - if contains(ChannelMat.History{iAlign(end),3}, 'AdjustedNative') - iAlignRemoved = find(contains(ChannelMat.History(iAlign,3), 'AdjustedNative'), 1, 'last'); - elseif contains(ChannelMat.History{iAlign(end),3}, 'refine registration: head points') - iAlignRemoved = find(contains(ChannelMat.History(iAlign,3), 'AdjustedNative'), 1, 'last'); - elseif contains(ChannelMat.History{iAlign(end),3}, 'manual correction') - iAlignRemoved = find(contains(ChannelMat.History(iAlign,3), 'AdjustedNative'), 1, 'last'); + 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 @@ -947,17 +947,23 @@ else iAlign(iAlignRemoved) = []; end - case 'added' + 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' + case 'refin' % 'Refining the registration using the head points:' % Automatic MRI-points alignment AlignType = 'auto'; break; - case 'align' + 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 diff --git a/toolbox/tree/tree_callbacks.m b/toolbox/tree/tree_callbacks.m index 3568ba9ae..4a2a2befe 100644 --- a/toolbox/tree/tree_callbacks.m +++ b/toolbox/tree/tree_callbacks.m @@ -225,7 +225,7 @@ elseif strcmpi(DisplayMod{1}, 'ECOG') DisplayChannels(bstNodes, DisplayMod{1}, 'cortex', 1); elseif ismember(DisplayMod{1}, {'MEG','MEG GRAD','MEG MAG'}) - channel_align_manual(filenameRelative, DisplayMod{1}, 0) + channel_align_manual(filenameRelative, DisplayMod{1}, 0); elseif strcmpi(DisplayMod{1}, 'NIRS') DisplayChannels(bstNodes, 'NIRS-BRS', 'scalp', [], 1); else From be598422f0c6da5f4af1d7793e50f680a96433e8 Mon Sep 17 00:00:00 2001 From: Marc Lalancette <31040756+Moo-Marc@users.noreply.github.com> Date: Fri, 2 Dec 2022 17:45:44 -0500 Subject: [PATCH 28/47] wip exporting registration to BIDS --- toolbox/anatomy/cs_convert.m | 6 ++-- toolbox/gui/figure_3d.m | 16 ++++++---- .../functions/process_adjust_coordinates.m | 29 +++++++++++++++---- toolbox/sensors/channel_align_manual.m | 20 ++++++------- toolbox/sensors/channel_align_scs.m | 1 + 5 files changed, 48 insertions(+), 24 deletions(-) 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/gui/figure_3d.m b/toolbox/gui/figure_3d.m index 82f890f9f..61b37b3f0 100644 --- a/toolbox/gui/figure_3d.m +++ b/toolbox/gui/figure_3d.m @@ -3954,18 +3954,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/process/functions/process_adjust_coordinates.m b/toolbox/process/functions/process_adjust_coordinates.m index e88c8a76e..08617212f 100644 --- a/toolbox/process/functions/process_adjust_coordinates.m +++ b/toolbox/process/functions/process_adjust_coordinates.m @@ -923,10 +923,14 @@ 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'), 1, 'last'); + iImport = find(strcmpi(ChannelMat.History(:,2), 'import')); iAlign = find(strcmpi(ChannelMat.History(:,2), 'align')); - iAlign(iAlign < iImport) = []; - AlignType = 'none'; + 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)) @@ -1039,13 +1043,28 @@ if ~isfield(ChannelMat, 'HeadPoints') return; end - % Get the three fiducials in the head points + % 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); + 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) + 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); + end + % 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; end diff --git a/toolbox/sensors/channel_align_manual.m b/toolbox/sensors/channel_align_manual.m index c846bb1c5..e919e53f6 100644 --- a/toolbox/sensors/channel_align_manual.m +++ b/toolbox/sensors/channel_align_manual.m @@ -190,7 +190,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'); @@ -203,7 +203,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 @@ -857,8 +859,6 @@ function AlignClose_Callback(varargin) [ChannelMat, Transf, iChannels] = GetCurrentChannelMat(); % Load original channel file ChannelMatOrig = in_bst_channel(gChanAlign.ChannelFile); - % Report (in command window) max head and sensor displacements from changes. - CheckCurrentAdjustments(ChannelMat, ChannelMatOrig); % Ask user to save changes (only if called as a callback) if (nargin == 3) @@ -902,6 +902,10 @@ function AlignClose_Callback(varargin) if isCancel return; end + % Report (in command window) max head and sensor displacements from changes. + if SaveChanges || (gChanAlign.isHeadPoints && ~strcmpi(Choice, 'No')) + process_adjust_coordinates('CheckCurrentAdjustments', ChannelMat, ChannelMatOrig); + end % Save changes to channel file and close figure if SaveChanges % Progress bar @@ -915,17 +919,13 @@ function AlignClose_Callback(varargin) [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 - SaveChanges = 0; end % Only close figure gChanAlign.Figure3DCloseRequest_Bak(varargin{1:2}); - % Apply to other recordings with same sensor locations in the same subject - if SaveChanges - CopyToOtherFolders(ChannelMatOrig, iStudy, Transf, iChannels); - end end diff --git a/toolbox/sensors/channel_align_scs.m b/toolbox/sensors/channel_align_scs.m index ed85aa651..63ea7c0bc 100644 --- a/toolbox/sensors/channel_align_scs.m +++ b/toolbox/sensors/channel_align_scs.m @@ -117,6 +117,7 @@ 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; From 9f3be2b7c6ce415522079c40912aa28f30987bf5 Mon Sep 17 00:00:00 2001 From: Marc Lalancette <31040756+Moo-Marc@users.noreply.github.com> Date: Mon, 16 Jan 2023 16:05:11 -0500 Subject: [PATCH 29/47] wip coregistration --- toolbox/core/bst_colormaps.m | 4 +-- .../functions/process_adjust_coordinates.m | 28 +++++++++++++------ 2 files changed, 21 insertions(+), 11 deletions(-) diff --git a/toolbox/core/bst_colormaps.m b/toolbox/core/bst_colormaps.m index aefe462aa..4aea9d87a 100644 --- a/toolbox/core/bst_colormaps.m +++ b/toolbox/core/bst_colormaps.m @@ -1334,7 +1334,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 = []; @@ -1345,7 +1345,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); diff --git a/toolbox/process/functions/process_adjust_coordinates.m b/toolbox/process/functions/process_adjust_coordinates.m index 08617212f..ce2fcab0a 100644 --- a/toolbox/process/functions/process_adjust_coordinates.m +++ b/toolbox/process/functions/process_adjust_coordinates.m @@ -1000,7 +1000,7 @@ end -function [DistHead, DistSens] = CheckCurrentAdjustments(ChannelMat, ChannelMatRef) +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; @@ -1023,17 +1023,27 @@ % 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. - [~, ~, Transf] = process_adjust_coordinates('GetTransforms', ChannelMat); - 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))); + % 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('BST> Max displacement for registration adjustment:\n head: %1.1f mm\n sensors: %1.1f mm\n', ... - DistHead*1000, DistSens*1000); + fprintf(Message); end end From e9d0e46ab4cf9669e01add1ed2b29c399597d732 Mon Sep 17 00:00:00 2001 From: Marc Lalancette <31040756+Moo-Marc@users.noreply.github.com> Date: Wed, 27 Sep 2023 17:10:35 -0400 Subject: [PATCH 30/47] adding coregistration export for BIDS --- toolbox/io/bst_save_coregistration.m | 212 ++++++++++++++++++ .../functions/process_adjust_coordinates.m | 32 ++- toolbox/sensors/channel_align_auto.m | 37 +-- toolbox/sensors/channel_align_scs.m | 28 ++- toolbox/sensors/channel_apply_transf.m | 6 +- 5 files changed, 260 insertions(+), 55 deletions(-) create mode 100644 toolbox/io/bst_save_coregistration.m diff --git a/toolbox/io/bst_save_coregistration.m b/toolbox/io/bst_save_coregistration.m new file mode 100644 index 000000000..0d06b8802 --- /dev/null +++ b/toolbox/io/bst_save_coregistration.m @@ -0,0 +1,212 @@ +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. +% +% 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 = false; +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 isBids + if isempty(BidsRoot) + BidsRoot = bst_fileparts(bst_fileparts(bst_fileparts(ImportedFile))); + 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); + BstFids = {'NAS', 'LPA', 'RPA', 'AC', 'PC', 'IH'}; + isLandmarksFound = true; + for iFid = 1:numel(BstFids) + if iFid < 4 + CS = 'SCS'; + else + CS = 'NCS'; + end + Fid = BstFids{iFid}; + % Voxel coordinates (Nifti: RAS and 0-indexed) + % 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. + sMriJson.AnatomicalLandmarkCoordinates.(Fid) = round(1000 * (sMri.(CS).(Fid)./sMri.Voxsize - 1)) / 1000; + 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; + + for iStudy = 1:numel(sStudies) + % Is it a link to raw file? + isLinkToRaw = false; + for iData = 1:numel(sStudies(iStudy).Data) + if strcmpi(sStudies(iStudy).Data(iData).DataType, 'raw') + isLinkToRaw = true; + break; + end + end + if ~isLinkToRaw + continue; + end + + % Find MEG _coordsystem.json + Link = load(file_fullpath(sStudies(iStudy).Data(iData).FileName)); + if ~exist(Link.F.filename, 'file') + warning('Missing raw MEG file. Skipping study %s.', Link.F.filename); + continue; + end + [MegPath, MegName, MegExt] = bst_fileparts(Link.F.filename); + if strcmpi(MegExt, '.meg4') + [MegPath, MegName, MegExt] = bst_fileparts(MegPath); + end + MegCoordJsonFile = file_find(MegPath, '*_coordsystem.json', 1, false); % max depth 1, not just one file + + if isempty(MegCoordJsonFile) + warning('Imported MEG BIDS _coordsystem.json file not found. Skipping study %s.', Link.F.filename); + continue; + end + + ChannelMat = in_bst_channel(sStudies(iStudy).Channel.FileName); + % ChannelMat.SCS are *digitized* anatomical landmarks (if present, otherwise might be + % digitized head coils) in Brainstorm/SCS coordinates (CTF from anatomical landmarks). + % Not updated after refine with head points, so we don't rely on them but use those + % saved in sMri. + % + % We applied MRI=>SCS from sMri to 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. + ChannelMat = process_adjust_coordinates('UpdateChannelMatScs', ChannelMat); + % Convert from (possibly adjusted) SCS to Native, and m to cm. + for iFid = 1:3 + Fid = BstFids{iFid}; + sMriNative.SCS.(Fid)(:) = 100 * [ChannelMat.Native.R, ChannelMat.Native.T] * [sMriScs.SCS.(Fid)'; 1]; + end + + for c = 1:numel(MegCoordJsonFile) + sMegJson = bst_jsondecode(MegCoordJsonFile{c}); + if ~isfield(sMegJson, 'IntendedFor') || isempty(sMegJson.IntendedFor) + sMegJson.IntendedFor = strrep(ImportedFile, [BidsRoot filesep], 'bids::'); + end + for iFid = 1:3 + Fid = BstFids{iFid}; + %if isfield(sMri, 'SCS') && isfield(sMri.SCS, Fid) && ~isempty(sMri.SCS.(Fid)) && any(sMri.SCS.(Fid)) + % Round to um. + sMegJson.AnatomicalLandmarkCoordinates.(Fid) = round(sMriNative.SCS.(Fid) * 10000) / 10000; + 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; + [~, isMriUpdated, isMriMatch, ChannelMat] = process_adjust_coordinates('CheckPrevAdjustments', ChannelMat, sMri); + if ~isfield(sMegJson, 'FiducialsDescription') + sMegJson.FiducialsDescription = ''; + end + if isMriUpdated && isMriMatch + 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.'; + else + 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.'; + end + if isempty(strfind(sMegJson.FiducialsDescription, AddFidDescrip)) + sMegJson.FiducialsDescription = strtrim([sMegJson.FiducialsDescription, AddFidDescrip]); + end + WriteJson(MegCoordJsonFile{c}, sMegJson); + OutFilesMeg{iOutSub}{c} = MegCoordJsonFile{c}; + end + 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/process/functions/process_adjust_coordinates.m b/toolbox/process/functions/process_adjust_coordinates.m index ce2fcab0a..fb8e005a4 100644 --- a/toolbox/process/functions/process_adjust_coordinates.m +++ b/toolbox/process/functions/process_adjust_coordinates.m @@ -70,9 +70,8 @@ sProcess.options.tolerance.Value = {0, '%', 0}; sProcess.options.tolerance.Class = 'Refine'; sProcess.options.scs.Type = 'checkbox'; - sProcess.options.scs.Comment = 'Also ajust MRI nasion and ear points.'; + sProcess.options.scs.Comment = 'Replace MRI nasion and ear points with digitized landmarks (cannot undo).'; sProcess.options.scs.Value = 0; - sProcess.options.scs.Class = 'Refine'; sProcess.options.remove.Type = 'checkbox'; sProcess.options.remove.Comment = 'Remove selected adjustments (if present) instead of adding them.'; sProcess.options.remove.Value = 0; @@ -173,8 +172,8 @@ end % TransfLabel loop % We cannot change back the MRI fiducials, but in order to be able to update it again - % from digitized fids, we must edit the MRI history. - if sProcess.options.points.Value && sProcess.options.scs.Value + % 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; @@ -221,7 +220,7 @@ Tolerance = sProcess.options.tolerance.Value{1} / 100; end [ChannelMat, R, T, isSkip, isUserCancel, strReport] = channel_align_auto(sInputs(iFile).ChannelFile, ... - ChannelMat, isWarning, 0, Tolerance, sProcess.options.scs.Value); % No confirmation + 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) @@ -234,6 +233,23 @@ end % refine registration with head points + % ---------------------------------------------------------------- + if ~sProcess.options.remove.Value && sProcess.options.scs.Value + % TODO Maybe make this a separate process, since it should only run on one channel file + % per subject. + + % Get subject + sSubject = bst_get('Subject', sInputs(iFile).SubjectFile); + % Check if default anatomy. + if sSubject.UseDefaultAnat + bst_report('Error', sProcess, sInputs(iFile), ... + 'Digitized nasion and ear points cannot be applied to default anatomy.'); + continue; + end + DigToMriTransf = channel_align_scs(ChannelFile, eye(4), true, false); % interactive warnings but no confirmation + % TODO Verify if it worked. + end + % ---------------------------------------------------------------- % Save channel file. bst_save(file_fullpath(sInputs(iFile).ChannelFile), ChannelMat, 'v7'); @@ -983,16 +999,14 @@ 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; if isPrint disp('BST> MRI fiducials previously updated, but different than current digitized fiducials.'); - else - isMriMatch = false; end else + isMriMatch = true; if isPrint disp('BST> MRI fiducials previously updated, and match current digitized fiducials.'); - else - isMriMatch = true; end end end diff --git a/toolbox/sensors/channel_align_auto.m b/toolbox/sensors/channel_align_auto.m index aa0f7931d..71d8fcd48 100644 --- a/toolbox/sensors/channel_align_auto.m +++ b/toolbox/sensors/channel_align_auto.m @@ -1,7 +1,7 @@ -function [ChannelMat, R, T, isSkip, isUserCancel, strReport, tolerance] = channel_align_auto(ChannelFile, ChannelMat, isWarning, isConfirm, tolerance, isAdjustScs) +function [ChannelMat, R, T, isSkip, isUserCancel, strReport, tolerance] = channel_align_auto(ChannelFile, ChannelMat, isWarning, isConfirm, tolerance) % CHANNEL_ALIGN_AUTO: Aligns the channels to the scalp using Polhemus points. % -% USAGE: [ChannelMat, R, T, isSkip, isUserCancel, strReport] = channel_align_auto(ChannelFile, ChannelMat=[], isWarning=1, isConfirm=1, tolerance=0, isAdjustScs=0) +% USAGE: [ChannelMat, R, T, isSkip, isUserCancel, strReport] = channel_align_auto(ChannelFile, ChannelMat=[], isWarning=1, isConfirm=1, tolerance=0) % % DESCRIPTION: % Aligns the channels to the scalp using Polhemus points stored in channel structure. @@ -18,7 +18,6 @@ % - 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 -% - isAdjustScs : If 1 and not already done for this subject, update MRI to use digitized nasion and ear points. % % OUTPUTS: % - ChannelMat : The same ChannelMat structure input in, with the head points and sensors rotated and translated to match the head points to the scalp. @@ -49,12 +48,10 @@ % For more information type "brainstorm license" at command prompt. % =============================================================================@ % -% Authors: Syed Ashrafulla, 2009, Francois Tadel, 2009-2021, Marc Lalancette 2022 +% Authors: Syed Ashrafulla, 2009 +% Francois Tadel, 2009-2021 %% ===== PARSE INPUTS ===== -if (nargin < 6) || isempty(isAdjustScs) - isAdjustScs = 0; -end if (nargin < 5) || isempty(tolerance) tolerance = 0; end @@ -96,27 +93,12 @@ end % M x 3 matrix of head points HP = double(HeadPoints.Loc'); -% % Add anatomical points. -% 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) -% HP(end+1,:) = ChannelMat.SCS.NAS; -% HP(end+1,:) = ChannelMat.SCS.LPA; -% HP(end+1,:) = ChannelMat.SCS.RPA; -% end %% ===== LOAD SCALP SURFACE ===== % Get study sStudy = bst_get('ChannelFile', ChannelFile); % Get subject -[sSubject, iSubject] = bst_get('Subject', sStudy.BrainStormSubject); -% Check if default anatomy. (Usually also checked before calling this function.) -if sSubject.UseDefaultAnat - if isWarning - bst_error('Digitized nasion and ear points cannot be applied to default anatomy.', 'Automatic EEG-MEG/MRI registration', 0); - end - bst_progress('stop'); - return -end +sSubject = bst_get('Subject', sStudy.BrainStormSubject); if isempty(sSubject) || isempty(sSubject.iScalp) if isWarning bst_error('No scalp surface available for this subject', 'Automatic EEG-MEG/MRI registration', 0); @@ -213,15 +195,6 @@ DigToMriTransf(1:3,1:3) = R; DigToMriTransf(1:3,4) = T; - -%% ===== ADJUST MRI FIDUCIALS AND SCS ===== -if isAdjustScs - DigToMriTransf = channel_align_scs(ChannelFile, DigToMriTransf, isWarning, isConfirm); - R = DigToMriTransf(1:3,1:3); - T = DigToMriTransf(1:3,4); -end - - %% ===== ROTATE SENSORS AND HEADPOINTS ===== if ~isequal(DigToMriTransf, eye(4)) for i = 1:length(ChannelMat.Channel) diff --git a/toolbox/sensors/channel_align_scs.m b/toolbox/sensors/channel_align_scs.m index 63ea7c0bc..ee520a97d 100644 --- a/toolbox/sensors/channel_align_scs.m +++ b/toolbox/sensors/channel_align_scs.m @@ -6,13 +6,13 @@ % 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), instead of saving a -% registration adjustment transformation for a single functional dataset. 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 subject, with separate -% digitized points, will still need the usual "per dataset" registration adjustment to align -% with the same MRI. +% 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 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 isWarning and isConfirm are false). In that case, the Transform is returned unaltered. @@ -21,6 +21,9 @@ % - 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. % - isWarning : If 1, display warning in case of errors, or if this was already done % previously for this MRI. % - isConfirm : If 1, ask the user for confirmation before proceeding. @@ -51,7 +54,9 @@ % For more information type "brainstorm license" at command prompt. % =============================================================================@ % -% Authors: Marc Lalancette 2022 +% Authors: Marc Lalancette 2022-2023 + +% TODO if Transform is missing, get equivalent from ChannelMat, from all auto/manual adjustments. isCancel = false; % Get study @@ -110,7 +115,8 @@ end end -% Convert to MRI SCS coordinates. +% Convert digitized fids to MRI SCS coordinates. +% Here, ChannelMat.SCS already may contain some auto/manual adjustment, and we're adding a new one. % To do this we need to apply the transformation provided. sMri = sMriOld; sMri.SCS.NAS = (Transform(1:3,:) * [ChannelMat.SCS.NAS'; 1])'; @@ -121,7 +127,7 @@ 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 +% Re-compute transformation in this struct [~, sMri] = cs_compute(sMri, 'scs'); % Compare with existing MRI fids, replace if changed (> 1um), and update surfaces. @@ -194,7 +200,7 @@ % Progress bar bst_progress('start', 'Align sensors', 'Updating other datasets...'); % Update files - channel_apply_transf(ChannelFiles, Transf, iChannels, 1); + channel_apply_transf(ChannelFiles, Transf); % Give report to the user bst_progress('stop'); java_dialog('msgbox', sprintf('Updated %d additional file(s):\n%s', length(ChannelFiles), strMsg)); 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 From 6ab9794022061f615812ee074c74b82b09d2c9e8 Mon Sep 17 00:00:00 2001 From: Marc Lalancette <31040756+Moo-Marc@users.noreply.github.com> Date: Wed, 27 Sep 2023 17:57:50 -0400 Subject: [PATCH 31/47] fix apply digitized fids to MRI --- .../functions/process_adjust_coordinates.m | 13 +++------- toolbox/sensors/channel_align_manual.m | 1 + toolbox/sensors/channel_align_scs.m | 26 +++++++++++++++++-- 3 files changed, 29 insertions(+), 11 deletions(-) diff --git a/toolbox/process/functions/process_adjust_coordinates.m b/toolbox/process/functions/process_adjust_coordinates.m index fb8e005a4..71d7c73cb 100644 --- a/toolbox/process/functions/process_adjust_coordinates.m +++ b/toolbox/process/functions/process_adjust_coordinates.m @@ -236,18 +236,13 @@ % ---------------------------------------------------------------- if ~sProcess.options.remove.Value && sProcess.options.scs.Value % TODO Maybe make this a separate process, since it should only run on one channel file - % per subject. + % per subject. Possibly have hidden option for no interactive warnings. - % Get subject - sSubject = bst_get('Subject', sInputs(iFile).SubjectFile); - % Check if default anatomy. - if sSubject.UseDefaultAnat - bst_report('Error', sProcess, sInputs(iFile), ... - 'Digitized nasion and ear points cannot be applied to default anatomy.'); + [~, isCancel] = channel_align_scs(ChannelFile, eye(4), true, false); % interactive warnings but no confirmation + if isCancel continue; end - DigToMriTransf = channel_align_scs(ChannelFile, eye(4), true, false); % interactive warnings but no confirmation - % TODO Verify if it worked. + % TODO Verify end % ---------------------------------------------------------------- diff --git a/toolbox/sensors/channel_align_manual.m b/toolbox/sensors/channel_align_manual.m index f6dc4cedf..77f57e157 100644 --- a/toolbox/sensors/channel_align_manual.m +++ b/toolbox/sensors/channel_align_manual.m @@ -746,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) diff --git a/toolbox/sensors/channel_align_scs.m b/toolbox/sensors/channel_align_scs.m index ee520a97d..450cc3d32 100644 --- a/toolbox/sensors/channel_align_scs.m +++ b/toolbox/sensors/channel_align_scs.m @@ -63,6 +63,17 @@ sStudy = bst_get('ChannelFile', ChannelFile); % Get subject [sSubject, iSubject] = 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 isWarning + bst_error(Message, 'Apply digitized anatomical fiducials to MRI', 0); + else + disp(Message); + end + isCancel = true; + return; +end % Get Channels ChannelMat = in_bst_channel(ChannelFile); @@ -70,10 +81,11 @@ % 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 isWarning - bst_error('Digitized nasion and ear points not found.', 'Apply digitized anatomical fiducials to MRI', 0); + bst_error(Message, 'Apply digitized anatomical fiducials to MRI', 0); else - disp('BST> Digitized nasion and ear points not found.'); + disp(Message); end isCancel = true; return; @@ -114,6 +126,16 @@ 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. From ca6c15292ff35c9f75066d713b540b308b043d13 Mon Sep 17 00:00:00 2001 From: Marc Lalancette <31040756+Moo-Marc@users.noreply.github.com> Date: Wed, 27 Sep 2023 18:39:52 -0400 Subject: [PATCH 32/47] coregistration branch cleanup --- toolbox/db/db_set_channel.m | 8 +------- toolbox/process/functions/process_headpoints_refine.m | 5 +---- toolbox/process/functions/process_import_bids.m | 10 ++-------- 3 files changed, 4 insertions(+), 19 deletions(-) diff --git a/toolbox/db/db_set_channel.m b/toolbox/db/db_set_channel.m index 0d282dba0..d866171e4 100644 --- a/toolbox/db/db_set_channel.m +++ b/toolbox/db/db_set_channel.m @@ -16,7 +16,6 @@ % - ChannelAlign : 0, do not perform automatic headpoints-based alignment % 1, perform automatic alignment after user confirmation % 2, perform automatic alignment without user confirmation -% 3, as 2, but also updating MRI SCS from digitized points % - Tolerance : Percentage of outliers head points, ignored in the final fit % % OUTPUT: @@ -181,12 +180,7 @@ end % Call automatic registration for MEG - if ChannelAlign >= 3 - % Also adjust MRI SCS from digitized points. - [ChannelMat, R, T, isSkip, isUserCancel, strReport, Tolerance] = channel_align_auto(OutputFile, [], 0, isConfirm, Tolerance, 1); - else - [ChannelMat, R, T, isSkip, isUserCancel, strReport, Tolerance] = channel_align_auto(OutputFile, [], 0, isConfirm, Tolerance); - end + [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 || isempty(ChannelMat) diff --git a/toolbox/process/functions/process_headpoints_refine.m b/toolbox/process/functions/process_headpoints_refine.m index 5d862fbc8..f6e4f6db9 100644 --- a/toolbox/process/functions/process_headpoints_refine.m +++ b/toolbox/process/functions/process_headpoints_refine.m @@ -47,9 +47,6 @@ sProcess.options.tolerance.Comment = 'Tolerance (outlier points to ignore):'; sProcess.options.tolerance.Type = 'value'; sProcess.options.tolerance.Value = {0, '%', 0}; - sProcess.options.scs.Type = 'checkbox'; - sProcess.options.scs.Comment = 'Also ajust MRI nasion and ear points.'; - sProcess.options.scs.Value = 0; end @@ -68,7 +65,7 @@ % 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, sProcess.options.scs.Value); + [ChannelMat, R, T, isSkip, isUserCancel, strReport] = channel_align_auto(uniqueChan{i}, [], 0, 0, tolerance); if ~isempty(strReport) bst_report('Info', sProcess, sInputs(iUniqFiles(i)), strReport); end diff --git a/toolbox/process/functions/process_import_bids.m b/toolbox/process/functions/process_import_bids.m index d3744f8d9..5997290c5 100644 --- a/toolbox/process/functions/process_import_bids.m +++ b/toolbox/process/functions/process_import_bids.m @@ -113,11 +113,6 @@ sProcess.options.channelalign.Comment = 'Align sensors using headpoints'; sProcess.options.channelalign.Type = 'checkbox'; sProcess.options.channelalign.Value = 1; - sProcess.options.channelalign.Controller = 'Align'; - sProcess.options.scs.Type = 'checkbox'; - sProcess.options.scs.Comment = 'Also ajust MRI nasion and ear points from digitized points.'; - sProcess.options.scs.Value = 1; - sProcess.options.scs.Class = 'Align'; end @@ -146,8 +141,7 @@ end % Other options OPTIONS.isInteractive = 0; - % 2=align without confirmation, 3=also adjust MRI SCS from digitized points - OPTIONS.ChannelAlign = (2 + double(sProcess.options.scs.Value)) * double(sProcess.options.channelalign.Value); + OPTIONS.ChannelAlign = 2 * double(sProcess.options.channelalign.Value); OPTIONS.SelectedSubjects = strtrim(str_split(sProcess.options.selectsubj.Value, ',')); OPTIONS.isGroupSessions = sProcess.options.groupsessions.Value; OPTIONS.MniMethod = sProcess.options.mni.Value; @@ -641,7 +635,7 @@ % Import options ImportOptions = db_template('ImportOptions'); ImportOptions.ChannelReplace = 1; - ImportOptions.ChannelAlign = OPTIONS.ChannelAlign * ~sSubject.UseDefaultAnat; + ImportOptions.ChannelAlign = 2 * (OPTIONS.ChannelAlign >= 1) * ~sSubject.UseDefaultAnat; ImportOptions.DisplayMessages = OPTIONS.isInteractive; ImportOptions.EventsMode = 'ignore'; ImportOptions.EventsTrackMode = 'value'; From 11b54bc184b593edd985d924b7ad5143d55ea718 Mon Sep 17 00:00:00 2001 From: Marc Lalancette <31040756+Moo-Marc@users.noreply.github.com> Date: Mon, 13 Jan 2025 14:17:57 -0500 Subject: [PATCH 33/47] wip export coregistration to BIDS --- toolbox/anatomy/tess_isohead.m | 3 + toolbox/gui/figure_mri.m | 2 +- toolbox/io/bst_save_coregistration.m | 25 +- toolbox/math/bst_meshfit.m | 5 +- .../functions/process_adjust_coordinates.m | 464 ++++++++++++++++-- .../process/functions/process_import_bids.m | 6 +- toolbox/sensors/channel_align_scs.m | 231 --------- 7 files changed, 458 insertions(+), 278 deletions(-) delete mode 100644 toolbox/sensors/channel_align_scs.m diff --git a/toolbox/anatomy/tess_isohead.m b/toolbox/anatomy/tess_isohead.m index 30928f006..776b12c0d 100644 --- a/toolbox/anatomy/tess_isohead.m +++ b/toolbox/anatomy/tess_isohead.m @@ -27,6 +27,9 @@ % % Authors: Francois Tadel, 2012-2022 +% Work in progress: Marc Lalancette 2022-2025 +% modified quite a bit, erode and fill factors no longer used. See my notes in OneNote + %% ===== PARSE INPUTS ===== % Initialize returned variables HeadFile = []; diff --git a/toolbox/gui/figure_mri.m b/toolbox/gui/figure_mri.m index 6c79a5c0f..93297967a 100644 --- a/toolbox/gui/figure_mri.m +++ b/toolbox/gui/figure_mri.m @@ -2590,7 +2590,7 @@ function ButtonSave_Callback(hFig, varargin) warning('off', 'MATLAB:load:variableNotFound'); sMriOld = load(MriFileFull, 'SCS'); warning('on', 'MATLAB:load:variableNotFound'); - % If the fiducials were modified (> 1um) + % 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(abs(sMri.SCS.NAS - sMriOld.SCS.NAS)) > 1e-3) || ... diff --git a/toolbox/io/bst_save_coregistration.m b/toolbox/io/bst_save_coregistration.m index 0d06b8802..7bdb27397 100644 --- a/toolbox/io/bst_save_coregistration.m +++ b/toolbox/io/bst_save_coregistration.m @@ -49,7 +49,7 @@ sStudies = bst_get('StudyWithSubject', sSubjects.Subject(iSub).FileName); if isBids if isempty(BidsRoot) - BidsRoot = bst_fileparts(bst_fileparts(bst_fileparts(ImportedFile))); + 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); @@ -76,6 +76,17 @@ end sMriJson = bst_jsondecode(MriJsonFile, false); 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, including under .Header, accordingly, 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 @@ -84,11 +95,17 @@ CS = 'NCS'; end Fid = BstFids{iFid}; - % Voxel coordinates (Nifti: RAS and 0-indexed) + % 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. - sMriJson.AnatomicalLandmarkCoordinates.(Fid) = round(1000 * (sMri.(CS).(Fid)./sMri.Voxsize - 1)) / 1000; + 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; @@ -111,7 +128,7 @@ % cs_convert mri is in meters sMriScs.SCS.(Fid) = cs_convert(sMri, 'mri', 'scs', sMri.SCS.(Fid) ./ 1000); end - sMriNative = sMriScs; + sMriNative = sMriScs; % transformed below for iStudy = 1:numel(sStudies) % Is it a link to raw file? diff --git a/toolbox/math/bst_meshfit.m b/toolbox/math/bst_meshfit.m index 0ec577892..5a1eaf364 100644 --- a/toolbox/math/bst_meshfit.m +++ b/toolbox/math/bst_meshfit.m @@ -66,8 +66,9 @@ 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 = normr(cross(EdgesV(:,:,1), EdgesV(:,:,2))); -%FaceArea = sqrt(sum(FaceNormalsA.^2, 2)); +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)); diff --git a/toolbox/process/functions/process_adjust_coordinates.m b/toolbox/process/functions/process_adjust_coordinates.m index 71d7c73cb..742dfd181 100644 --- a/toolbox/process/functions/process_adjust_coordinates.m +++ b/toolbox/process/functions/process_adjust_coordinates.m @@ -1,5 +1,5 @@ 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. After alignment between MRI and @@ -90,7 +90,7 @@ function OutputFiles = Run(sProcess, sInputs) - + OutputFiles = {}; isDisplay = sProcess.options.display.Value; nInFiles = length(sInputs); @@ -101,16 +101,50 @@ 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); + + 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); @@ -118,7 +152,7 @@ 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'); @@ -219,7 +253,7 @@ isWarning = false; Tolerance = sProcess.options.tolerance.Value{1} / 100; end - [ChannelMat, R, T, isSkip, isUserCancel, strReport] = channel_align_auto(sInputs(iFile).ChannelFile, ... + [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. @@ -229,27 +263,39 @@ 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. + % Before potiential MRI update since that function takes ChannelFile, not ChannelMat. + bst_save(file_fullpath(sInputs(iFile).ChannelFile), ChannelMat, 'v7'); + % ---------------------------------------------------------------- if ~sProcess.options.remove.Value && sProcess.options.scs.Value - % TODO Maybe make this a separate process, since it should only run on one channel file - % per subject. Possibly have hidden option for no interactive warnings. - - [~, isCancel] = channel_align_scs(ChannelFile, eye(4), true, false); % interactive warnings but no confirmation - if isCancel + % 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 % ---------------------------------------------------------------- - % Save channel file. - bst_save(file_fullpath(sInputs(iFile).ChannelFile), ChannelMat, 'v7'); isFileOk(iFile) = true; - + if isDisplay && ~isempty(Modality) % Display "after" results, besides the "before" figure. hFigAfter = channel_align_manual(sInputs(iFile).ChannelFile, Modality, 0); @@ -317,8 +363,11 @@ % 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 + if nargin < 4 || isempty(sProcess) sProcess = []; + isReport = false; + else + isReport = true; end if nargin < 3 sInput = []; @@ -351,15 +400,23 @@ 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)); + 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. @@ -370,9 +427,11 @@ 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, [], []); else @@ -387,16 +446,28 @@ % See if it worked. if isempty(NewChannelFile) - bst_report('Error', sProcess, sInput, 'No file channel file selected.'); + 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)); + 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.'); + 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) @@ -553,7 +624,7 @@ iHeadSamples = 1 + ((1:(nHeadSamples*nEpochs)) - 1) * HeadSamplePeriod; % first is 1 iBad = []; for iSeg = 1:size(BadSegments, 2) - iBad = [iBad, nSamplesPerEpoch * (BadEpoch(1,iSeg) - 1) + (BadSegments(1,iSeg):BadSegments(2,iSeg))]; + iBad = [iBad, nSamplesPerEpoch * (BadEpoch(1,iSeg) - 1) + (BadSegments(1,iSeg):BadSegments(2,iSeg))]; %#ok % iBad = [iBad, find((DataMat.Time >= badTimes(1,iSeg)) & (DataMat.Time <= badTimes(2,iSeg)))]; end % Exclude bad samples. @@ -635,7 +706,7 @@ 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 @@ -1079,11 +1150,326 @@ 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; + 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 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, the 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. +% - 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 - % 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; 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_import_bids.m b/toolbox/process/functions/process_import_bids.m index 5997290c5..f4a9babb7 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 @@ -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/sensors/channel_align_scs.m b/toolbox/sensors/channel_align_scs.m deleted file mode 100644 index 450cc3d32..000000000 --- a/toolbox/sensors/channel_align_scs.m +++ /dev/null @@ -1,231 +0,0 @@ -function [Transform, isCancel] = channel_align_scs(ChannelFile, Transform, isWarning, isConfirm) -% CHANNEL_ALIGN_SCS: Saves new MRI anatomical points after manual or auto registration adjustment. -% -% USAGE: Transform = channel_align_scs(ChannelFile, isWarning=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 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 isWarning and isConfirm are false). In that case, the 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. -% - isWarning : If 1, display warning in case of errors, or if this was already done -% previously for this MRI. -% - isConfirm : If 1, ask the user for confirmation before proceeding. -% -% OUTPUTS: -% - 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. - -% @============================================================================= -% 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 2022-2023 - -% TODO if Transform is missing, get equivalent from ChannelMat, from all auto/manual adjustments. - -isCancel = false; -% Get study -sStudy = bst_get('ChannelFile', ChannelFile); -% Get subject -[sSubject, iSubject] = 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 isWarning - bst_error(Message, 'Apply digitized anatomical fiducials to MRI', 0); - else - disp(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 isWarning - bst_error(Message, 'Apply digitized anatomical fiducials to MRI', 0); - else - disp(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] = process_adjust_coordinates('CheckPrevAdjustments', ChannelMat, sMriOld); -% Get user confirmation -if isMriUpdated - % Already done previously. - if isWarning || 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. - disp('BST> Digitized nasion and ear points previously applied to this MRI. Not applying again.'); - 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. -% To do this we need to apply the transformation provided. -sMri = sMriOld; -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 -[~, 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); - -% MRI SCS now matches digitized SCS (defined from same points), but registration is now broken with -% all channel files! Reset channel file, and optionally all others for this anatomy. -isError = ResetChannelFiles(ChannelMat, sSubject); -if isError - % Get the equivalent overall registration adjustment transformation previously saved. - [~, ~, TransfAfter] = process_adjust_coordinates('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 - - -% Modified version of channel_align_manual CopyToOtherFolders, with fewer checks (all channel files, -% not just "matching" ones) and resetting instead of applying a transform. -function isError = ResetChannelFiles(ChannelMatSrc, sSubject) - % First, always reset the "source" channel file. - NewChannelFiles = cell(0,2); - [ChannelMatSrc, NewChannelFiles, isError] = ResetChannelFile(ChannelMatSrc, NewChannelFiles); - if isError - java_dialog('msgbox', sprintf(['Unable to reset channel file for subject: %s\n' ... - 'Registration for all their datasets should be verified!'], sSubject.Name)); - return; - end - - % Confirmation: ask the first time - isConfirm = []; - % 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, iStudies] = bst_get('StudyWithSubject', sSubject.FileName); - % List of channel files to update - ChannelFiles = {}; - strMsg = ''; - % Loop on the other folders - for i = 1:length(sStudies) - % Skip original study - if (iStudies(i) == iStudySrc) - continue; - end - % Skip studies without channel files - if isempty(sStudies(i).Channel) || isempty(sStudies(i).Channel(1).FileName) - continue; - end - % Load channel file - ChannelMatDest = in_bst_channel(sStudies(i).Channel(1).FileName); - % Ask confirmation to the user - if isempty(isConfirm) - isConfirm = java_dialog('confirm', 'Reset all the channel files for this subject?', 'Align sensors'); - if ~isConfirm - return; - end - end - % Add channel file to list of files to process - ChannelFiles{end+1} = sStudies(i).Channel(1).FileName; - strMsg = [strMsg, sStudies(i).Channel(1).FileName, 10]; - end - % Apply transformation - if ~isempty(ChannelFiles) - % Progress bar - bst_progress('start', 'Align sensors', 'Updating other datasets...'); - % Update files - channel_apply_transf(ChannelFiles, Transf); - % Give report to the user - bst_progress('stop'); - java_dialog('msgbox', sprintf('Updated %d additional file(s):\n%s', length(ChannelFiles), strMsg)); - end -end - From ef9fc51e34efcbd04adeba2ae76a4e01b8676322 Mon Sep 17 00:00:00 2001 From: Marc Lalancette <31040756+Moo-Marc@users.noreply.github.com> Date: Thu, 23 Jan 2025 10:28:41 -0500 Subject: [PATCH 34/47] wip coregistration create head surface --- toolbox/anatomy/tess_check.m | 199 ++++++++++++++++++++++++ toolbox/anatomy/tess_downsize.m | 110 ++++++++++---- toolbox/anatomy/tess_faceconn.m | 16 +- toolbox/anatomy/tess_isohead.m | 259 ++++++++++++++++++++++++++++++-- 4 files changed, 534 insertions(+), 50 deletions(-) create mode 100644 toolbox/anatomy/tess_check.m diff --git a/toolbox/anatomy/tess_check.m b/toolbox/anatomy/tess_check.m new file mode 100644 index 000000000..c069580c4 --- /dev/null +++ b/toolbox/anatomy/tess_check.m @@ -0,0 +1,199 @@ +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 + 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_downsize.m b/toolbox/anatomy/tess_downsize.m index 972c7ac15..e662f60ff 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,11 +91,13 @@ 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) % Ask method - ind = java_dialog('radio', 'Select the resampling method:', 'Resample surface', [], ... - {['Matlab''s reducepatch:
' ... + OptionsText = {['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:
' ... @@ -95,10 +106,18 @@ ['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); + '   | - 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']}, 1); +% '   | - Damages the atlases and the subject co-registration']}; + if isLidarToolbox + OptionsText{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 + ind = java_dialog('radio', 'Select the resampling method:', 'Resample surface', [], ... + OptionsText, 1); if isempty(ind) return end @@ -107,7 +126,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,15 +141,18 @@ 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; +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); -dsFactor = newNbVertices / size(TessMat.Vertices, 1); - +dsFactor = newNbVertices / size(TessMat.Vertices, 1); %% ===== RESAMPLE ===== bst_progress('start', 'Resample surface', ['Resampling surface: ' TessMat.Comment '...']); @@ -411,6 +434,21 @@ NewTessMat.Faces = Faces; NewTessMat.Vertices = Vertices; MethodTag = '_iso2mesh_proj'; + + % ===== REDUCEPATCH ===== + % 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 + oMesh = simplify(oMesh, TessMat.Vertices, 'TargetNumFaces', newNbVertices); + NewTessMat.Faces = oMesh.Faces; + NewTessMat.Vertices = oMesh.Vertices; + end @@ -529,15 +567,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'); @@ -550,15 +591,20 @@ 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; + % iso2mesh is now a plugin, install/load iso2mesh plugin + [isInstalled, errInstall] = bst_plugin('Install', 'iso2mesh', true); % isInteractive + if ~isInstalled + bst_error(errInstall); + % 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; + end 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_isohead.m b/toolbox/anatomy/tess_isohead.m index 776b12c0d..50d8f9bda 100644 --- a/toolbox/anatomy/tess_isohead.m +++ b/toolbox/anatomy/tess_isohead.m @@ -30,6 +30,10 @@ % Work in progress: Marc Lalancette 2022-2025 % modified quite a bit, erode and fill factors no longer used. See my notes in OneNote +% To visualize steps for debugging. +isDebugVis = true; +nDebugVisSlices = 9; + %% ===== PARSE INPUTS ===== % Initialize returned variables HeadFile = []; @@ -100,10 +104,10 @@ return end % Get new values - nVertices = str2num(res{1}); - erodeFactor = str2num(res{2}); - fillFactor = str2num(res{3}); - bgLevel = str2num(res{4}); + nVertices = str2double(res{1}); + erodeFactor = str2double(res{2}); + fillFactor = str2double(res{3}); + bgLevel = str2double(res{4}); if isempty(bgLevel) bgLevel = sMri.Histogram.bgLevel; end @@ -161,6 +165,9 @@ headmask(:,end,:) = 0; %*headmask(:,1,:); headmask(:,:,1) = 0; %*headmask(:,:,1); headmask(:,:,end) = 0; %*headmask(:,:,1); +if isDebugVis + view_mri_slices(headmask, 'x', nDebugVisSlices); +end % Erode + dilate, to remove small components % if (erodeFactor > 0) % headmask = headmask & ~mri_dilate(~headmask, erodeFactor); @@ -182,8 +189,21 @@ % headmask(2:end-1,2:end-1,2:end-1); % headmask(isFill) = 0; -% Fill neck holes (bones, etc.) where it is cut at edge of volume. +% Fill neck holes (bones, etc.) where it is cut at edge of volume. bst_progress('text', 'Filling holes and removing disconnected parts...'); +if isDebugVis + figure; imagesc(headmask(:,:,2)); colormap('gray'); axis equal; +end +% 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. switch iDim @@ -195,22 +215,65 @@ Perm = [3, 2, 1]; end TempMask = permute(headmask, Perm); - % Edit second and second-to-last slices - Slice = TempMask(2, :, :); - TempMask(2, :, :) = Slice | (Fill(Slice, 2) & Fill(Slice, 3)); - Slice = TempMask(end-1, :, :); - TempMask(end-1, :, :) = Slice | (Fill(Slice, 2) & Fill(Slice, 3)); + % 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, :, :)))) + if isFlip + TempMask = flip(TempMask, 1); + end + continue; + end + Slice = sum(TempMask(2:2+nSlices-1, :, :), 1); + if isDebugVis + figure; imagesc(squeeze(Slice)); colormap('gray'); axis equal; + 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 + if isDebugVis + figure; imagesc(squeeze(Slice)); colormap('gray'); axis equal; + end + Slice = Slice | (Fill(Slice, 2) & Fill(Slice, 3)); + if isDebugVis + figure; imagesc(squeeze(Slice)); colormap('gray'); axis equal; + end + Slice = CenterSpread(Slice); + if isDebugVis + figure; imagesc(squeeze(Slice)); colormap('gray'); axis equal; + end + TempMask(2, :, :) = Slice; + if isFlip + TempMask = flip(TempMask, 1); + end + end % Permute back headmask = permute(TempMask, Perm); end +if isDebugVis + figure; imagesc(headmask(:,:,2)); colormap('gray'); axis equal; +end % Fill holes InsideMask = (Fill(headmask, 1) & Fill(headmask, 2) & Fill(headmask, 3)); headmask = InsideMask | (Dilate(InsideMask) & headmask); +if isDebugVis + view_mri_slices(headmask, 'x', nDebugVisSlices); +end % Keep only central connected volume (trim "beard" or bubbles) headmask = CenterSpread(headmask); bst_progress('inc', 15); -% view_mri_slices(headmask, 'x', 20) +if isDebugVis + view_mri_slices(headmask, 'x', nDebugVisSlices); +end %% ===== CREATE SURFACE ===== @@ -220,6 +283,16 @@ [sHead.Faces, sHead.Vertices] = mri_isosurface(headmask, 0.5); % Flip x-y back to our voxel coordinates. sHead.Vertices = sHead.Vertices(:, [2, 1, 3]); +if isDebugVis + FigureId = db_template('FigureId'); + FigureId.Type = '3DViz'; + hFig = figure_3d('CreateFigure', FigureId); + figure_3d('PlotSurface', hFig, sHead.Faces, sHead.Vertices, [1,1,1], 0); % color, transparency required + figure_3d('ViewAxis', hFig, true); % isVisible + hFig.Visible = "on"; + fprintf('mri_isohead surface\n'); + [isOk, Info] = tess_check(sHead.Vertices, sHead.Faces, true, false, false); %#ok % verbose, not open, don't show +end bst_progress('inc', 10); % Downsample to a maximum number of vertices % maxIsoVert = 60000; @@ -228,9 +301,16 @@ % [sHead.Faces, sHead.Vertices] = reducepatch(sHead.Faces, sHead.Vertices, maxIsoVert./length(sHead.Vertices)); % bst_progress('inc', 10); % end + % Remove small objects bst_progress('text', 'Removing small patches...'); [sHead.Vertices, sHead.Faces] = tess_remove_small(sHead.Vertices, sHead.Faces); +if isDebugVis + hFig = figure_3d('CreateFigure', FigureId); + figure_3d('PlotSurface', hFig, sHead.Faces, sHead.Vertices, [1,1,1], 0); % color, transparency required + figure_3d('ViewAxis', hFig, true); % isVisible + hFig.Visible = "on"; +end bst_progress('inc', 15); % Clean final surface @@ -245,17 +325,46 @@ % 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, false); %#ok % verbose, not open, don't show +end bst_progress('inc', 20); % 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', 15); + % Modified tess_downsize to accept sHead + % Check if Lidar Toolbox is installed (requires image processing + computer vision) + isLidarToolbox = exist('surfaceMesh', 'file') == 2; + if isLidarToolbox + Method = 'simplify'; + else + % This can produce a "bad" patch. disconnected? intersecting? + % TODO: need to fix and use tess_clean, or use different method + Method = 'reducepatch'; + end + sHead = tess_downsize(sHead, nVertices, Method); + %[sHead.Faces, sHead.Vertices] = reducepatch(sHead.Faces, sHead.Vertices, nVertices./length(sHead.Vertices)); + if isDebugVis + fprintf('reduced surface (%s)\n', Method); + [isOk, Info] = tess_check(sHead.Vertices, sHead.Faces, true, false, false); %#ok % verbose, not open, don't show + end + % Fix this patch + [sHead.Vertices, sHead.Faces] = tess_remove_small(sHead.Vertices, sHead.Faces); + if isDebugVis + fprintf('reduced surface (small disconnected parts removed)\n'); + [isOk, Info] = tess_check(sHead.Vertices, sHead.Faces, true, false, true); %#ok % verbose, not open, show + end end +bst_progress('inc', 15); bst_progress('text', 'Smoothing...'); sHead.Vertices = SurfaceSmooth(sHead.Vertices, sHead.Faces, 2, [], 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); %#ok % verbose, not open, show +end bst_progress('inc', 10); % Convert to SCS @@ -355,15 +464,20 @@ function OutMask = 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. OutMask = false(size(InMask)); -iStart = round(size(OutMask)/2); +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 @@ -385,6 +499,9 @@ nPrev = nOut; nOut = sum(OutMask(:)); end +if nOut == 1 + warning('CenterSpread failed: starting center point is not part of the mask.'); +end end @@ -394,3 +511,117 @@ Vect = cat(4,x,y,z); Vol = sqrt(sum(Vect.^2, 4)); end + + +% Modified version to detect duplicate faces based on vertex indices, not strange alignment of face +% normals. +% TODO: DELETE just started, probably won't keep. +% function [Vertices, Faces, remove_vertices, remove_faces, Atlas] = tess_clean(Vertices, Faces, Atlas) +% % TESS_CLEAN: Check the integrity of a tesselation. +% % +% % USAGE: [Vertices, Faces, remove_vertices, Atlas] = tess_clean(Vertices, Faces, Atlas) +% % +% % DESCRIPTION: +% % Check in a tesselation if there are some identical faces and remove the bad_oriented one. +% % Moreover it removes isolated triangles and some other pathological configurations. +% % +% % INPUTS: +% % - Vertices : Mx3 double matrix +% % - Faces : Nx3 double matrix +% % OUTPUTS: +% % - Vertices : Corrected vertices structure +% % - Faces : Corrected faces structure +% +% % Authors: Julien Lefevre, 2007 +% % Francois Tadel, 2008-2014 +% % Marc Lalancette, 2025 +% +% % Parse inputs +% if (nargin < 3) || isempty(Atlas) +% Atlas = []; +% end +% % Check matrix orientation +% if (size(Vertices, 2) ~= 3) || (size(Faces, 2) ~= 3) +% error('Faces and Vertices must have 3 columns (X,Y,Z).'); +% end +% +% % Face connectivity matrix for edges +% [~, FaceConn] = tess_faceconn(Faces, 2); +% +% % Items to remove +% remove_faces=[]; +% remove_vertices=[]; +% +% % Sort face vertex indices to identify duplicates. +% FacesSrt = sort(Faces, 2); +% [~, iFace, iUniqFace] = unique(FacesSrt, 'rows'); +% % UniqFaces = Faces(iFace), Faces = UniqFaces(iUniqFace) +% % For each unique item, check if multiple copies in iUniqFace +% for iU = 1:numel(iFace) +% if sum(iUniqFace == iU) > 1 +% isFaceSuspect = iUniqFace == iU; +% +% iRemoveFace(end+1) = i +% +% TessArea = tess_area(Vertices, Faces); +% [~, FaceNormals] = tess_normals(Vertices, Faces); +% +% sort_crossprod = sortrows(abs([FaceNormals,(1:size(FaceNormals,1))'])); +% diff_sort_crossprod = diff(sort_crossprod); +% indices = find((diff_sort_crossprod(:,1) < tol) & ... +% (diff_sort_crossprod(:,2) < tol) & ... +% (diff_sort_crossprod(:,3) < tol)); +% % Indices of redundant triangles (same coordinates, two different orientations) +% indices_tri1 = sort_crossprod(indices,4); +% indices_tri2 = sort_crossprod(indices+1,4); +% +% [VertFacesConn, FaceConn] = tess_faceconn(Faces); +% +% % For each suspected face we compute the mean of normals of neighbouring faces +% scal=zeros(length(indices_tri1),2); +% +% % We remove faces whose normal is not in the same direction as their neighbouring faces +% for i=1:length(indices_tri1) +% neighbours = find(FaceConn(indices_tri1(i),:)); +% neighbours = setdiff(neighbours, indices_tri2(i)); +% % Isolated faces +% if isempty(neighbours) +% remove_faces = [remove_faces, indices_tri1(i), indices_tri2(i)]; +% remove_vertices = [remove_vertices, Faces(indices_tri1(i),:)]; +% else +% normal_mean = mean(FaceNormals(neighbours,:) .* repmat(TessArea(neighbours),1,3),1); +% norm_i = FaceNormals(indices_tri1(i),:); +% scal(i,1) = normal_mean*norm_i'/(norm(normal_mean)); +% +% if scal(i,1)>0 +% remove_faces = [remove_faces,indices_tri2(i)]; +% scal(i,2) = indices_tri2(i); +% else +% if scal(i,1)<0 +% remove_faces = [remove_faces,indices_tri1(i)]; +% scal(i,2) = indices_tri1(i); +% else +% remove_faces = [remove_faces,indices_tri1(i),indices_tri2(i)]; +% remove_vertices = [remove_vertices, Faces(indices_tri1(i),:)]; +% end +% end +% end +% end +% +% % Find all the isolated faces +% FaceConn(remove_faces, :) = 0; +% FaceConn(:, remove_faces) = 0; +% iIsolatedFaces = find(sum(FaceConn) <= 1); +% remove_faces = union(remove_faces', iIsolatedFaces); +% % Remove faces +% Faces(remove_faces, :) = []; +% +% % Find the vertices that are not used in any face +% VertConn = tess_vertconn(Vertices, Faces); +% iIsolatedVert = find(sum(VertConn) <= 1); +% remove_vertices = union(remove_vertices, iIsolatedVert); +% % Remove vertices +% [Vertices, Faces, Atlas] = tess_remove_vert(Vertices, Faces, remove_vertices, Atlas); +% +% end + From a01cd6874d854cc227163e944a6e9d1055f77216 Mon Sep 17 00:00:00 2001 From: Marc Lalancette <31040756+Moo-Marc@users.noreply.github.com> Date: Thu, 23 Jan 2025 11:24:45 -0500 Subject: [PATCH 35/47] Merge master, BUT NOT channel_align_manual which will have to be merged manually later! --- .github/workflows/run_tutorial.yaml | 91 +- .github/workflows/startup_test.yml | 65 + .gitignore | 2 + CONTRIBUTING.md | 15 +- bin/{R2022b => R2023a}/brainstorm3.bat | 6 +- bin/{R2022b => R2023a}/brainstorm3.command | 53 +- brainstorm.m | 13 +- .../eeg/Colin27/channel_ANT_Waveguard_128.mat | Bin 5683 -> 5729 bytes .../eeg/Colin27/channel_ANT_Waveguard_256.mat | Bin 10857 -> 10918 bytes .../eeg/Colin27/channel_ANT_Waveguard_65.mat | Bin 0 -> 5825 bytes .../eeg/Colin27/channel_ASA_10-05_343.mat | Bin 14333 -> 14453 bytes .../eeg/Colin27/channel_BioSemi_160_A01.mat | Bin 7336 -> 7470 bytes .../eeg/Colin27/channel_BioSemi_160_A1.mat | Bin 7419 -> 7563 bytes .../channel_BrainProducts_ActiCap_128.mat | Bin 5983 -> 6091 bytes .../channel_BrainProducts_ActiCap_68.mat | Bin 0 -> 4582 bytes .../channel_BrainProducts_ActiCap_97.mat | Bin 5098 -> 5213 bytes .../channel_BrainProducts_EasyCap_128.mat | Bin 6531 -> 6638 bytes .../channel_BrainProducts_EasyCap_M10.mat | Bin 3648 -> 3761 bytes .../Colin27/channel_GSN_HydroCel_256_E001.mat | Bin 10424 -> 10718 bytes .../Colin27/channel_GSN_HydroCel_256_E1.mat | Bin 10394 -> 10678 bytes .../channel_WearableSensing_DSI_24.mat | Bin 0 -> 2942 bytes .../eeg/ICBM152/channel_ANT_Waveguard_65.mat | Bin 0 -> 5817 bytes .../channel_BrainProducts_ActiCap_68.mat | Bin 0 -> 4643 bytes .../channel_WearableSensing_DSI_24.mat | Bin 0 -> 3146 bytes deploy/RunCompiled_2023a.class | Bin 0 -> 1332 bytes deploy/RunCompiled_2023b.class | Bin 0 -> 1332 bytes deploy/RunCompiled_2024a.class | Bin 0 -> 1332 bytes deploy/RunCompiled_2024b.class | Bin 0 -> 1332 bytes deploy/bst_compile.m | 31 +- deploy/bst_deploy.m | 63 +- deploy/bst_spmtrip.m | 4 +- doc/license.html | 4 +- doc/logo_splash.gif | Bin 17826 -> 18647 bytes doc/plugins/brainsuite_logo.png | Bin 0 -> 59503 bytes doc/plugins/mtrf_logo.gif | Bin 0 -> 8398 bytes doc/plugins/neuromaps_logo.png | Bin 0 -> 33183 bytes doc/plugins/zeffiro_logo.png | Bin 0 -> 27190 bytes doc/updates.txt | 129 ++ doc/version.txt | 2 +- external/easyh5/ChangeLog.txt | 23 - external/easyh5/README.md | 103 - external/easyh5/decodevarname.m | 72 - external/easyh5/encodevarname.m | 67 - external/easyh5/jdatadecode.m | 306 --- external/easyh5/jdataencode.m | 301 --- external/easyh5/jsonopt.m | 36 - external/easyh5/loadh5.m | 281 --- external/easyh5/mergestruct.m | 33 - external/easyh5/regrouph5.m | 134 -- external/easyh5/saveh5.m | 377 ---- external/easyh5/varargin2struct.m | 40 - external/intan/read_Intan_RHS2000_bst_2018.m | 13 +- external/jsnirfy/README.md | 198 -- external/jsnirfy/aos2soa.m | 40 - external/jsnirfy/jsnirfcreate.m | 78 - external/jsnirfy/loadsnirf.m | 63 - external/jsnirfy/savesnirf.m | 77 - external/jsnirfy/snirfcreate.m | 36 - external/jsnirfy/snirfdecode.m | 65 - external/npy-matlab/readNPY.m | 37 - external/npy-matlab/readNPYheader.m | 69 - external/piotr_toolbox/LICENCE.txt | 26 + external/piotr_toolbox/tpsGetWarp.m | 77 + external/piotr_toolbox/tpsInterpolate.m | 52 + external/spm/spm_bsplinc.mexmaca64 | Bin 0 -> 216898 bytes external/spm/spm_bsplins.mexmaca64 | Bin 0 -> 67746 bytes toolbox/anatomy/bst_normalize_mni.m | 16 +- toolbox/anatomy/bst_warp_prepare.m | 33 +- toolbox/anatomy/mri_coregister.m | 74 +- toolbox/anatomy/mri_reslice.m | 2 +- toolbox/anatomy/mri_reslice_mni.m | 2 +- toolbox/anatomy/mri_skullstrip.m | 215 ++ toolbox/anatomy/tess_deface.m | 62 + toolbox/anatomy/tess_downsize.m | 69 +- toolbox/anatomy/tess_interp_tess2tess.m | 14 +- toolbox/anatomy/tess_isohead.m | 36 +- toolbox/anatomy/tess_isosurface.m | 188 ++ toolbox/anatomy/tess_smooth_sources.m | 187 +- toolbox/connectivity/bst_connectivity.m | 77 +- toolbox/connectivity/bst_xspectrum.m | 4 +- .../private/direct_pac_mex.mexmaca64 | Bin 0 -> 50664 bytes .../private/direct_pac_mex.mexmaci64 | Bin 50064 -> 13248 bytes toolbox/core/bst_colormaps.m | 18 +- toolbox/core/bst_get.m | 200 +- toolbox/core/bst_memory.m | 133 +- toolbox/core/bst_plugin.m | 644 ++++-- toolbox/core/bst_set.m | 5 +- toolbox/core/bst_startup.m | 45 +- toolbox/core/bst_systeminfo.m | 102 + toolbox/core/bst_userstat.m | 61 +- toolbox/db/db_add.m | 2 +- toolbox/db/db_group_conditions.m | 10 + toolbox/db/db_template.m | 18 +- toolbox/db/private/db_parse_subject.m | 4 +- toolbox/forward/panel_headmodel.m | 32 +- toolbox/gui/figure_3d.m | 134 +- toolbox/gui/figure_connect.m | 42 +- toolbox/gui/figure_mri.m | 53 +- toolbox/gui/figure_timefreq.m | 29 +- toolbox/gui/figure_timeseries.m | 12 +- toolbox/gui/figure_topo.m | 292 ++- toolbox/gui/gui_brainstorm.m | 14 +- toolbox/gui/gui_hide.m | 7 +- toolbox/gui/gui_initialize.m | 4 +- toolbox/gui/gui_layout.m | 4 +- toolbox/gui/java_getfile.m | 46 +- toolbox/gui/menu_default_eegcaps.m | 127 ++ toolbox/gui/panel_cluster.m | 1 + toolbox/gui/panel_command.m | 7 +- toolbox/gui/panel_coordinates.m | 99 +- toolbox/gui/panel_ieeg.m | 908 +++++++-- toolbox/gui/panel_montage.m | 55 +- toolbox/gui/panel_options.m | 49 +- toolbox/gui/panel_protocols.m | 8 +- toolbox/gui/panel_record.m | 37 +- toolbox/gui/panel_scout.m | 424 +++- toolbox/gui/panel_ssp_selection.m | 109 +- toolbox/gui/panel_surface.m | 169 +- toolbox/gui/view_channels_3d.m | 47 +- toolbox/gui/view_connect.m | 2 + toolbox/gui/view_contactsheet.m | 236 ++- toolbox/gui/view_headpoints.m | 14 +- toolbox/gui/view_image_reg.m | 2 + toolbox/gui/view_leadfield_sensitivity.m | 2 +- toolbox/gui/view_matrix.m | 13 +- toolbox/gui/view_mri_3d.m | 2 +- toolbox/gui/view_scouts.m | 12 +- toolbox/gui/view_surface_data.m | 21 +- toolbox/gui/view_surface_matrix.m | 51 +- toolbox/gui/view_topography.m | 20 +- toolbox/inverse/panel_inverse_2018.m | 5 +- toolbox/io/export_channel.m | 13 +- toolbox/io/export_data.m | 2 +- toolbox/io/export_events.m | 3 + toolbox/io/export_result.m | 12 +- toolbox/io/file_compare.m | 5 + toolbox/io/import_anatomy_cat_2019.m | 8 +- toolbox/io/import_anatomy_cat_2020.m | 8 +- toolbox/io/import_anatomy_fs.m | 83 +- toolbox/io/import_channel.m | 4 +- toolbox/io/import_events.m | 20 +- toolbox/io/import_label.m | 8 +- toolbox/io/import_mri.m | 162 +- toolbox/io/import_sources.m | 16 +- toolbox/io/import_subject.m | 4 +- toolbox/io/import_surfaces.m | 9 +- toolbox/io/import_video.m | 6 +- toolbox/io/in_bst_channel.m | 6 + toolbox/io/in_bst_data.m | 54 + toolbox/io/in_bst_results.m | 8 + toolbox/io/in_channel_ascii.m | 3 + toolbox/io/in_channel_curry_pom.m | 2 +- toolbox/io/in_channel_pos.m | 73 +- toolbox/io/in_channel_tvb.m | 8 + toolbox/io/in_data_edf_ft.m | 44 + toolbox/io/in_data_snirf.m | 52 +- toolbox/io/in_data_tvb.m | 8 + toolbox/io/in_events_brainamp.m | 46 +- toolbox/io/in_events_oebin.m | 23 +- toolbox/io/in_fopen.m | 2 + toolbox/io/in_fopen_brainamp.m | 2 +- toolbox/io/in_fopen_bst.m | 8 +- toolbox/io/in_fopen_bstmatrix.m | 2 +- toolbox/io/in_fopen_ctf.m | 13 +- toolbox/io/in_fopen_edf.m | 5 +- toolbox/io/in_fopen_fif.m | 7 + toolbox/io/in_fopen_intan.m | 2 +- toolbox/io/in_fopen_itab.m | 10 +- toolbox/io/in_fopen_neuralynx.m | 73 +- toolbox/io/in_fopen_nirs_brs.m | 4 +- toolbox/io/in_fopen_oebin.m | 48 +- toolbox/io/in_fread_edf.m | 11 +- toolbox/io/in_fread_itab.m | 2 +- toolbox/io/in_fread_neuralynx.m | 24 +- toolbox/io/in_fread_oebin.m | 8 +- toolbox/io/in_mri.m | 8 +- toolbox/io/in_mri_mgh.m | 4 +- toolbox/io/in_tess.m | 21 +- toolbox/io/in_tess_bst.m | 5 + toolbox/io/in_tess_off.m | 2 +- toolbox/io/in_tess_wftobj.m | 200 ++ ..._brainsight.m => out_channel_brainsight.m} | 275 +-- toolbox/io/out_data_snirf.m | 84 +- toolbox/io/out_events_bids.m | 72 + toolbox/io/out_fopen_bst.m | 6 +- toolbox/io/out_fopen_edf.m | 5 +- toolbox/io/out_fwrite.m | 8 +- toolbox/io/out_matrix_ascii.m | 33 +- toolbox/io/out_mri_bst.m | 15 +- toolbox/io/out_mri_nii.m | 4 +- toolbox/io/out_tess.m | 8 + toolbox/io/out_tess_obj.m | 48 + toolbox/io/out_tess_off.m | 1 - toolbox/io/private/fif_setup_raw.m | 9 + toolbox/io/private/neuralynx_getheader.m | 84 +- toolbox/io/private/read_fieldtrip_chaninfo.m | 2 +- toolbox/math/bst_epoching.m | 2 +- toolbox/math/bst_meanvar.mexmaca64 | Bin 0 -> 50424 bytes toolbox/math/bst_pca.m | 4 +- toolbox/math/bst_permtest.m | 5 +- toolbox/math/bst_project_channel.m | 7 + toolbox/math/bst_project_sources.m | 6 +- toolbox/math/bst_scout_channels.m | 225 +++ toolbox/math/bst_scout_value.m | 13 +- toolbox/math/bst_shepards.m | 12 +- toolbox/math/bst_tess_distance.m | 60 + toolbox/misc/struct_copy_fields.m | 16 +- toolbox/process/bst_process.m | 26 +- toolbox/process/bst_report.m | 79 +- .../process_corr1n_time.m | 0 toolbox/process/functions/process_add_tag.m | 2 + .../functions/process_channel_addloc.m | 4 +- .../functions/process_channel_biosemi.m | 2 +- toolbox/process/functions/process_cohere1.m | 17 +- toolbox/process/functions/process_cohere1n.m | 17 +- toolbox/process/functions/process_cohere2.m | 11 +- .../functions/process_combine_recordings.m | 298 +++ .../functions/process_convert_raw_to_lfp.m | 2 +- toolbox/process/functions/process_corr1.m | 24 + toolbox/process/functions/process_corr1n.m | 24 + toolbox/process/functions/process_corr2.m | 23 + .../functions/process_decoding_maxcorr.m | 2 +- .../process/functions/process_decoding_svm.m | 61 +- toolbox/process/functions/process_detectbad.m | 298 ++- .../process/functions/process_detectbad_mad.m | 419 ++++ toolbox/process/functions/process_dwi2dti.m | 68 +- toolbox/process/functions/process_eegref.m | 5 +- .../process/functions/process_evt_extended.m | 20 +- .../functions/process_evt_head_motion.m | 4 +- toolbox/process/functions/process_evt_merge.m | 67 +- toolbox/process/functions/process_evt_read.m | 60 +- .../process/functions/process_evt_simple.m | 70 +- .../process/functions/process_export_file.m | 205 ++ .../process/functions/process_export_spmvol.m | 44 +- .../functions/process_extract_headdist.m | 2 +- .../process/functions/process_extract_scout.m | 45 +- .../process/functions/process_extract_time.m | 4 +- .../functions/process_extract_values.m | 25 +- toolbox/process/functions/process_fem_mesh.m | 288 ++- toolbox/process/functions/process_fooof.m | 456 ++++- toolbox/process/functions/process_fooof_py.m | 3 +- .../process/functions/process_ft_mtmconvol.m | 3 + .../process_headmodel_exclusionzone.m | 231 +++ .../functions/process_import_channel.m | 25 +- toolbox/process/functions/process_inverse.m | 5 +- .../process/functions/process_inverse_2018.m | 18 +- toolbox/process/functions/process_movefile.m | 4 +- .../process/functions/process_mri_deface.m | 16 +- .../process/functions/process_mtrf_train.m | 173 ++ .../process/functions/process_pac_dynamic.m | 4 +- .../functions/process_pac_dynamic_sur2.m | 4 + toolbox/process/functions/process_psd.m | 1 - .../process/functions/process_psd_features.m | 201 ++ .../process/functions/process_remove_evoked.m | 4 +- .../process/functions/process_segment_cat12.m | 10 +- .../functions/process_select_files_data.m | 10 + .../functions/process_select_files_matrix.m | 5 + .../functions/process_select_files_results.m | 5 + .../functions/process_select_files_timefreq.m | 5 + .../process/functions/process_select_search.m | 10 + .../process/functions/process_source_atlas.m | 3 +- toolbox/process/functions/process_sprint.m | 8 +- .../process_ssmooth.m | 166 +- .../functions/process_ssmooth_surfstat.m | 124 +- toolbox/process/functions/process_ssp2.m | 102 +- .../functions/process_sync_recordings.m | 310 +++ .../functions/process_test_normative.m | 419 ++++ toolbox/process/functions/process_timefreq.m | 29 +- .../process/functions/process_timeoffset.m | 90 +- toolbox/process/panel_process2.m | 2 +- toolbox/process/panel_process_select.m | 264 ++- toolbox/script/README.md | 107 + toolbox/script/generate_phantom_ctf.m | 2 +- toolbox/script/generate_phantom_elekta.m | 2 +- toolbox/script/get_tutorial_data.m | 69 + toolbox/script/script_view_sources.m | 17 +- toolbox/script/test_tutorial.m | 315 +++ toolbox/script/tutorial_BEst.m | 209 ++ toolbox/script/tutorial_brain_fingerprint.m | 339 ++++ toolbox/script/tutorial_coherence.m | 151 +- toolbox/script/tutorial_connectivity.m | 52 +- toolbox/script/tutorial_dba.m | 294 +++ toolbox/script/tutorial_ephys.m | 4 +- toolbox/script/tutorial_epilepsy.m | 32 + toolbox/sensors/channel_detect_eegcap_auto.m | 241 +++ toolbox/sensors/channel_detect_type.m | 8 +- toolbox/sensors/channel_find.m | 30 + toolbox/sensors/channel_fixunits.m | 71 +- toolbox/sensors/panel_digitize.m | 1100 ++++++++--- toolbox/sensors/panel_digitize_2024.m | 1735 +++++++++++++++++ toolbox/sensors/private/bst_beep.wav | Bin 0 -> 17632 bytes toolbox/sensors/private/bst_beep_wav.mat | Bin 160194 -> 0 bytes toolbox/timefreq/bst_psd.m | 89 +- toolbox/timefreq/bst_sprint.m | 1136 +++++++++-- toolbox/timefreq/bst_timefreq.m | 300 +-- toolbox/timefreq/panel_timefreq_options.m | 7 +- toolbox/tree/node_create_subject.m | 3 + toolbox/tree/node_delete.m | 22 +- toolbox/tree/node_rename.m | 2 +- toolbox/tree/tree_callbacks.m | 305 +-- toolbox/tree/tree_set_noisecov.m | 10 +- 301 files changed, 16779 insertions(+), 5346 deletions(-) create mode 100644 .github/workflows/startup_test.yml rename bin/{R2022b => R2023a}/brainstorm3.bat (98%) rename bin/{R2022b => R2023a}/brainstorm3.command (71%) mode change 100644 => 100755 create mode 100644 defaults/eeg/Colin27/channel_ANT_Waveguard_65.mat create mode 100644 defaults/eeg/Colin27/channel_BrainProducts_ActiCap_68.mat create mode 100644 defaults/eeg/Colin27/channel_WearableSensing_DSI_24.mat create mode 100644 defaults/eeg/ICBM152/channel_ANT_Waveguard_65.mat create mode 100644 defaults/eeg/ICBM152/channel_BrainProducts_ActiCap_68.mat create mode 100644 defaults/eeg/ICBM152/channel_WearableSensing_DSI_24.mat create mode 100644 deploy/RunCompiled_2023a.class create mode 100644 deploy/RunCompiled_2023b.class create mode 100644 deploy/RunCompiled_2024a.class create mode 100644 deploy/RunCompiled_2024b.class create mode 100644 doc/plugins/brainsuite_logo.png create mode 100644 doc/plugins/mtrf_logo.gif create mode 100644 doc/plugins/neuromaps_logo.png create mode 100644 doc/plugins/zeffiro_logo.png delete mode 100644 external/easyh5/ChangeLog.txt delete mode 100644 external/easyh5/README.md delete mode 100644 external/easyh5/decodevarname.m delete mode 100644 external/easyh5/encodevarname.m delete mode 100644 external/easyh5/jdatadecode.m delete mode 100644 external/easyh5/jdataencode.m delete mode 100644 external/easyh5/jsonopt.m delete mode 100644 external/easyh5/loadh5.m delete mode 100644 external/easyh5/mergestruct.m delete mode 100644 external/easyh5/regrouph5.m delete mode 100644 external/easyh5/saveh5.m delete mode 100644 external/easyh5/varargin2struct.m delete mode 100644 external/jsnirfy/README.md delete mode 100644 external/jsnirfy/aos2soa.m delete mode 100644 external/jsnirfy/jsnirfcreate.m delete mode 100644 external/jsnirfy/loadsnirf.m delete mode 100644 external/jsnirfy/savesnirf.m delete mode 100644 external/jsnirfy/snirfcreate.m delete mode 100644 external/jsnirfy/snirfdecode.m delete mode 100644 external/npy-matlab/readNPY.m delete mode 100644 external/npy-matlab/readNPYheader.m create mode 100644 external/piotr_toolbox/LICENCE.txt create mode 100644 external/piotr_toolbox/tpsGetWarp.m create mode 100644 external/piotr_toolbox/tpsInterpolate.m create mode 100644 external/spm/spm_bsplinc.mexmaca64 create mode 100644 external/spm/spm_bsplins.mexmaca64 create mode 100644 toolbox/anatomy/mri_skullstrip.m create mode 100644 toolbox/anatomy/tess_deface.m create mode 100644 toolbox/anatomy/tess_isosurface.m create mode 100755 toolbox/connectivity/private/direct_pac_mex.mexmaca64 mode change 100644 => 100755 toolbox/connectivity/private/direct_pac_mex.mexmaci64 create mode 100644 toolbox/core/bst_systeminfo.m create mode 100644 toolbox/gui/menu_default_eegcaps.m create mode 100644 toolbox/io/in_data_edf_ft.m create mode 100644 toolbox/io/in_tess_wftobj.m rename toolbox/io/{out_channel_nirs_brainsight.m => out_channel_brainsight.m} (86%) mode change 100644 => 100755 create mode 100644 toolbox/io/out_events_bids.m create mode 100644 toolbox/io/out_tess_obj.m create mode 100755 toolbox/math/bst_meanvar.mexmaca64 create mode 100644 toolbox/math/bst_scout_channels.m create mode 100644 toolbox/math/bst_tess_distance.m rename toolbox/process/{functions => deprecated}/process_corr1n_time.m (100%) create mode 100644 toolbox/process/functions/process_combine_recordings.m create mode 100644 toolbox/process/functions/process_detectbad_mad.m create mode 100644 toolbox/process/functions/process_export_file.m create mode 100644 toolbox/process/functions/process_headmodel_exclusionzone.m create mode 100644 toolbox/process/functions/process_mtrf_train.m create mode 100644 toolbox/process/functions/process_psd_features.m rename toolbox/process/{deprecated => functions}/process_ssmooth.m (50%) create mode 100644 toolbox/process/functions/process_sync_recordings.m create mode 100644 toolbox/process/functions/process_test_normative.m create mode 100644 toolbox/script/README.md create mode 100644 toolbox/script/get_tutorial_data.m create mode 100644 toolbox/script/test_tutorial.m create mode 100644 toolbox/script/tutorial_BEst.m create mode 100644 toolbox/script/tutorial_brain_fingerprint.m create mode 100644 toolbox/script/tutorial_dba.m create mode 100644 toolbox/sensors/channel_detect_eegcap_auto.m create mode 100644 toolbox/sensors/panel_digitize_2024.m create mode 100644 toolbox/sensors/private/bst_beep.wav delete mode 100644 toolbox/sensors/private/bst_beep_wav.mat 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 a288c9dfcb9f108cbc6fd76aac4b8dfd405848ae..3b39cc12bedfc5c1bb542335d73e6476b523118f 100644 GIT binary patch delta 4514 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 YTJ=-h7dNlEZR2(@AyYv+0rQ9SKa?`u`~Uy| diff --git a/defaults/eeg/Colin27/channel_ANT_Waveguard_256.mat b/defaults/eeg/Colin27/channel_ANT_Waveguard_256.mat index 21619a30225b667f1fb492e7cf9cf63b40c2de6e..36b5e807ff39a5bacb032c818ec91795e073cd2f 100644 GIT binary patch delta 9988 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 delta 9940 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 OF==-hs;=06-Tfc2$6>4h 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 0000000000000000000000000000000000000000..3071f5b6bfef3089bf3d071049d22762586a61bc GIT binary patch literal 5825 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 F{s$N4tPTJG delta 11712 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 QuX?ebTvghvlfUNw0GBdk_y7O^ diff --git a/defaults/eeg/Colin27/channel_BioSemi_160_A01.mat b/defaults/eeg/Colin27/channel_BioSemi_160_A01.mat index 0fc8a4993fdb71a6ab393b0faf5ed72cc48503cb..cf6e9d83e51cfe6eb8823e2dadff3bdd6f45c69c 100644 GIT binary patch delta 6385 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 X@ay+fSp_U5nI1*3C)YZkpfmp;Fm3cH delta 6267 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 diff --git a/defaults/eeg/Colin27/channel_BioSemi_160_A1.mat b/defaults/eeg/Colin27/channel_BioSemi_160_A1.mat index 99a03af2710f8b96e9a120314c9beae7d324af18..3dbb6f07ddc2e42fd3113bf4486f531013b1da99 100644 GIT binary patch delta 6438 zcmYkBcRbba`^U*>8HsEe6_q`Y5sHlLz4yq>N!F3^CWT}~_Fmb0i(?(3keP5eCtEq3 zW1WM;@jLo_|M>m!{_9$=`+hv`_jNt*8!z)g?jxt5nwE~)6DbJ+F6CFw_P)+eTwpI* zF4b3VTsmNXE>TG?Q88Ij8Cg+jE-?`?iA4Q7iFOo;zBfL#u&X47$#^=FkdQ#KR~+r^ z(n}IIfd{j1X6&bM}3F4EHsdxklcc zKH@569y!n!_(2&%nRYidnK9y!(q;W~)V=kv^se%IEXNPjv7{s??Tl||iDd*n>5;ND zN|Jsc1=g4@sxA|&n(fAhhf}NjkciTgk=H35$3E^P(P$*A_fd7Qv2mx-%cT$kllF6` zP?U3pguZXLo99zx@r(!bpS#;!)w2!t5{jnF&CJLpUF~36OJgKiI}V!-Behq&&WO(_ zkhpS%B(SDt=jk6urHR4*->z5c$H<;>vSk7a>DD44*^dQC`*~C3C?c?0EiQKV$2wd3 z2X@YU-CE0UMWH4Z!@X`(^^8Qk6XwPhZPE7>&@L*dQX1jJNzG%QZr=HoV-&}EXK!U= zAlzs->OCo*)~I-A#^RM7^3n4g@E^8FlhYfH!p1Oo`YFbLDpc=4%6UFCG7{v>SyT;d zQz;tp5#&Tz5fB*s?I|Wz-bm1fvzdVzk*5fM0cwH+D{re#iz14aM#K0zwZGAVYkHh$ zoO31dU=cxv;12hOvm(8MQO-5m*hmmrE=KB(Vkh2oK4t-E^aSFAhj6KiA5btUXvd(v z{J$F6rx?c6TvyYR(9zJEb|6S;tU;jh7r#P#4)?1uSBUuUtKjQr1oU@>kSnITHOaEWw^0&w-Z=Non6UW-WYK?SJ) zmO)TSl&M4tHB;PwKB~kto9!J;dvrm+(KZac)EdhL5Gaij6PmF2q^u__G~wHN6iMQ8|t(7>~#Db&^R^v@5MhmUFpOZ8Gq+PaxPD@fF1Wxkap zZTorI3VX@#%MFlUoFw-?ANFz6h;RC{be%igg5OVJ_S03*7+=Lq+4jj`RuK3>CVxd> zAt=CgdTh;3!Apu7CIQTp)Jt-vrKKEawz#JzVY-UDoGU!qd z_$EdpJ0`1q*|b*NGYhA^4p&QP^8fNM+KxZ9R$@!*>)+QvAa?-gvoGgB2U@Y?@Xu&3 z79m~}=cXkd7-aj<;OAgKJ+HqMpQ0BRB07F-ZxFjKS-yZ>1g1!xgVR9YtN$7d#snz} z7}0^B4!U^PLsxkJRIvjbcULvO(^k0mh$7QxFsYCtC|MnJ_)!Gq-PatxoIblAo{6Nb zJPp#z)PcGAP_laH^|VK0p$esBaRToKA-oZ@`7s_e+p8h(u{3?y)Wf&5JLwZjO8KJA zI<{mJ9DgEOfHCyVi;CiPP_>n7g(d5fAI14O^pn+>CR!#v3-r`F={WL6`?3BAop&E@ z{^SA~d62 zZvOf3sdi@~!ZR2h-~Ltb>~?^i)9LEVx&(H{oy%jEw|U0si$Rx>9mndnZRrJ*le3G0;S$se^%F@SlU(8m%H$n+}>VYtK8_6OEY4MWU_$~RHgPrm2+zmu9BcKgiM4;8YGPtpO2n$J zFHJ)BjA47BdtvQ+lf?q{C(lFJP&gm0!jMsz@&v}#p$yTqM?(s zB0qVAtgx4I8QI$5G^WZll<(orSiANSAWscwEe5@G{Ea_ zqa;aJ&i`rR1Gnj;GQj|!CUGDaiG|CIVS|6j;8Amp`;v60H50)JOO`{pvff=6?bW=6 zg#`-wqjoZ6Nd!70HcUBom8sRA*~Pt|T2y(#tgHISiBcnZc)%KAe_A$s??je!L%#B#05$UIj;q)IUpPIowml~HS<06 zTkok@N7Bbomb*Bz9L6o(XiT1MeW$D97AAZ7W~Mb#{?#v%C@B}zaADVOS59<@u;Jj_ zO)3pchyQ-&d}yDG%7rX}z%H%e<_-;b zGO7By0ezD;#oTunJ~+xt56MhZzj{0w3N^IA;v4Ju?Fq47S?ub0*}S#jvce0^-}XO4 zeYBAU)r9diS{1_&;@oW#ox|UEc`r0+<_aG&SxN8!P_Jl9=;sR{UXxcqqrx__*?%|YsFxW zL+-CZgA#ttGhW4E~8-FZ-4zKW`KANVvcyQmd1}T4A zHAKRsCpfd!>muoH;Nz<;CT->dbF+RvI3GWiW{n4}Rf|6=!9A%0r!EATtW9aE4ExBy zvS!Z65?Njb*=$h)31bptjv<=lZ#e&JK6C$?&t*)jN*b zDXS+6=@yN4(Y3(>RLzq%nLLG*F-gCt1)Ezs{-kDE3}O-lzKVU4c&0C4C|1~aKPIy+ zKUT+^pl~@fJE%Pv-5yM6i{gyZ)i?V1In_MIC1(Q|G}wa1eU_fd z>s6?znc1(%Yg7#9rtx^ocui4->sw0@#rW)@vQv|5lb;&Rayv;CH2I?@S9LD_bs@V@ z900FkWz8xl`x2f`C>eD>lJyH#ouzyadWXTna0*|xFzbF_WJQ>qyJQ8%dzKbkYG)Mw zav>P{;GD>yhIx;>)ve@4ex(B%MdoEO{Om~G#d}1f90?L%l@Y_>sUJQ-TfecWvso~y z;?-*G#7i_pZ^Ko-FM@Z~JPzb5PKj3%T=(O0XYGv%jpve;}~AZp7awo4Is!#t(RRrmO;Q zu-IO#$)AA*d3Lk9%A%4%vx+E-5ag8R6Uu*igW^X1_`VL2(B5!x43$&b#Q zb5R|WZdzoo+gUDmF|*nksvP&etzPXU-Ht=r#(!}cU%pE{zS9<}JYw9}kbU*nPrh$~ ztn+N$-4cQ8BPDg(T)UOfmBi(nkP7{>1(CCjz}*YzIzM-C3?nh&TDN;lqI!Rn{4)`d zNPy_M{J&;bHi=M^K&{Pr=Vs*W;Fah5bZ)}xg5ojI5h@}yIsNgG5+mE%?^&@)zn!k2 z!<9v33FbeE;HXqNL;D3I|Yrzt-n$Bx7I?2!#L>Ep( zDslKHIHkDPn#-XY4K1{fG)lJ5xed!JprIF&{ltc+=VN~e&_W&L_WcW7K}P`=p9cO* z2qaW0$!SUb1CbvCM9KhFj-HLiHw2-mX;IQL&B618A~>qFW7|R;ATrT`3>#Y_2ZEol zrJ@1ZsqZ#x+Dj`MIyf`bDBWacx0PT;sGp?k9f*pyF^hfVK=^mWtefo%H7F8b=o3nwT!6wa(;n_tdUUv_9<^Mau0d%0D7P-i5~n2F3>k zIu3VhV*x5!Y1(ZW2B%i#V##CP6<6Rg7?Smo6VWJ+9zBh-s5m0oocHv;nc2ixmB751(C=AJ1^6RvnRFvOBr-+?`N zi8c^BFV6nE5z_t>`GZTxZ*1{o0t-SANky^q{M@XiQVg7MrirT>S5}?ce!(FtRsV>Y z8XSCLMfHtS(Ock*&|>DC5aK*l{NBEK3U%iPXv8x3Wz(ZxB8Vj99SA$i3*Z62w6m{j z;jNa;rSs?ZKZ1p7WGrl#X~BDgx^H56o1|N|>uBGP)63;bk0M^KgMXZx?Ob~hZ-8!gwB6nk&ZI^oO6UzJx zucov|0(N6Js`bI#QZ=?N4j5ZreHbK12u}PRNRO(;q)Mcm7N0|I{r_gvvwN&=WWXff z#K5EZ%y4!`BFkQpGr)Z6sh()nG!IN~>bn4o+XDp=TQ^qc;&qQ=pYnZq{l<~|oGIqi zK0ml@^(U1Lzh@RW8AM3~D%oUFi}=BS1-!i{-rJ7_prX`lmrSp~Id$VJ>_{BhI{65G;?CGd9dxMuLap)xPn_#=7TBk%4s zqU#Fq5^?A`7>4aKFjvj+FnN;H|FteejPsSO?z-`!+uaMR4-sn^WEDJUO(Tx?)!??W zj89Ogwyy_zg1f4|5?}kUGijyNd>hl@Zf&2^5^T9qnxlj!3q2Jgx@gzeLw(F8?L}D* z?OuZpo`28bQQwK9dWpM9HSb3}I4>9G_`Hg{xgDoy%PR^?MaDrX!VMGwm#doOuNsr@ zpUV^f`4bZigy3(u%DI~ub8@iqF!>$aJ3A(MObUYg9&sm1FdLc^Wz{wb_S~-SyC~Bm z2IT|8ZR*Nt%jgS5r^k~o_5**NRa4XnK@Xm0S6(@x`KfxFnL7_mq$QxsD4UiA4Vcle zT6yJ(lwFGdHlW%7#NNFq%YJ(NYI-}RYBic!jP`jTSgZE!=8rZeYQeStS@)LZL$hA^ z$w*smh6ti7-x7ww15w3J2=j$d)+5J@qL1h|bBS54fL9w0&Om zV9LI%KAccXdI+*Q@#?m9>9n)%p55BzLv|gZK%gt)VEuPc!uBmtyQ@o&T5mtOqS6(@ z7R4~%*vIn-bufQ0cudNCdTZU+ZltctCAu!AoTj-xDj-q1!mXDacU(-L}v)y4XoEMTTY^L^_I|zMWe+T<9 zMeN!l)cyJ!%J!uE>1^PQ4T^e8owNb>>tW>=dhF*BhJ>M9sh@A+Q$CqD$XU;_%_Jg* z5Og%M@8$ZJ#(ltnc3Cr3WSR8^8P0^U5X6Nz*Asc^df=FVWax`K8j3OeBl8Uw8XR(sy2;u)Fb1iw%|X20qC z#f6L)f)t(5>##VF!T{QIE;h{RH(op-qO}HR)4m8T7)z_&Ly0eZ&r1IoJymbu!s=Tp z_@UA8OFgKnXnaG{X0~SjKF_CJ-xmp2lbc9|chZ5%%Mnm}j&};S*Mv1_G)|^6)LX^m z<_X-u%d6uDmNN1%L|wd6p0M`FSIyLoF;V%7R5!j8p>*@AGM&BKJ^vY_l02jE7He8l zYRImSn1@{H@0sTeKR}ldoBPfuJ-gzkLKJsSi(}KR!Q>P8_QF-h(~wIzRbo>2+{|64 z!;wg3*Q0Fn>#mSHLIO5D-Dxku2gg7I9Bf2wl}shS;~l6mMLBIQ<0qVOU{74-nSyO7 z*~KAp{BLX{ad^)4{H?={-{cNgxpH4>2>v)c%{-$}6SO9jRyEf`ERj7Sz8&(MdOt{r zL*2>T_(%k4dZ3mbt2O6!(vVBi2L`>k z-jhd|HYQUD9wLvq3aJY{kNv~w{oZP8n{1t(AAamPMVz>%TrLK#v6zt;NZ*$*!lj=B zN#83DvB4=FZMYFj;sx{30Sg=PGS$YL0{=7AbGADlF08gSC5ta6B(s%kUsr>%g!cZITEG MWlUoX>-|gr2T-mDZvX%Q delta 6331 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 diff --git a/defaults/eeg/Colin27/channel_BrainProducts_ActiCap_128.mat b/defaults/eeg/Colin27/channel_BrainProducts_ActiCap_128.mat index a1d8916cb5cc311a8dde6bf441f5217624dace24..ebdf00fc71d7e5e30656d1d1c4955373ace51099 100644 GIT binary patch delta 4828 zcmXw7c|4Tu_oju)GO3tCMp5%tWX;YfMTt~OQH&*9mLYo+PkXjdBa#dyNs--To!%^C zACh&weDXocp@3>pGuvs3$u#Qef&(xvbLtS?yD;*uUxmk3bo%gWWLv5S|rp& z^5d&sLw^qu5s}FElOC?FZ$+B?%{xV#Y$SL8+%?@OqOv<=OR)Ks7>B#}F2u;(J}9wO z{~BdyY}Q&xi2a)Ne&x=`TodK|{hjN!YTaV-V(F@Bsd7)4V%{@(FOJMSeA`fPi}<1A>NH3wDW+uZCNF42j42DPKO z+r=5L%zNB14-?;;(tO@4a(i{nm3;`gZA%_jec}^aC(ny!)>kCKi@axaIu)QJ-)f?( zuuOF?;wmIh!vSTgsXEbC^fPE^Hn$&{pz6(_ml6>Kc8Z|gAC2*>26Ls-Bwul~>=|4K5< zr5DhtvBiz($9CI9k{jG$5jm{@GtmzH+>-lNQouR(r@k!sMZX-FRCYPnfh`NZ+($$^ z$0;7(Ah>e~5O97zQ>l~W11!GK&+JQk38vTgj};gh87#sQS()YYCCS<8bU^JvPH88f zt1x_~a-oZrh_8oqIaWl)HW63og7p^7*4mN6637O4!LPD)3;1ysAZ`zVLtgO<)`aK& zC-5_UtA5qdx06s)%ZCkga!>#bfye||! zyUX;~!bycCfXas3B1-(|J+av`JqXhYK4*uSbamQS? z(k-7+^qUWV^XnYkc+2Zjpdh~3#EIkKGnJhpjLxdseA;@t`#w0h0Z*@34x>SHq>r*#zc>qr%8%TJ}nT*O|JRytRqV`pKjYPFUjU;1>@S?bl)`qjKp)0b-6$aD#!j$6XgI< z4`}`$Ik&z2e{x>*`*S>5uZTiLwxqV#0l7S!MCWr-x4Kg5hPX;h;tPOlLg9pXj+IG) zu)?7uY;bND=u8Pz{FdYolV{kU;!b-1GGzVvwDJ->@Oinm`rfi-?b9Uo`-7wC7yt3o z4WV8#HrXi!0`{94P&~Q9U4Z*^GaxCzLRD~6$X_?D;Mc<&0nC`^trG;ES|*E-^~aiV z2dVwnq_}C_7Owh>XzuO0A1$bbRa?+vDB0N_i~Gg5`b4SGPYbsA$eTeKaMzNnD^q{j zSJJ=(XA(9VrMt@5^vTH18dskRD%Z*!G&df#p8$}*lXzR$W+xZ=(6#jNqPtrJiHG(e zEE^FqkC*k=i|$ez1Qh7GbkMS#Z70CaEkuV1#k|Qm{ZOz1L)@G|MckeFU<)Q3{jN1d z#HvE`t#BXg>I&uHs0&pC3c=O^ zRe%y0=KgJ24CG2s$dYYO-~v$*41R|#a8oZG4Tm>6pwEHgyNbefXip%{hMx_5ai&L> zf95B@674@|V=kxiYW$hZzu1gk%Gx;6hVRFQEGOJe&dC4~uy1Bu_qN?}u<~&jBCMZ@ zQJdBy3CLH!Y_d9m2Am50dql($uTy@75*(=6pgX4La)g zE^SJo7Vv3G-Volqn^Nl{lMN*ee(P|)1{reC{$0mwovLfLP!Sj?KtRs$%}CB@6SRXi z9lle)A**D-?DzGr3-q$T9R;o}|00LuIuaVlw*!gmxD@#Ja2h|B>X%%EH99*1Z*m_Z z5Ls>Hc%+7U6`(;XY(M<5))TlE+G9aZ`P;FfH0LQ)LNi8E>N%RbvMsEL>>^-$}@C;EiI7GYRKc2Yd-b$&K>HtUlSzQ z(7gNy`y5Z5o$%h42wOr1sc$UD-3OB~&`U|ZBQ=7Rwh{}*_J14`Qk?Q|oQiN}r(6ul zXLRzE5+K7P-_%v)Hdlxf}jtBn4QvkBf_?yo_g-cC=9+4}(-v{d+=^q{I*&UZXj)-wxWzrYE3M5!a$EYf* zr5i7%J5j+8Do~W)fUCN4l3}e&7(?7}#0=k>a1rr@;y+C9&xslun;9zvdPpcyl^j6+ zH!yW|^IaI&QC;B}by$B${4Adc#|{`8%mSLitFGH!=d6C^8~Tr;wnq&u7i3-hU-N9~c90L+z z>#WMm=@k^xs`w_BwKoOuhkvWCQknyB8r2nUeXr(EEPWg0w+l{;>MC!dY_HB&a%wk34(Zt`xj6-O6}DViH97CGPafaD6h&xjcQJgC6(+pkn! zDRa<>CXP$l-SH|VnQwBeZ@?!b5TK_;WT9ws=%~H()JNf%7dBDiiEv;|SVr+sozrAD zu_B#2HeUh`%7OZMcVF(r3`%E3-V0Zfp)pe7qT)sY-Xx@KEjz01wJmG@LEn89zGN0_ zt0p$l>WoR3{V0!P&Q}D*utyZhvL}|(IY4cR)$-5F@%b{nsG8uJRDM}bHOu?HS>50>5>(NJc zFqN%ejo&_0v;1Nvv1PnDevixUhRpKv3u1EUUS@9X4UrdUaX%B&W49;u3BF0Zw4Gk! z`jOv<1YTh3);Bid&##(fynGsYwWc8Csxhjno+(Gzlctl&S{&*Mm>w8544nXusW!Q* zHeqbQf}iPw-Xll^rsUWY&qY3Sw!&oBY~jIFMpMf}`EF%1O{}Dp_H)2w{-7*gUNfSG zmu>e-ht)k&hCkgrV_!czb^3ta(5n4;^M}*tTkT+ZE)RjGX5K+Zu{(K5A#uZq%!1ut^WPQsUsV4@LU^;fYsp1C!`nnoaNg zjw?N(H)MA!#s7WnIKKhud(p&!mfn#yU8~`l5zSa_h)Y~#N$34b!=^dI zD5(?d1)j#H{IeVK>mKl;YD8bSs#yJ%EcRhXUU z@XR4uI~8{m_o_HT|18FVMqT-OvM6YvjCYGpOQ_&ze`>Fn(NYTj9p)M2I~~uHKZZw$ z6_ZL3XFtwa=aPGTtwc!E!uW|t6pa11qI4Qiw$fUa7njKf^h`I-{6Gg)b(Fre5}EWp zn`qSv;H&jd*`V8EiZ?7#a(DbG!1*-dC4Xkm{Hz!UOpayjke-Qads%RcE$Dl@9_&JL zN8?_kN08?g-bNnY=c4x4c=MKcmBw);#Dao@8q};Z8t^0$VQb-4r0ni!&wfKc*A`@3 zEG$>}hnmx&tQ$)HRN`%RdD;3UkpCIe2|$V#lUEL+x>`78e|O;-q$*emvWrlX?ez^ zIYTz9*3`G2$IT!3>T9XMGH&XYZYaI;SEb4sHQ{+z2Efks>}#xKow3?}Ex8UE;LP-DhOeWn zFxU3k?_YfL%zM5Hx19XjYoewiY>__)*h7Lni!k_o3IHUt?E?*yR5*&bc;9;98%AMC ze+pm0YD*YK>}FV8GTXId1*f=k!)LLtIzRvo96}H%Q3W<@M&OroM}4o@!y)I*4(niT zgpH%V)K*=;bhHkcw{-a@drp?sVH)V1xJuFek1d#%uDzz&c~wj4eVczKpabrR3rCon zfc1xiLI@Lu4;tXO4$R-9sDsJN*VP;L43!Cd(uR*h2ZYAuB_z{Qx64Sb8$JqX>DZ35 z!Q5CP2D{!AP=TN{^ncI&q}}vM;yBE1p=SC(2PI%gV8dl`U06)8kB50?%9gY-e*9-; zm!|H17Go{6iWk3tIl+blJ`$&9vBM?EF=r+;Ykq5=61);Wh1jxrQE&}=&G-b0gg3`o z_%tSEuq0(OL5W(I5vdlA+v%Mea$;D){s1adk)y3-QwP3;w#UfpBDVs1r_HyuF+*o9^r0B>v(B zTpd#JeRcNf@yWI_<}AC{I7Woy)PwtHuLBo;|E-WpMBMe1XCD4m{%?VlN`G(fbo;f_ ywa#s_;v(kxTU#;jMU@Yfdb?b>^3m^-aIbVF#`><-*1gx1M4m*7#baXR#{UO@Iy2z_ delta 4779 zcmY+Hc{r5&`^V9uQ|tHJ&xu?rA%5l*>?tG zFqq0RBq9{|I}1KY`c))Am?D8))t#!xb~SW7uFgqmX^A2 zXLYE!mALp9T!lmyK$kG(4rDa3ls|R*s=GqQZa{+qKf2X*=E+4(4N^YbTawXKOUvr{ zzWZ*(n`2nrU89tY0!W8sSC)hkI#31fO6@^BH9k?}903MfF1z*Q%|Y!wO6akkwjDUP z_~{C&%}*|)r&)icPCqAZb0~5(7#x?rd4m$2BbBvjaDWSNs$IOs5{DR3w4@g$_*ac+ zE>=ZxXSK6^f+6{i(SeZGD`9ynDrapX@){46ZKex2)a>YsX#QlrJQ3-3xnRGnGnLAL6Pbx3UV!|k_Zz|BA~ zR`5NiLm8}(MSrk(KPvC7-#$u>`EhwhkiQq!Ctd@EiQ%X_&M5INqyt?yatR=jZCw2Y zHX1B!ypCR<#^Vm{)CQ<86kF2r(E1bjyXx8I^FtEg+{931&w!-JX(efJ^W$#;CWr%d ze+pw^D=>Su0uvu@_!Ws=y_y28_JYEy=@DpD`O79@$m5!C7Q=erSeScooPMp})h@^$ z`{>G(54I~EklNTZ4b=#BSb3}rn(q_5({*k16Ydy9M?8;`-Zy>X%%jh7QWUjzxUoL4VS}#XK8I2e95Ay6Bs2P)`R?fjSyD!uld4Fg8AGc z%??)2Vheq42f#4H0{X5i?>C3rlwsdyCB+3~faggaaQcMSM~#yvnD~P3x&0vGkb+6$ zPZaM}3D8gO9_Wo5U4WL^?9mUeW>+u><(%6YLTylI0$0D1bNduM3p{rEtaaYJ8Dc(@4d`GrE3LHxv;oV#c|9~$d zr6&o)YmPbfZqlFP4ZhdTF=2NNG{oImdpPU@pL42xz+_kDm;hhEF!p4@va$YwhoK{-lti5g3_L%ST2K_R7NW!U!Dvz1W zM>8ean`g(Yen)wj=gJ7V>wX`v=XW|Sv&a`5mDW)qmHJPAmIOTM%Rc)II5~sZo(_Gw zeqwPmvWDKWd4K$9ug)G8!lfh=OBOvW!?ZY?fe5Yl4^8r6@MU&BGI4SCkQ^_0m&WI?@-uOUc=#Ug{$p+w!HaEpRf2L5q&~p?C^L4iiWQ+e>L@nU5t4W;a;z0 zF==sr=3n@Xa%OMl6WumQ$fe_DJ-H-C3SuBWGCuz?peu5kDZiID*K~JH;3vOh>*mW_ z1X!^%A#`ml|C+F2zm#y5RYkgUQ(@U1y>n|=0aJhj({;k(hj@l2vw$QK!}2%f(MC+$ z%in^RjmPzy8orNeCp_ZH?k-0Da~VDg+spAetJZu=`6^ktBPu-1u(#El{byRCdGV3e z+8y<@D9iaW`#*1cvA=6n#ncTIh<~*4?ZQplCKo=_;4u%=B1hfEPV-#&g`F*PW9?3g z8%!m8j_+x?nU83vcfjL;EETRs`=VVSvlKMkzKVEZYWjBOWAM=q`82{>=C&>xi^|J% z>&8`jlUkLfZRV!pk8ju_8OjGlakWL7h%8K4zX%SK!kXJJ8L!lz{E4m)*ys<0H9UN9 zE8{`j^=osy6R5FpP@tdrNl|ayo2~twXfuLZ{p&dU9>>=TkehkGv@#KPQywwChHq?| z@6OZXLNZv6af`&rZymVmwd8=zwTQeF;Jbe|Mrqp!$0jA0NkjKx^j!?Y$04oJr&NFA z{IKN?6dKQmF)^quY@Ba>yZco^SE?~%1M)8VfGoctu=`L(acXZZN{10UbUN%c#t+#a z_5_qna@=8&HH4DDrO8+V!srI7i|wCf7`Ns4(`z*;AQ|pvz-++u|G&jkwupNF_vp&k z4-_A;=b6Ee<2&8M`QKLdw@%aB4#2gLi+*g|XFk;LemTqYFZP3J8)5j#!&uK_owzYk zMfS&%6tzZs>V$~rfxMuB0zQ=%eK1luZV)~L96#FOeYC^riidm0XNn=qDU)aXb>C1R=C@G8+T8IrDbE+Ksy3J+_38Ps$IUSGaE?#7+($>#5m0A!$&vr1a#sZVfnpF(s+J1QwTLX)U8cKi5~q6!>~J!my6HeWN$Ro8%&XIkoX$z zb!V2fATI^-C-c%TT3du3eA>_p%zF?$X31}Ka)-c;jSu6K(|D7&GlA#RZ9edd7f53b zcZ8VQ{nzdgySYgV6?XPkW9EPVD<>zqwb1GUJF#3X7YDY7C?4`mx8l&l{ed=Ov^jNe zizBaMH=O7Yhm9oUDCVMT$eKVJSncyOhxA37)^T=uT0BbT>`DZZ&4C}fIn0q;x1WSMp7@a$a^GVvQnm}j3ws}O2o2&9OSBFR;?fk?m1lCq{&In`D4wuM2EqS zeKv_?ORUUzG2+9eu@=UMsf_XG!;zq=_laJrqohCYNxJC3SmDU4pZ zd@UOu9cnWRyq@e=hDy%mXI+KbvBSrrT0@rwKXc{Xj#eqKsWC^ees{@Jx(okZAk}v^ zJ0YTrOepmBy(e}>EnCc6x>{U62^86N~Yl>yrV{<5X?%LF{*MlQ?#rUvZF8ww}+*l{QEpZy&tM0 zJzbXEC}u$%czv)E5T|J94StJHf$ou*?)-G`K%ZJmu3NI-P6c$5o5gXiDKicoQ40_Y z^F5x#cl)FD%J4Bkh|8*?l(G`V1B#u+OtT1L88aa-G11eSU`(N<>F(@ORcp@IU^BTH zJ2v8)pf_xp83Dxtvy580pgHvYPk<0|?s#6h2=U3Qu}W)`feDzNiSBncH?qgeM<(L9 z)q=eIeegsP-c@)lt2&b!pp6c{(Zv2);0|ud*5Y`^E71R9GEh~E!p_t(9%Y!vw5rVo zumwdQ&U8DkKpi>0r$i|ljb#UN+NHVe+?z#{festR9vFaso%NUYMvsVX7N8L^pE4K= zc3;LjUK%xG!n$-S95hkKYSpcEMY{5EPqU$UbSWR`tJr{>l?9IjcW9D{=}8IIfAn?z z^2+LDo|N>dM8M)>Cu1hnvU1!COD9eyl^C(%0q%&ckfr{`g~SKj)043)>{Y|tVI^)G zIezr$g^`LO`lGpR&d>Y$m5uL4$|%Y5Z-ehtlI)SS?95A6Q>}A^F~R+s@PQ(}d6J_A z@F*FJ{5ss+vaP#`%V>TA^Kl-s2TR_0fbZ=-UEMvG$r;`#a;iYty8 zqskAGeij#StaC8=+d^4+`Ikm0qgvRrrK%~LW(S()h8ydg5E=FBkDH0l$5i-j&0Ank??tJ||$% za(KJnNX-f`>YKVQ}hIW5RDAypNu0{NJV4Q5vv+Sa1(0`xr)MKxU$)ClbhJ>;co5yV|8>yM zUUav>l9JccGB)mwBDnNAxB5{;5&1?Gkt7Lql?+12m~c9>Xd{E0&5tX*r0_@mK@mbm zBEk)wsgIp^uN8`C`t8}skp@v6?&;=FWsTKXp^l(~ zK>Zb|gH^-bbR&j<&lK&B=~e)s;C=k%>>@0h$YcBN+@$i!E`$YeWDrhoI*jcOrPN vl5&K*{s9gR1vgrU)Uou9R=+bFEW%(3?$xWsyLUE@z{I$nU$^6y6sG?Nltn6O 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 0000000000000000000000000000000000000000..c4ab50847b59a3fe5097a08b53e84cb6c832f805 GIT binary patch literal 4582 zcma)92T)Vnx(1tX76{pMYFH?W_t23Y^jE$}tJU@+tY z7^0*BQP)s{fRz-LRKWkw2k_6scGZ-h^=`--q1iL8aCkhxMHM^;a1rJ@0KdN>7H!L8 zQ~@jtMEd$7oDO0DR-SfwGjh&Z;Tn+;QdHBNj`Q(1{ z`TycW_k!zrW*Y&g1OnO0{1D;hh^#;);`o*9gXj4H?kXIL`ykifpiDrMk9H?plL?=| zKQQ(HZ40Cg;?7m1po1hYP|a%mV0;caFwl~0Bns}lw|5ErTC|g5D%;KVkSpW#({y3X zCYR^jtGF|B=uF((Gh#dE45@5@-5TM0`=Mlihx)Jl*aHkLT$}>3{16&e*$0h)09P6Z za32HtUom9hZ%nP3%XP#x2vEpM3J|Tu<**Ng{|0)&-QaLwuY@+A+dZ~Fj$p-%0{;o; z%?c+867Yj-FL>(q5!k;e2I_dIDAw0=M{vCW%r3D*w>UH2$X3U_1=fs2bA%|fl>riy zub6j804C@7Pw&UE`5ou`f5rKoqBr876hY>l7`A*YX97~^bRcKm-e^-@ZKxc?T zyMi2`7rN6152Pn3>GbkVy?wSQrk%wvH{5S^(`cgH!?!8?&AfKPTwTv(@ zkaLoDDTPv&zla&)>}xsLbVR!@+L*F!)i6dSo0Qk7yjG}Gz%xHgPJDPmU0Bgp;~$eO z-9BBAoXR#JX`(HB>eHzHrC=GCJ<;T6B?!$V!FhBI*ozfG9% z`Od{`PVtuH3%~+D7E?#MaLo+enWY>%sprd^7grS`2fy6)YD!*TOU54iATlWLKjMX* zYN^8PY$0f{2SH{vyuo~rw%;CSth;Wtnol*wnNs<%+}OqdV95t;kN9ku>)Aa%q_mA`#k#}5>fh-RGA)$PjZwijB)A@+EmOMD!F z+S2XfzYI>F5QKG~%0so+!_x$U%iJd@v-WikMykUR!#RH8s-A@J@mRs{Fn2FvRI4Vb z3Cb1c_(j~whPVkmHcN31^73jT99Y0ANf2joME_l!%-AH*)LU((I;+&kgI1ECin^?? z%}kHJxm0=J3oa_{r=)_M-IpNzS7sF{v5(u6j*)UY!S=Kfio|twIRzVe&LN>6-yK~} z@1lle4oa)botHeaJ|m&H*TT=^i@g~2qrZ;6SgqRJH8bimQ66SLx2dk{x>v6b-%bX# zwk!M8fonpUX}F-&;eiB_nJSkkC{jsf_jNZLMI6TR1Axgq0*8C}2L*XfvP_})A4T~l zTF{7B`&J5+|8$g(*KTHhIDGeFv@LtVYg;+RwxD%}JuHx4U*56Oxu`ltE}fRARjc{E zOE-jDC{1&1(bY{mh+8P-Tf82q7B7~%2Uu5A#ss3p^rrOQMF9n25lCB}*0rt)RF|8h zTjAnUI!&sW=#0ACmzM| zC|z?S6j2{bhk}kj=Lxh~#(M?NUPx#aXoCr}(7`adKk6z`1B@YTrmv&XNae)4$BU(- zF%5Riyto@^{A_75D0X)+W{-#I>dKNUu^S zKj*Delv~>^kEjVtoDXwsx#suy_4io*z4bB5aSE8S<)r<@k(Z>xS)s6-IU2Xc#9Fqk#Nd_Aaaw63*_T&vifmurb;xD!PsK0fIe% zE{DM$(d`wNcNEdzx!GbU(f!IV7hLxp^1AikMt}!5Py*P?I0UfnSo~C5`X&{`=_6Ha zIaJAXDqrjZh=HQizDo?IhVrwv*`az69A z%VFx%H^vS5nU~Z>ciq#-&bX*O6U|kI#eAPEjk7LWD2X)g;+#2r!7SqEG)t%5&8*U( z%G{maO><9;YU;eFE=(0C(QYGs4FgBCSljEys(vPIJnA7+depvy=O2LQRs18u&N|u*_$i&U{A4l;R-Kmd$+P}P>$;9e30ji z-|_5a@5F701lu}!()LFG`>J4Nx6Nb+cUu>HGV|^=17-tjCHkkp$iuRW!2lg;v*|S%ZvsH;V zE;%?W4taV}P}#tDu~B(H7fNE5y@tw$87l=4?2XIZrKqd^R0haV?I+%0yxdvMR{!O0=j40T z_Yprf)={FNQ|fWwU}KoRQFJ_52|5g4m2$vsH_RXUWoahm@`~;&JvQ+zv$`r<%jBC$ zUz)?H#=~Nmipa)aXU(z^{&lEY_zdGl#;b3QF>@p=rOki0rJZ$9ve;|UGunZU>%;W> zRL@L|vmenpU*v|{mPkb;@K=3zbwvYgz;keIfWAx@$EzJQ5fDF=w?zC-ft zDU??@s?%_n16>C|1>W2JK6Vb>nA1d@Us}4B8YLC_mD5AS1SBnB3|PP0=h#Lis4l|> zPzzPpXA!h-_Sia7x;?^7Cc9@0qPXsQi8M2hvh2-JuCd?3+v0cE2rZ5CtXIO-T57No>BS$p#(rWa*IkuJGoE&iaa-1K#*T&9r<4=^ zQ;d#gXQ|r5&-Pp)q?x!f$Is+k@*F)t%1F6bbdbdCKm97d7pk%ZQuAzYSL0=;mc3jA zu15zH2>e*m-wJJyJSwp}gnFRWIV!HHvg7Qme)05lThHz7LERHJ*2M|)q)KM(D8;zg z@XePB?6X#*mSx?O$Wgk3z6q3PJC)(e$k(DC8kKfk9tt8!tI>AwR7Yi*nFqgaTe;SM z-?mbc5gGUIH{1D5F!eUSGAYvf)C~4aCF1IZJ}o3*02w8;fBBSVjlY|p(*JB$(pR%B z|8z+E)2fr>7&k!sHL!L6^Z%<4DD!l>YVpGR8mmvSKpG+b?gKEPTo3nw2-c4e%_co1 z`1L6sZF#iN{K-V(qW@)R{)+hz(ACFvLL)Nb+2@-fy>3-vkJ3SwOAnV}Q-mB-%wOSg zhQt>|(?&M5-bfgdN}E$}IgtOE|A6GX)l(Oo@;2x59>pQ*d`j@wXOx`VYikTzcGbfB z5Ao&Tn9E$Nrs92Gl-t&cqWOG%kQhF%_so!Qyl77OFhP!{GYZk*JIx7bfyw?e$?Wbn zb=#>16qo2E#|;;Iybt*@d*``iU`UPAtS4gL6C=W2MaDLW11rcALQV^nQV@aHoAD@42Bc=#Ih>E-DuktZ1`Xu=0% zw$Bf><<`|Nsn{gxcXLA$%=TIK4!zhBzEbMtvCtLfWVmzKhxzaitIBkTt!^@Jh-F5{ z;T26x(TpWMcPHax)2lZ&FJxA9$SbO$`)Xk_mmd?18(B&@Rv3CDIroR8T7OS8+5F4h z3dSeHXP<{Ga!o6vRnA>)9z{)Ky?cY-Sii}Lei-f7xWx&b*gD=j^yaUp14#pg1E0pn zhKuM|j;omPfL6Gp2;`)?>Qyl-kF^@g3fyKRxcFqzvPKD652~_}gFSyVtK`o$=tqC0 zY<1tBuUdZoIj|T*M3>Y>&Ra_Fv<(B^d!0YlP;P5(U5H3w|GLH_^ZKntnmK(e?C|lL z?zk8+BY1-em#RoF-~Bv^H^s)*0nUGZDrg8|oK+}LT6^QRqPx%{^VQX&5quA%irk%LO0`lVNFm0NZe+f-%$9h!v%rgW&l*$! JFaePIzW|L_;Kl#| literal 0 HcmV?d00001 diff --git a/defaults/eeg/Colin27/channel_BrainProducts_ActiCap_97.mat b/defaults/eeg/Colin27/channel_BrainProducts_ActiCap_97.mat index b7bc020805b120de249d5a6eab0d0a6e2afc1e89..51a6431e95210f0777f92f9d797c9668001ae688 100644 GIT binary patch delta 3734 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 delta 3638 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 diff --git a/defaults/eeg/Colin27/channel_BrainProducts_EasyCap_128.mat b/defaults/eeg/Colin27/channel_BrainProducts_EasyCap_128.mat index 670387eec9645e633fc881d87185d7e1b2228506..ce50f6913d89958898a6c212e238426a07b3d37e 100644 GIT binary patch delta 5018 zcmZ8_X&{t+)IJH1^h6I4L$n~-LiW9sr6NoAEZNtw)nLrrLbkGtWEmk#LWsfG_hg8& zH>SzXEQA>h<{kC_zaQQY_qTJObDeX3zw7+26D3cSPZ5&RyJuvjqj*D7_*S5+Q?RRx zFxXF3_)egwuo3vFu&jcxtemQ>imIHPu-tXI8>uFjQyrO8gEM`c85kHsa_5~L9bYr_ z_-GF?_S`$o*>P&Io8dBN(1`$T6S##}paJ}>ogh1tlKC84T;5htklB{udEueR19joj z^F!PBC5KrOShB8UX7EJqu(&V1ixpXlc->Ym!nY@`PiAD;Z{msMpofu6f6K^M7sE(o zWc;4~geeM`XsLm{BD7)1Y&0SblWdU-H@MO)lEw={|B1ir{($3p`xQL18!3Nt*R~wBpF>Zfo5Y`&&prbYmf61}g5;*< z`R7iDbmc^DANrblw;~*}U|r~P*0%8JkL`|nY0U%kqZ@s^bBx@@V9Nc$(CVQ{d{$FK zC(sz$>qXs>;I#@xPpSNW7p=1!-#HFZ6%=+hg}~n6>bdIX&|9*xvsI%TZXJSiM==Yr zlj0KCNgE~E3ps`ymB61sz`0bI?`g!oM9ShHh zlb&^<{Tf(@XfWQ@qeC@5c5*-b4FcnD*F>w2c;1TNxldIBDmd&eH-yvE^^Zl`p4-C_ zv=()W_pwssvm%yVa}WEbDaKD4jtW{Je@*0>afBx{mv6pH7og+1m%HOVGp}aD;|=cI0`iz9PS{ z&)}BIZ5o*Kp*EB*XWZ!q0U_yH2*u6Vxpabp1c7&DR&GVg`Xj;#F+Ksz6yXw$5kU-l ztJGAyM2#oK9dE2k0j49yVKs-<%ZpTamOyc62owF1&}jJA-ume#=liizl^?j76u=(Y z1+}M_U0vUbQPy%Dn|wG;9WxhVPiL5Z7fpCMv#y3iLJ&y%qRPJV^jjJ{p49b zd^dI5fc~#d1cKBv;}aHXdxo+<>)B;r?l}5d#8K)jc3?|)66*rL^NQ^nd|H&o@3q>we+wse$@XLsFRI( zcQ1dd!enaT?$2+}tFm8niWKmxCF#w%q-;g+L))I8I0$Fo>MlJ97TFkFTmrvm7LhDV z+MP**zuKMakW~t}m(XAGaZ5@SqKL?OcY@l!^|U^~OZyRrx9rcypw0Ui!=b;Z6RyQO z@nH`E;jfjFW#20K+mg!+aWCS)8Q__SaG7K|LzQ3Z*OkhfszRlivj6=M_v30HLT_JD z7|X{dIx0|fqC;TY`ps76&D+_iXoPlnQ8@eQ0i^n8CB^>VYy$PZNC$y(#%=$8A6Hkg zudK;{ML*k6A}q1 zMNxkse%#Y(-0--?i3o6I3wY(JzF?eN>6H?x8(qYr(xOu&PV5TB>L7d)vo`R+PoJmz ze8qux)tZ|lQtVe+kfYjW;!h7oc$?rs0?;^ktpN61*H*?i=GG&87?;#$;;)lVEDKb> zbXLovXrt+Qy|7Bx4vDp(I4{$B0*rPtUHg>kd9J#X zFg@**ERg?MK_|>L2jAOv^0iTTQ|Fxfg!r(r7wDK3jYD!$h2~aOKf^0B{c6GErnTh8 zMkm^^GYXU0mlUJaw(+z?7)DxGuNynKSqEE_wkBgZkW&bA^K*U&O`d|)j-^+PuI;#Q^YAsAx^|1k@blnLJyLupjKxcfT$!^&S=rrHC>kIEm}@O;h$QMRT+{xN4sDglS@3t)ympCNrSJS%}<_Cr#j z5>TgM$~bwlVM47hP?G;&usYpDa^MWumU;O1(h`Ae2;yg709lYYrd4MFjiqjnkoj-R zJU8L{RcFVxjepwtU>iI+Ah{$Ss`wEaqPoaSPxS)K@8Uu!X}*+4tEQ=bN^|<9(tTU# zmm|L9%vt>gI?V9>nD($U8A64kp zEH8>vzkT0}LVAHQ0HXFW9}u*VMxTd8G98G6?MfnQIi!E$KGi|}ryuSJH3MJQEvF!q zEngm^U_4qV1>+zf47?5diN>-1Or+t^oBV|@_|P`B?l{UykVM%fwH8ROs+L2nb2V(> zN#}xDY%Af`(=ft!herB;K1u!dZCOM%JFG<%rGu?hQz zm{v<-y4Bs`@Trz|{P_MW_!uLZrNWG9*AzlWlBT)6$>r#uE;tw3f2n6!V5+VCPU-%b zoO}>enlUz_vTt3_Jj9msYk$I$Jtr28p1vOS?7*LNFTo&*2jfvqjX%sxo4Eod0#(J{ z50}ZwF=|Ym{z`>cek@OB{Z?B6e}bq0o}sNav?HXbvCJ#Dz`u~2I3iv^+awGq;3}@p zvS^@L;}^cKrInMtE=dMSiY9*u<92BLe*jdj>MiQbrPWf_YA>fVYk7ziF%|4TdX)LKY48CbfK9H9Pfp^DD^?ooGq1cnC!-eMSh!V?!8&24&mB{~Gf@)0h z&HQH}1=u`26Dt_l0wcLyn^k~(4#zzkNx&8U8=L5QX^aARHahQMnyr1C_jVMD?YyC? z#M`ph!g4?LD#mT+ZRX>)>;it6J)~0KNDRKc|1o<6BNAv;S6Ma~psRBn8I%N_FOTsz za$*+XY5K-^jjwP}I!hp~>Jm}AT`gJ9X-C5cI`;M7eqHlY{<77w%IF`hO>1?P$hbcl ztF;0W?QFfLOgt?N^p+$cmTSE%`{9VCFLb3R|JItSAKrO_Qt=pAaNFra&uBlBBZS`*I4u#U_LXF0(wVPJ+z}h^ zQuSm`ND1l3%f9LyiJU0z#E1XVyy&jH2-BM^qA>-nW~0~}F?FmjT|BbGUZv6k_za0sAwoc2_PhgcqbFc_=F62;bOi-$5xU%4(?C}`soQU}u=z0yS=b-2 ziY&b=zO!1-NUwS{Fim84`#5>YHImT3U>0?puP!_!i(O$~fQW5yXqE68&a`8?r*Vq(mVw9bLko6A|*=l#X^By#+v7UU8F*gF&gso5F-cgb2 zRqI?y2$%LS@}i*Et0J6{ZlhVooJ5H{(W*9l^=LiKvP};Cx)EsjGRM10AK!V7D)UP| z_Y4{*MVqUD(3Hkb`+JK64eqg7vm^HjVm=Sq&?*(s4KtsuyijAJhQWt*cwgu?l4QYv;Zkx}JIYhqz0xw$6II zIdDZ;*E(Dy_{}H(7^hZGTaMiD6tu<}t%XDYMYhi_HsKECFpxawo(t5c%*iqpGQqPl z&B240g6H;+)e~s$&O!`id4kn$8VAKYV4#7&QF_bpZ;$ROwVF@U73Z-g>A)iJ+lN)K z-|vRlZ7Fqbij_^X7;jSM4+V|I&85F?TuMo>nHTN(0O|=%4csolZ~X|M@=<;y&DOZN zJXccLDJjay5f|AhG2QCDJzY7A2Yl3h$y?jks9Q=hMxT`118)4_`zHp)CxY|KJ6aQ7 zU-Ko)ii3nk_Ui*_mpk9w4GYYn*Ow7!M5(CJdoc3Pmm|gTJZF**dJlGg)s4!99C7tG z^c8Bro%y3*oL$nrxK)5+>7X~Loi`T&T$Vf$!5(d`VPw0LMO$lWQ8H!?j9eVEp z|0AJht*`$_-2ytM~JgX#4k&(8bS*)GXljhicacGqU`|)4Sao!X1dhZQ>0~2$28+ zkwM|DyQMqRUeuWW4*@2n$p39Cvd<$N0}JUsN?H4ouXW3Ulx*<|@Ah&|^^X?}TB;{E z&7T)~o_hFpl%EQ}aF!`50!=onXraDKuA~obmPnkRjzJ?u)0zsHEDiEQSB21;Gpjk2 z>fsSm61LfkUh0ilsJ=eK?bSoX9wSTDhqc3c%jR=~TMaXoQhRqF&7=PA9YVy8z`nFJe|K4fMP4h^SbqCzRE^$*8}!Kz zDsa*(47k%RXdnc71Jwz4Yy|PUqzDm(PKbI4X|#^D(NrxH3n)v6p%yw7A{nAd^izGb zec?Y&`g4Oas@f`H1sgq5{G7Jml$t}JF2!LvXXIUvk(WXok8dGNX$#=qvHR5SH-0qH z<(@;yDXv0g_NX}jv7s;kK*E?Bu+CL?_}?X43PSElnRk_q7r15NMOw$^9-R*&v|5?E z^9r}^MFZ+-_UMU29WjEv;Ffg*tt(wT_r4PE%QAnl;$*hlLR?YqbaCH$G<`IiglHfE zXT{g+wfbDzCvLu-p8=I79zPP3{2neKC)evZD%I=1z@PLClY{JWWXBhLrZVx<$sab) zo9;BJ(AOQlV~<-A$n6=kP<}>iRCyF9TfFZ&P>kdInN>9l2eftMLCuZk|f6&n^n#< zOOi3$K5`h@L1<&M?YH#({vO}I?tiYweP7pgKd;wyU3C51`uCMjT(NSxU}tzrOT{Y6 z??#NDuL?BWSj9j1534Cw279miR zSD=u$p~{?)_ffI!UKz_bQukyV-8%hL1FUI)Jc8N&E-&Mj!g1*CuAy}#vN}O+mz2L{ ziqd_DUHw%UjRLos3oEj}(0qI?V!`|Fcb`u; zk0M?muIZ_oq%^>7!;F(|BG69fDmxp*qglAfc-QMI@qyivddz?Jk}hemA0L>^mFwHh zh_#kIQ{Elg0&;l33MIFc4iXrQ4Vhw} zS9>rA1GtdA-HnleYP6;MNe*dv$^XMalCbm(Mpvr|8S)O&2v&sy9%hi3d&yIigoe?I zxV%h|Ncqw$m1Od%0@nlz?I)LecY#dsuO&f1)u8KI@neDU`v2~R0#?hNgrn3PMwc9c zS!Ap1+H@qiocnf%VcgJ*(dSdxRCUz7K1qg4bd0*RQy78aBfpCGNF=2o`f3~#z~?#@ zph;g!HoCfNDF4H8KL3@XF_`HcgzMB=}%@yXSKfdlG?Vrg)3}@!gw5KW1^lLu{ zIHSf}$=GK!hV-EIpI1y!RvPEYFR6q#*0@nZfO*KBk)erT{#m2CZZ0{H-J?Jf1nY!7 zxBKVIQ-pJ&Wzy|z{C92^<*vK=&4gc@p#Th_9bZ{UaQ>%+J(?_t!M%EqaYR2auTqSV z|Lx`9P>!7^zpXEY%X)a!^YJxOm^IR6jBpYIE)#)z(ExeoWWi6R+~F)m_w!PXi0Pq zodC;|;#ZMI1>lOKDr*eySp3B#P>CVh_TWxt60hWU^|km@$n_vs(WS9o4M zymrV~Hh_zPgo5p#Uu;9Is$e@61a-&fd;6ve32R`tb%|Mg7VA>HBEQx7*-e`xyS7}i z*njw%vLRQ?c~$@Lf~?T8y9crVD@D<3>vzhnUbL>6ZgQ!Gmhe~1&8iDm*K@gH`i7Ax|RJUB=~9`bN7jMi!-*Z0uk6UlR2C|IL-<2~ur9}}Z(_bWQqy~A#+`MhG_ zLN*S$JZb3mY^>I@ITOAT7(YF{8TODd)W>hoER6qMc}Ue|XA2eFeQe`9!0HiLoUlKm z)xR5BO^gPInxgu6R_ARz@Ad8H>~xcCS@YDI6l8aMvX%zhIdv*5&>}p3k+P8S#N6E% z>hzeKT=}DrD>L?Hb?@Ud05mnI|Q%kjqx`067u?s+s|#PhQb47635Mbja-KJCQCbaJ$Pymfa#l@=Z}ThbSgmN zO&sobD4c1)Q%=FgffNiVH~F_WXEwm;@||S`8i1RYfbRH;_?Oy|EO*k32_c?v9k{ul z=2Y$e!|$7cJZbHdNov|EH;qi``?%E1L2OUzO~}R~fWiEP7p#GfzlAC&v((weTZ zeock9WO&E~Wn$N4ti-7wDL)1a@PLhd;+oXzc$zN72*eNGn_tmkPZig5U8tW-d9|F# zLUsLS-VwjCV2}e7=+;2(##!okva(hejJfn6PSn848>;%{`Nyx-;R3cyDdoP$U2%6}{ z?AL-|qDKDcnz#TASi^P8uT7j9W(WvNM4k4X<`JAevCxA`m&t3ZMr}_D4wCeL+s+Pz zwh-#ZBVArC4+V3aTDUg3!2Zoo$qV1_n}vYgO6E;5Y0MBm?J$WD4>SOA-FV%Du+I?y z3O7ecEswCW%Ixv7=5D?f?ut^qHK=ah_wl3^u zEab-4*&m^n85Vc^TG%Q6RM4>=(?KkSe@d9$PKEZ2qwGRRC^Do0X3v4UO%w8*)Rpj;MsGC# z_}C!?2z*@pR3)0HX-LCeHSa-OiN^9oH7Al-uiq+4Kuy_`JKhL7{DpA2U$amqUVT)9 zI^;nO11y^gIG4%Gn^j+%*kni+*LVJXfN4YWC<~eO-*-AWSmUvM z%9jyTM2?5%x|ZpO6!zUWd3^>Z0*edE>@{)S*8bg$_E(n+hiC*=J4&}?h4SbL+U>zW zRt|aW-s`0nm8WnSELx?bq@?kNn8^ARX%^z`3j=PWnI!p_Ae=fdQ6%Rwq1Ehf@hd}6wkW1km*95 zanD~=Gu36v6z5uU-zG{!T17TH2Nj=m-r=g^wxz}_=F_j24qwtd-qm-hJqH@P9IyF%>G%bzxm>>7z3s)EQur%J`dR7MLB(oqx!aG`t2LiIuJ!iG zzH7mtI9^TkR4;NHomD!RrJO};5E9y5=zVK=aNM{`Bk_8gU#Va96Y$gwy13Hcgx{k!kw_{ z9rNvs>iWoVv&e0^bZ2hIvDdTVcVFyI)F=eW$qzj{D|<)9ytq{#Ins2Xjtq3&#D#uQ zbz&MHtg#XX^jFZ=R(u#zun+TjqYN3@%zUtBbD!BteOvEg5s5mA!sX*XhGw)YHI(w& zp&@?NRnLUt<{kVlbjrL3!YCQcePTa$A0UcEOiE(2Pi(X`ZU5=dZi|r{7~W^;0LeVC z2j)d6s|=FDkBj2g06PnakH*D7haCIi@*=9PpF#I>SyRURomJHr- zj3@BC^-8_`R{e5))nJ1>6H0C3c=FRUJ0;XB;aV}97?~ZP7ZU%xyMCG;VBYkwN3`a~ z)QV4;XoCE=y2<5;4^^41A_Tg$wa@P)OXt$)>nnOM?w@^_AY%3wtceS9b3QjI{M^J5 zWA6ccrCdB%_&nPZaTd-n?mC%XF(0eWcap=&RmVu=ao+hmTwI&2(bc~2`dZV^sT}Ul zdqn8*Y!j~-$*xEWYnSB0ySYzcI`CDQ%hi^n!^ak~io>M@A$wdLapr2@L+cPjwQa1P z1<|((1h4!^nqjj=V7BJr)2z2uRoxXFJu^YyCR(isylp>$nI z>Du$OCLK5@Uj8)U2Dv8IRV`AvD7tjDa9U4zxF)sMe)`KeO!Vhd?-8 zg1>D+?l&7Oe3F24>07mPR)AQedMWsQQK2e38u+G2qgGa2NS>JCoGsibAov+CkCG`` zC0PkzIqC&GDN{ft6Z34mCryHRrojKmOemohGyRTv?s`Wycb;wmk`(ir)3XdZLq9jB zo@%AsmYlomH9cTU!+~e)?0Q+Z);$V;catqzg53lAp4ue8;bwmz^UY@TA+2Mg};C+9TML1Ap5hIiI@Jp>>24b|C7?(CcYxY~ms3T7RL$`9E8Rmu$Xf zd8Q=E=R;C(p;N^w(ugoji5RCc{2r1Sin5iZQfdEbl4nzZc>9V*%1jV0j1x_nA(wFi z=)8EObd``f{lMwkVliVn$Z~(Wzqo`$sNfh-Vh|Nt|x%~aX2DCWkRPya+TQc0~`4S(-4Z8^~Mk`rw|0}Ph#e(Xy= z(x2pSH5AyoZx3DiB}-h%dyx4;o2cWmJBjJ}5fu(dFKkb(0#zA}eCXxc7z*HS7xk1D z3B*`%iK4~TM9eZ5g8*Ctx_QW(7}bR5WB44prc~pYvF_$2WtFQP=jUmhC$hFoC&vwB zl$y_PUhIvEWYrQI$JPR4rGMugz0QZnH{M(s|KX45dzo_A!lh|(7XDVX4x%VH{xc5$ys65Z!8ZmE$ zfIko(<^J7&^q41AGlaW52mT=O;bDFRCTY1N$9*uLd&o0*#oH6wRDE`Rc!ToZN6>zkvXU%P!PX0AWwwTW^xjPFUP|T7vhFdw zeFWHbnLk?imrm)Iuso)1fFzmto}4nYdd$P1hSeKBoAd_K61#azdx=}Nk=j)FzdzZN zL1V#5Efu0ClriBAkjAsG(Hdd1fvx&dyI?ynej@7i1J@7h#N0y{x$ho1@(#I$@D465?n-{E>vFP9*-V9Mwl5-wt@ZTI5Osp5U<#cyw4a|=d znK*7l|+a{RojkIope$xKmN1E-T7!n*hhBE%+Nh{W&8B7c1YP1tZo} z-V3;dU`8?5os^I`*(V8;y}eVmCAzMG0$%xT0p-qdYaXeZ`dvDjmuVuxTUFXCC7>Sn zZXNjLL$2hG)Ktbe(MX{c)%DmA0_T_jkW?^Qq*K7X`Rt>0mIEn=2~P1*9pXf-@Tx<5 zfuIQ70STDc#)6pu*h*m4yy6CCScYE9X*N_uk*+j-D|fnk+a-j*A;|WJ3ev49yQ_(k z8!@(%>-~k?q}T=>@lZ$`=9$*2TO=!Oj|@G1RqxrnkN47BRWJ%pp2VSl)0kZ25s^|m z>nGH@VZXtRj_`@ig<{^(Ixe13vZ!wMi>S>h0a+JT44m4D0Ad4co9t%#|SCr_l sPti`=Cbf8BFLH#`@_dRs)$ltzF!evGyFMP0B?)B^QzshHvU*$o4|9caBLDyZ diff --git a/defaults/eeg/Colin27/channel_BrainProducts_EasyCap_M10.mat b/defaults/eeg/Colin27/channel_BrainProducts_EasyCap_M10.mat index e0e2b22f4baded82d158ea125d7192444a568242..95ebce4c1c794dad42f287a46ea38db14178cdb7 100644 GIT binary patch delta 2352 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 diff --git a/defaults/eeg/Colin27/channel_GSN_HydroCel_256_E001.mat b/defaults/eeg/Colin27/channel_GSN_HydroCel_256_E001.mat index 77b4449e24fda1ce6bf44f19a907b396749d51c9..d74530bab2314f38d99a79a87af6b86c4a5bfaa0 100644 GIT binary patch literal 10718 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 VOo#;uBQb-xXgD^;!Xf>W{|6k!eD44N delta 9828 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 diff --git a/defaults/eeg/Colin27/channel_GSN_HydroCel_256_E1.mat b/defaults/eeg/Colin27/channel_GSN_HydroCel_256_E1.mat index 80d887171fc1828210bf7604fb7648c0b2532429..079044fbf705ac0ff5717607501d223e7e6cedf3 100644 GIT binary patch literal 10678 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 ZIF-T2|C!1PO=v4k@-se5aYB&F{{czd*lqv- 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 0000000000000000000000000000000000000000..5b54295d13aa31739c52d7f0c78a24d554d0b962 GIT binary patch literal 2942 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 T@3|F3)Fo0U3WWecCItR3c~WD+ literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..48603e510fdd7e17816eb2c676e1602dee890f8d GIT binary patch literal 5817 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 Q7N}XD)uaZ13V^i!1^dbPCjbBd literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..04240e1e3ffa5a40fb996499cfd4592e4c66a6a6 GIT binary patch literal 3146 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 literal 0 HcmV?d00001 diff --git a/deploy/RunCompiled_2023a.class b/deploy/RunCompiled_2023a.class new file mode 100644 index 0000000000000000000000000000000000000000..5ce5775cf70bd16d68886a8846fcd85bbace2e85 GIT binary patch literal 1332 zcmaJ>+fvg|6kVqcq=ax6l#5yfwX~KZC|(NHa#Nt9V;LOv!AaW;M%$)ynkc;cCBE>C z58!2n5Aa|70cR9fQZ9v2JIy&cd+)RNT5Iq2`|q!N04A`kp$%~rqZ$H;HzR>D71uR1 zAnq;WO^mq_!c7g6xTWH@hGyK+XuPW;sbWf?amp%NPD&sc8CerhX6=G0(3Y{v=89X& znR3<06=~_r*mgyCgmCP5(G zbxGzo@@B=cY|p0{a-CSZ{!)Z2i&oWHB#8FfZP&4ii41MLsCvG~u0w<;Ic`=Cc@Cet zHC2bx^Wpz}=yp9DtI4tfr@JM?d57D7x5laGn{~iP~I`01C+#54b#aoZbcsw|T zMxnqSN9qKw#U@8yP@v-(6Bs%OI_~AEF8RY%S2Vj>b<7fLklN84S$5(HC(&UWO{2u_ z)bu@R=8#mppdu&e1h%>3B%{)WKwT?8}1>4E6pM|8!a z1LI$y1n}%bKy2;*IPgFEh6ctpuEej!cA$PjsIzGY+7?19@g3;F&uH0Z>Ii2k&3_67 z9+XM6_+>-Th`Skm+{0uOA%`)-m}FFn(9#4uLy!vuxI~B^^c0C`^!4&qfYGh!#{fak zF!y;};2Xi?BJ%+u4(1>(`Sph|#J2#3Y471)^IbmjU7_tNePJlSps9$6pXlD_F{omM RM}8qxL>1nMF)n~>e*j@3GxGod literal 0 HcmV?d00001 diff --git a/deploy/RunCompiled_2023b.class b/deploy/RunCompiled_2023b.class new file mode 100644 index 0000000000000000000000000000000000000000..e13a55760a6f37e266f38dcac98f8eea8693e164 GIT binary patch literal 1332 zcmaJ>+fvg|6kVqcq=ax6l#5yfwX~KZC|(NHa#Nt9V;LOv!AaW;M%$)ynkc;cCBE>C z58!2n5Aa|70cR9fQZ9v2JIy&cd+)RNT5Iq2`|q!N04A`kp$%~rqZ$H;HzR>D71uR1 zAnq;WO^mq_!c7g6xTWH@hGyK+XuPW;sbWf?amp%NPD&sc8CerhX6=G0(3Y{v=89X& znR3<06=~_r*mM9#~|OoBkV z>ypfG*`7}^z0g)6l{V#0JcBxy^3v0I*y%Er|wkz}I0(B$OIuS=bL3FgE zg9LpIeFAAIjZKdQbLdFno3XB9;XD{yhj}blm;Nxi@B>inkt<@py0w zjY5Gvj?@WUi%pKapg_knCNOjmbll5RUGj&ku4s0%>X;?gAhn}8vh2hYPNKs$nnsD; zsp)&t%ps|GK}Al`32b#!+~!C4tzDHyo*h)o4U2`8$l}Nv2GPo&s`m+iQlNutCr=6P zf_p=B7h>CYcX4g>jg2_NcfA&XZuIcfgtItD3ona71fkHj{0)KUy9j22(*x1HkLZd; z2gbiZ3EfY{pqao~US4GoNIT!~+c?Lhs6P-oK)v@L{I;yciTpV6|-)Dg~7n*S6E zJSdZB@ymvw5qC5CxQEFmLJnhuG0CVDp`{6Sh9DORaETB-=qVD>=+fvg|6kVqcq=ax6l#5yfwX~KZB3=sBa#Nt9V;LOv!AaW;M%$)ynkc;cCBE>C z58!2n5Aa|70cR9fQZ9v2JIy&cd+)RNT5Iq2`|q!N0B&GeLmT2MMl}QwZ$<)RDz0m2 zK-^o#n;0_@!c7g6xTWH@hGyK+XuPW;sbWf?amp%NPD&sc8CerhX6=G0(3Y{v=89X& znR3<06=~_r*mgyCgmCP5(G zbxGzo@@B=cY|p0{a-CSZ{!)Z2i&oWHB#8FfZP&4ii41MLsCvG~u0w<;Ic`=Cc@Cet zHC2bx^Wpz}=yp9DtI4tfr@JM?d57D7x5laGn{~iP~I`01C+#54b#aoZbcsw|T zMxnqSN9qKw#U@8yP@v-(6Bs%OI_~AEF8RY%S2Vj>b<7fLklN84S$5(HC(&UWO{2u_ z)bu@R=8#mppdu&e1h%>3B%{)WKwT?8}1>4E6pM|8!a z1LI$y1n}%bKy2;*IPgFEh6ctpuEej!cA$PjsIzGY+7?19@g3;F&uH0Z>Ii2k&3_67 z9+XM6_+>-Th`Skm+{0uOA%`)-m}FFn(9#4uLy!vuxI~B^^c0C`^!4&qfYGh!#{fak zF!y;};2Xi?BJ%+u4(1>(`Sph|#J2#3Y471)^IbmjU7_tNePJlSps9$6pXlD_F{omM RM}8qxL>1nMF)n~>e*kE0GxY!f literal 0 HcmV?d00001 diff --git a/deploy/RunCompiled_2024b.class b/deploy/RunCompiled_2024b.class new file mode 100644 index 0000000000000000000000000000000000000000..32f0e28af2899b139195764ed5ff3fa34e57c15c GIT binary patch literal 1332 zcmaJ>+fvg|6kVqcq=ax6l#5yfwX~KZB3=sBa#Nt9V;LOv!AaW;M%$)ynkc;cCBE>C z58!2n5Aa|70cR9fQZ9v2JIy&cd+)RNT5Iq2`|q!N0B&GeLmT2MMl}QwZ$<)RDz0m2 zK-^o#n;0_@!c7g6xTWH@hGyK+XuPW;sbWf?amp%NPD&sc8CerhX6=G0(3Y{v=89X& znR3<06=~_r*mM9#~|OoBkV z>ypfG*`7}^z0g)6l{V#0JcBxy^3v0I*y%Er|wkz}I0(B$OIuS=bL3FgE zg9LpIeFAAIjZKdQbLdFno3XB9;XD{yhj}blm;Nxi@B>inkt<@py0w zjY5Gvj?@WUi%pKapg_knCNOjmbll5RUGj&ku4s0%>X;?gAhn}8vh2hYPNKs$nnsD; zsp)&t%ps|GK}Al`32b#!+~!C4tzDHyo*h)o4U2`8$l}Nv2GPo&s`m+iQlNutCr=6P zf_p=B7h>CYcX4g>jg2_NcfA&XZuIcfgtItD3ona71fkHj{0)KUy9j22(*x1HkLZd; z2gbiZ3EfY{pqao~US4GoNIT!~+c?Lhs6P-oK)v@L{I;yciTpV6|-)Dg~7n*S6E zJSdZB@ymvw5qC5CxQEFmLJnhuG0CVDp`{6Sh9DORaETB-=qVD>=bGxq=h literal 0 HcmV?d00001 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 6ba1ae4438d0e02964027656139e7c4f23612abd..e6de3f5c3b5f48ecf490e65b46726f695647f80b 100644 GIT binary patch literal 18647 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 Vn_J!Pmbbn2?Qek_T)hMY06S9KcI5y7 literal 17826 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 diff --git a/doc/plugins/brainsuite_logo.png b/doc/plugins/brainsuite_logo.png new file mode 100644 index 0000000000000000000000000000000000000000..16cb37001a174e762830fdd407bd4c4c03249c96 GIT binary patch literal 59503 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 a=>eFMG&wmfx+3`qeJRST$yLdihyEW(8B}%v literal 0 HcmV?d00001 diff --git a/doc/plugins/mtrf_logo.gif b/doc/plugins/mtrf_logo.gif new file mode 100644 index 0000000000000000000000000000000000000000..d2ab0c485a7231709a393eb0c4b6301b8a0cc61b GIT binary patch literal 8398 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 literal 0 HcmV?d00001 diff --git a/doc/plugins/zeffiro_logo.png b/doc/plugins/zeffiro_logo.png new file mode 100644 index 0000000000000000000000000000000000000000..45914a756597fc4030a4c1e5529e124056faabb6 GIT binary patch literal 27190 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://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; - end + % 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_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 50d8f9bda..4615727b0 100644 --- a/toolbox/anatomy/tess_isohead.m +++ b/toolbox/anatomy/tess_isohead.m @@ -1,8 +1,8 @@ -function [HeadFile, iSurface] = tess_isohead(iSubject, nVertices, erodeFactor, fillFactor, Comment, bgLevel, isGradient) +function [HeadFile, iSurface] = tess_isohead(iSubject, nVertices, erodeFactor, fillFactor, bgLevel, Comment, isGradient) % 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) +% 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. @@ -43,16 +43,26 @@ if (nargin < 7) || isempty(isGradient) isGradient = false; end -if (nargin < 6) || isempty(bgLevel) - bgLevel = []; -end -if (nargin < 5) || isempty(Comment) - Comment = []; +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 = iSubject; + MriFile = file_short(iSubject); [sSubject, iSubject] = bst_get('MriFile', MriFile); elseif isnumeric(iSubject) % Get subject @@ -94,11 +104,15 @@ bst_error('You need to set the fiducial points in the MRI first.', 'Head surface', 0); return end +% Guess background level +if isempty(bgLevel) + bgLevel = sMri.Histogram.bgLevel; +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(sMri.Histogram.bgLevel)}); + 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 @@ -111,8 +125,6 @@ if isempty(bgLevel) bgLevel = sMri.Histogram.bgLevel; end -elseif isempty(bgLevel) && ~isGradient - 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]) 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 0000000000000000000000000000000000000000..4688651ec006882f95a1b82640bd738fbff4f485 GIT binary patch literal 50664 zcmeI5e{fS(7RT>p|$3^Y^ID&2zM5q%ydfGRrQq{*Sacx3{OVcGtoCJp@oo$vN|E$+Ui=zRS2P*=4w zg2s{gI3*vPT`r+~LZw(;BTCQ`o$qxe-)dz%jU)4sYxZY#dnCKFLKz>OZ^Esi4!RaC z;W!jU<%UMDTaZMz%PUz$`Owk%UQ+UHmY*ac;W(Txx)ublbGhAV6YS1%7j#DF<7b3s z)F=~aJm9=h2?ar{6v`#DLkx99=i9C1D^=#vSQU{&M@VHvDC=$`GzdabzA0ZQoPWn% zA@%Xfs6lF)lN!aK2NQ$}SA^^a%}2+|g&(*tcoj%&s7L;!??X3OXkQT>4T{ezbyOVQ zSD2w7RGS5Nox|d)7HT}wp!rIabX5J3yy$BJ%=(HT*vuYtUwBQvl7>(peaC5`ab!NZ zG2(-slddfYx6i&~ZgJ7<@Vnok4C0kqiM#2`4ux`8Bq+z3gfvW`UPtKrg@am-X&aQO z9fTZ$nma%U4>jeJ3RVg+eJ0xv5@Lje^sJ>|v_!I`%SLjL2Sj|7!u(+oao9D0L)SU? zaY?X(z2%)vXJ3#$H1C)_5!#Gkup|_kd5G-GYibv&Z-5rMuW+H?9}~c6F1q({;iVe0 zr!u$NZgKc>Y;{nFdVa=uF>=Ds7;pAOEO5EcsmJ9Q_mfjVyExmk*4FS;jIT@t+8N@ zFusE&{%4@3`|V(P|I^@KRQ9_be7HI=iSGSL@WbbL6#O4xj5>4`V;xyNxycJ{^t@|n zJp$%{zY6?rn9IfT?cF-ko)JT88=m3CVJf$OneU?2%2VS zNbOw6p9cB6cNJuFnTdvMCQ+ANuQ$vK+<*96Smz3?H@%+Ua0b@MzRqBXVOZUM!c}c= z;V)m%ZcEErX)s(>#kSJtH?O{a!}g^&2M&PEfivBwB~8av1|7#3bb>0W=lGSN>1L9q zYpXXH+FJ`)24oHbotmm`x%C@Wdh5%umv32BwwKZyBlA9O8q9)-5kUCW)9 zpw=JrIa4*MOY)A@Ij2EACaWCwvka^PY&lpBm=CNLtS+7InPf>|Rxmf14a@^3f_c;1 z#=tqolBAtR@}2o|(BxNXTDK8h4}T$O`ZJsfbl`{kUc0m5ouxK|;d=5JV(MKn8{Y8} zc-LpaJ3kZN{o9jI@ZFY7xcr0?;d?_{d)@nL-6ax);I(DRx`=EZJ=-dRsM1}==)@Z zzUNbYSLOx|fJN$iHR;#)3D9=|%qdt|zkyU*0%k?unc)97eOo}^cavn@7c5)F?%bAE zpy-=bjnq9$^$j}z_`&)OY^5t;{mdfk`#SKcR>Jx&0DT|T$Qtj{_eELX39TnU-zM10 z5HhS!->&~b-?=dF*V1=^F{JOKnPGjWwC-p7_1(Lo1oSuybU73BSqwTY0v#8&fNo_S zSI%lEDFdn$FGI-vW2bcI%b3O5ysNAKqQq8@(f# zdJ%kjpN;zG!M{oI&w+ot;(r4^y_-wNoCW_0#Xk-HCdEGq{;P`rCHVWmFZR`lRuA0k zHF}o2jCN;@*W=D1vuSO#*&U+OZFf1{Mu*+)Fng?(;jTPmrP*!txQrIjXmvSiszsmC zEJ^0N@PLUC18gp@rCJR4m(EqGhY^nB$SdAC_rezX3cbVV5Pf%x5?vJbl0NXdGS!g%z20RJy$Ss^QEgNgKLJC z-*)Wx({}`I4;vnP?6KH;-S&-T_bofMdQr(Zf9JCGmqu@C|KlUwYmTT=|NeQ`g!)UX z=8Tv#@%Wo%`3LV$|2Fk-YT%8x^^VS@(FLuA_iRduf9=4P;YnYQdU)S~w@zkWdFIr@ zGn&%5T5gAXudNYwyc>+32`Ug-^qCT`IaBFe2JR2`6Nt=12@EVh!XuOjh7YoAD3Knj zBVYp)mClWUZ%BH@lMP*sXJ`b4)}v%^U`?+)(mU)t)Uqk}$EhD#=)KZ-xI8`zjFwSq z!gOf}dqG1~8HB)-m#@3hbn*bYJzAIt$_V32XY}zIefKw4!%IL*2OUkzqQrH~3(Qt_ zgQ{8mOw6;I=d>HSO|j3%ZPsm}_dc;80VIF~kN^@u0!RP}AOR$R1dsp{Kmter2_OL^ zfCP{L5TadJpwvY9|K;PAI#sE!Q|jNw zX>b=v00|%gB!C2v01`j~NB{{S0VIF~kN^@u0!RP}AOR$R1dsp{Kmter2_OL^fCP{L z5v&sTwGDc_7Qte7+C+)0qvNIM8Rces zHFQbbP+o9|zG6w5=dLjMJhV-p1Z}=TNi=)JJG>52vRm^d$y_J=Zo&lGpDOpy5Z-`zrSP%NM+iI9>$G}cLnOZ3UM)&ut=BGzHgk=AnJCeXk|a8Vz>Fa)% zC4@Fd#`8llO(U1Z@Cp6!H&m8K#ZN}XEm85FsCZvg{MV@XNGR^xA9b`~99S$E9F^$6 z;=vNY62UZJ^qfCutdY>?()Sc-Fvx=#pkG;y16o3g9w^PhMF~CE$}!Y9h!8`aAVI~E zsM9YyIZy<5%^M|HL7ZNXSOnU#dP?&P2OK)b>Z-Qoh(PfTpye%?@)i* z*;&QTUl0>qxp(CQYd(LkvE=cacKuZU>GXz_FPb{PKeT++sfu9h>4#oD>wfuR&D7hb zHBL`U=!yNQ`M-4kxcplU4{gmGyD`OoqUe*8@9g+s>*Dto-L<&%!+*WI&RlW$mtKG5 pXXFph|4A4cui3KTpZhm!-)O({^{>+_a_b)*YTPw+$60uK{{e7?<%R$N literal 0 HcmV?d00001 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 adc2ec53d0e9ab91325934e0f2b55ad566808cd5..3071d68df1d130116581f0d00f30796712f011a2 GIT binary patch literal 13248 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 QNs{T5R^o@?-2hhq0}4c8(EtDd diff --git a/toolbox/core/bst_colormaps.m b/toolbox/core/bst_colormaps.m index 07304c9b0..4a7917e69 100644 --- a/toolbox/core/bst_colormaps.m +++ b/toolbox/core/bst_colormaps.m @@ -389,9 +389,16 @@ function SetMaxCustom(ColormapType, DisplayUnits, newMin, newMax) % 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'; + % 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 @@ -1447,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_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 190965a4c..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 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 93297967a..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 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_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/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 0000000000000000000000000000000000000000..2d66bdec47ad81fde10f14df8931a215ac26042d GIT binary patch literal 50424 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 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_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 83ea75748..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 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_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_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 0000000000000000000000000000000000000000..946c0485cd72ef68abbd890601b55df92c66bf8c GIT binary patch literal 17632 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 literal 0 HcmV?d00001 diff --git a/toolbox/sensors/private/bst_beep_wav.mat b/toolbox/sensors/private/bst_beep_wav.mat deleted file mode 100644 index d6e8cbe9cd8ccd8feb15789e8a19b1d5c6bfc518..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 160194 zcma&Ndpr}~A3y#X7j6uB> zgYwBdxv(!VHkV_UR}(0=S&7|yXb^8XoS;zH5*W)S8rHZg2$PTgiB4xfR7RV~vBE0- zp$K^6SmB7bA7g7%Royr`63tyCalZRih&$1gbkbCnwZ5veZ-m!R2pBbvh)NcJ&VGIs zO-+`Khbv4pbdDfN`t&J`cvbxbJ?YN;e2|2xKP5g%!OL_UgH@k0=yEgp52|EbBhhx% zq3;$l%naiD1IziRDfa>f!xUO{`H99koebU@Wx6Q*)nKal1vCV=>^EYd?3WKr%LCrv zCTrMhDyt{=23uZP3NtVf3l@in#xzfxKxaEIDlOh z9o%_kQ{aaNvxXyWYe|7)Y0Z;2gz4Ew8=ksjQ>oPpD}&oU=D8;do^3*S$#BcZ8;e}E z+<(2UU`T#uf!N(y9XmSBhL{ z(^T)b9CjooC)c&`R7X&QB1STCB}XV0OHd1hnO0&B`3Gfo$TepzgqWze1K~`(6={3) zpNMym%e%v5!-+rRG2Nz1h$Ugt9e6qnydk~R|FN_Dvie%%KGon2#?PeyNNGi*w@pz< z-^4Xp;aOs#QGGa8K@Nl}yowM>PR$2n)yJ zBK@>%B{G12s}cWMgTOH2H-@pl;$dmwBucdzlSW@B36g-=p|fNk%FP&aHpp|<=0nlD z!Ow)(`O%m1Yu#Fu8`TXP!lU_@R}tG1a}z$_*~tVb#f$4}H!`dlM;JyL@~P@Ag`u;( zAAOATcqYPzSxiVHuD$qx6ch|Rz?r&%3eJsoPm6XvW0Fe)0bljSBlIO&kfIjob4y?! z`<3FWJ}tj6bY;elKrfVG^4$%zG#p!go&Ie16_^>XyMpo_h!~iVUpih3Oq*-OZgXF`A&#`sM&+cMj#Oe zxUzmw5iViOsB`l3Y7XxidW38I%fj-)(kUxI&l7wUes#g!!St3Ujf5%w;0an(Eb~@^ zsJBf1Gbwb|j%U$@-+>>1ju*I&WbIPN=`S>p~@2P?d z9D4t5U`auEU(e5R$v@cy%Sq~IrZI{5i4lZ^>hR>P9m#xJ#8datnW6pe`qd0Wptbb$ z!>~U~fB2pQyBK;ciP3hGA<2s99DAQrcs$>;IdC)206CIQ)mj>p#(a3kSWQcrQN6A! zMEM#2F6*m3L|VrXjET}n+bBA%ytT2*^UsOgao4_{(NgvlhEr~(LfuD*%hb45KW#Fj zHCngGy8~vUP`+2G2h@r4Q%l$K6LLIt${*)%bRDe>@g?ck8(tiSA1ByyN<}NMGby{o z^>`7?3mW>+k)Ke7wl(3!PD}NQMqB%KtaL=2_9nv)Z3|JdLSHttIPD*J0FJEo#ePVV zk9jAI0p}zOs{(DAg6l(RyslQyLge-)UT(~R%`nDhkk}!mA)RAF?|Z=WnRe<*v`xTG zeVcJ@3fw_4>|Zq&><^)cTIKn5INL(NgjgS0)Sg)0I_RMwm|9|A;~u&5-UCNx6DsY? zi@9jw?YH6efoL0`Th%f{0Dwk5ppATh*HXDy>JJ(BYL0Ou?rqQnFSPjmUGqQ7?%^9qU%K@`f z4B?Cb6E_gw?jfe_7S|nxs*|Z3A`F~vq~~8QEp1OM?Y(Ik;M<>KxgbdV0ag0_%xQpJ zfe;R{^x5RDjNcO<&$8&TAzc*9rQ;Q2o(Rn1BOvc&;0pK&ORu45F zCNq8;Vu71kDY_;W+A^9tE7=D{We-&rJySXhJT26o1x0S48(jPe&aX#M_Mx20RhPha z7T)-%xt@OlMrSc=E=dCq_qa=|vh7Q+qjCPTn4l%E1Gw$m;Jjh+wboYrinPOW(P}dB5BDXAuAESywxn&);hp*_r%qD$X&W)b<&72m3$I#MWMS zn*d_yV>Y-dKE60J?@#`Ctz-R-*vB5O8x}EZj-1+`!*aeneHCzg`x(IMwO!|b{DyI& zx4jcaNyep0tXR<_E#f(F;xnW)-g)5&?!issqi$Q=3JfI<*oG5w zEPURMLuV+CU!Qo>?l|C**SA|gV6&8cxkOafZp-Zne*~%B{V6e1jBAI&9|1x`XPUs- zIh=;iyNgk%MJv4_&;tNF3sM8yG)y`8DZ%BsctcjDZZg}GkX9h^ophP6U4GvFV8b%f ztt8iQmSp}(`0W%ony!A^&YM%H*TrExuZN`x9xHea``K;7O_Ath*SE{-as*l{w4$xK zh^^9^)oM`s{LT;>JE!!I<&nkDP_)lv2Hs^KX%OXmeM5t|(JRUX=?uWhnAT(V%TRW1SI%_|onEaEu+1VDX!au3(+A8+W!+$Ov&m)$p{bm(bm7~976;!6sz_rpYnDlw~CwB;y`iqTb~t+ zojq?S#LdHDD~)eAkau{Y z=(^RQ$ig;&4bpKB@OCuaGLrd`c3|aPvtKBGU@NP*6?<+VoU%7c4;z<8o#yNg8RodQ z1ZhD^?I{jzVE!-U=}F2d8z&Ar!IKOF(24z z2-*!I`j{9<1*I$Y#(4n7C!Bh6n~+DvK{!ccQuI%TBVPgGiME}{j95G<{KR)N06Y%d zX~`EWKCw2#JndF#^B*vj2D@=ecyaYuZD+{t=i&tF}>} z(QLXFot?ycDapI9zGp4TSLU}oXxJ0ANq)Z@v6IZEywT_0+a09XoJ`{mQ64l@c=sBP zNm{(l77_Ns7^_lhvh$=ZPl%9fS2H%`=nI)zS^_$_9VK{LI_839|V?d!2|fzPFV z%u*N9$?niAYAeMTJg~06*k^vz6VQ>;6PJFOr7-%25?A5wgksvEC`s29L*iqA#53{_ z6;a+<4zWu)5trE|o6%+&$<2J>SAbfoFdeFmZBJuXLUyKdK1X0Q3n$+yQic9yXu4M#YW>6j&w@#t0-yvMb9<5Ga>N><;7n_-^ zG*IMdQL>gCg=e=H><>#z{f zxj|O!{P(ORQ@ZE7D*DMCvJ+aANv4}lAnw^tZV;T^?yZB_g%&4>h--_*g_1m0#5GjPv!3VoI(+JgIl z9>Qgsyi?NG=&a3(HBufW3^XEkpa=Nub&@=`@g~{ujMaMJsW2wv_Hmi1K@FudS3%K+oV)7os|w62N>cvTDF>uIwV_hK zhq_E(t8jp)J_c^fJx_SnG`>0Z(p{C*tfHp+Gd;y&*`WqYY1Z4iyvMSzD;cZTBwZt^ zd7r#qz>dVd7rVb0cstq4p2Y8n41KR&H&p!)ce`zbqSJ3Wf)F|2n;Ra}x!)$l|4Woq zlp6rkePMTvv=%iZ%EgWe&}P)p@O_H#T2B77@rJp+`;sGKL7=1>j^5KrT(+_q&}LqL zWPkIn+#w?D3tW@9UGl@bDDTQy?47$b?(%Hk2u1lo*d%^I*J|VeP{+7N>B!DL7#EYX zRL})(FbfsZjU&HQO2supxhk@osxC*?F({9iuhtGcUa4Oux8_%mg#MG*Y9;_m;`u~e z*!gdb)OU1P7RZ18#thD6WiV0OjR$+vB<40$WAWz$-Lt&QhZfvfy)1cW&SdM3NT(); z3++zykUrOKz1B1+R8*+MVLl9e#qd8l7oFyF*2!}T>~NM7_WI!}$H{U)q`@^YG;aM2 z=#n7*IOJJ7ei!mRSIzw1=rnB^`O;K9*AR6Ib3_{im{M84E6VHV`Ok9?S~h0ji*(-S z9~rba#N4GyuSq~I%3uNc(5H+*4b0hg%5X0SM!TJqMak;Uq44{Pug40ynV<%-)pFzrujfGc zMuN|rDbVp2klUP|fUA2i-XfleryE9+cL)qWyURP*z-&^}*NeXD0xvdE25inD&vLI# znKl@XPu8O2kG%6<=L`PO`BDz^ z0kz5H-?9GQa#sT6qzPpmZoO62RYCxQC12K*@2$L^Qj`u6GnV@5Fvu1~uZ}Bmf zNiYbDQlhkp9N1Z9mrDFw-}*5EN^e5YI{#wVG}$47$5r5Gm#rkoH$xYH9Cj)I=v{j*C-j_!%?5khHlGUx0IrMO15 z#8F%OC`H$WM%gB6rmMQ9^859C;Veq<;QA5DJjzPU1y&H85Z`*~0$ZkG=Vy)VckZPDLYo zxtCFUoDjbE<$cJduumon>x@4?=!dEg7D!|BD6Z;j5a@a#ZYz9FA>)eq-0fUavzYK8 zG(-J!umOMvoQg|VJ5n>$TPaEaUJ~0UFY|0;guui@wD6}+!h`-x3#5^Ry-q;narL!N zMlmnE(LmnP=!Ehga%jzj2YTPI5_vR89TZUpt;pa^`PdhpQYP|ecX*?r!woCmee~r* z1)ZP1QrNMWXabNt8GkxLO}5Kki8k_|{kRnKDOnLn8ckR%PEQnxm4edW^6lvDO``zS zhNV8Ax>=z(!gM%TY1ln@&u?Nqc0w7jJoqYS>8oVrWh})77*POr!7U%@p%gEV;M!Hh z2}4!YFkJPQ9UhOLxyIgQ4|zHAnP=w6&s%X+JW@aBFx64F5Wk2K`eXcSMF3qoWnW<6 zt)kyn+zLOP0K>=i5(CJ`o=Usy4eINz(q{P&gXzRgRRi8s+Gc;6FWdkdt=q)C4*K=y zQVRmPFhOPB^fs5u)cUc$8GsGy$Vy2IvR_5cT-ix@r=m| zxJfpR*l2i(AG=yjSQ`IipQ~I8pBnz?#3-NjUO~5sh2@O+pN>mqdFy9C#p$lvUJ ztBBa4vmH=`IJ?4Fj?`N;k6B`8cc`|Iov?WmHL3ccF@1vZSf#LzS8bFP&RZ#hMF~)v zYT8JMvwtVwDMzO+g@0bHA;lPj>;y6TYGN12MG&e_r~RgSh-kru*X4JVz;kh~lUBk@ zEd-9Je*U7Q3q19FFZO3kM-j)F6|cwJgxVvwfMBTvRn;b>#Q(5*J8-ECQ-<9Xn+&=9jL`P84|B-~|3Tl(?N^D}3&{ z*4#kACdBW}uvY^acyN`e{#hY=zdExygdjRPi*`40EXj1RWL%uV0thsg+;Fb)FJ(9r z?*RKcU$0qX3vu60$@e$;f*#vDhN3x*Va3kj(_D{}Ji($1T zn*K;K7Fxh=V^8#V3YKNefI51-re_b;yIe@e2@V{*0-WXw5s!c=;jKU`#|#@ z@MsnXDF7l>T|%)|&Z1}~!_1kbnnLhfA%Zf+Q>K1BU?e8C-GYqwW zXez@aj>5BGOFinOkM;C&<0&em9+)8s(M0u!JdVDJjs4UNoS;$_!A{!XiXZhlgCCT! zGtp-YVK)L|v0eA@if=#Q(a5L368+kmNy=(yu;Ri6*yHj#Z8ydkj;BWMM%zeA3_?}9z0Ed3p zNd=Rta5gM(!}3DtX*B4!D}wZ0?1`1_86NMkFkjTa)SA(lXl9JH#22XShtn&F9s1@y zs7T!5=`p{RM68w#tRUo;qvJ)a>5Vtx2fqcIAz72=K(grd(&bT9XZ`3ydkO2ghJtvz1~gDr ztoZ(RoXPW0;G@+Ait|*)V(dz3)rcAFqA84htc6!4XMD1^-W7N*)l)9Lc@|JcKd!U5 zDM}11ok{~`)Q{9UM`C03E>96_Q-;xD^192{9WZ!!i(qUy8B+oTuGDp5MZ-Wxo6#0x@P7=F~}^&Z?7SOZ6TUrW^nP zt=6BMx=_%}tev3Gy+lXyo$d4D(=u6TKACd|LU^5%95eX5<^{8XiOI&vBf=#(_Iq&2 zAxq~Fz7${JZPxFQ3odU)#CLQ#N@pM2Kl^U@ zbLGb$@+IaUJo0o3HIldzhm>T_1Nz0F4Md_Mr&>C>;25yxD`@(h%Hp$Il zXKWYoi2~R&A#yCF;4QWA_DN7Ia+-xQDq_!xUSMn|Q^p63P{9M37Kd7Na(FSaE*TM5 zR@_Oq>rZrHj0P9k6Dp|41c$_jdc#_g6rh@r+82p8h@}T?(*L8yZL53)%w~Bx^-QtG z3&b{J+eqM8JkDF#fa~pghB_ksY8V?+5IS>}z$qUv5kJI$XFEe;rT;n1+3`%(@E?M6 zg17rELwph_GmhFM`31EmxlNt#_?Xf!j}~Yi?}Lg4%e@zscu6BuiD{} zxbOQdT>|>H5YEiylfjloN7R)u2I>?67pn-stO(VAC3&^_;xO*E*VJ|$zR~eh&8eaP zSZxUMbKMc|oceuNOHAfWVVqyk<$*&PSMO&z4rH~(mNY*~O^v@?t$EF*|It?Bn#*5r zwLaQv?!PI-dB?!vYk3cHjhesstO-rEDlEGJE24$|mecGw1!5A%iNO=12^=T8Y$=bZ zXwOh5cQ4s#n#XGgf}@tym;FubqwEi>BbXI~*Zc(@;-<>*)p`)ap(y)^S>fGWN0zDs z)+%_XL4*BsIdRWNWo~2Opk3qHeIGx-rf&k*+(wRr#|M%7Kh6%@!vsj?y+KU|cx!F+ z;luJM=ATSmu{L()!WH)UE2ZqUk`BMS<&|vH%juImS2F3xvj zV|G~V4Y=08R$7b9(@VP>B9%XcF|QE$_8l>g%)qe=x@dukxNyzqZrx9HMe zG^c-@-XFkt)eO|~YwgH0)D)))=iRIa^M|(}>9r&=c_yJAa;q69%Dy`|0ZJMC(O@kt zb6_eRHEF$}7Df3pMb!8XOg5b|smzaKyO3=fABnlx7JGLlK51g`N zc^hvqdFg;1x03z7`nr|}+i_Wafw_*|^Me{dYXA%#(#PJS2e$>>@9IW8!#yw6Oq2vU zLS>VqYXy;kkf}Hw_g*%Tw|a3ZJilJ6=69v?b)$_WAZ*Mk!9NDQAuwgKX)pBhX4m3r z?yK-VgS$UtW)G`N#QzBarHHi^>#~)4USz7vX_!rA|eD&(VCZm#kKK@PPKE#dXG8Tr<#|)o78vPE{lP0qo9nUpb#7 zQ8otiv{gs`Xe;c>KtGX?DVZ_K%Tw?66GC6ihZ32oep}1Wm*69Cz*Nyw1U>vN3E1jp zj?2r)xxpzF2n%FS9H2tQzB=@OXb=t@3Fae8HF;=I#DL>n_AFMs7g&Mv03ds!WMo!C zIi!3E`jhx5S`~PE-B~Qc-)QJAH4s8fI2vIKpf$1b@5%)Y51?lL;ZF^1bSCkCyJP^g zBpz6}x}_;ql$n}sr6O>&wC2X4k+vrW*9EB422EKtSwM6S;qkn_{~rBmHc|Mzahuqp zDa1$V4jn9B%;D$TRh>~z$LSVF`N$TT2Z z$HI!7BqqsMiq=CE$Cy?Ex(+-ycCf84OF{T1Oz_$*XP0l*hP0#p*V-8Tv zDq$iw&UntKq5apv#4&@~r%=WZ;s4bC{Sq%fVk|A()s->~m$yfu=dLsNJIPycF64o9 zuF^GR+B+F3J?`J_$#HWGYUo2(`ftq9XCiRf&-K~pw_lvev0c`pZ!Q6se-PHi_+pj;Ai6ZNlB`VUOfA_b%4goU`S z@~^TF8iKJj%7Ms2Sw$FoT{sQmE-&~3Jh-s7dl>i3t}QYjJ?FvcYzuCo`Cw+z*oHF% zQ5stl_My)VPBE&@Q{Qe3FSJKoAzMk#p#*nHFD-CkY`?BnV-SV7J!ctf5p&RKr@J6xEuPV)&aIg($NQy; zNAAl$bWH=rW8JZLZ{j{b((6FfC9-N0>*f55Q4KHP0j0!|`-~rjtji?0Xm!uD+o-#a z9>qj21>b|}PI^zp3Eo=TC1SDpqM7)I%RU`yGrbH1 zl=MdGkb&}Orgvta>Uu_`r()rj^b4bQK~^1!icLqKW_W*?9bJAYeuMMwG>yGpCCFAJ zZV33K2`kc-uD#$!%}jP$=SYW!C@`86P;mQoo0!mcG+i2e|Wp8qSg zV?t-@QuPs=IXt=8hqj1AJQ?YnUDCe%xf4sr(B6Wq ztrFvl)cB>`bk?h;T8f~&n^wVH8ySsYo>37DbR4^_Z>oRKWAsvMK&`w(v0xF*T)=37 z4g7;U7NBmD-=(mkqXoXNsa6Sb`Qc=6bl+OkgzRPA(NXfJKxO_GLuc88AKSnaxQmQ0 z>p`ufGlDOu*2oM2SHL%j4L0=e;wAKFlX%CR6_viQhV&kBqs4UBFVQ_DIf=0xuoGh# zY6vrF`%Cc1S|r^dQYmuY4t`PvJkLq#A4V&@lppRT$UP&YyOugEv7!Zu+i*95&_dZBl8F@$jf&MJ zRX}5UZFu!7A4Ny`uiwbUx$zL`NJ3p&ACUES2QC*#C=e7qchnSrpfL>9X8b%dhkk$1 zuYhu<0A4EF0J94NV?w@Uep@ay41DJ8+KsqCjS8qHYluo_srV_v$DeBanHe z7|;4z$3`CQk-R!&W)JZQjGCfgq%cDHH3l%QF}$6)Pd@L9vTaYv6ns8F``P*V9`DLm zauEFYTla>)>b=WpV|RX*9u(SQwp4jHf2!+#!jT7q3o0|IIiWL+Z6(~I-biy}=+p?C zLgx*6aR{S^4;i6eoH>E-S-BGl-&uSSc81IYOq#ifYMD5Ept9Scu7AR=yy)ywP30Uf z5`5H%;tEB(l4{zCtt+O$z6q}KDP-Xt!*~!Di|7^B%;7x)D$c3PV~;Dt1$Hb1&L_^v ztcvGmG0Quk=M5Z=@PEAiO`gp6kB$wkT_vb&b%*HUsU?+#fHbBFGEW( zr$*xKa_!9VJxKarcyR%`eu?s&^_s5KQdlVAeIW9|m0b@?Z;-tMfdPVm58H|i8H_%C zi)k1N60i|}PQ}^ZRcf0s-yec+Hec@Po88@@tN2c>`?Q?zBG%2UpSipOkdB7Tc~2VM zgbmypW3_-_XBjyoGs8E=JfsnoN>|e&c_Hi$8k=L^=+_kAybx0#%WwPtAh7>1?!SO3 zlhyq6`%k@Qj~v+S|K$9=`Dl8O#sg+M3(faF+wJq%rq-7jP*L56 ztgLSP$7bLkqSf4;44Z+xz@W#kb*FZ}y&k|hzILi7#M2ZHAZ=ZeS5J-qXkbJ5{v~YIt5kM8a0J$kje6kO(Qj$nNZ+Iut8DzpF|tVKbb`1 zb+uJg`4{c6$Rpi;*4rYcPBr#;pbOU7%U3IQGItxR4>N&F8u>k|An{oBa$C>jHm~_FbNb#r!H!;Hr;X{!_{wXqz-SaqJ7$EzabO)5E2R%Dx(Dt42 zK;QztlYWX|bVV?nq-@Zjpg>oC)@wM@^(FB_SN_Nu!qQ)(B!aJkqx2vzMEM~N`I1|+ z8A-E26G*w>@M1elpH@s*efCkhqCDnDUw=g7F2NDoZG1ObfqDr^N5MmTSWg7K|u7;c+NTmjcfA2x+W7VBvKbY5?cm_gevb(-Zc*4lT|TvUW+ z9+r(lW~$Z4bHIYnzK}S4AP^>(Ox_c*iT%+9SuieQBFCO?7@;u5W?;WH)Yj8wg8wn>%B{5&^q|6L;4i0g93n5{hA{nE9;!1C zg9r=k4Z@cm4`?CVpsw#a{`(;6;2} zH|!k#_vOLa{t`Mb2k6)HdlVy_YrIwwZDK_)hH6+ZMXc=Q@C7?b5Gfu^Z-Gixq)--tC@T zDLNTBa-WmW_c6nN1>>{{4?2?Yze0~0TfFp6O-QE(q7I&>v|bK@P)Uks%OvZVGFHt= z9%HuQA)_uZ=M0+2mak<8BUKHg<J1jZ691-~AsD+E<=t)5;~x_rHn{;!(- z_vMWjdAu43IT7g;1@cwI9l6eWlmlRtCb#0Q=*vu|?CBcW9 zupy@MZQt1nvrIy?I*uFH3o3`ZcMzp!v%3+`nC9XWjhfI$Z6-@&hXWG$FI#YJ0uRB* zCPBu)xQYA{bm(6WR7`jRtZ^*bgtJ+?qq3X$?lv{M51OBy5*5=9IRm7oXA)mGH=`*g z-@2S52}cg<_pk()!1GTDzTJ*h^y@7@#}A+5B*@=ht1xFBAc8F0>#Gq3oAgDqNeI@} zohx37gfznFWd3HA)0M!c*qSsv&8#(x8@ScgY-x}>iW&?*3SDjYD;5uZjju9fVXxQA z-!(9jHOvL{cVuE89N&BOC6FW1<_a(wMB;lPuGsSedYO+Zi>+#hHO#ftqQ5-=bhVD& z;||tAopbIfe?S5{`*V*ff9nee)34G$yd+L0V=+P(RLEPw!M_Gkze^3LX9%4wrw;&X zdbzZi1HXglipib*IT=HC!A`ih37f@b!L(nToR}vylr~obmD^O>RZjWLrK92hI#IgC za=s%?jWEL9pxedRpCKHKoeO4*md}B)&jGG2SCcts)Vr??$ZrU29;r#niS5$w8u$$g zshJ3UPaV$^B_bF97~ZK_Y{(p(sli@Tg-?L(&3WUND!o=r31`lOcd%vZM%^Cl4!W7Z zjoW*`!yw|38a##9UFxxdP?cxQu^%Rtu?n223b+wuG1tCnFkXPuE}}$^+tz(ar3}1e zbQ9yD$M82WmwrQ?23v`=-ju*Tq>}+9-+29y!UDUxJMphQ>Bng*zW1v$>8*c_HExl@i#w&lRDOW}2`iL*w1F#Uhz#2=m$ik*NX$b&hCkE%hfgC+J3N$S3~YW=R4 zl(pCc(52-Dx1#zt?&691{g#g~tm7s{ujB>btrXxM%wXOC;?6hR{w8N%|1oOK*EaT2 z|6Ua!n^={GMPTT2Uaojam6Y_!D>!{VBGYQK z0$O|5bA4^&AESlH!jdjMsu)ZUA?-nopDQf^S9dDoz%bi{26|0u$^rW({aCb3vHIt1 z`Heq<$LPGzxI@B#H|@9}$@?kxYC*LJedZmMrMcz}@-k zD6)HA&2O*|rjLY_eXWZ+Sx^~;ZI3-Sm*@-hDqW;vEqCEJr02-qC22`~F95GmyfSJ4 z@`=qNjH4<+iu5qaWca;tuO=?Mrok{Z{=xKTii__8&;1GoB8)TTNZQ~lT30Ktd1Y5u z17qYp4J$Bp@BGthTh{_b`i_sc(*|cY3Oh9e@%DkCW2G>o&H8SKQ+PM!&(@(rcVP|x z!2ieq{`=rRQM_x9L*;k?0})N8-k2nqNhMpbhJxt-@w6j9x7Dt~Ga|?Lu`L90Z-x6= zUNZj~+ehF#zm0%?O16W9w2`Y`Rf)^lUe%dh1~mS{R{<9lkWLuVflmm(pthnJi?YqA z&^?PG*T_-Irdr&l9L!QsxPtM3EKuLA#eK+7UMv-7hqJ{mR;!3vW+w@3KWG)R%Y_}h z)Ng<$q{k))3LG&tQr%2Yi_9|2w)bkPWqOqxgUX31+f*v0YQAf6(;{TFU@s^PXX1th zC93~Mkf)V_nKc)r#U4)f>p3&EXCVPCi4^XOncvO&2jm$(g8bKm-8Cv)TI3V7ZMWm& zlugPm-&o=Pz)#b6E`nU8kZFQZi(4QaY!<$dO>1MTy;&ufNG6`ZhZFbZHtesy<0D3D z&XVx~rHc(2-o`Uhy!h`hoL=-57d|m~kPH_n!$?IJTv;k2-rYeEaS<2GHXEdN6~qPw zj_lLx3{rEEO~OMh32(x?rIb4(@)k9!6HRqj*k2hd{lj$H#7!naX{DdZh{sIz4Z&va zaun5U_TyiKqX_@jbCO(Z5*Pzj;S1iA>G3F<2lg=?|H~Oy>#53};>mP0zdgd2*uS^7 zEOk@Rv72dk6eLfl_oDF+o{$2TT$`M#_Fm|7{o3rKw$AAwW(<_6p9@o3y0VfS`RBE= z=NQM(6<*=o$JSQx38mhk&4e;g*h3|4RjPW2|1Z>y{2RXd>sP3M`^nsM@X-C3q3`d? zM{*xBFZrZwpdWf3qxUt^WcJCEo{y`y_ntY({mAk%zx4j{vp^;?Xnsb)#JpG_lO}pA(>OKZ!o zNe{F%;+>2>O3Ol3=`QgPR+il~LTy)d&k?_a9{Z_pG#U_B8P9FFTyMuzr_9+AQIwGRo3RtpVc@~a)0$Ju@fBwnQ8C(KjdhgZys zJog{&AwxBYAxIouQrsaXO>#*@qfx8!ILVHuZ?DDBcGiGRrTa`PXU3Dlxgg!l#A)*>mVU{$JHggKvFZ0RrAojRLX)XEP5?Bil2{YNywVpu4@kT z?Hva4%ZogFh#Lm1sieBQ(SzCg`VAWg93|HqK^4}~^}Lxk;&F|T-XPnCgl-x&aToU8 zEZ9WE%+Zwomq+&dNn9drl>m>1pCy-%0wVr1FM-$8x`Q0NOUvY)JSZ}}IeI~l1nIDodB+JmOf^Q) z-W1tkL)cr%j-^CvC~iNQVzoO^I^`)epZ(|vO~LoHJ2y|Of0F*7KN{JVgQS%MT;)Ox z@4GegL`dVyoW$0f525B$p97`03J3`Txk198MaMzIa>N$uGVeDowMyJ~6EuD%Biv{i zjrueb1|2{g1dPla{Q#5uMNY|v9EE>I3!sWWBn5cuz3EGHQ9ka_3zFHJ-mtG5fV zP*r$e)lu?6**SE-lSW3lm_!HgykL4Ax)?I2>ja?4lzy^iqS};sK|m_fvzAQgD)u>* z{##(!chmCFi};=ogXie#JaE<*+?huyak9O$49S0ur$vs!&48A~GwS|rjB`}uKxh+# zaP_%8Ar#^Dp!Aq*tl6-uKTZC{yUZN;-oWlA?}-j*SpnpqE+$<%PTb7}jBObhJ(iRH z*iG>eV3$r<`9)p}Oh(eEZl6g-D@s#I;-v!GHgPB))}`>A#$AEMo~P$IGj6InJ3O^1 zrACOJ?D(8AJ0Bs}PxI34wJ#G6KOQ{ydcqZ{7Y-yqV zx8%KdylgT9)S_Ig05*lLHOUdu#LFDM5*~@TgQXy0r)$=c9Wr>dp9FK*Kx)c5?rc^{ zTToyQymV&JKpBN;)Rr*zFX~Pe2Q0h&3NPViO`(^vB%qwZlYGaru(4OT#0nUC zNc589kYfB5M}uzUTdRzZT2?>NV_6LJfrkzvIDGFtgp770ZbtnoHLfY-&X`WQb?|gf znYYm&`7UGyy+!W|g8U{&Wv=LyXxFj?`hhTgHfS-0D%eWH|E1yury5fx zXg@)Auw9W3#!UpnbUHvYwMd~gtX;%{$l-@ij<%_{pLy}l_hO481v_!ap) zq;mOCD)u1Ffu+x)Tvp9(<&^SCDbQA)HmRQ^mfU>DAxveXxiT_m%MNcE<+R|rWLAUA zO5`x~q~`E3{fXd*wd1?smF)cBxh7+p!@EHBQ_FlQn}}4K5?X~)ZAGtp%3Ar5)AA!u z!6Do-1n}|wPcgHhG#l0_8At7jyfcHBGY&%HCxVkKlR0^k#41 zo=iJI$GhGnSRdUDKP6Gx;EaW1+A&~jbX6b$Mf2spr;0tKH_57Pv3uyv!Mu$`g+%f#yIqF(A>pQ3oRYY2`y8Vmd1@Lp>Y?7z!P5^DuISuXU z%>SwweIHf|cr$u3;R9hUc<+?jNRaSSe>#<&!Ah)_S6Z*EMOdkvNU*K3OY&RN_u~pI zCo?XV^wd2OVpkMuV6Hjc(Ol-&`dk^djrUw~YP;Qij-{fik$nSF*cOr>xmN<^jVE!0 zT@&xk#;L^!mMX0(daAs2=|+~)x077_JO12+ldi!W`puCmUnP`{xNIcPGo+KhovXYp zVsDL|h+04h+#XpnT6n&-YXa|#g^`C}6~(khzk!UdF=92GLoC8(u&krCny z$FHT;J0ri8UOEmuI~>0ym69N@s0}yHDy^R^%~+CB)j1*H<>}g0hp(oKZ#=i+fOdr*0Q5P$!{r%zm(*{_xnJ+F*X>y_vQrf99 zpK|b#*VNMTUisM+KCB4sZn#vWJS~f3m{5nsH!$!rbnIF>s4c#N;o6euseol+=5^a_ z_+7En#8=H&65+hQY^t~i2|rDZG??0>sYr%r`1%7*f_H|vm zE%q)W=7sJwBTY^V?9lNSTZV`E3m0WvVbG71)SF2^~iirD( zZz61>f_6UK@K^7%o{@j(&o7)m+n#P5Ry%mXelWfMor(RHmxC8-2Uo4ID65XRf2KD& z@!Cn=rl=LeO}`4?+Vsq-djOR>&>=ovh~g*XfAa&T#WNszfrD|g-DZ%0b#Ln<*fwbBNL-1_}&X4zUn^i$S%x+Q^HhIqI=_tL0 zTDhIm%Ovy5YgpN;BOdi`;mG7EDA>li)Rn_WK++n1^Yv_Xy-4JXXcx-tmf$^YMfdp@ z*2Y0csL*gbeBk%;Z;cL3{kM4*Z-mAI8&-6PH%)DE1Y0k38nqF*gB;BWRf@MX>@Laz zM@givR?w0SVm)nd!eF$I3dUaTl9K9)+VlWE_6EObth~XAc{a5^QfKbWaWo`m0-G_ z(KzO5P$2P|yN8gB@)(c2?6oAkUEhVkF0%svOsA-HykL1f#`;YcH(?8^!F zex8O0H`My7GWpbhd51J@!le%PvlIkw*KMf<0yghWMO<@rlHF8fUzNQ)3I4hcH!C?6 z97Q`K9&%!g^JLHAh7{mj?ASe9_1tT|wfpFL85ifSjDDzm$BE|+n$1^w9j;LrqCheaDO7a~k~*i5NkTFvyjE7h@lEO_74_xEWTR1j-h#g#oQR#ipYvH>KATCkmUFsx(|{sap3GkbRK*4 z0R3cAsSl;_bl|U!XtU7++>3|8ia6)htPLtL(gN0EN3G&jTpNm@9IY8@yebTJGN}%T zTqTsCWs1Di$T5!!6ED-S#!FUBk(0LJR>VlE@QT0 z)$DMyCvz8U!!euwf(gWC)S+-Fh&WceguN3%*-VfnkWb*74B35gx(VeqRm&-4>?zXM zS$!c*EO6>p+c2;;!@w-*-ngNvp2{}wBdoH&8>mDl*d01m%E{V4l!Wo@YlS6hU=YRr6qhm4%&oPuj;{=Ow~_$;Eci(1)o9$klZDUNtb#``CCv z?hL)9Q}-0gx=ZvV9L3GnY}8xh$wyiCx90wE#o{%vMO!eN4slbNkm>R2UFrs&y`y$j zm@$GziTsNUPGg))JmmIaAYa*O{wYiv$)UpV&l&Kf!MY5nZ&gLjPxtZR!h#c6vl`Wl z?=U;llWD^sxp^FWbJB&Bh6n^Ohcq+sVz+zsM6-)VE}}EJ7z@lj;9kY}OTK%*;FREV zoz9=#%~;sJ)AZrO>7>_|F%nG|@1tR?J!cE0qFr}Rv$|dOXkjyNX1f1HcE8An)bw|{ zE;`}DXToL{H7Bg3XTo^qn6^$`8D>(>=EXsF0ryYAGKr%u)f1)XK$Ft|ChQpT93qIa zXoOw!qdph7+>=V^@t*n+2L6_&DsQ9|k%278?^UyE1gUrZqD6(9Uyr~=$o~q6=Xs{z z{sj)-t-UJlc%?RH4Nzli*FP3DTI|rlO6S{dYIR|Q`Xi%ix(YAawe|gCgbr z67oK$ih-`xT-P){N_Oir+#-G0Re?O=`7;I}14Ht7AN?^!Im8^U`YI31$wlaTcR?nk z%o|D9wo*HrV2&}p!wP>iurvM+D*rKc8=ztWcUY}CH3GXMx77eTRc1V^foFD^!`n$V zcXl`B{ir{Ow;u6v16v+&I!e4GbF`Wpifx&L2b?t+=6x*m_bsu2r%U%DL)&4M&C#gd zyX+!SOHPd0eA!rYEq_Um3$z4%w2oiUu5v)0Y?9kR#Z`**BsbZLPzAVUXSgfu{UdfG z`+1stiPCP)m-@w2*h=_>09S6%S&2sepq}lU?bB7LI%Yb{G%?=w!Uogb0qop4uc8v& z>A441?PEvcil0LFh|PpPISEKpY-lHxb`1ILCGS|{ww^BUqIxsbkIO7g9DBBy1;tsa zTDQt}oH~jd7H9Jx7D7>xcN%Bc(%a^8=jidESSAhFuKODVEo^|b{Jg5AZl>O0<%)~_ z+1A=(Vm8dXp}%F2Z|gE0vL8w|dBk*4Iu>AWDRy|PEEFmtGVCZWKGkVL_>w#bO|75# z8mQbkhYJ>A9`_`%0x#pwX)ChjU^OT@gc#ph7Jn8W_do&Ii2dk-7sKw8e89mO{Id#& z4mq(CdlcgVC3cBnj?hlc8H&cMN@>~~KjLmQQL$Xs5~|)gx8|T8W#L5d16s&&EnqyO z4-tI7!A3!%Gl9yBKuYq|2OFn#%%Xnj1!SJI!+~+OUEN7st(Uv19v_jfGMr(rY0BI8 zP^-}0MJ0}d!rqQo)gc(|ebfun;dz*3St&kMJ+Tb5bM)u z-f%r%HvS=@B+VD@cIxHs*0LS90)%D+w8ZPHE~-eh_jPrQ@$e{v&fNp6$1 zPN;qk)f;o+71oL3b{$3lF9QBY=c^Yv{FnS1i9B}rMm*l%zVF-GUgsl0%qKnjUTwEI z(EfPFGP-8F&Gzw!F3f{hEmJ?&+zxaIt;!Fry;NoHTOMS8sbi0MRLiAE_rAxS39l|@ zHE%8Xc+q(Jr1y%8mmxiT^$!^#7B9uiTZBpsK`1Sj9(bZ)u)7EtHSD;0(`43ikSvb& zW~!&oB+-9e5op-``ZNFFPh6e8`yS;uJ;Z3cmnOTsMJ`+x#@4r$qMZ8Yr@B_)7m zuL=tCz8Il*2(A_@f(`mg41i>dnkCqy3SUl@0j_}xxVc!GlB>7sx^Hln`_Qnh!cS3? z6N@1EQdYQs)xkM@r8KmbG?;{0w#(4n(M2Jw-GF|BO%vlJaIYMsro zF=e~~uurhw6D%!eEH{kN3oQH*JTInaWP!X0u{#_SPdlv~7`3dKJH9Elj^)D)urtKm z;cu_uw59xvH-HeksydlhmS|TB$*%Cm6xwS|epyivQ2*lzPT8hioLs_GXx^ z%o6xM`^9a+i@sH4Ar`{f0sA&q%iU2D0({ZcAfg7M@VMJ!s<(W-9o3hRvz79j4-nR- z+U^8OCUz&?8d2K{@(VsT7_zcRo}^{u!-R)$!-Z$^oowqjT}7fTHs|_?UZcAw3Ua30 z+@sxMpCyIP1W=pyXUiAe6nwe*M^@@N0rD)yWa-IrqeHUFM=%pwy+Ygh56w}cU5jrP zJ%6Nx;(X*b3G8UX@{l7qe+J9|ioY<`c3qrD-u*ti^t?h;`&eeqKkqUiON(o&t!=?=!qbc2crBJ#M?}@DKzoX`zx4fL-=uc4oPg7QQn|-n z{LN$lGmV~4e_`aKo(I_ux`-2KTa~vDQ{IS6+OcM#y)(y`z@ibTCbI$B`1%CNdYdg#twr_kExcsu|~g^3<|8w)uS(*EWpv~&5LB)o7JwI9a>ml9vCgJiVlbFDqyzRC&QaohgIFcQ&+jbwWQ`%_EMFK7S(g&SE@wvy?~A zjBKO2&VIZmdpD?UzK5v@6()a57RSx3lnV;bYG7 zKBT(m8S~#5Kk?6F?h{3@2yKe|d?dL5=1Mgg+KO!|fppH(uP6Q+3c9A*ththla%`2} zPO=v0$`Gd5dJ*Faa{MDWMb|t~Zp+l0+bfg_r^9{+6kKp$^0X|%hmS*4?-}PGP|=H# zYX$v#P{!PAR#b<4-SiMq+^~TSDeY>OfMG4aHp+kCnX6*6DC?k7@8FThW<0!AC^=lB zIrmY9NddDw@C#z#52%3YoxumO^K*NEmp6IF& z9vKXw|n#k(MLj}Sep4wR_CYc?glZr za&KhiLbF|oS!9P`g6^}4s?U^bepYJGC#f1i0m{^+p67m}_ zd9_ac4Ei>c*?Xk`lkj9tFmc$_6PQuANKXOzg8md%ZT0WeR^#(Ow<%iOU@Zq7SoZAr z9{$eGz_DZx&!#DHO8jCB&lq(ko$VctN5?z|cXL-IFOw_CW(BIEB)R2DiC>%=QXh$V zh|lD#+Ukx)bE+G+-j_Y}CAJL#{MY-rzc|EW5=A)|dO~Bu?y;L#^Z}V(7S&`yFNn{C zqc*YmqGUsA!$E?a=9&-`6ZVje9B9$wRxOpnnt-oD%N@oT#X5@}tJ;!<@fZLn_lctA zF<2&f=ZI&|E7zl9^7w)C1=umPkxk6=1_I>7^m||!P6t>ggkvw1ga}4l)-w*zTr(?n zBlS{Vv6OE`U!3u$sC(z1p?TH+kKkf&4E$HaVC5AQV0C(PQ}&jqORfGv z@wF8fjVs2#e&26%&GJ|f^Gt88iLj>a;nubb|r| zeiT&ChH*TsbR^S=SF8!olWd= z>mTfI^rJu#Jv(JLcy}wjUe+^h+_)*0AnvkMqkIZh&h7*24i^vzs~e|yDRuSGLbtYL z3oh2MWpbK2)#8Kkq1fD1?~YsaU{lQ~YZr|I5UskiN!MN_juIai>y6wMQyN59c!h6*voki@)w!xI0-czz;G;3vjAnNP~?%9xylA}hpCN1_c z^@4Ftv?5C3983*G5X!N}u=!2c02MjOaI)$taqrCWj>FCSv_v5HUX|qQb@Bu!Zs*81i`4f_3G))>?HwQCvo=$oCg>-qjNg^(MXB(& zj(*P?o;gAH9V{>j+5+HzUhONEDW`uQq(V#ZcEaDBqRk3_nvhm83FMSsoBuLF-AH+) zda%jp5cl7gxwedli94P?ysbA<56&l0*r@e7vKhioTd}G4s3> z>;SeHT+u?x6<+<(kcmTP#s2GEr*8UtWP{C`pchP$G;Gx)qAx!Xs>Q>E_qA%ghS=m_Pi}* zio^C07@18Rc=-Zm(ReI*1iWUd-ngRLCLffKK89ewyMcqa>xLZwCJ zqV|_SzKVl=2|p8l@5B2RT%XmyW%b4%!|)f-*Yu8tC67&dQ&>F{^8TLwM&t&O@rlDAbxI1lDa^rA)E49pXH zlW3nYVN{|$O5V5`(g``M7VQpw{&0zZ$!digFY6S5QJ)+O0=!Xf#|O-brbS(9Niv(g z!FgxTdP&fAoUF^jv&IUu697x(% ze^wW7P_&?$n=p{hLQe}?n{v3~BXC+T!oQulfqf=T)y7TUh@oZ(gXe!;Vi_&EP6GKv zNJsoCmSDOj75a5yxwQqrN3S>aE*8Om=3cb%5I46k5~CRF8cKU2I}{nR@a+KgY8@;! zSK(JKjQ>_N`LXM!ap+Z~&}7yZJT>PIRVX6Qcq^&*5vyeC@a zDHt&{*Pbq}5G+rYpl-PpG_@W z7@px&XA9QE$4`7ir|}Gbc7c$ba0mLUpSqDx+Z7kIR3JW=t=p{Vq6-rn&0^U&)kXIa zq4ljlSVdE?KS?79-6ggA$Up4lf~FsT5)N@MJrPdvc&jBx(lqKaRsJc>y@aTD)MpF; zf9ww}=4k5r#*I4p7#4_8aZWMJ3*F2aa_*L~j}wvs2Py;?yJ{+TBTO&b*SyEbH{?6M zuo2qu$F@%@H&G+zHw_TJ+h*|1+-GwMJ$?6-1gc*EcnhrwfTJS1R=T`VnL@_c|VOr$} zdEUZRdbcCy_?hJ=qhAJi(?GJ)fY9k$_Judu&|Jb^ZoD-Eo*@sF42FTIFLnPR`w@UV z4L5Ku)HVR8jmG?f=cQ+K_oPM79nMge6~=Ov4MUXIkHPUBf@tKK@7LTs{W;=n?N1j- zn%u7@NM}4NRrtS^lKN_Wv2~zlFTH+&L39j!wN2_v!btAMUywKb9k&G5a%}ElcfWMz zd&Q`sR6Lx?L@*HliE*I_?BznasH+}M*B!ug?&)H zuvIo!D|pXq`LSCN8wjzSkX}=${!(qh^K`@mT^AxL-X0K?VO?N91c z1kk6TTMX_%4?jYmqoU)6`wZONb+6kasZ!Bn#kY>W!bb9aAxKPxYG*p6Y%%zgQT#N* zkh)B)`x}ovgK4s4ICd8CbgR+O#g5+!{So~m#kQg3rUO0Hvoq=%P;Hiz(Lfr49lTK< zs{Ua-%+q*`%zOlT!g1vJwIL65JWS;2kWA4vHFeBi8GqlYrMxElL?Zc-t*^-WcL?e( zu!rl)Mi_?07Jtc>&lSq2U94r?dypB}RhB<;K;nH7lCd-aj9qK$lSuxiT{x%4 z=E=O#$Le`HFUbfNp2h>vRdy)Yrzk^f^;eBLRuN&~5TH+DD?N=IO~Yi!Wn%47PoHL8 zWnRN7xRG_NNe~Tqbmn-5y5=WE0@BLAK(2v0bg4J!Ouxkej*@`TEw{0J-_Tb+kgO`T zWihX!n&mqcm(#I=*}4<`@&OW9{r|!2Klw~On9aC+uCnpWJ156ym$yZ)i+h&1b4^3& zUppQhzxnBk|BgqqN8;vsZXfUqfcpg${hB&0Ablur?%dbn*SAmq!1cEc*B)=-s<-EU zTegy0S-0)e1E)uyhxA-_os?0)CkxSRIf}}VC-ofi*`bxo7s_>QZx>Pqs`Os1C1HY9 zLz+{1%$f3qvBY*`tn~`SI$QOg@F=J7K~3~cFDy-O?Z-?|a-fDzVwMbm#QM^{o}|?p zQiu~47u8kza%f5Q@0CM}!?X&*Qftt?b7)=bTBK&o>nc9g%t5WR{&lfZdXqR^ENZGPQ28 zD=5>kUZhL?xY(>>?c*CX?5O)*{vH&K46cz zkxR7uIBkd29Q2|%#V(7a-o`nJ)O=HrBRWSR1uRdIv*rTQaN~#N#q*wNRT94nLwC~?TG_4=%^jd&nC%+HD{&US;jV30 zslO+_2#=L>bQw`WJ z=@*kqTG>u0yXl5UxJ#q%$5U4=7T*m7kw!Hg3Q{UOlsf)#V*c~(rPj$k!s~6Qy}+-{ zA5wh3)%k=TFNg6Go~4mC*anZw9g=i^u&AqnGp50m6+tw+Va?o1uoZ{8!=|f+ROK2@ z6u$jsMxnm7#Y*h!A?;p9)8ZJ~0Z?(LYAt5fxXf6KNVm5WnVp2~q4xJRomRjpr{t zwp`LQD{6*)3PuMcj!t=&+NfzHxAyX5NiR(&$A3E8?CMtC*cEO|*iaUL<4gym!a8`H zN{|YErr4xuKtBYvwYa1c7YjErzPI$L(#Jd2H(j28awPG$i!!eta(X&NslV!k-L5d} zo9^9)Wt0K8*K{GCn)XPcMikvT+24$qkJPUkH&5&`mc@5SRg53SLgXI;|53WPr}O~l z7V<{Az*_0XT%7#Sbjt!_Bq9SyOz~)$zs4_gaRUaxR*mhW4yWl}C!C*=_sObShv?&n z>k^MkCiLhfv<31l>bIW)MYBZisEWPHvxjOAjKOVL()a*6+YGw{1EVUo0a-QdSA%)&+3}TC4@xUtU?6phE5VT%C=Q!JT13h zJuLyC0I%D2k;883z4L@|oQoFGqluU!wZZSnp+S*X6o~oJ6khBy2e!@RlVr#Cti|LE z_pov*gInY~^TS=~?$pDTxn0;jy71_`UO)wI)gW>l5%Alv%h~z?V`C-iCR7b^ETKp+DnZtG*gCjI( zBB#r*aCd|Okv%(Xf~QOF=GWu__0cVR<_oT(N#|{i&o|)q@mA`afUbH;Z9YUIdSVVd z3wbMEG?bOEVKGb%6?!AphLUP-^5d|Eu>;66Z*whLh&iUnf$}4omd1w=Jh1}2_ipNP z0g;#Ornd_5Q^rPW*xr?p}FGX%wVHO4jrDePo_T5jwId z1nl9xTjz?tr0a)19Kh|sZ~PwRLdX*_9t*le!<9~1y?kqjm%Oub)3&qtx{W4%(I%eB zl~nv|Z*9dz+la;RUyE+FdAJ!b5R5&vCzjvKd#51AQM2`4mfjb!Y?{=VG9^}Rr3&_j zMUf2$%Yy4Aoaf-2_dR|GjrM$Cd6KC7@w1B!bNqYO`0HYqBdJ+&n%C^SGLK==$ZGy3 z0w=?JYJPpm#?8VGmAUZHh{pgyAbXM-pR)@&FYS7Cu+Eq?Nwox~_P;(mbM%5{&P*r9 zYuckE8uLb4lYM+>19xqdTD^Q6;eiSMYM?M6q%2 z+cE=pC+@!^>^J$ZC!|rppJ3emT+wv;5f0h-A7K2r^pv1%rCPVXlhRTMu)!- zy#VRn2mY58@_yfL*sVGH({?;%ygww^5fbz16ehF>_pRquhLddT zJ~^K*4MW9ru;sS`q=+Gf6DW|D=+fgS>*^CBL-@V%IBh}+_CxDY$u{u`+8)`y!_-Vo zbiN?X81*u&GLRbC8-3~<9OQTa^9-D!+)jXL9d+>Ud~2JxQ_ZUvXy+JFGFD@cQyyes zA?-?!g)a(@41Hw!(}?jjbtT`<3)M%8DXyQhJ|m1^H{Oxg>2sDFVw)-H)p{4}21fAI z%{A2%4WH%$bj$u{t;0|3@sj@*ufC7B9KE~LsNnCrdEb}q?#Qn+eipQHo74R*Jzsa; z?>X`71b?r^1;JjQ*p91Ci9K%))R+6k1{7V0s`9&WJs>K2#o@cFx4p>iNnC$K|3Xd8 zTee-%YYNs>>QIOHXm&hXu0m57(&C71VgmJ>Jxpz}{(+t0)m{U$Xj7f(4BmwI*6bn~ zy(PRYoJN)!q(W9I^2LwhWRV7N^g0n8w)~{uatC;esxQR~xI83L7d03Bd`06IO&?&{ zZ;_`2MRSZLCa85aoA5;| z5IO`+$uPt?Zx(HExP={b?-&FwB&UwecpFskgr-K7$=%7GzJ!3Wzg6@)vYg62&oR?7_4%9rZwh?X8#^RP*KkO5Z z4JTi5U$Q{iKq0<$4aXE9+Evz}*C8g24~0qM;1uBjgfZ4?1U|W5!(l5Y8xlUwe6&~Z z?TDQat+`P<_h6kmnfljWl=tE~-lDTVer=?ufmC#J1qmkg**F+>(*|eMp`to1LydK% zVE0q4blc~4SpY z=bw+GW;sQQF+i4JqyYy#@X2%V8ci3IR6Apc_|+r{1n54@BS?^Cm3nJ<(!V$V14IN_ zuL_C?X)`^l?)7b0!~SLo{itl-$5J#N7KNHalB)5d@bU-Ke`JCa+`YVbWdQRfVZES-~;we1MPo5ay}I z15^VzqT;|{78$O_qdwd`82)qX;`iy`ICuB=VGWL4g(@B~e$N;oe7d1AW88b>t!mELXCU&ZvfZ7!C7^+9RG~oj6xqZ7zs3q5WMb z2Qe#W9wvr{YMpF_XSV-In+?!=@I zP?uxQAGD-I_+b3$>XSrYZoOCNwNHJ!k;M<$(xd0c2~K7;7Q(8|vnUtS+yt9aZ&vbL zfm?y{mBE{Kzi+TEu_~JfwJmJ(x`6+~oSu}h$%5gj2H~KREkaelo1Hm=$GqtgLKS6yLED7jao$x)@<`Y&Q668Rm^ZmiPjZN{y^%|y zZ7z(yo%fhT%y-6ZuIHehZenv)U%O2=(b2~(ceT)8p`)r7pA2C46_1+=&sUO2Dw;f< zZlFC_hPga0Z61FTL+-X@FYXBUq-2Q6Um92I;GeqYsMkv_6ml|-34g?bIxTpUcGK(R zSf9AUe(DI_qDyEL@`ApREaTN?0MZrXuM~{NFo2*TZl&kUB^yZyxblrxiTYr-MWW=Y zi-Q<^EvY$#^VLDT{O8o9#cI3c>01^GV?FzZ! zljA9ip--_s?q^!pqeBSd#H6=hwWx2H-Hw>OlIRk2%rnd$M;uhylPN7N;odr2!ahRK zD8kms1I10})%@qNSt=7Iu836M6DE%oLFV9B`)93%eL~MS^V1zA2Fa!R2F^@nq1^hq z8s%GX3{@a%Tgu+Y6WW4E5@^0ke@M#?e2MZw4kG5jNqgqHVtZTAK_Li6lC}qH16$0U zCv?;=K3SjYb!Ctqeuqz%8%dpdQ!*lphlJD=~)mRIWRZ_Eeh&}_!b8fgJDoc&dg=)*GCER<5 zkvC{j8>>6f=`!BOpb;GGrRqgt!zI8SVZc126I_7JO-PqD!3aNJ;jVFW8-+6yrN_i! zM#Iz2>lo1XBooib@wzM8gABJ>Jdg-Y&-P*wAKHe_JDhTywrwha2o#<^6Yp1fff@9Jz8y>#xJbNgDs8_p5XSP zib)u_0J68!a3=KdH&d=+6UH__@M;!+9wr8jV^E|`%+7eApmI!4R$OaNc^=xro+ZLT z4L)f_uRzVYbFS*z?&J+t4P61v9TSpgDM^ZK_XT%Y7J9&e0{ska&Wy~8fE8z=H^R;w zeHC2Ccb_7hV@RhHvoIMfVMQZwEH}^H#X=+57S<8Stxd`8yoVUoPkWTF4RG>w9{T1W zI!<@$z8ocOrVrAwl~$@LecN~SQb{`hLhN(6za`_|D#B{%c7{=9Jj;2#v<`_}0)9k1 zeQ*Qgo;V>Ba9td-1<(;+5kLPtjjzNPjb$toWYc1&oTtg*y@xKrD|y}^BHIr#4euCR z6?TbLm``_W1!2-<2j>_T9cV+q<*}Hi(b^a_wg0!BAo0*YBawP`umA51W-N6`m|M`r zUstuepqV>h30=Xv6Hz}$~zv2z&+<0H&WK= z#32nQ37-B!2eK2yuWdI!Gp$!-qtrr57O>Op5uFQZ`P^F`wsVh$*Ed=~qt&j|%skL#gwGSU2YS7UKO_$7-m>_0LQ@T4RpoZC~b?cWW@T_GHnMTD} zVEFYt;TvH%eS%Ti#}s~+X=@uIEQQ(4XwsI6r{fI>3nM?KQ@SxEEiWZE*NScD%0=gn zd925-9FlB9d2)J(^%7R1eke;6<>^2iF+-H-#5qI;-{mjWnttD@k8Rl4ljJWvNxMR= zqYD%8D}%gKlZ0;b+rp#B z@D3yEN`dx{rOOTvxtrabbYMYN6k>OWGlS>Ha{qd6@-rph%)nb* z#e)kBF+&$y(gE2HORf3$kW4@+Z$E?TOY|T-%8)OqMs2G3bP&BQT(3<#$`1V3J1lGp z0bnRLY5(B(h|$IbcHe||V;7PdXHNc$&Fc&tVW-HKj56E3WY1FU=QoQmKA38&H*#M} z#g;j}e#%e#_Ft$cdb#q&=7sMs9#Jjb9iPGWLkuHqi*Ap)AO>VrE)Xs{41ZnUt}=_% zdpFe3gc+nU-9Sp00(RdAWc+9tMEPKeqn%PicFp0q&Ap0oWlHcjTLBk3S2ZQdM{Z-Vf)wH{Qo=Hm@Z$x5IgM++tgzNU^V_hnt)s zn-%TCcO`@oMyE1N7$q{Yxtb}Cy4TdZ@3LkEnzd$=ek5>?wio@do$rX+&_25rG&h-R zXy(`%tw2kh)r9JK1@JId?^FBXb^a$aPQeQQm z?~koaBYGr!rmnvkgttBG_I0M~in!?^(H67WoDFHaHe@X|VadnPJ?cjOj;Z}#x?_tt zB!((~B&)g$O$Ro@T0VoL@Fq41|42&?u{9;AdMk*0LSsT^XHZ-SPYULKL{ODKha$(Y zu3FXf-ibrYw1wt&yOEXiB052#DN~6;Csq4puL8B7$4Fu74~&>hVGF|Zs&)&(x>H9M z%zUF$e*b6GQaN3$B(tmP^_r**q9O;fYo3~(9HU&Y;-fSB z!bGRq1I=6BOub8UO8y2n*tB2B)N|7dj5I=8K^y$N zf`Zf@Z%1u~jHD?KTgy3uNHN)lge#(UL5-BW9NC*8y)EJ_@8aT{fr?Xw?*MxQt`8ME z#U621st$c33(qqP2gdCF0`L$ItQEM2*|*Cxl@*AdGW$3{ka2UjUX`|eE<;A`LIG@^ zD|s)Qo+i`i`pwK&Pp+FA*lydTFB~|ajkRODpxAkDFfa)3sY9#VE z;$K$75a8<626<_z4Od8~EyYfJi{qAh0GTeQ^ zI{|suskXYe~6?m*GeY4tJc`$E_mA%W;+PWEfqh0M&jw_((Boa7|V zok-m!&hLZ0RvF&rjBN{f)3>MO>2$zj?+c=!ET;eBNQN@Qq^O&Y?6XQtOG?+4;)4b9 z^)t#!`R23UISYYlF^HvK`<5xUZimihq93ahvSY7yH*lntsKg$`1Cu(@pCjJAu}gIY z%J3fPhcSqw|KxPvOTr#$GS~~wziYKW(g|UW4V2k(pu`>T9Vb@fxE(gyS_u&Fnr&PkD1HJr%OWq zW@OZaj{H~sE~-yq-dDOHLcz!!-~pbdP?;o;e!+aKnw(~T16at+HX}3Jo<>f86TXfC ziT&0IYYCa+WV6B6C1Jo$X*{K{fl>Cl?I+38$f@teaALFKat6O0bF}jaL9fO=bSSA9&2R*=0qr&tAbiHyN$1{(g^9KICH+=weQ?(~IUq}CG=@8>b?AFh zpx;qK+R4vM(wX{z>};pMuOaIFf*N*=eybV;iFloT|4aEihgo-v^2f%zEKlc^sE0u@`WhUwdZdRWR+`by>{GR-+Ah? z_4@Hmr;q#}n!Y`p$v*!7kxC*eU3K8b^1@);yf-61xW%BLulx^Z;%e321+{L^jd$7Gn%b6wz-)nefCV-E~M z3syW&?Up1qb#85Ny6~eRPz~$~Uh}XEhvJ)oJ94K zQ`xhu;HGxn4lB&XP2;&z{VhGqLcz9Gn(o`Q;+@)RtX?o_>6~iwxNfXvDz{!x)Ae-b zT6drz6+YK>yHWN>G&;=Di%qSZ#NG~MoSA)?N-#lyFt?*TF0qWyg3NaAGau|LFopk2 zv4F08zHRB8@-I`u(t?6jGBsWg7f$R7)(ad*3?}@Udq&bXNhS4(^2cIjPn;83q8A91 zWmOM3CTuqW+N3vYZFIKwHZegZXuNH$5}iN>=z;YK3bM?Dl;kWY}ldrZX zlZOKFdoi_}a({J~EIOfux0;F~(D9@73a;d66@6y!UEBtc`Y)`^`tAo=bbD-CKJ=!OnmOYpjvch z0C-6M*~FIk$L*=EMNtzQ!8}edxFtLng5Kh+!ua%HpVb%609Q@yL1LEwI$P>2iG4b=4Y06fKsidc1Jv zWYPIC+#N#$!FAs=aRT9 z#uP{gf806rLl0A}58>1Fh5<7obfd(XvL-GafQWw-6c{C+`5O?$ zt#WpLDWo<*90GWP#@{Je#fd9W6U+m(o;ul@_=<4&Mc_ZM>dMNfh7XQB5$8fw_^Pv3 zhH%ZrBxJvEXtEmix@$w>`A`-OvR>&x)~@9TekXcCDvm7=U0_y#_8rc=H7O;>FkP0< zU5V0W!4QtpqfG%G$IfYAAs@eOEy`3OUaHTMfy!XyP?c|6A4nS*&}Xep6=<8_-3w7T z`BTj`3NLJNMPt#bv)}F}NhU5c6_J=!ALIi%#Ul0w`~y98E*}u=aPMcxO#E)14?TRy zwD&#Nv%CG@6qoTa%NllMIEqnjmkkS{BhbWLP;aui{4x~vg{@u59Fbvi$cgK;e-?h+ zkWzCrz#EDu;jE+~#QokpQ1u78H07U*FfG}UQS zm%cxQIiE#CRg?hiLvP##m23W{%tm4yV{cixPNb-}rashCzA?nRB)mw2(3aNlS<)0W zN9@*0;;=P$w7Th}2ckzx;lELqEc`e#dEJs|*Zvmd{x;XagVvBz`0e||zQu(39~+5` z!q1u?aMTi9B%ItD(!EIde%AjzHHI%8T-wvaFesiC7_%7ko(kwXlge2FV{NeU8xQsP2;PJ zLd1<8@sHOSpcMB3>QgTY{{w!5m{q+-*DR3WCe(=c&&kj0MtX}PwrwkWApNM z4(1?gyCnMBMK2<>)&Nv&2Y~h@`G1c zm|6?&DS8t>Y={mc-0@0$A!yvM_^%c+r7#h~(*l+$7ha<-;K;vBMd^c1m=9eceyYS) z#L0Bfnb(ZHb8S(ERB$2|)wEwlqO0qqxlLYGZvql6NkN#JAmqB=@877N_GSK-IGHJw zAM3-PIBalk2qY-DoSlJq1W02Xh{eHrw+-evGfZ2>Udo&bw;4R)DEm_w@7tN*3|OpNVhAhKGQOe zyP1^at3NnmQ0aj=OlgBgVoHF87C>m<#9!#lpMJ8~Y)%&Yfm4W!H0QcC<`D;zQZ8mIKXrHgi_qRsovRN{ks0SeaSf9i`G*>fR9b+N1kxtcs zlf%Q#t+TLN3`blWbqf+7zMpqLq2`Tdv(kF93~0~E1`m!1j#FQ>2cJQoYvy##ZM2jo zM~i=52>cQ6pmC+cv*m_b!%6!Ael9$BtMhKf=yj6Yfa^1{gOD84s)$}WplVBaWD75% zry4+Ex1HD*&K0yJ38q!5B~o=k?#5kg}#)qsT`&YM_^a~gsn)=Gz(J6YqaOgfGU~$**;mpg@gsLq?$urR*LeyVc z&$bBqQ=VH`4#$%n>2S==^@s;Czn8jW1fMezalBv$HqTJCDl0E9ZY32r5_M^uAtbk< ze^+oO<$M(XLz~7D?S~&24(_$nKu`BeuM|eEPi?6`s(A87_tuaHB4MHF)N|xj6Y6zE zH3FqURYlD8^ki-oB-j=@Tj`R*CzcPy*LA~h%vpKlVDTH}6*30}=m;kM@%N~wliGWx zGkZ(sd+z5=Ce~*#YGF9eiN;EL%BQ~=y_da;u9;iplkvL(?Avv%T-`2{WT*0?eowrk zC#>LM&9eb%j4Ecfv0^l zM9u$})&5VAvSwg!xKA04COva6x3pATwAWQQyOl4zRZ3kyQ3MrD_jndpCazvQ# z+Gn}%)H}dWtM*=|il4vrmFOecvBt4K;AArvo%=npFE!e5masW=KdBHPea?zd`7J)? z@6h=XTQl+#%g#l?+#@2M&u9LbZO-_~F}pPpVG4Dk=2wwk3jSjH(3(xilvSQN{4~~m z?1qBd%5N|TK2DLI5gBJ~d!E=vGn`c$C~Xs4s)O8u8lB%xtvyR?y@eh%!OC8?umI%BmJZ)WczC+q&V_zg_P;+cr+nAdmp9Qdpx;su5_T=Tc?Lq}m;T&grptMaQZ> zO?USaZ=q+C=^rBhbZ5-wO*dXCOx&hsY6iOQtPB@a890Bx99~p!O=y_lB)WTNLgF9E zAFx_y#U9hi-Mp`iaU|IwpnG$LOtWHa|A&M^=;e?9LO=0Rr`$9}#UqI=9k6Klwc@~o zx~%)nkv01*LRn{eYqL#|E0%Y4 z52o-@&6kPy*3Kf)4>5c-)eEi#IB(FlkWWvVp`rJsNyG1o{+pmigt=>iFfB6H2kpdv zq+N(ImL^z0z`Nd$`tmDGHkK=;(^~Co18tC9&M${<2MeKd;F38$*VRH22JEZIy(G3> zua2FGpJcpMUoW-0jEG+CJW;%zz`9SJg{wC(9OpcL`i>`xY3!2gpe{%Su%V?g6$6D{ zH6uDIavM3} zBZB*kYtR;A@}(sL@%}Dg{jGC^0Z9d1woEtQijTL9^0~s3Edb5%rf_q?BRuhzH@JCL zkIat(N>y$eTqM&LlhDAiqD9!O!*ug>ZzC+2H8k^m z8*ZIo-vOKrWysF7Zz_TF_=wxmhMD96{-RIV3F7vJh_U1i69$9(E`hC8rOvR+b5X7G zoh0;hyS!XO<%K>hVSJEvO+tB1;fv`4u!uc6)CGyw7^j{k>vYo_S z7P!u|Z>ZeiE%m!EWkZ{n7w{B^(RCtN@SGO83g=ja=iW*;kzxs6XbVn00(I+mcLgMQ z^Fm(g-N&F1Ob6NFJGxUbwwQ`=A8dqY`gO4RFTdC5V z^)D`E3Kc1ieq8fXVs*FHRqnLo`w3P^kc*$s!~=bH{Ie7bp8ap0CJn?MyG*K z5KCD!i z)aUbyPuDx7lg96wbn$%fx~*k)O2E&DSB`7(n!4{t(7)fe|gEm@?#$a zwuHy;rkif)xqCYPWD?+Cu7M6=eqrJ*FWU|j4@ce6MR9@OcDa)an=qq8 zl#ZE%qpnl$my>S~r3y#abEs|(_i-)q5AnxEO0wI}BMjg->opGhgncEHss5ZK>G{j* zIMm?+pHUIrkA2BD@CW;~VLRdtkn}@^Sy!ZO^2^G?<4htzRheE-P}O_qH7NaZ=TH zEj&A5ef;AbzAzmzo24i+16oPv-_m7&NuZDP@?`Wz^~DOvbVv2dWE zVP;QpQJed2@!8k(SA6kDkr;A}D3@t(yUpBBg$W-gu-s-r-?zjj^<7>EO;9lO>ev02 zd*3S9WB&Wo`V?AY$J<)%+F)zu&;)o7uXA(QOPv^pUrni})VwOvAstgiG27-R3On40Unsjlmma(o4jstgY@{+imAE{AN??P&zsqd{r|Jz#&ed^Q+D#_? zk0_-CRkbZ6KDK(*ej&$DL+NC2Cd1cgyW2@GEYv^48T|dw`HiDO4%{`cRqkxv@an<*I`nKP!UKw;YVx@&0?Z}q&Th_t$+syk2wO`^+V8=g_l{u&O$0`rccj8K!i)F`251T`fp0 z>{3f(=YBx7rZL|WV<6ovl%s%qq3SJPA!C!oJ!byJ=FiKdTYc) zYsMr$pIWdpddoKyt2Rlht(IPFDRfZ3t}m+iN8W#~Su`|)mi&kOqDquD zYf4OdGGZtyFPU`;Jp!+Co~H@j2x1{V-E(?GX4{Hc!%r1&3zb6PA7|=R)&5!&SOqOv zM&W&8YA>AkRnKIog9Wnsq*jfAp%mg@`#`!D64i$2R%OUdnA#SL7s8LTKU7tJq~Q7i z?%7|5hhJ-#Qq2dsUm3J0*=sJiD-qFN&pHa~ny!b-P$pJa7gNtfW@dwH^M zcD;;$FP58ZP+1$B4Lu|0hbEuO%;k3$NeZhRD{tc7CH2EvftcU4C^OS!?w%Z?8EC%s zhLB00J>PFF=UJz8Z*XZK-Q}YwwFB#c{Dtm(@k6``l5?A+#1WHD1=Gex*g-`e_mPq{ zU;*O36ybJPJFq^0)J(D_Fk+2q;+filb^Y337K{dAt2e3_S;3*Deg z){-gGYlB>`iA!7m)m+`c$eumO-RSbP4MTuF6GA2^9@l+rO*NA!W>66H7q9)*q?}>D zgCw9gCcf^i$I=$41IR>8bbl*hJ3^C-l$sns2N}+A|FCs=M>0Fw<}g z7mT>bVPc79Kxl=C5P{=Y`0+ZPD9$&r!q|-~OL8w&0w#VHBN>{A1V;0OGpHvcmJa;g}GBNIzqg3@yyI5lwm+; zpzUO3{15nvFlcUyGt66i0^K6wyJCQ0=nE=O)%n=)F}HX0df`%ZC)|eEx{DH=Lk}Yf zemHCLhkQ`|H(A}KLA+ouSW+8RALTpgu7STaNl*=Zp_Gouow2Z!S$8k>!b{Df9Kxus8IFfl=I>ueII3KgW0YrTY!rr063MYFeiRCM&m4U!JEEB7QDYcpnzJb_^7>Of1hs17wW1G+Yfcen>q zotAkSbYtSrT9jhim78pHm~Zy>?O z_U6Lr0d&#k9rnt3* zh4jIvFiW&{(w-H6fnwn?L%K<=X5t0vm$m89!9J6TM+?VZGnSy(-l;Bjp!NXA%2HWq%>PsBAS1PFNXvm$&hqzd4oQe>KpxVm$T1OisG0u^|9vQkCM;5sPQ|wxF*b{q)E6)sbo|>VH%GN_gXH z4#v7f#mW-><6$$Q-MMh;H+1qrPtx;X-%YruG_QluaATk^=x1h|ri29hI!n5@;N_`P zS?|_K3IxwuNA^PkXIy28i4^_hFqUg&nC+DIfQ=28uVBIqVI4t@Zm#n6VSrC2azr<} z)+cixaz|U8BP3W@vlg`tV4YZJsl>VwUk7&ATB-4z90Y@s@adj(sLlJJEuVZ0@4%I8 zX6mGe6(Qj~Usfz^zf`ytml@S#5iGm7nscHZjNt8lAfnqNu6rzmbJwHx!q&}Dtu(KE z&6T*!@j%)O^ln8LuN+=wI|{Xu%^d_I$NVu~&bdIq-U2Ea#hhlTQIG zJH1Hi3+P@Vx67BRd59sz5&7pn$$anRVF2_b$BwX+r8@~zwzs-_6trR>izsovF$T`u z(uhrbK8?F6uB+zDmi=d1!2`~+=ed_5C!j+D-3cZe{UzZYAa*>}rKjXV|BF^P*e;0s zC+KLLI!#*fHRMM8&KMX#g4`N&4<|=IZ*cfPasVbW59{zSHy6Sw?#CneVb}cvgf;6! zZ>Gt<+I496gqToqfgVU5Cr0@{Pwuhg#5B6W$Sktx)ty?$kmP+m!Bd~_kRJ_M;3ixa zA8}>|Jn}HpH0`$Q@-RHG6PUxR+5($yqa7#YtC`A4WX+EG`E=7QHXg24g0KGdZQ9u{ z+>z)sZF29Qc~6c-rM;@J~S<#^9U>zB`?OE4?c()dFNF? zHQ*Z{*J|-@Ev)?yP!qesx7t$0I(Ux?0^QUC!0*`Z?fM>xvXadv8%{7H#2dpn2d3z> zkODu!ksN1RNs_DhHf#{nOhDz8l(u2tCz;Q>hzu0EQ-<_-J*7dFE;#ZDS1C3?~A2r zKs`g5$VcjLDxKL%+cy==*O>mFIP(Sg`sgK_cJQUzVz1@*tUcfzabEY#?)MsQ99XMo zwJm*_cfs)k=7-`A=;_}9*ssvv7V7uPU}>D+(XgBU86?&gx2}35c-ZW<+|=0hlHlRQ zJ!8P`(T8_%Qt7FWpFj#v5|qi2V<=JQw3?if1nr$E_0_oLUt%oh)E5P^;Ph@?W^A(Y zmf=rf>3{AtLuN;6R{5y3mYxq*G3k|Iu5R`1TI%lSu@^8gy_&U3IaTAUHA??_I-Oaw zJe|@!h+7_bF@1!(0cmU;Ydxy(sh_@om^FnMwwv2SwF;e1QG7Zmbx0SHlf{M2TZY2s zs&4E=6Hqr=td%k7D?1xjGPoE0@V4n@Rq;Q@0pz00p*8U3Hm`~4jqM77LwfW66mgyA zbMq4^ha2f6EbzMmq^(akNy#AL|Ez$_S&zFc&#bAMz^vCvOLGI9;+D*}Y5}vqKn^1l zb!3-c)1I3s2}T$M?UiJuQjvx#PQUByt{Te9Yj;X7m(@6EmjzXlCiziKU=4I!Ys+@9 zNBi8j5Lfq=9N_C%nA&+{WG_9iV5kXKN5!JYZX(yoU$aI@&sH|?xzwj+}-5#awS6gQGCQd>39yBO6?O~@j3Ss*LDt+gQW&JX|% zS#BN5SZ5I$n?NpniG~uj*fBCo>(7{VTRJ zaPZGf8{_Q=ZtYrA|6X=V8_Ko_I*pom`Vn)sGZ!b-lCaN=R6kD$&3 zv*!6)Q(F}!*`lziWNi1~+2j^AcKnH3{OOufppe162MIT|Wo`yAX(VO~bt;)q&5tz> z(79vbSBvRuJdDTpY|*lUQR68viN40Q%H;wn+E&;*E{|jmZFZiFMzVa7j33;8(5nbh zFm8!rms*c{7r#prYKPm3+Xe_|wpXb@Nua-g{jpQ~2BQRp3*<_+e%NkR$&v7G=-#U^ zk6%AS^hZM z2xU{e#4ED%JmlqcWyo)a?3#Pghs-+uvc)6nj>^>r+}FtcZ7?&L#~IjCT#_vOeLwbq z1TZqKMGZ0{8Rh8m%Hwn!9^Q9!OMhOda3T_uMhJ4D^Xw)z8jJ}3GmhoqZQwD@vmT4) z^@q40Gf)+^Kb>Yjm&slcKK2K%A-YRGJOuEoiqLHiriTQ;R?L$6-+6ZwXS;1XeFgP7 zjcHnoVXryM23m@BsvbCn8)^{UMx-UTwfXf(+r=9v_8|H7i_1*8y6TTe@o`8nWTusM zLPt7t*{`uY`tn7al!BWCo*csHLg1z!o|Uf!&viZJ->4=;{Uj>9RmTyYuhIQ|_J32$ zd#-7YH)48OX$~)qnw)w4-d+ioGd$DNrZ@KVQ<4MK0TjM!E-rRa3$Qsm+Vli%(p!}iW=yMg^iz9LtV??K4t(%G-6DuSl* z2xURwMF15ezAa1OWqN5=?sa|T_Xc7P4%ja5IEeS2TR)$CK#~y2!{06Jc z^;5|X*&zgBk1l|558LiEB$I02&3UPl0!jRo!v?(k+VCjnA!KFBq+rxgF^C`;rFdi( zqra828n5;e!>{&a=|<>9el?~W21!YL8hc=1f;P4wvC$BpdH~|bBExQ1ZezG~q}wJk zvKeXcr2T#Wu7{=?BFifL2%w!UE=l3LGEKUEzeqYRq95aIE1MN)dRN<4b9#@6$UR2@ zb`S-R+FMdmyE*V*NX^$zJ>|>gLM)i;{f&X1xL+L<&4RRL|hzUAD%3xi&EG<|Y^Fm5( z_F`YW4~eb_Y~C-ed)0ILJa^A`WB~J6HqHiGEf3E}=9429FPmAI%j<4u8YEmeHQnW# zX$A6VyEC0on0K~~wq;mWDAk!`0~49-&|#MU_1N1OZFui%nIwA@Xw5E!@8EP z8q{mE;2ggI0ikXjN5vE;gvuQ?#f&9#k#5C)ez_zVK;!$3S4=W^ngeHp{R;0)*vW!3-$AU@NXg|;K5K{4yF-d7z)U}7c_mUxQOm`_VT!&rAlueFbSCe1RPT|bOTe%# zol!4+dyC(rjZ$CL**jB70R^)DnkWAebuf&dOi6=rYra9TCpp;rqa=)8YHtRv`cMrCi;8`UqG*qoB`0?;l1N`ybM-Zxn1ggc*kq`s^#+&7sfL@_3iJ!I!$V~jhBqQ2iKe) z*?T$&hPyG~bEM$ek=P5B7;mn3*b zliOk^8_YdTcQ|X?G+tOW3Tu#R9ynT4+oq1fe5vi^o76!X=&7m;yC@f{=_x(RyCLS? z4(5fXy%G5dX_|f5b4zeFJNEO>BbVbOndZ_7(Ymv7C?k#jBEI))AOCyVye0`7lxW#$ zg1)WQTU@Z0xuoXK4-JU?ovWrt*SH1lm~R^SE1ZzFi|qP&x$AhtZHz%wNLa zG={qCQD?@=Fjkc$VS_uvP(Z)h#D~xVoIhQGrT`1I-|C#t7DV8kcHYCSA-}C44ks+Z zDBIp|b87wfz}91& z<+L3=H5?LPQ@#ze`LwcUyzU6n@F#8}NtaRH_C83!q5)GzIl=~CZo^8S1{G98!iK$p zoh?{h^Uat0V;a;}o>jY=4b)d{x)()oW3TLsGDEE+ereWM9g?G$Ga}JZy_d1Gdi5zP zs<@1nCfyy}yDBwaLr*oscrDb`WrXFq>NeK;I{`9ZJzV92yUH;nyP0(zIC)mRHUy*LEgHRA#j7%W z;$zA#x2ZZ_@-G?D3n1+P_H5`NZ;Xmr+h{-%Q^Qo7PXp`&5CXhY8)1E1j@n+5Q$`Zt zA0WS9RCCdix;1FC;$(w{71_ZM^xB7kCz-BM%nPNT?%(L~FL+)`X^DwsXR(KtGE)RU zPXHV+@bTSB;Xi;iTT~Pu)k#3S912M~ljZz^$4ow$*pW@Aqw1%<3oz?~|I`@5>{M)b zvSR=^onsDibe@b#$J>c1=7`+-acu3(LH4fW*oKlYLbzT~5Q z_W$74$i0%z0E-yg!|#MiEV%JL9^eXv|7{bVve5dpZTqnAJ*s&d=|8%<-(k7u9`4RF zp?~AnRoJrIEx}JR363tUv&GYy6?lPBAZqiF7nRL{UD>Ek1`?8s)!Dd20wCJ*>;(~^3DEmND+ zgKb=vUIxfkB+}MY`J-`7da4^uoKW;D(Cg0&%6~Ai9l>6*U{-PMQ??5o#rEV1x04_G zBE{V8n8$%#(Wh-9Y5?(xgC+88)f2a=f+eiq zpnmKc)Zttfr=L1MupRg^>U|*L0)uF-c=Ryky~#ap?+Nu?$jfNCigMR^SxR)#+Q}Nd zBws_Hwx0r<`s31PzeW2cIbNE@@ylO!!faY1Z`JS|a8K#`GGSSPq&U_?);>f)UecD!)q%xO{==XsI z%_Vo}kEZAD2bN*>VIJkaVn0)KdmtPKK&!MJO~~q4BIF2kdUTlJrMB(`mNR$sMLnR_ zawoZ-gy}3J$p`j%czGwr3!On&CAM9PiTfH=$;4_pdg&gH+=H0YC;0%n%k-f!>}h3z zBd#^_pQ07NfxOm`e3xL=A{6Wqex1Ody!rigU1}=CC@ZmoZIDP z5X{=P7q-L$d;Nvy!*$*^ z?VjYgmQ@ZrhvPawRycKSKfm`Z;lY{W;3XbgTUKt;U$J9TZ(MR*Q}e1o3)sNKU9<4% z42uBxL`H^LOz9adWLJZav+~J)Hi-Yr;Tvc8YX4wl(Gu-?V)`n_) z(+w*HLa(rHFdG=DHBIW!2N*@r!$pI6iIH?{Y0Zsk!Y&tx<Mdf>exy#{n@H$+&&EF%wyMXfL$X71&HX<)E-&jiSJiFNJ7C$Ni zxTOzK#Ag+&n32Mk+9+~o{aU5f&Yp9@)hx)Zv(3r(0AjX20F*LmB5HJL%2T?lZd3J4 z#&;czRa)$-EFb=Q;4vqktG3bZZG!!A`RPSZO%-?^m>%&%j(YQj!S_VJ(uC2Y97{zH z_{TnR(w;9yUjgWoVmEs1=kfYXCTUaZbkL=Hqf51hi`AW?G}Io|lS$C6`;>wx)hZ6g zK)zn8utGR|$umYdS>an&fgHvg(hE0#G=f(=3Yv>h?U+v9ia9)bwTT6bdH}mo*@V^A z0~d%^KUTVnNr!-DaW%=kA?-nPmzLQ~X`_bI^@om%1WzuSIxSYWCNR+UNJliH&`UVE zUm2>WWt9Ma(|jD4I6t-yIksN%c`+slw||GR0&xpd{A=VIW~ zz(>A&+psH9++K@RbNMQcra*~gsK@CKeFw+?#`J-8dqk&IU{jFh9X;TrnAMo|XDbc* zr-l9-0pdSr3)tb=+#Cc7+zZgyrBRMqoHzf$LdWOffb)VNW)LLn&*h~+ zfDS{>rpMe9JZjCOL8k9>{6LnA$sf(cn>nFAXlcgWG8@vSIx6oVE$7-#>g~xQwnzM> z*!Kb7o-?X4H=c}d2j^1lpg&nTZyl&dt4;Szpm@LTDLP?)Ij~dGSpi?8Q{qa;=`Df! zMFV-m=ne3)Q^6N3$_2eU_mhdB;*40@Q}c$?;8OS|=*VFlb3SVV0V7*M4M8>bC1`U)TYv`Dy;n)-H$mhQgB$B+5Dw89iBTXBr z){~%YkDjuhEoK@_SC_9F_gq$e^(OvD*;8AI`@;aya8NezwRQ%2>tm~%exRISQn21)9rP&8bn5issndq6uAz(Sot>BgYKw`D zkbHbBH6u01<@}g6`1I&;%Bk0Md)dD68*lmNeM5hqrqGaePh7eJ?K?3)Sxl^ar`Y0H zJE|5hzs&)D0;=Gu!v>Vp@tF0oeUWk}+WLV%_sE@*(Dk5Govd-{N!uP|yJ{19HLJg7 zH*ohEf?i|wLL{-)6n!xMkla6c*YpVq(iB!UNiTDO7gf;69#aG36}5ek`-l^&GhvkR zgDHnJ6Q^q;rCVkNz0piN>mo-}$0}3W4VsXB4mAJ5rHX2-TLNH- zL4WM2(VMV6(-a>RGx7oJA68{sN=1EldF4IanItf>!{xop+m!e+G*+=|E^@-#~<3Cy(R3&$bM^PUnveD`8;rY|SNoB#1C4|{(+YU&-_Dj@7WLt1eKW=ZAW|elc zwggo6h>(Ui(UeK-J!KgQJgp5nmm*zH^susQj?$-n*5X#@^>k~wglws@kP+3x% zKiuK@4LrKffeRKFQPDB&Ztr8ZuCwTkr2Q)iF_(ZDtAy?E z2_(=#+Pq>*ZY0TU_}x@nIP_xB zm(4g`k>pgS&$Hmy6+@+w?;lUyy5j!7KXQND36i1oLjy$VWaKxnR?fg~R7r>0SsK%S z>Rwh+-6K?2T7;8^I!2e&R355bGmDYQwZ~C98Jr}eM7B-#GehEEsf}-mOKzD`8#0;% zKQfHsQacgz!rrM2318JWDG`H-8M5yP4a1C6hJASMdKucForvm6UB zsV$3GL#*|%&~vU~36)lPp{dAfIFds&t<^F!T5rsA5OO>j$JtvEEC5o{2+Oq6YxVqv z-M5gd8q{W~VRWeQhfnGl8!~t5PN8NMFa)0E-|1@*YsKh_5g?25zq$-Xk)af<8bcQa zz_kv7!>VOu{x_YzlW*xHZp@WSRj;4x=JVTVuhAG=~a2ZcAuM{&Gk{ zqSZBxfk!Tvc*<9HLW8)**#OmfZ(4*k{n%KG zGugCgTb-)M`15C-9p6e0twT(D30%hc&9K`*;^Mh+QhWZQ38=Yi31CnG))YaT=eZYl z5}Lf02O5kHRfWp{Mb;C|vJEf;)@{7mLpPg=ZNA!Nw|Ldb-so*4qT+p(PKduKRPIA# zZmad>E$}Z4h2aq4Re6!*0Vjq!Eq6Vs0kcZ@2W*TO{z|K<$m}r|4Zxpdw24$oLtHg~ z&)oAosvVjY2fE0*KifTNL~;P!i#@s{$_vf8+;v z&Jtypji9!R!1vSzgZFA9aULVCRq?&{UXhQuzd1w?W|SC70e+2MKfHl~{t78|^yPgE zAA1<(!y~OMd@wD2Q8?iJ9+HJAoL#sU)-Jj^$HG905pQYramc@rfvqcB~(y-XLB6)fGree$`)-!^wGIX zs&f%i8(T}MOVd6MPRIO(?Mr$8J{?y(zdHP8FY>=~e(w?gu+Rl+h0%bY%nojRr2^B0 z?sgn(7(6%p_P<e?t z_^0YknrmS;*{;)%>=%3Hv{ebZCr1ZSbT)(=gdZh&k86L%gG%wyAPd_}`~k&y7&J}$ z)6|#u@{!xVrdD&nlEp6fo~lS6wLoMR%;=jr4wbRn?$f7&SIX=wn ziy(pkdF<<GB6OVZdwB>nJ+7CSSkL@|{athO@5)u&&EbA0z#+Ko zi9nkl(80EC%KBwO`?D4a*8y9rQq`AQBrip{zGl0g|Hsm|__g@{aq|r!H3^}^kmM^G zLZ`JMguWU=rNbf#(Mqkh+Ch>^Y7#=VAr#U&pw!w*XVq48qS{ugR$JPsc0N3Ne$Vp< z+^^UDy6@|{KG)~{c^_g%O!?;QdPTVK{BuHDXbQT;^*4o(6*mudeb0Wog_y@}ETK8H z2}bH37g^TQC0?W83EYn&nUnY>uzP@^lBY$+@M5_;`Wr@%Ff$@g@XC*oe!dW7+7#(Q z6u0-ZJWzhsUT6RSG!!bi4gAOQ@f5JE&qr- zyU#;%G!?QT$^g;wX*so1Z@psIXA8a{LpG5uW3_9a8y?}!AHWTF`L^*FBeP--mVXbH zt_dHk!shb|4+1?2XK?2&^_D8OTpNb}_l|zZnmRw|AbGAgX!Pc+okQ*%+?}#_>U*_y zo9au9gDRH4Z6@FuwAT!+N}KnXKN5V4Imlpg^J6I4ysA_}5N#bhNf|-OS6=i2Z~Nqy zZcc}Km%uZ1)?%PeRwBT^s3`>zF z`mT0gmwRQte+iUj1HD0f(`w9L9QCp*+B^$jdt`fqB$k{ubq51nN#XRtP(0_T7iC*n zP7Ivm<1vhAFq)}F5IxQ5i5C3JsB*+-mdj}bJ4pi(-MHvZrxaC?j-Q69;gXF1y@10 zh=McynLBHy$|VOdWiX3|KZ30|&^6ru=aA_^smhVFt5V{R6?eb7vux>_|JW~XWjZz7 zPHo+|+~CUn)Xv60$zxJr^3AV*`TU3*$@Qo9y*iX?wySRIF*IR+xxq#;5 z=WibO{5d<@_px@(cOcCXs?p+?{vsINyVwwp3l^qH4ESIBdyeo+h8moVi-Xrs0N+3hHVx#j%idB z>62xm8#s-36lt=WFCmmhkpKl?I%30`eljF#ui+J!^>D+M&1LWUkg!b8Ud(U zV~z3q^Ch8aUM@(Fo2*%T!&+!LGcr7ZMS0gGK}+v6okXR`pv^eul}{R)e_12bM*56! zqBbrBnD{GKWj9od+;qN%yfwVs1YLZ$#mkIS9p zrg9f4tzMh{E8RJZrRVPku##bem7^#lSZBO}-)QPnzGHNc!jiKHKW-0BrRX<&%pJ|l zMY27+>aF_~b+#|~eFITG>x-WKSL)4P1lyy2MjZvCuZ3EM=#*P!zqBBUmOIEOVxL0^ z_er-}d(Px^!a_2!d4{%;J*inJSd*?Ce!_YM;e_>G`L$&H(SF{sPf!Q_@!;X^QV1V6 zQrW^5&GayL{)Ms$CM9jYX41&?L}chVCKP8w4$A_l5#y+O@?_V4nBDu*)@96UJX3`wJJ%t~oaBG-*F$-kn|SU7z}+CWEsu*mlPChbPgu z(0+9CK6xrgZmw#fPLhsUAM3T3+Oy+McAVQXdOr|xCvhWs2mU|GtD+|w!${IDQvSMWE_=Q+ACgwi>14@Iks#6!Sz}Cdd6WhC^@-! zE}ksH^(XaTq@QGh{b$I9fafLQ>8*71Vky7u*BeNv&RV56rKzsc-t5lI)Z9~FnE2Q! z$ zT!34_qq>T$TcP2`_u?BkmaTBbt!Z*kO)3gVej=iM0X5Lq#B~k!X@_cV)SUY>q_t~I zRxe{DvYZVj`1WDylXMun{W65+*FsLXWL-|MW~WMw`F9qON{ZZOx%u6$+b=Onf*85$ zCYn!p<{R`$6UBFt)U$sQGq9eL`$^ zoVvd)Lp%NE?m@(ABY+{e_vxx}@JjrRGwZo#m~z~S5CjnIC+<@IM&EC>Bjg#Tn{&6h z>DtZsV@Dwu&6U#hnii$=s;VB;R;r=;BK}!<%y(*`B5EHf7P6;VM^%>8&2ZPX8X{PTT#YMKW@=)l6dgR+m zaCzwy;Gb6c%}zI|=}SeMy7vcw+f4Yu`BHP(+Opu|b3>Ugj`DddU46ilXY^5X$xxDk zZF?#|DLX;jYw64NMBc4w*M^=#2$QklT?x0H`h(nM9l~zc5m^RrSxxpFFk#Aa&x?Tv zzNm-gFk{5lvFZuT5y+osxxF`)57L-(fh-Q~H?9S;67mP&&t1ZMT7dJzYmR82Qcl^q zsurkF;_4XH4fin3IAu6tO+gzbzFp{97fp|Ff7+9Axj?i&0(sO5t-D-Kx;@gzpNTh| zxuV^V-$LUkf(Xj@UvREF$QEUZ5!gu`JDv6RwYEY~>sMO9dO)vKM+$ey*GjLjL^e|V zS8ybC52F)Tb?GLg!n%xc)_2@1u!|p$b<(aYsN#^HCu~ZRW=et7aOWk|<hL(OBFp5XOD^`Sq+=xiR8R$ONEc&0OGW%3v_aIts7qgfBD53$h?6Yio_wtc z#3CWr+>bIXRN|V1eSmU?P+1TeBIu;-q%7J$!v)R|5`HSkEd2hD+ED%<1rWW>@~OzW zK=pyU8GS8~ze2{~J&g(CexvX6WgVGsCcV{mX`bJrU_gt#^E@ACtIYPFxoB}81T|W@=2)~v{!a-@X z+e~7c(nTBe@4)dy)+KBb-#af(`xpw~WMmCz4!rVNE;f zsj@+?l`PcV%Z8rx_rgTD22c2srsH5|6SJ_MXtEi_JkCVIww}3F``H)Mu86mZ8~1`) zBjs-s7gK@JLzsoN10`^_xwO^El1u%2K>?k-rnd~!V668>wn@sWKEndHb6hp|+@+Ny z)JdZmC(=`|#CupaBhaZ8IWUONna|ul%P7Y$Coa;v^=oSD;0j_oW?Jw>7h$z4xGVV& zs6kGhl1QL(Xtrm&8g&+GHxR_Gc`94nxY}*x%FQ~zFwIlR%lTq121fB5qc|b_0`RMF z#|Z6a;@#PZI>jp*{jn%pbGsm!k!YySMm+|VCC@i1bNK7D)0uew3Xx@(8Si8be3tJ5 zeY2`iwws==V!9sJWQ_X+hOumg>jW`Ys+GAef={%jOwo{!?;YH)EJdr*S!PSTRnSAE zJ(iZ!?`6Ccg{v1a1(Da{IRo-eq(82KCQht3F^KB|+kmVV5T0-?`1xwD{4D}l!ErGD z!nWO(hNH{)q6PS`E$=DI7(dK<5&*E+kd4HNC)U*U!7*A+nh)x2%0K|mx26N4j=*}s z>}drTSrBFf^?nTBGc2`}A@fqj3y<_7l-y;ibOieK!a{9uvHGHwBdLvCLh8)0m>F6; zJ5inR-8E{&H3V}B@b?bwHD)`=4=;pgm`t7TkchLu4|^kqLHr`%{tWIz-&+N)3uZ5~ z=-UBpGV8iur_Jw$QwCluj6dX*2!v4)>JF!V3yCl8oNv45)X!p_`3G*5$xN$BZ$_sX zZ=K?{JThHia#r8T6h!NgO)g{bk-cR!Y(VM2=o<9q;&4%7Lj4~~IppLIbz6cj&CfF( zI)A;ivZV;O33f}<0?3xQb1O|M;HU8K^~(gBK(W*9L5w=_FHf#f=yvdv2QN~aifP5> z{kg}~hP&LuPeR_9zl8^R%H0}jznP30qTMBq2@ZQVVqQ`8mz1{$0=J@yie~rzA)y$U zi_lG%Xw;vttO0}Pti7K=`x^QkCA;|N$KE)3cY@}D%DorO`5m{d^0pJ5Adk^K56wpb z;zcv~R{R{$09;A0;b0!7_Ww}d5Fr94KH`jg51=2yXURf>>(_%te_GcwjH9WMv_RQ_ z@W?-gO5v=%&I2DTlZgL=L8%SY12|ivon+1YzMzSk8M~G4mc4eiRf_~k;5Q!<(pEip)>CoRK8_PrHn zN}Fl)bDg>8g90Q^6Na77F#L+jYv+$xeKv*{6TW7~M=6a}y2X#|_@oZX_3hYxjA6UE zDu%lNjeBif&ss+HK`cUkbG_}H!TaHVO>f%6v0PMpvMfPKftBlsWe085Dqi)ZIuYTiD~9soRgNw_1&r1?MeNQnmh2jwV1Vv&!jS`&EP)r zlmqhMS>RCXhgxruc4{!NYOPmd9c6dpRnDJHUz|g43 zH%#|7i5s-gdx-oVFHs(UZum)gLY36-s2EIe%#n53|Fs9cCrw26i&3*i(}s;OO6xTy zFgR1ZJ+4zz6`e`F-s8J-9{P&(F2v{41dv;2`Z?=n?WDFr8IEHUf(up<_R1Y4?IuhD z^`o8k(mrU3>YnbUW?vl1RQ3xN793l`Wj`@iG^5uh3@YC`JS5z*{ta|tgqiHbUmk%C zzb7Or;Hs+t_0@uC`m^&tB>s$B!S4MXW;}_%5> zoHR!Quz<&&C4~w+AR1pP;0sRCz4AM*h0_x2CG_}-M3p1gR{Tjlbe4sjNP6Kr^J}7T zDYTCj)K?`HDcsB?8)=@!p~=!Cw8Jzfe+1i8ehQAO?a;J z0foCCu&9ouUo48 z>6UGq>VtVpv5Q6yU>8}vBD}O2992;77mIIbi*YA?uV|m+ucQhEAYHkcwlUXe1!L_q z*M4Y{c>~mZ@Y9Yczr`$(B|SA@BLg@q8TJiq)r?rsp<~J%_Kj;v&}8UdL3A${B%&G3 z1efFJSlQZV);o8Vmg(X2Pnii;%p`;}y++j*;}|h(#0SgvaRpK8b*O9(AYx7})N^uL zts{(P%ERj+%MwqD|0&8F6j{jdBNw5=tw_iIX)o-^1JyfgU7LhCTL#Rkq~{Ypo}}c5 z>1B@JbQ1PO;RaPR)HLzvjLRim5~of&iTcjuOs*2p?ppDk} zn_G|ohu#`?ZUR_EGxgHiz^XGth^Eq^!RUKXvE9sj;6$UUg=r~pX6|&tINLB9{ynBW zK28wUj2cpoiw$eN({%?hjLaC+ujE8a;Ak_EGA5si=&D*odMAFM%MiYi)Qua1+W{N! z)iCrSYa>{>1#UWxL%DDD#ZH?h43%~KV;90K*2n8~!VT789kkX$7^Y)>yf2m{b6Y+e z4ou?HE3c3gi>0>#NzT??x=E~he@P$C()YSibFSZfjG4B&} zLL(K$s6udnm}V>;EV3(=-RerE1e2<~pQXN&+uYJmB4(gModME*cu7~0m~+_*ZX=y7 zPgR6n6~nYm7jSzqZC$SAL?5R~)~F$WDQpiVt$%lv+E4PeD|d81YSCVT#^j9ePotS7 zpjFbZxCbSM{gWTN`;E&~%Om>-;wN|W0fQp-V6Q13e!eQO7s`gnlHJ<{1q!Q~HpDct zazDAG53{}GJ8%uGjqlaDdPUlSX3YMw}lzA4LiktXL9t zh4_;&$O4B!D(PFq=TR-_xg*^G-XTpA?kwV(o#ad0dRBjN!9>H_XrOKkGDfwV?F%<6 z6^ynz$o^tKGUCUptf3!i{|Me{H}nQ%^LzaPgW-iBLta`!{Km*Cr|XFXp5F}9$G5~x zdF?jHOz}F{Mr#AO^+dPu-eNc48mRMNRFEz$6~oelVF@m^Xkqt!vA;YUM=y3u^Q zGib(3f9e-kW?w7QX@9~2t5?)@ z<9xIJkgrVA757kR?m-n&bxo!zN%vJb&+pcrCYy*C2R|3w!ERK(tb(K4V)U1-Q$79! zzWm>swIk1F`$QWu2PR$s-9;ZGUkq05!z!<0@E;hSs6D=hL!Ns8>x#{@osUhv&3%a+ z5AHJf8$q1TgOH1G&)`3~i@nphS=E3N&#|BpThJb4nAAzuZixEQitW)}F2oU$EXvcq zb4~~NZpb%N!DHU;jxVY{S!6m?z?|r^GMNK-Jxv1U*J!B98+E}dsCZA~&;o>Gv>A#~ zVhw5gLy0q1RpR;g68!4cwCf7zfw&v{B#-Vw`%Z^>Un2ejPJ$$FHTM%P1xt=u`Ar7Q zrYf`D`g^0fTcw7)Q~anRdNZ?MaqSmk5Wpi}cIRT+!<7s7B6x%bnPZkoe=%^0_|W?z zX~!TLF;kpX0qGn!Qctlx7yR}3I^&DV(j-2HmQ5;KocDfs&|kBcXUM&v?D_U)9#<(PXxfUYPXUwXm_@$vx1ZPe;3gMN#*ezjZKMs6 zR`A|jp`YOyasM`U1m07cwkOk#|Ni=9VF1n)dW<~nt)-tdDTnip^?aMbA24()iqi3R zN;2jPi}VQ1POde7AO7{#phu|$o-RIk_;l+JuwQK8_&X*|%UGP_xoqLM7_~e=&xIn8J**}temT|F8p+99_yD@HaQP!Fa$#F$UK~yAM zOpV)Yt)6{FMVbW0=0{fm=8E(ng0CO^gRHQ$FW43VXV>UIR({@Nmsi@xtbX|JeKBP_ z2%TEP21WyR%WToQildSvEkGRN2M&LI%sGN_g?VX&b(8l1`vCWuyiM{i zF9luxQZP-qP(Wa=m|Qq_Vl-V;4Ecg@$(jzsLR@zpHuLYex{qY)J zV6Sr(z%zHte44To;x{rOGol%s@%>H~zsrhHMa}zP<&}$q9!g$K%QC1ZPY&<4>!PIA zY_Vj6=P!Sbyp+2~_XQEHU5dYN{e>_9$NByg41Cj`zgcz0DWBtPej4EnxH!8uYn_uZ ztH+m4P(vO&M`|pEsWoRcuF=5}?xz86@Mwo`XIQ>U4;5aLU(vk=1T(^8zNM1uoS<-Cq_-W?J;zCMcY!y>>#NK1<=Am;gXRkB? z@hixW@%X=@x4Og6TT*t&9y(p97Vrniso<+K|CTi!#3gr+T!q=ih_uQhtRdRq)!wS5^!EnBzT~!duYSw zk*)#zpCF7})KjGJ)S0SQRXLQzm{=or6`RP8z&w5;5|huuR8t}w0U?`}?OEdamE7(P zA2f6k)jgNe3&^yU`Tlhn4nF|IkT{+rjq;G?j?qESP$gzc$Qi!r%3TbfnDWR~Kz!1J7{Mpqb& zI7h?YnZww({TdFpZI5X~&MldJ2Vkp>7^Li}${9x!qNi{~NKl$H9{5V_fxo#{j2YIP zxjJ0kl&CoXg?m7bi%$^jG;e053HD<`e}R*-y%_jXb}x9yV2j!}(2itfN4q<)oc}{% z4PBhrkt?DkQ5vyLp$C6-uutNyg1Rdycc70u+U~1urvJM0%RMFT)l2h}2D&93Ox7ic zEUS7WV2Wk;Q27LRTN^%A`7IdYCGSVvk6uJ|#O<3$5hXh{Gt4TxYu5T{!vcZ$Sksfd zI;!VbV<5#wPnH-1Vi_VMPUEord(C~)S*i~k#A%HhRQU`YT4ndb*`<*()GioER6P(< zFrk)N)yXAfCE5mjZnSEd+g?ov610w+*rK~P(h)O$=KHTJ0-7@7+a_Fhj&l>@$Fx`7 zoK~EN^7LkZ3+$tpm^PUKj zA|%e}Q~7PGQ2{f2igAs3?mQZXTaS6InaevT$aw5DZz0(^zz?4^A3KYqbyTCjsWXUn zV?pEl#)OCQq#y1NKAM`(8ARjPqU)+W9CgEdngzfAq4=)+DYQ8y?jOWCy-px-nlZ9} z)og=m<7|WAbB@%;`!M!U;JRW*y>!n}QGC^0(Ox}^%OcNnAF%c@S+}f}d!Q);T|S#h zo1tmdFR^Jx&0y)S(F@Ss-cukwjpef?Vb&%$TUe?dobyl&p>FLPVNnL;S2baEul6!b zTl+&NPyd0d+T-1(Yki#wqTcFD!6+9}s){lj9T1m@O=KMp-c35o7}J%5>l4uJ3e`2> z-%w)QrqLF8hc*XOIUC;%RHmVX+ol&&PWFc@7mM_n(eERR<=Kj#SF!&v?Nwj?YJXQ> z0`G#G=(+*p{^$dgosQI=p@ZIEZaKu+ocX@Zlw$QDvtI=5G@odlqv(*&n(v;FmiEG=HdVD!^fSBvP%Z@)W+` zs0R^3jjN#N6pdR5T;{lYf)8u2;hUW!sp0My11SmbX(aSw(IIFy{1C`OQ%!-KLhKz& z!u~7ymwP1mvSnFm8KfK$#tgF#2~Ha6G=DuCPjt?%8V^8wsr(S`GotIHW7MAD)3mmL zUR#dSc(jQ?@9YNBA}b;#Z~OmFx>- zH)9Ll337xQMoUsz0A5F;NOOhEOt;CP-rok=OPaqpN7X5LI=1*nHRVdsMfeYA_ykmQ z7&8TIQn1bW?=v2BREdUZWPYU=~t#f#v(MMu_Y%VqZH zT|5s3?<5UA&eF1OJ!IB;yXCbGL3cT{SGL%rb}840pJ^f`3+Ao{0<8C#kfrDdHmHQ zI{pW?p%Nv*o1nvs1}c&5i%K)y8h1+`SWKQCViCJuu23f!)#=vB@ZKt|t+uZSO$Bc!((UlO2|vj(#Oo|y^>u(8stla;`P1e6lKNA3 zUZi_pT}8VRbeOnWSNK5}XvhQdrgb$O;QyaZba@AQu1pcwpr&n&*8Z=oAA0&tZQP>% zgIZwFlB^p$eERV*+-+Q?a2xvZ;pdVkeekj0kCjYn-^e*`e(CJ`dwAU6F~%s16*+TI z&sE5x(f`iKKI8L-TH%vlHQ*}zgk~mysWZ(Jq%z8ZmuM|;DiC)<<@HB!Np{KnQtsEu zxHR`4k{l}BjiDlxlek|;T7$H%ds^J&1J z2>AvJXk*w+P6-zr=PuRfOcRh5>$G9d}|RH&r$&$o)CU95h7llSdQ`9&wyWdALqOutNWL-GJfo+ zT4a>P=BCSA;>YJ**W&8VLH28D_{KXQPs=}0T1(3ugt zu!ZyW0CDsmJRG+Ue~!OCti3gEvdSjIVH}&d6MO{NoqmsLsSjzTaXo5Rd$Z=X89-n{ zekI~TgO20zr;<8it&~QYC2obx@LlB09@Wpy;a=lbU7lg%s;oXi4g-aqI+A^oMO9s0!s999M!fvgV+iK#~!aTtxf&OvlJ+t)& z@^$kR=|NFj^)X8?+WVY$8}J_5-=t!W32qlTWy17 z%y$s`Yng-qZnWs*+#}7{Aqj9FJzcyAG&=ISKZTg|3)y9^6W+y*H=|#q0jRKx!#{UV zm@{{Qm^p}op${i<#UC4>s6EQ3v&@LKPQ@Gvskwie(INq#6{0Q)$%pOcz)* zp$j`F?{57#x!588F{nW2#lqlWkkOOVnW*}BdvRm@aKziJgg3;6twIA#mPYa}@%OvL zqA`2^5r(%)w$EDA5Hw=33(&mSOWkH8`;Q9SLe0lZphq;c+mX9XP=^O%OeXPZHSj|W z7g)(P?PtFACe716k|sm>4>KEv`utIb)GeSVK;Om;XC#ucN}ohl0(Z6p6OLH~ae_CB zM)6B$hqePsfuJXVmQ;A*9}HIq>JAecle_=rBvW3idSNs6Ul7~KQ=8>=e?@e6A4Oi9 zfUm_)EcC)2bx%pb3saG=5g8j=Wxv_?B{m6+Z%@=KTb1}!3fHCPFcU+lE>_$o?HkqC z=$Ate46=HVvG+B1Q9G{U%oK}@T;lt6zFK&cCqIPQln1uHHhi2``iH>=s5ipcBvHNO zxf0k8j6Ry12AX$L-y<~{Q~I&#W1boSeI{Jb(*y|_3ImN4y@)R{nP;676ln$3xp=xw zsnN3muhC@HE^%t`Db`g|vAj$W5+uNqNxCKf*PrCV#?JKG=I{@OC{l@3+L+`3C$&)w#7;^7o_ac@jol+V=j+2_QB`QUT=ycAs7 zB?b9kP?)0bCo1%LE8>}*YW8pCNbcWu?e^MvFuC9d?h98_iiTf~zYzqNn1n$q=}a9F zSpm9SwGZJdHCNI1b$zU;WqiE`{p&YSP9Xqsjf4wuhtJvzaUrTE0`NDX6yGVxlyU5+ zxHO1dL~ zf&9(7LAf}P9;%kK2(y7N)IlbGWVGR0n(%hD{F1f_-ZYdR1zF6hQX~%SprSz0S-`aT z$s|SuDnwohP^-=i0K!@;!sF@&YX;EpzaJ+v_i7_?|D^8ZY^sUA>|F9YF)){1HTM={ z7Cd#5HQ5LoSQce9=sLEDDZ7DhYESPsen4O!!Q2>f9SdcQai07&8@?Mql{5Cv7zs2A zrU0R=)?ItY?pZZ~3g#24myNDR>q zbDp@B)RDsjh|8^$zErbOk|g^Zx{3@sEsy%l=FP14i!@*+RI4w4SU6Z;k%xX z?u{`u6g~xyW7kn$!+tw@5t;k(X@G2V2QgB<$fU`MvQ75;JSxQbj*ls14W@yU<3E?{ zxX(;o}zky6z0Cjp>7QeX0^Z5 zd66~yfI6A+L^S3tvEJjI1W+D#H%2V^3LMK&F3~u5;qJ?u$r+u7H?X~G!_4Nqcl?oG z_yGACc0ts~910=L5anfcGU!g}tJbDG-om+m)k&;&kMpUobjH?gtdYV}-78T}OU46O zJZYd6NrX{VH=ep$Vv38>JqUQkr@V0$dx^#h_xDlvmo<4{hO_cI zZ)wyaGP@xO>lCV7H0M=wmb-s{(1)L|Hs^1zzB=p&BW@x*#2-~8^oE~?@@pT%^GJ@P z@qLXWJ6h3-i#ZNb+8oEt zn4Y+ote3NTn-w48H9(o~X_$m_Ux=<*B$co~Q9qk@vDpJ@d)G zstN+~?*N>;LG=bof*~hqe)P0mhhSv6&J!Lv51*3=2N=m3{Gn1G-D``vczY#cz?N6& zt!aYy`VSUS9O(bH4^bElMz=k^E*%uR-u-3PE7(@hDd6dW*KREaWA5Tw-q@5;NUMiVCOLO}%2$-)co0(P_TS5C>CsRPiABD*)gIDKnw*C=)RQ@l= zlAG`^W!3%E?bb$=lXuNqEcBC_ijOSaE6zn6y&{tD-(54xtsRZbg8%llggF8lQp--> zqKxh?g)+E#xGG~L&R&qqHpJNPa6$aI#mohW!vl&nBZSzxi+!?8${F?0^Hb4eAthWD zav6MEsS}@W^8|n1*Fhq$HUB$M>iP>N#tCvTmgv}>lvTk~i9sy(@xk_1gN__4&S-(~ zx$u3zUOi!(Z+5{dJz;N!dI44kyG4u@olZHT=uOE4kGf8rO9_|TBHD6yj@}o35oY%* zf~&U&R$*&%^l|SccI#bWnnLP6YAQhEy*~WL*jpj~$56#{*=aDhgN1hQ{bu_Ki2Drp zu-l>69LQp<>kZIn*v3I)@v*}@@b~9-^7?h%tV{;EVAJE5tT8bE`yg<(C z$<0yIz=rci_*JfHL`t_3G6>mgb&)le+pW0=d1>A>f<9|in_QHU4=rGs1OA3)GG7SOu3f!nn|5BhS5$e3+0^?TW9)sjT%0qgYwwrnvx0z8;2A|+JaDLe!Gg9@yk!Mh(2tdmeWk2)EmC^5cXgoY<2algd%)7 ze|Y2&=y@&1Ctv96xxNwLsJH3*8UE77$px?hm*E<&@tWHwcZenGB)C;nh!H920wCt? zxd15mwNvkIRGGdAw2SS;MA{RG9)(+ zh~ZHaB}NV8$>`mrrdHZlWLfD8&MHVBf7LN)F_u8uunqR2Y#p#iZ{h!aq4t5gTyddy zpS1O~7J~8bL3RdoXTzR43GXo@bbu2079cyr_2}?(-|s-}r)dag$N*lCJ1~H>miU7rFA5tnh!yMTQo(4pZT)tbrN)2(b4*S_7sv6u_WMDa4@bq&*o-Pmn92>=5GDc$(~bn5J& zl6k>W*frxTPLlhivb&hc&6ba9;-BHkf3OtpEBJkdt;PXDnrAzb_+VTV7Xu&9s2aWciH3jq@nUppaxVnb& z-#UoWxpoX>=Wx3AHieBfi&-D!sYzEJ0b}X^LPAZBlkSu@4%m%e(pt?Dz?0wF445{% zi?%Swjj_hj=iCXd9@e{xF3vuHhvk5TY{7~}j4T8MIuv>A+)0e|`x-CG?YNxrdD*H7Udj4O?v;dD;I zU%b^>Y5QcB?X$_LGxukn&Y-SYj0Q13>i!6~IQVmav~a475&@{5t#m+G{EjRyNdSXN zfDcjeTh#Yz(N<$u0^%B5MxL?!t5lD6YKp!>qo)5gY5lzfk?<9uzV%MTc0Xrf*^t;b zDZ$)O1y%_>K^N|MX4CA(vQbCyt64|j(gU0{T6*)}Vc}h;)c&K0wF-@?;2lV~qkqDs zdZny14|iGwLC)Lulm7AUEq?8T10J z{KYNxf2LMDGp_`&-n%TgJF4&!9UrNZTr!oQznjt~b^p z{8XJgl&Pf+24e=4AVONz8h$IViMxxlDjKrOZ7*dVfx1%X`OS*|-GcXFO++PLU3P_X z?RR_*p|)S3{fETq%zGXR)0{MZWh*554@vpmn>3a~X; zqI{819>(e38EYDxY$WQ8|9X>k8nhfSaBVE7TH<=o`Hl5vSmo?fEGWS=!DpN+B!OMZ zG?9N2#^F3`k~un9;9<_8yy2!&84p66G)elgjEgS3pZXb@=G#}4|7#4>;*9!JuJ{Ee zfi02SolWhU>{wpv96&<{@6n6IcEKya~74cZcSEtYml?ry+! z7m~)c&H#D|3A++7WrRw`cTiiS>&MJ_>xJ0BKjO+8VYu7S3~;IZ!`B;{BPI(5nf((|WaVVSc;Y zlT6hni29>6K7fFJm;6PQO<7FPP>zqJI7nzF$VZ$L8e|^HNs2$LIVr83gmR-x3>oa> ziW``5Y-vDZ|LpT4g!d{9WQ|oJ?u`1LN%la4h5Kf0kBz=TKR*VS{{w9U3U6H&0J+?9 zeDmnXgg!>5?{5$}HWP!Gj~O?O&4}(b7GAn4DENbGlZ`{8JV|oqE3DmG%QhRv_vySp zEqQWUsZt`p^R>T_d8q1+`M_5Y@8aCr;8V1y;H|V|Bjk3}Yh9Bg`VK19cTQJ`hK?JG ziZ+ou9f9|^s~r1128xYb;0@*|SiuA0E5Ou#;){nNmpZvB-1 zuSTVzwfwlvR@Cuu6SZ zip1F1AxTJCrBawBBsq*EVm4<*j2xyMHpk6|oy^YLz2EP?@BgpI^|-Fjwa;ho!|U~Y zQ)W0W;>8NfOy~`&bLyw`8(k2!Ime<85Gi*X9>d2#TO8L!J%w=xT`k8I6?3|nX?53j zErH~WU-;i5r(SMz|H8?Y$MkZWXz%~_<*wXa+xPft5#;#Z*dtG0em+3lx(F8o{l~To z{*UXezpdo-I5$7@3blvk=?R(e2kWl@$*PtE|5jQHC-V+-_$_L$D*PlL?(lZ(yJob<7_dIDe&C>X=G^#5!28kRE?vNgcN5-1iFAV_!5bP$C5$mR-5?9xR0rXnYR)~+|A>NqAdVYoqW*(?D3hD@ZB)q5kBvt? znEka`TbKQlxLCVdy9qcRO5enYiR5NVO==!;Z2$Y^@xX%wEYtm2I&*K7i~mFL7;$lj z)>yj~cGmWjZEN?j6PD|{j)pI1rAlv<)8LqW@Eo&ZL3h-kJw*!N4m6Nv*aPA7x2Zl{ z5Pmu9h?VmA2B=~~V`N=QAPO(v6|SFD895SAKHRmHzaIM`Gow%LKo|$W?ks}jp$1{g zCIA`_$5Q|N;(lGcpvmm;up}x&>WS)y5viWz$W zVd8kUD03J)(v#`SS(2sP&397blgH=@VAQcHXFow#UKk0r%rA+SsV)H>(NgK{10+$Ns72sf5h=kU|6HvA&=2XiAV1cFhSm^*o12 zD*TnaR(W71e~@s?hw|=1Lus8D?;kM zDnoK<-E{qDh#?;Z_cK1seh{?A^CRYnbKs7ok{i0Cu2*H{isTiai{xPHFE4s*Rf(Nm-A2Fxh9r`h3 zbqIIBRUE53g@4e4nX^_p3modr)U7r4NYz=a5pKPGlYNKAb#%8?rQUieKOkPK{Ud!$ zmB59^a355c)Mh9va-0#AD77n6h_tTbu~j2gma1LD;1MWx6TB3HovL1jdx6z6o|G;4 zJ$$2~kMQi`^SZ9RSdZC?V_2V_*JSP*j6)G4OJ`vsBIwoXk+g;p@E^f>*kDT;{exY< zSsuZOn4r39*`2C4@6y&qE!EZAR(50;DEy2w-v`Lul%}GsQbQJB@bKwT>>F_{ezR)F zZZSIw1bb*N(EpmOW&EFps7&go;W67Gx`v@G#2pbf*{@OUZcN9=tL+(6W9&p)YClLq z8zx*QE&D@00^*u{818muNBbd#o{bJlqLnmW?We?>YN8kduO4GUd;z~$Qu9dDDVSk0N6p;U?XK8c4cye_d-`K)F zoLw7Z;QVxNbAQXlqbtWJ7q5Ii0Q>+s7t)OR>Tb&)IEg!;yPnIVd(I48jwbhMaX1wp zeiKm9&j=2sE!VcHm=$(@ME*Ms{4Yc1Q-Zv%gYvKFO%_Ywi*-h@Ww52NnxCq=a&`s# z=kAU`Z*m~ce)N?r&t)DW!R%3WH=UV&sp&RW5ew=6)}%;XNs=4F&{@&Oi2l~~@J1_* zoqWFv>`iXX)axjseIuvoKk(|mC2+nPp?ZPqDBTE$8q$6^!WO{+bO7OL#8gY^=7^Te zQc1X_`ru!}8x2PiN>?|k*Dy3I(Hx39n&nnRIugBh z&@k87dl6SX$hkEugH>8W&-$OuOP(;B zPPZd}LW~kyfxOlDuWs$V77b z1ZNq2+S=wv5=s$=9(5TkYB3trMfb&*`k=3^5v-DR#|@jY7(EctQgye{Yf!X(i`Rsp zVFUgF2tK8)uEYH?eAH5E9f}}r0{t*k6eYG4%${p`C_KUO723)!{jTZU@iVp9zL$M> zIudrRg_f$LH^aNH&9Sa)H-LV6@YYD@z6(8{V+onj4IrU>e#gl*Iw@B3L}U(~G+*ytR)T{|3Da7RS*u;LWFv$cR3Q5Ke}B(h?D$VK0G` zz-b~pZFVtXi&3;VJKI*2H0>8*OI|6HCq{j=j|a7ZdJ0G)=1C9s@*l$c zNVdtJvY(kf*8JV-C3L223L*#bx->*J3W!VS($&lCZ1ZHDMuc>ksf0xum7}1+G zz8LDSS@J{A@9;$kou6wH526h5V3-FpDUgsH#QXCUH<9NM#S2%HGGPX^Pt+ro)gs+7sJGQ=e5A;=Alqu|f5-_Hi8>f(t4Bb zs_7;{RfHZnAt-aVI>1>Voy&R-AN05G5~|1>IM+k;*Y?6$c%w#1*~a3}`duKHi#OZ|-A((X-VBKh z@30jcKo`KNz~E$A9{vi$TiR@2acg$3E9`uwi^`!rgq7sck9eJ9{3{}~=l94)Qq#<8gXW?qXXeav$p>`Yc|cVH=E9`Me#uVt zbJ@GVgHp~fQ)@yGay7D3Rx~x(vqK(&`r3e#)PLUJ2-o;dBf~CF#<6=7a?3KnbLKq| z)nfl>%L_U$)OyE6WC=j@5By4?(xLBnb!JLW#*3;++8|~#$p>l_0yX*#IS1fUewc)B z6`cm@F`RR|g4C}t_F>YKl2uA^TFA-3gQ^=|oMT?zoZ=;2DP4#%)jh_aMA>+Y8XTb` z9h9EJr}xsm;Dke3g!G+o@JUB|*aDel<7UZS<$`up8$2p=1QR8VlTSsdt#DCO`_=Ul z(+&gj`)J$nV6~Iq133HKq$ePokj7hs^JC&gEfiyZG|ybXMNZxB@KV$(Qc(zTgJwgR zwRq%qV^n4%Ez`}@v3fn+i|h)%>Hm9>$TmaARrz2*Il2wVPOl!5_GR9zd_+IUe*uB0 zDx16Di;iKB;5cV~7rb_YKr00yXfik9eq7xd3T>RMp^bqy~$D({2qOkgixP^*mNIw73 zY{IRwUo_;7yapS|90!80fzl-PM!Uk;{h=kQYDKitHhh}<)jEx zepxL!#wTWIcD=H8tX5LnwSM46VFucS106Q6SyoUzEO7xC4ODmkW>r&T#;RybeX&keD8DA641^FSxjM5{CM3ZsND+n z)*Sysk$hOhH65pvrhkWc_xB#QqjBQr)GLn9^VE3m`4p>&R4L{qrKi2RmhqQ!^u6>6 z^?7_kV2Y8nm>|S&Kuj*f!~;73()zG6;M|lOo7ie7P6>5l3gXT&xo-I24alEn*hDmW zDIw8pJO(}=i_G|BL(!4}L(k!lY@{Qb?|M_{UDk6U=b3lU|8E`Gk>;b0%J41z@7?r& zKqO{WNYWE)#c_XII2JUE@b4-J`D~sEqNB}Je~m#2dSm6#Nx!JVw=j*br0Yj_Kn-$# zh!ChYr$4ArMft0P_s_>d>J8`q!KL+3b7mPq>ZyW@F)MW2kitZPcgsg3=PMC?WIP0qo>3-B>cY-~$0vd|z}#`?2wkqfy*?t& zQX)SGDlbV9vmyK81OTl^2Znrf5(5zk1DQeBfUFcG`=0&e9cXkhC zh&N#(k<9!+vnZIXdO8+2bBoS3!zV-729hX8+=7}_NN(6qFfMf7YmlYs^~Pn+nJ5cZ zOpHZu(4MqPNK)nG_w~S|wq|}^KsrUsXMA7;_W%?}D4heuQc$>|<&laX`ZGDvt7mW2 z*L{%HvS~s*%(j5e{LXj?a%gRdXI4SxPau17#z=6RgG}`EBc&Pr*NYUDaLI*gDQOt_dbU zo8F7cH0WpiG{pW3CqJ4cfXkCT5Eib*1%kWDa79(V%xzqG7A7?0E%VZ#cP4tB+SFj`CJv&XNO2w|VL3iR_lI;!T`2 zeI7$*I484_9kg%RoJk=4iuOxz#!hXcN0@71O`1j#HXDS4y~B?vRu1n2o`;+NUtS|sB-=ukhTd0fBZAJMLGFh8iB z%y@x)4!|y=%c7l?XJuZ+k?rR5in%Zn*oX#!n_2NO@AA;BZ{Jn_7i0bCU3>85z5f;b zhrheA#=C^O(B;#Uv#%D$JYxMPtMibCU^4vFtKe@2<0U-G^N{P99>d6ZcQ_#Zqe4<0 zr87(Ozqej3eA0p_t#kex+wL|UQU|)FlRk+hm(mO10zoXO+h|hNzE5t`{~*r`ZKB#Y zzo8O#BZCc_DNT3&tIuT?6EgM*=8}el>aFuF?7XTPXdccadhPV$kltZHx1(95Zo=Q0L60OV(VKU*0M7KwYV~OX4rCVVQh%#eDhq*X5 zLj6ql(>6Q^N%Rq4d2hFoToh0U7}|uKjV-fxAITqY%WAG~vpJ*@(G>&CF*C<{UF2vS zXMCByxk~M=Y8j2QYeEW-)NCA%d=fHYs&paUzNj2Nt2o@P7oKA)&t+UCKI9ryxzMJ0 z=w@_eP1yi20q~z4;h0JrmLB=M*C;9hmgOous;vjWN1s-OT5o|T_{<~wqS5aybz{iU4Co)` z*lK(tVeaNf4tG0tr7PDI6EoDU90AZ*(UbY*CA;CjQWXBnx^~ZlQh)jIH_#GnN4u(4 zz~@u$@|II;!I-1!G1)CQc=+5YiA$b+REK?nYxrLka_;uV0aVQyGW%rJaoVd08jDHI zpf?&yz=#Bd+Qo8RYctY%qOJIw-l4h}kxQ1`+BFBuz`VhkDn6iI+oix0#)S92=|g{E z6YaFUC$Cag)|2$&j=WCCwU*dmIv{-BPS{9{T{B%CScvbRr}LL8nBB4jfcXA_&KH{q`B^0;ch!x(nIWW;)m#f)TwWD~4a^RSQEt2{codT1WEs_G$Gq>8;GIoMO#NJr)O#Jxew zG#Q#$cp;dEs2B8=y4DEvMsmMRFf7?)79Ly7z-^S)qv|+^_&_SxD!1R`nLuzkFDlIz z9{z)KZ>9f*_%`iQD+}G%kViCKSx5Oqx**IiZ)C(tFP4ONnyDQ;9Jsg?2H&~{ez$al zq*uX0am2H;11#oD;|^^TQ9i^mV##6z5+D5XA<+#-aOSZ0y`iapcNZI$W_3(n(?cEkJP z{DIfUfPxeYnLI`9olnBUbnORWtVq2$$q$b02qQIa^EVU2WfuJP#6Bkz06CgzVViV$s5+u zMY45dwNSALhRqae8Bs1n7E5unl9~im;O0ibcr3q07BHT~m%Z+k)^qMMJy;d1ILU#R~#`v@C1JI)*L`4f+KgiIwx#s8{@d zrTgfSm2tR7yUKn&bXntlY^lrAjreCP`d2RfiL3nMd-uS8>FaS|d2@Q+w}IJNFX`vc z1D;Y&npSDpyYrrT{~~?YQ76PNqZ$qL9v1HJ)D$-eE)+Fq)ff&ga$ksCNPYxqBiI>b z0E!LLYYz!mqu1Qmg{_l3n@@d?kFUkC&&^zxU{+Y{j+gvvDTpbnEtfw4{mJG%R_Ebp zquMiSSxhueunT(IO8)bh#B%ZdQW~%b1Y;MO)bqE>;{}3RpF#R^m8X{!(2(3~b1zr% zYa#fHt0^C2XyFFx&o-_H&p!~b>B^V>HoTUwzg+J7&kl?b+$-WfF7VO-` zoyY>ox?HTk`Uz75|17{!Z;^7*i*?-pWR6I34l$YtG^SyxYk`RK8-RBn9NlDex?Ec% zkE!sdW&_o*@N=4_T`@BcgG+$Q2Ccbbaa0|lvJm)OUjCf?0Vq$^!TXod7`iD|P)5Cg&4`S8WD-YOzF$FkF{Fy8K|F3Uu`UCpLeFgr#lr_P<+k&qF!bg-{q9<~;dIeb+eF2{7K*YL1 zsGO)MNF#8cdy{>Mf#NyZ#i|ZMYB}0cqzC0`9Ak01_D{tRb8tlgh%g&9$LJ`|Mk3On#u~z7Ic!MCpi(6+OL%%E&MNe(zUk)F|tq?@sPH{~r zI85lvMja+s(!{V8!!?l5Od+13Iw|ns#@h(fUv$d4mSGGs+118T@DI2XA0$ut+hwr? zez$!t2~{Nz)D%m;Bk*wu^?<4_B{P~ls*T2h9D{ql!Pp?n_d2hQd@wnU(2^UgmlQn% zDi*0I|JwaPk7AlJ5}cWpJ)~I_$a2RWgI>0UF+WK&CGZGR5cOTB^iJPIVGG{`IBEGc7(2@i!?Yaa#N_`CYR?Xb zy{@7>mXTUU+AyY>cfj&(1i5d-=g`8IxXk?sc@smU``AdwgyA{gbx6I$YFTNz#6a=` zy3G+jg_%tsXUzL>JXH?ViYWiq{VguPcIquESud^@0S5$3|-HW+^AHQ?*1#loEia-6E(QPylJX2xWm-%#J~TJgz>$E17?zSt`W)Qtfz#2@*aSKDSgOo^%B z0w_@g2z1Hp!qk{E>Y?Cruvu-9`Iab44jW(KINLinX z`Wh23;&a8tVSc|+#mp)ZSume;)W=)B9KEM4etp zbehH*`-2oQlqZL%wUWy}sKV|=Mh3`haOaUr5%TA(dH~)g+D!F?{7{YWLie@#OW>ma zG~@l7T8=@lm`NTWSyrXoh1EK2WA&V5ju3voQyn(7mVjZxI&noBAMn7D(KS>yYlyDc zaaXu0+`=bl_+8(G-TW%m+FOKY^YrNv0NhKxbKVrDnMF6txns`Mp3An{IDdAW-M_FB zlc>Fr{tCCh$I-{Y$zI7Ga<`8rqidk=fzuECGTp*5reIJ+9Z9;#(cmL=Ye@)$ZA`8MipG0lN$4M=o_V2uh{8ujeL=YA zu0pstaqKmJH~D!Q@WK>0)5a{Po<3~;WkisX6({WcN6H)u?5wa&LnKm=wfwghJ} zkMlQEh*Y46O{J|FZD}MG4!4V!a^;KZ!lQ#udE8jL$HjiSm#&>nm+mtC8qKB$^GQ8^ z6OME`Q*PbPuWYJHY~UK`He0;~G3ouHEjjSSpvV?$e!$l2`0$6xBUxNORWwuEwG1Cb zN=S`H)gu2TQK!PIL1WjTMzRKG_J60 zv8YgrpLB^@IZphq=hEd9l8w^w3kk?obR`eTEPt1LhtM3Ek#@_nuxuQz@Y(&VBfcLg z4Te-1MZjd=R}PN^ew3Q}od8V(@g;5X&nrivgw^xC4KUqd1VaaN)`*GQ@67*<{Lu~R=ehx6V%4=Cja8HFY*AKobVp?5C(QqCI$#&+;7hiaSicf z7cPce6`TeZm-%|t$@bl`D1Oq60Vtbfcv{SiMn##aZQg2_diyJ11}l5K&Gk# zEYIh^U+!mnw|5@%T=bJu+%UvYG;oe?RyD(5#C~!q-@Vc^nt~5!+f7il;fw@$mWo$O z_@waxy>mb0q%4EhbfUYj(~k39IP9c*pKy#g;me_rKf3nri<+lQFNASCmd-}EVa%d` znanxfiwOQPYG%IHd6V6!4_0=hPVtiV^8Rt?wF^$S&vVpopOpXbK_nd{^$h-0Oa zYW{GcO*<{X4la*mQLC03Oj)c7z z=;Nbgx~lq>;4b2}19Fe{yhN!2@V6j^uW(tCBQ1A1&vpUr5Z5`YSRQpr0#{#z9O^&o z#y+XG(=XQa1aze82kMoge~QrePVh(L*ecr!&BZfmA~$uP>}kiCH|g~dD-B4X+$}en zJ10&+%cBKa!~e#P8GqV>*B@QI=q#+x?Tx>l2Ds4sP2BUi6>G;_Um`mwE{RnM*bl$o z|N7V?nWmxmu&95_SS<|<_?~DBv;}&-++KKBU;Aamu)xBW+7PI&Ir3qAOm$3pOr$}o z0mmRDvsn=%GXT{N)oS#`Wah&V_B%d=3La~yZTa6tOan6nl0(?vSGKltePy!|LpXO? zxV$wWdNxkb2$>z(D53M!;f2e9eedq}4v6!{lM}we-th+)=Ak)CZ+w z0QoYSncvTb7{TIfqHq=cr!oB{vCv_DDM&ORFO6Vo*U^FM?M#I5rQi{HcU3#o>=)cs zo(QXNTT|T_+;wvL&uAJp13kOc-#pKTy8M8D98rE_+SotS5H6BMo~=&uxAo5_a%F2s zU(u$7FYq9(2a-!&VT~)6udQb2{T*4VJgdHOTy>penNR@jg z`}A2ltZn%iz6r_XIX{ITz%7EPSNneqUxmL6-A~Av?;{PiC)0$%cQ=&%@0UA;iTO>5@B7)Bs z?Hk0r@Val(Pzpy+F(wvNQ{nj1fAJ$*@vrSHYPMKyfi)V9+-R^1h0pItJW37R6LH9W z!m&Ajs~mw3^qZ8q-UBoTH$%ocGfVCNA@h#bq!kuiC&{8-WZK9pEHEm zOMc4?qfO;B=Z8K5%+RfGLhtKxr19b#@GpT@T}@9)QGyIqy6ae~s9PA|*fXCu-X7)c zSB|xMinl>Q?7bfp(Z1;JvBoZw*5>^(H}8yUvu8qsYl83x2^CRH&8s&IAInbhcU+ZS zxnO|jt6HTQ!UhlZqT5yNX7eW}RX%+)w5D>E&+g2`X4|m~w8)nJs>oB|Y zbSk^ol-E$zSCu=}h=sk@DnyzHsmUO!%=p3?#)%c_x^{O9$9 zy|APGRbB)yQ1rdeuppgtM!f3TZ2Bh?z72o(Ouv|}+9B@Nx_U=)u#+0P{oW4SJF4fq z-R~Yzy`M*p|MqgkIg-%t-M?KaXt<2;)H)20srG$?gbv##i>l)v&UZJz`xx&itI(|F z7s}(btMo3Ka{%Qnejeo+{4U5%Rgm(HIHAj5%#XOE8|G#8ArfY^!YeSHN;hxzKUqb_ zABSM`qbnDkwK|&Rv}F0}?JJiaUAeTrkHMMmUEbhvV2Z zGs`F!PO3oE#Xv*Q8W=?VO&F(Q^Xn_(K!${Sx(*I*7&CHfq`Q7IUq9Y~-Ts#~RNoTE zV0YJd*Qdr&3|V!y!we2RRKUk*U!Z2ph97>P4}!}KL3NE=Lr+J<@~?Lm&t_eb+nFH8hR}anyAxUafyspxQ`dppAlWI6{M`2g&BWJgZrh)PZL>*C5e4D*O&#;gsO8?D zd*R#VsCc8{n+MKLfc4M7Qe;$}1u00}EtoF#7FB;KD|+;KQkef72Q7qi&&i1z{)o6=^Jl zw&XSQBxv5x2eog?1$T*%R-EBn=&_>AivK!H)kkYnDA#2u_p5er&P&+0t$s=+tnKi2 zL?NUI5~N8%og6xdyNa$Nat?);!jn zQQqYEr1(!~yF)3xP3mH8m-<6{+%rHa$#1 ziwJIMUb4^K^($C9h#FlZG;S!6dZ++8@48>c`9J&5!W!oji)UX@5)d9*5|5 z<45OJ&6^SaHx~TsyVBsXJu8n%m+kQTd~c*a?&{LV#%q7(ahMMWLp`CKt8W4=(qry; zZ;G^YsGj?xemWxo?n;emiLow$!Iu|?%9TmrI!SF??Le)nHnJ{SLW>%HEX!ETjA^U2 zElj$^hXD)GLuqJ`+~$(ZuP*j#d){OXR+AiZDWr%#+E{7KS3QvcgbVP&3v7P{G(i)` ze42!1T2L6sk0|JzWpmvoQNr!7K<>hjQeDhOJ4u4K|DHp=cNN_0gO zI2edJ&w!8BP;IQqHwrqjuPY$pG{kh^a*a32|L~to^ zA0@?1-0jkBfU3jZP`;-WCd7(fpi9wU$G3FQ9+n?yZL{lWllIZnTA?y#IY4_>BlfHBc06hE=q>6AO~9^k3!+qkGdQnl*rA=eXOmHIZ=J$^Wu;s z2gT_3R}i?uZ~b9To82dnv}D}ggjh`=!`5q#1$06+d_)ZK@aG* zK+O*1xMy~R9xf65%ir!$!OM)^LV8Q4|0Q@phG;fZjdNEOuk81NMo{+5GfH$XqKf13 z(M&A*OunQe@%(!MvQ(jvf@>~xmq$C0&y$|M;0^tWvhMX`Xh#08y}IXZX>k3KmB$o| zPM!M4Z1w6jA2{8TpmQI5a-K>Llm+HpNk6yBIwNxKUzr^V&(lh+^KNi&aJ}(1j<0FW zf_VR69E(0Fd1}|K-URFT zV2`i>^V)WSb8O{n2{_e<9A&-jR2{lzG5! zcD#fnr@R7+7bwoZK_8Dpg5!U``me47mWri%sVzoAGQN zD{S+s!fM(Q`GhnC*ZK8Gy@K5%h1W!~Gks7iLCL9enMY+S(!Dl<$}k7hJz5PWUyn2> zN>fe5Hz1%L;9%fKSoQb2f-!(-uLF}Pz6t>aGtxXOT)PHqHUA6`%y>s*PHIYM7Y-^q zX}Mu>N`d@{``2;JdG{)+9cTcPi@$K@{!88+e-xdy)xApc2z^o|kl(|&u+no>7b|q+ z)|RgCuRC6NyZ_b-q)qKF5F=Yin}=`(ZN%!x#%PGCymMkJ$s}Ij?U|zg4Ee}>V6@_d zKv0G^2D*w~44dshe#2u8{+obJzoQ4OKDV-^f?9vA&AQ^mi~7-(NvoaQMP@s;jJn); zm9*9*@#gZq$Ig9OfAE~~fuk$dkH2*C*q&2TQmr!Br8YT#SqV<~6=@W4?=7Y}x-0gy zWTeJ6B!VTY4*Rq9+#LAj$o7WMb(i*BYrUx>?Y(4r`YC4e!Fyj>-?P^${&~Ytyc^_W zk9YPP!F=76+nrAFM#w?X7Eis?rTx{(6^Qaq7V0Xcwd<(Y(7 zP7(i@S;XKU%_lMu-GD&yB}U#P(Mwh{OQh1!IPrX}O7_J0aL6!hne->$%^SB$8M(EG z&enF;3$s7La}`$&3tX=c^L=#uy?ZWyH~>z?n-+QW6TgI8r(?32Rr6Jl>vILb zeM(P)wbDh`DQATb6E)lSSJE3L12|9%R#uZCaZ;WC?!JZB9E~Nh1?y*MuT;$DVOgY% z)+tG-^2Aua;An#HtH4ZC`mUN@O`4JTFtd4P`cg-m;I>rfaxTJe2%fQ(|282^BHw^s zYO!i;GyCcdtOayQ<|0JQ!FwyAJ9$qNaIto-MI=k$9+w*9U&MzigwVZB*r-F*naNkN z@A_{&^h%H}D7v#AsSeAS@qfye4wzp~wCYO0H z^9DD<9MefbrOFMfddbGtA&Kptqdh@|kX7`WjYdT#70d*{w*;1L8SLUY%9jO0W_yU# zE`3{B%d`s69u5tRn{~iEs;YPnPn6!^{wJ6oZ)D!7vg~kl_PuG$tfTmJDhxv>O!hvW zCBs)){|LJCjqty{aN!T`&g+SFON3r6Z*JS)S@U>Bz;vTAbLHrI=M$;nQD0ESJ{b-^ z_Fn6%_AlzY)nY$gVt+j_;d05frhiNeEWWahtuGc{S~-$nYHi=WXH;(jPuyGRvovUF zS8%|PYbHzLs$#sB;m8br)vOAi*}X-#ETM#W?U`Sl_>nq?6!S2d^ z=h~yJ?h2&kux`T@Peg65;sL2x zL|$lSDLGoC{ExrYy!Ky)Z1J6;btPY6SAfImR<@ZE#FLfZy2 ziEwa@bjakMf!`8+?uErUJf*{~z=vHMz1p|21pmymD5}EgxbEE{)GUttgvBBH%3<5| zil=1NCLJTmN7Fi0b6GXwsMb;Ev5rjn*!O&O?>maGuF>92))|M~-^HmwNZlOB%b{^# z%m~$vXAA@@!q?aQm2bH9m|ilQ>Rde${v)^yh+jvqZScFji&6huX46-6twpRfKEH z>z;@m6FXOne7*)%2o7o58a?xH9Q<`lIl2Eh2y_2|)t`$hv%nn;)abq&YF@H%5W!qG zdKhQ;3Vwcc|BJ|-Q$6ZawZ}u>c>)Ro@a2@j&0X!T>_FDA>QCap23Co8A;s}Hk@cU2!(s$UZo z{~o;w&*|uRPD!GFaGf)Sn-Sby1qH%AlC99Sfk%A>GYg0!kp-u56XBxBdH0xv<)_GMbTN9?jVDXNlNZs?GtPjoNWg?)zNiB?0PV>n(HtP?Gb!; z@eBE&%5A=z6$Z_F)x%eae5axv{RB_oE-5kC%f&W#4egg`83vt#btM^G!<_u?VOp(*j?W~~S?d&(U z3-PBiE=VNh@@EQ8bi9G~lB%Gw-E+>K_x+8#;m9r&eH{CuGoT+6wuJR6BU{|DK$LOr zCFM!SOwnFFp2H$UUm1iF`W#XrsYLC2O>)))0sV<9joiUP&Aof3Gf-N3F!`hb#N`&& z_|KP>q%5Q|?1uH zHmtND4u}Z8)xn|nr2GZ(aV}%c^apbeIk4Z9- zv${BQ%Q}HZn~K8Qfq^8uRrfaf+hn;uqei|9<%{%*-~mk@-S}R;T^kN!7HK-08`jiS zDY_`EyQN(l2}+bH3ZpL*)RxjE+GqIZpx_CWoA8hBc5SG7xdQ=bB0%OaPb)P8s*tWg zvH-M}-X5itkG!`V3{pqUL=}cLDsnxcJ&n;>Q3Ha^(neZjt8I-Omy9s`fl!rKC$N~E zxeSR+L^wgWsp_dqe;VD4-ip~R!hehim^|5pvZsXqa23how*-+hgC?>Ms^Q1uHR5)a z+)rQmYY|+Nd(!AV5+hMcJcn&HcA(ay<3{9@fha!L6f}`JCTQ;MOTTP#?}C5O$AJw< z`~y}Kc5t`V>I|k}C zTpeH^fu^P4P3NXof&mi+3u|uo3PNR{k8*Oe&>=5Ue^yRfBj@l>Fyjx@ zdv^+Sh61?4uQrk!`qsU4cs#&T-wLjleXU-iyH#~2Ei$bt^VbUWFs^JOvqZa-^XI8b zgAz9up#{&uYsAqMb;SA(wrWfcKV308k}r}N`^&mF&!{9y`f5&g_d=H8BvF>+M2%Eg ze(~@VtYT~y&s$+HJV`QDSM>}03npSz2L!vMU z&5JfsJrA5BZ0R$OD*B*`!=4-YZZZ2z``%5hxkbbQB?FIevi`szXv+_Nm@x7m3gby^ zo1BiQhTN2y%}fF%l7R)fT~>Ydx`b52D=G5+T}Ft`Tfda|@vf&wb^FyIVMf>u@v{dv zW?Nc>2bVFMqCuImG11zmdOHtP8ahXp=j(&3sz%A5lxA|AY=zhhj(qwfJ!vA>9#he( z$r~SjrMgio*X2G``?*qF)U;(tM>X~Zw(K9>jqpFsy0&o&W|-iwJ1QSScdSX>syzK0 zbsqLh;#y(LJnH5A3H+uT8ES)>?e){!~HcH!asRInG2-g-s9QyzWz@#Xj10OPZ^m zr82nV%qxnf3Qy_VEM8aNnc6fv&|*er2yvgo(3x`1c*$}IC~ zcI>l54~mu#Z2@d+8&CUVG4sL%YF_~GoL2s`Kr4|6@F}qlicaQ19!kZGanE{{L%8b=E<}x z!5r)G?c}b$|EI?bGqAB!jG?)XLlHJ(WaDtlxoWC7Seko`mMvvC4lYLB_R;@?Gtwu# zx3dcj3-}oa2JaAebUtX|mS2HDpAnsB%mHbBJd6#6K!Q0g_Vp)^1aQ%5-Vt6}7a;pVX20a1m@DdC9%$3t=D{+)3W`Jg*1Z`q# zpNB0F1&Q(fpb2DO}l z2ukS2^*&8lY9HH!sXLxSqV1I~H;JS-u=`vu%?%j~Z}A#VAS+h~sRmKs2D!scq7qq6 zyuXF9)N0Q2QS}3P%?WVvxCp`TGKr&i(M+D;$gI1^x{fh+DBiaEHr9O4cT11UeXJUl z@cguB8dpXDgZye+xN7S0;-MWUG9QV}DhWT^B4KjEBtbUR#+qPDe-=1G7YXy8jsy!Q z{5j!ZM2_{&B5b3zD=mq0=!)o0o5UcxAc>IDKCd6=9o`uDJF7)rnHrWQ20P>63+b$P z7LB@0*+cLOS#+0&7qaG02~arys;C{dX@pN9?p*xaoiVx;TVvOIlLhtz$d}rNlZ96i zjas?7Y%Ziu=X-YO z%-aDx88pzF$hax!GZ$8Miv6xt0D3TK;FWdCu#e7WShDlgtLo4`x~pnCG70gxHwqJG!QJT*=9IbtxA66OZbVrTyX_JAEq2lRed44C2O$0!f9ujb&u`sqCNaP*+m+Y&u7>LHQ!b;dneJLiVkjWigfG6{nYr4oXK)9zlJ{Ysn)mV%K-N zQbp`K^6Qw8d9PoMEH!z(|lIJ3QWg`l$G2H8qy5b3@1PtKai zDdJx2SBTM@sZ~cy4t*?MZ4K&{P0pG04~t>$lUIB_8iAKLxRZCh7MCbo^(6Z<{eOx{O|e#R z^M??PsRC`ey|ZdjsMUd~1{k*Yj9-08Y#>fM!B63KvBwUwUtF9!-)`7pjq<^k+g>`r zF%kI(^Dclu`y>TWNat+i##IzwAwpxPsB#kW3)$IU9?yRN)SKrQGV9(Q{`IL00eF~e ze)I^<=5TGS{Ugdqy5Jc8^JF1JUi*vy=vf_@cut6{mTNtbHo3NyjtJV&4V5G1b=w16 z1Lq*3+&h;LY`Bw>%AvIfhTPNs3iLgLD_vSi?Hxa0dzESyKx&?Mn<8}c`vF58_UBVD z)S=k#u`TH`nB=Z)L{CeagUCF0&iraDf%Q|cwy+ub+ z;Z35m(#yfXZ=A>((GmI&ZcT4ZHCL55X>{6PFI)LavTLfqN7}Uwke`eDSm~>~UU&s| zbP0mZeYE6s{7KrChF8oz>Wq|O{V7t69Q&=gX%Ygrr!#Po&zzSQDUNBBX0@oU-;^Qw zkUa2{0KvP%D8RpR{ivauy^VICO!~Ue7InJ~>noXgB^T&5;9tj z>p>do*8r&Ff_DDrx@S$f5;|{#QY5tbIFL;pO+M(7Uyx{owyP%EV zrNf#Ui5hyupo@)CRr={jGex@KT$Sk7)3iO)_AIo6q^*a@l2uOfmiJ=VMsed?zuFFg zYGJJElJ>?26o`0XToMzXlkc9zW`C*1X{4zB)}5%ofJ(`5l#Mt35^Q96&{Xb1--5CZ zg=aZ9yFXnm&PCYh$F(C5_RMzzxRmbK%q&8vbqPgxT5crxkgwY zwew`!BNlN;ZvPv1j`!7Tj8s88$n#5?3(Ts^iXr*2RlhEYmJ5~+q{~(!#bY3VBGC-X z2b9u$xNIOd+fU*?(u=$>TJVkL@v^@7A-5 z9go(hzz(XyGiVznykOhu8Iel^=_5^R;cngKj_@fbu&tiX1ooc>e}h}|3?OOW2Gzh= z&SbEhI99noT7Fnn(k94`V?3n7@s#^)oEYsE1A-s@Q!^w5=DWutX0J;(@llMghN^Y{ z_b$fX9r&%YIu}0myf84ga5!2k_*L!Wcfqf2Q+FZyj_Dt~=BMF@+f<)=;93=O-!qSa zam}LQZBbRbw;%S(d+oDP_uXFLwlUfOyjJ!bX=@at?U+{d3cOMzTSH$Tv;?poU%4;) zLJ4ey$OxQ`HAH)6R*A3=_CnhlKr)OFx`H9w%7WGHq94Z?lfRCyT=6QQJQ#agrPq*( zop+-H%Ydr1x2hxci*&=X0A$mFP_@A?Wu2YE;2X5B5oCU}8WpNn);LsKFcnGj23aNvrOTj7DNeD>`_yN<Jatnp0@oB*kOI{zx7>APW^ku%s#4E^aWJu zE>*K-?1*qmmVsSq-5GaV15NPN8ab(yY~&i(R@F8+RHL+s0~^23vmvdMU%;x#ch(P# z*Ds%F;`GAk$@1$rtr>XJyxbeOO+vd9K|fjEI^^UrtP$RzSVE=y26=i)f)EB5go3ktsU=F(@kl5ch%n{wm7If?u#z8Wr@&4y&Eq% z2uXYDJVMv9evkfEGx)5Y5VoFcKg0>I5CpYbuJ{L2z2jU3g!$ZRLB{uZgGZD0B%|Wo?AjPU|_N;qN)l1}m={d9@mi%ua=gLGU}T;TxnKL5%W+m+$k< zWKl8PZ83$L85@s35==v;{dMx}Jt5u?i55t8e4FQkILs*&s7)4wq3psZricmOjU2=! zI++zEjCQEdhqHF!W~RZP1oq^i@zF&Vd@xc?k77FpR;uncT~gYmV0^?Y9%6bDHfGXL05ij9DY?DIoRXuUgM~T+>}IF#Xnul{gsyardnfC zu@_<|i8dyO-K4ZFXPg1FOw-IU31Ggqy=68wYfRSK*?*^@(*4q?bCP?a^iaQVKK};& z@%=sWu$@rT+gfjqE!j8RnwZ~VRQ=1DDEzMhlkTVd=0A8F(0j&i)Wy?#IWDL1dc(m? zsx&twPwZWWH+%g!CJBuHzR}tN{kr!gD60Idfz3nFUUHx!Y9q-arN{!*3<-`o`@>GO zWAFhdc%b8(=(%+r^QO>e@#-=>S59~nIL7#uB)zi<8rCZN2YtSkab3~E>UV|jQzw9! zmB7@dMDAX6ZhLHb-0v3P);P}UT67(EK4tHudCMGc1>t_KCU!ZjkDEL%C%7E zz$&P25^SdU)nhv@cC=S%&8Sykh(gYn`JrnE{i3WLpB0(A-RN;@xg{!Ekv;9f zY=wsOH|oS4exIllRpwj sPme$k6KzT&>xc)GbPB@7ZkJUYMk_r*tXlrG57viG!s zsoYo<42H}a%}Kqe)4!JqAL|1(0A2!QE zZDi52!>ltU+u0ET+5u8tKFr3t49h_b^nYzr=R{^yH+4$bGv0cbduxgF!mlO{=-c_4 z9}OtsY#F8m;WAg5F5KBko^@=gjpyaOVjS&XY*~1*m1a8%B|F@D0<`0bcjK?Hc)BQ1 z``}(w_c%v&gFs6u>L-K}Hu6Ya7(o~5I%5kW^nx-wfN^T&bn!%X0}R<#cvT)#?pPSN zogtgG^yJ6y#A@=gesV}tBF_m0u$-O&n7j5Yai!hV659TA9pCGRcspeW%d?iRSjT(! zLvh`|i>LklIR`LtQyU5X2ElO$AF_|fAIKwUF1%`0{f$p=7ghAFpKq7OG$=xR)bSSp zSH8rsA@=(>Ri~2rHwCkXMCf&}B$Gt4 zDqj5tWNWPBJ}l)1VPE^yFBkKi!`Ns1eBRvXR*Aj2X+R~iDvp-c+!BmIoR%*QVGAh! z``n6&xC0k8Sn{73#@g@;?18dQMA|v|Z+_=?#jz*U+Rhg76&~h342ZMg;>F76R1j~zaM-Wi<4*Vyh!(r=VutN? z3;V-)eKK*|cI&fxDN)bg3AXX?_&YRJM(oCawVtB1z`H&544j!cj0aWpt%X;%ae%#kFHl%RJIg=B*M^^&Wwvt*mE8^|dX1fY zagZ_AV4SK$&YqSkZpulmo*R$1sVv%JDKS-I-T8$zf(cn*Yt2{nUg(^FI(SOu_0&PU8pAP>3Vwemt;cJR@Iqq_BA}o!)+5zHbQR+Kt zdnPj8%@sey@z9Q#rCr*H9&59fnr21Pj1Bu8^Lo+`h#^kZqjjgS~C>@zMv< z`3AAalHZ%z##hf70yOol3EqQCis7^#R#99!*kenu-4NhbHncrl4*eY}Z^#%sD2KsK zWY2b~s1ALq|IPMZ9m3uo`=61pv*(9bmlC#TrZAVz1?1h>TIY4jlU4BWMfASVsD9depUBN_^_?EOUmXHyCLMF(k9>(#q`*rU!-wwnhsWa}WaFJ#`g zGrh5Re9`PtYa=e*D#cEZ8`fZ8y7q9xt|J0%$T(~UF4Ius99qBduG4ZA&ZJm2NaEx> z$}|TSMSW6>Mz!>`cn+^j8A|G#+60-2LY%G#hPQjpU{lRJ_p66Vy-*X9=JET2IHSBy z6EWeJKLWy}N2DeALQh=GX_7^pM(!uvc=qX}#clOXG4#+ySPXG;6$c#eUX~*Ta%kQ&A5x zbsI|a8P@cXFC*d)k-M3>{669JrnO|f85?8eVDm%7Mhp;}3<(!>3Q+q?58I}p8!s>8Yi#Qg{RcAb|mP+v0Ly1sz%Wo%!fvNnBFtHSy2do&hTXhW9rrDxx?}IpP{(X^3>0i zNLJPTL2YI2lR;-b(iw`kXDl0IUHIz2tCnUf&X{Id=Cp4xxn%i=Ajqomb#dih<*ioF zf!J3lSP{p0ybwk{YdEbgSqz{1ZWhfXh#L=JtOU;X*ySO&eN&Q94Cke4N&Xz#Rapa* zKVcu2rO@{b$aX_Tzu0MVc$Y|VocTfS=aRWH_uWQbFzbF`DZGWfAJuLLJQzkb5w|w_B}Zt4kg@g}z4e@0( z$cI~?&O%4D`1})5L(GBc$OaEq^b5|$Zmndod>7F0z563EVXMYilvz(Oz`!!xZaaI9&TdKOJg0mkeF5GQ!Q&lK>P`f4Gc(e(CuC1h?f_2O!~!v%n|lZF`41#}^&i}Kv0l*5o=nz87$*|L3;WPw$SKlqzFUFy63~B% zu%>b80*%8Y9-900pY6>>*;dCE+aHsKr)|GjpzP)wlW-2$ley<75dXICF}T2S@K=4~erpR~AM}xz^HlycIZljJ9mUyh@9Q&P8-f{oH z$`nQ}{62zMDL9wo|562=N(>Y)@hqTe?V*9RS*k<%Z{o5~ssE!#@ojnn$ZaL=NT-d z+D*{viRi5a1kVPD-eY#WjTbc(O4ePe`(c0f2PW)<;kn_UeN~lFoGSWD*{i5g4Zg!m z*!ot1L)>Sote<%gJBiuK&5pBk!fDP?3*y2*QBDxtuIGkThslNu<7iIRxp+<*W{YPM zf%Q|t@&W^EaK+nWT~J#E*j(v0j`vnEV+Q;p0f7q7^CdR)ogrq+ZA}@WmErM&0zzD= zjz5dKj^R0~Gu(!2%aqrhLzVFT&Vco%(p`9vwS+2j+Bni|AT<7Rhx?iM7?uJSWq%VN zLsKLYSm=R9l+Pa8&!^Qj*qRoUGu<2#vTYs}@ly4%Bv=-Mn8cgE*i~d za2$ED)iI;MHsk~bQPv*mWcxEdjHvi?AM6=qh$P>m4E?M&rUbyCP= zky8WgBL_~adK=`V+6ttX(HBn6Uk1(f7wBJf6YZ~!OT82n^1D-??~u>1ny?rcT5JFH zd!TwdsbE4&5@u?U@2Nb5*|l8-SG`4oi;L{($gJ>0QTQ)c8Cjp-vQbHD8$5^j10>ZcG~VL19W$`{yaUu~Rd@TVkn`qzBf7=x4jIpsVxt>)@L?C*NMjZ}r?D z;~#+QaB7cs&m*VObP+S}YR2L*Hqf1Qhrf&Lc-5=CgMRk4x>o70^Mpt^!4bF1YroMw zKK2)aUB}7Vj;#{T|L!InY7;4|GX?I+Y5&Qx`qkCgfLzu8O7E91l0HaP73cIok@r|V~N3wPV@R+kPH*o}^9Z4FucFQsy5^@c7EEFOB>>uSNTbp|Afb)x{9~Vx)b}rm{4!ZV#p14W`mV-M_0q3MgcH6U8%^2+d zBTAW`oY}5w`tGq7;HM{FbZPF@Pgr^yz^VvXd>L@do9j9atlvM=_4n7;YejD;80{|2 z$Lu8q^mXY*`U*Z^;aPyOo2(%~8QpyMVvpiTUIv&ty>q4g`=@p=C^rpN)W_-_37+xy+I5#m}J=UAr@Q>JjJ~ZERz2BbV^aH%yi{kQiGUk|5 zCns`YF#dtSrIWMgOmmNZLM9XX$k45HLuy{jmf?^Y^Nt!)&z?>>I*!~SUN4|>(-F&7 zZk?EfKUw4H%I}Di0GZ~y^sEfH<#Nqhk}jAH+5GIA^Rh?t?g_`8qIH5>MF}__!LCQJ zLpr9#F^{r2CEHK28mdl$*@_(iF_C{MgjqnOArk2pVSpd4Ojz->F@b+ez=6RZnc>ns z&4d6EgQ2W`SiXM++j?e&v76BoN%l)J*6u@59J3y02uWAKR@{Igl8w~m&^7MQ~iS(NW+HqeuZr{*hj7tdv8}Q+{*Qk7oWsfwck+nbtYIY z94m=eXy+=|U|Sskhv$YWWM69xmf3^j-ai)K7H=2YnGz^WhZ5P*bak}#W^QFJ# zzLVR6PW3!ZNbBUtCb8*})@dw~&CX2Yu*tHKP%&&b?2jNJch0f3`R_WfKj_VVs8*FO zg=0?kI-#+^bY6H5?nKFZ9l8pTE z>Frk83Kz=!b2&G7l#a9=js^=YFstwSx+?_l&F5K^<1y3Zf4qpH_m*tIr)K~Sd%W6G z?aDa5f9eOCsllMzqaA-gE|;yBr(6gx_%y-#gMDCq)H$HvxLoPornrM$OaG`M=nH9GBh>;+Yxip}+FQUZD*n>adRtt}vp zo}y&*A2DogZp$CXH{cuKzkpod4@SIlPEYd8J)rEJ(T;r>&UH$QxOwnTwI)_qek%#+ zI}`CH{>L{mjv1ZAsGPBLxmq;e_*mgh$&5)rp2NqDQAJk=DhUB9Y$#h|iwSrEdK-{{ zO844qFo2?gLEzG@#B-nbNKg->-;47dIk=f1`%_R}umOI)^*~9s{iG~<>Z0IZw*DGZ z!Ev(ciOSW<4hN}bu4wQ(3|k3aaBZ5u*q2R@*lvG!x{XV`D6UL$ z(`a6c$a|ogvu|;fIZgxj0fxAxybf9;#2Ft2dI!P`wvwNnjH_7dvS7W2v}Nx7?$F;B zuGnW75ZI_W{CcZwbB&w-j=dz>`#$u{N34(4Oyk8BuO7()$w~eToVU{X59-!R4)+a{ z%yHWw+$^%|DLOTZ*r6J#9%1WM;r9a-?u@{mp(ia2M!8IHmQ4=J^L#1wojCOb`8rL; zdosNEkhy>2pbvm>$}P^TS9Y9N_(PVCs_&?sYb`v&7Wf4Z?n`p@U{Oz^)Ap)L;k1828)7UdXBy^-bcLRX zY_tIV&!9iPx}&~%^J1>s&NPV8?*}nE>Mc(Fs9t6#>3er?+pmHF+_D^~sv!c(QwM&V zA$sGUAo>aGy7gL1^?C#UhD?0XTTLeLzpoWyHbQ4VcJC^noKx-MarP=2XWpL9)Q;LU zi8K>NYWg`pvwE4JG`Xp2{1a><4?>VE@ZZQ!Jh$*`=X@M+o8gn2c|Q^rAHAxdrP_#T z*F=`f_c~OJJkmf2BI43UT_P24m&@`SSRkf$j`1rA#Wsr%Nql{l%iW<#@?{Pt}7JfU0gK7h3bKcBYeyfXS#ivc*Y`6T* z2$tt3*}tyU0E}G;Uu5ZR=q7Qn#_jVm_*(40EEB&Udt!hrr0)#Uhmq7f#3F}2XZ&cr z*_h_}CJ6i(J8O4j<@Gvfk)-q9x058{LkZ{}*O*EAd-v_I$tJAd|9* zV?0N3sZu!&dAc$}y-aAe2Gft+z6fkm{0Ax%*6}|M*y%*M^ieiYq~y}*@+9p&a(@%L z?@Uc6C*Cb2H&j%liz$+vWmH~W_`cJTv2Er9N2fo-M`hQLs888y@F>lY-{KUPWzyZ+ zOFNO~x;)SF?@jTC%&DP+%zPJ9r6nERIrT_ElD$&w(^RCwW$hGU{%GeV0U! zS$i_4S0vd)8R5>D_#+F823A+du0L<%|8ceKck8KDHUw-`9b{=-~Ya~t1MmFnd>*V=qh&~kY83WM^e}uy9XnZOj zKMtN(CALWE|54l+?g&vtN3t>hnFi!4-)T$kL21|-dI`%DB2MiiMMtO05))HR7Z`i- z`=GagGrOj9PtRXTM>hVt`#H$f@vRd<{}+gHdC2Ydg4$_43GF%tAWmQ9)i=UN&kILk=_QZRmP5@U9i>q( z4I$4Leos^A)-4ttx+$cWXvk29DBZ8&`Q0|vO_~fOrDQCmvU6$aV}Ycl{YZJ@jX~&n z;etsAUUf;2m+JbwwMI}69WT#jRR;ZttZ zbAvJXg`48Lp*uv^E9}HRH{F;Mv!HavxL!v$>iGheYjLdm`07AE%9LCwdl@Rm^3Kdl zohxjxf7*g5iD_$;;yO7VeJ5?!s>yvZ>jpBSMp$`+qM=$3_66;&|BAn>$h|>KiPgZL z!|QSVN19anc-2M*aGxDG2&MG?Hqxx$A!NPPowLC-dFVune$c8!Z71be zskX6r5J3xe&5|a|Y}FE|DLs-x*ohe}Ct$=JBzF)b^4BZf11@G>TZ7$)x}tou@CvnA zvY!<`Os*hsEqK0{$8X8yOa1zaLTmj+l>nl3A7jT@fH9L5n zTfx<0M2k#e$5p3#*TJZ?3s*CZ`Y5*`2-b;)s#&xJ|Dkx1p+Aqq#T^{4>X_zJn`En~ zf1_JsBm0bnkKl;EICWskbff&_d<_9sa(l#av7?{-kg@PzO%u_JX+mmo!Q4%%4`+He zA$l?OT*`XjHrC`?^ufxJxb9_8e_)m>8RpBjXXQ{fl>|De;Jl-g)fwLtmk}51xA6XP z>t|vDpq7~7ck1g5^3Cq>*_{!f@!+dhyW&7rVZTvYO4}yYrd&Hyqu7SQe70%yC3cun zalS&kmDI^cErTx?n`&47rF@JWa{H%w!U;$E=FkCW)dEsd31;iWDBq&S#6*fVd6fj-xB4FQcu2ha~p zEdDA-*Hm{wAJB#s!DJoD0hvuFDYn&D&n(4i{9dL=GW8Zc&_!uwXy*d8$E((GzSj?d zQTEVQ=R~u`J1}!scht&nVjG7SV=O-V^-%ZM$*LcITR8H>u-}rP$w%hO8lbg6P^xlf z<*_B-WiL#0nWM>HF(y;lcbMG(O-XvGW}gwebn!_$*Zi zlha|LxSdSWq{CWP#jnK{BC++6++$mK==`5mHjgSl%%)qI6EZk^;tcu-R|y$48Mk~G z(r@z!W$^*l8SyqHUJsT{2Nfl^f6;6hoD^-CpW%r#St0gIRAwJD7LGA2wEFlA8bnv4 z0O;>AZ+Q*(G*up+j`sbR2!@{-LT1e-YV9Y$TgM5L`Zi4KMbjlUZeLRTw440A!oAkx z>O`$DNEQPtKOKIGSjSpCFE-BgeH^kOmB=gm7sie-=x}ZeZw<|X{$@LfVM#J4-jNUL zRi89?M=H_|8i|TS92AJZs2deVzg8Yw&;9^r&yB9sPz1^Zc@Ih7WI9b|FS$0_;3v9B zd}fhIssZ@0MK7=+c~J){|a+c0;~bHgueZw@s0wre(qwII_N?GiIt3H-e>6$y_HHFhIxH&J|oV%hf6 zXz<)_lxts6spMn7n;}Xo#~s&w(UAT16S8`;$R*A&(Xl@_7dL)Qsb&x(5pA@;)3|z^ zY8aomvY7trxetfkVn)F8&4VT4*MkS;CfQ#B%5Ao9c`bbrSrddl`R7Cab9w5^({^+3 zzNdQzS69ip8(kW%a!?nOY`=@`+0)))jtrZaTSl%4*B`gyUo7teFppiPo~AEmLFZ-+ zSAMqFhx0c@r&*-o7c*tDOS6j0sWTeV-6m-L? zc^yE#&c@Zz?N{=)Joku$3!rXqm@i`ox^6&}l8WimOJhMM!!O1zwZ`rd9d}ve>>?KA z)&l3Dm}B_WCiSC?Vr6}vMcy`{=M*dp?bbV}Jvulmz0XKZs?0<7r#uOqaBZvI3JR5% z{;0Nu|Jlrj)3zY0YwU;MwmNLV3)E;2sGGs~UY+uZQC#UZ7&LCL#?X{gNciH>7Yhh@ zKb7;(k`}LKhv;&5h@pJb%q6({uAn5Ce9ZnjyY>l4_D(${B9uEHDp+12RH)3r8ZSVT z5;DSp<0hu8CB3wM0~nQfQ)BV3qbKFN>jk&3y6z&03*g^4ld9ujTfVRNRe)sTu)WWH z8EDnifMSsw@12u(9`aL`@1}ia9(~5q=Akbt3vFWC75@DrHzkKNL6k<|R-!|l;mY6# zL~2{(;T#8SbuRVo@}zy;E_T^~v=@;&3r%K+D`aZ&Pw5Az)4+ZyGSEs`n<#_j8uv-% z3s<(y8_?_Z)L|2a#E)O>rqjf4b)n@1r-r4%{JG`0NK+vICSIMLrY0A0^iAd(Pc`Q7 z(qmB6K=w~P(HkDxBCReNi*I&pv%k0jUo=NZU%oRWD}uwK1fKujMhm@r)kAmHM*AV+ zr1_bRA!p1VB_FtSeoINQ#}{yL;y}@vBJbl*H*Fj~>D7E+Z~gagf2kP-1l<2i{b4{; z(_ib)Uk?aq{C?(3!?#Dt+ugm24qQF|;@hJ~AJ2~OZd2A$5+`eH;%TBWBrTRK$c^OO z7{aPtQSeUH`71IxQ8iKoGZ}%o#zb2V6=d8Ed^Ye&U%F6nFJ76# zvk+diBix-YxKR%x@=WD9ZL-?$+u&KRfJ41EaN$FoWbv{`#WwypOC&a>rTW{78*rrG zIG!%8nr@}4?tom!-^FK`v>iZQO)#MdyDGI*84;T zyg33%(2LlZJDfqoNd`A_7Pet{BrS@3<%Sj0U!t8#FlgNu#p^mV|0~f!G5Mb{x4wC5WhhO7$e)rxt^& zyLFVNeVOZo{~C(Dm6L8ff~#MOWa&D=Nw|X^OVDF$Fg4lcqi@}S&thwzct#wY2~xgk zSlvK=a@0X}xE6>E8|<}d-UxejQg#aAe$bSl$!chwB=U{xO-i~+)LpTficq#xGF!{3 zr`s^>f3RZB08;+DIENg%i!gphee^3&dc^m5UyY?wXZfbH;ZDq}h2dSJLrw^t=#rXF z3uw%ES%PTFULL=N(mxU^|Cd{Nc+#Yi)K>_BEcwK0nwhScE{AV~Sz*t{4Ae~3tF+W_ywF8oMDX{UoSCsAZ^mKPd79b8 z(1$u-HtnexPYnNwOVsEv`<@Pg+z>rIgzoCzk(POpG|#WXnl&=2DQc=)zkeEo*%GTn zN>)6hz1|4Cer`xM$O9xL zi!!Vh56vaTZJP9?#_O}pA4h6&+*q71VnS2;jFp`nwiOpz=SNkQW3DfYmPd6NTkOsB zxaV@}rFHvJpIPR4ZaJy(MU)>OUp6kRBJ)bhiLD?CFpay)7%nZ^W5&Vu9tydjj-!kRVPifTLQOnXfv@DecT=s(^lKL zsk4~e3@cNqS6lYe6$jAK==t+GA|pN)arKE?Op&Tj5+ba=rmzm`YLjfm#J1ZclpY>u zob{VcrmZ(fv&PAt(cLQ=S*TG@?0f8|GHp_WbkGpI{wA)mww5%#3ifQXjeec z3NmkAXC-=Ax^A>|?4A2yuIN8{nuFW8mj_ipd>0V{{;`>XydOh5Ek2kv=_q(_l!1;$#EflbsJdyyCd0CR2nU zEdgeIQr*xiBzD6WL&f0{i&Dwmc=CZc10&L}!uaK)xSUxHmVcn{*pT^BxwU8!S6Ys8(vaJD1W(w;)x4ANsC|PA z`K}|Tm$bEeYE0-H-YqGOGAsTiDQ<4BJ?FRdG-Nq@wVXqtItmu%LjRNBwZX+qIa`S5 zBo9$w-RLvQ?b4scx`?dlB;{nWIA-!<435M5cMjWW?>~txpV34u=KO9A|s2xxYc}_KXuSpHYD4L>>R*Xa6f*+eAyI(GhDnk<#AO$ai3TbGo&t z>l^k70XNm`ItuzxsYam{iu_ipn@c;hcPj3V(%h($-WsmxA@Ao*Xn@8wgYo+qxu=rSslS)DnjWhi=^3na35^N4%FGx)WIupQRKlBUJKDeY08q{Wh45UsQRD;(|y zcggW3k6iL>=bKRUx_ceF3)os>s=d!Bqn@GI0D;E_mGV0WUh<cq*XY#i&Hy*;MNmX3&X^Ysy$q^`!3eT^3!@vg1* zmZKkeh@B8P2|u6j9Pj&`u>Y#Bp7yj$BIYzKDmSbuABGu!+^~%ltZ-k3ucJR(u$v2$ zW43^Ij&YI9zvwPJpOm`r+%s`kCPWrBUsoE{Ym~nzJF7pyKC?o9#wN4k&gTvv$DG2X z$sreh$Z8nL;AdjX72~WrJ@oI-h*LySxH$LIHW5i=SYjHH2$ zTG6g%5X$Ht-=Kuh4z`o6foRSHTsp&^lRoNNJwCQ6H#94wAM zDw;J_{V#9ya+cd&gQ=bEP!Am+k56wC0YyJP|Mwl|(RNK6;+3&3f8gxvxht9MITavs=PB{%bR1`U_LMm*P zikwDXHDs95yx#lWk^p-oAVPKEHn+f9$g7{MVB&<_S$wbS;Y_G#Mms{l&RCMKcQdYoj}#(6<%m6|+$43q{QILZ(abXfjQC5Icpqn(ZwqubVjH1c>TiD5QS~L&yTWht zQ7__c6S6OTTC&A@C2I~}xijV0r)MQulzX^rncm?^POT)GQ{i<8~^B5Cy?Fg>O!klE#d+yYIWaad`7;dGf#VP(uVoUIdJslAJzsdk7v)`9*x*`KQZ4Vw4``FrFBqK(u| zG@8n@Uj8dx@^3;<vs}Z4t4sL!=SlmQ&nvXxcX3IxO}&V1swG=8yl)5+A_XHZ zh`Uqs)W?|3ztt3v!9I!2I9MMtr%?xN=&Z#LWYLZIIcc#3N0hco{8l~pA{8LT$#X_8 ztsDcb)ot@Qi*TD{eP>74Wpd*Aj(-5d1~zYCk4@TslLWy>O<>y*c3Gg$8F+s7>OB*l z)560;oRN#9SGYCOV<;JP$U1=Xk7#&9^vm^x^~L#G3;byP33LYO33>xSf_4u)97T9k zlue9%n(R-U`(7E(Ivsy{(-KJj=V;hzRk<}~Vd;5nS(~4iu@0Yi<3f8Z#@WhcdGh9_ zKtKjTlQk4c4tR?l`$yp&b~>>w>YM7C;++=Wn6s+|kN!KR3$8d~3gEFZ8w7cG7>=Uh zxi>6bbr?c7YIAoJfcN9WR?Px^ExipAUb=_gDV%Ry06n{8dV#KS7FO7@4Mwcuqx@uA zk%``P-4_4&!4qoyJ_@pp=Nw!LPQW;1BtCl@}iH7_o5Eo!JBEQ4@})CP#Hsb4G<8wC-O!K5z60L}0D zT$DECiSo-1tU6u5NgXA1YGdxKM1#9YliV~1=Ya=-2j?lrAp>w3!kU`M76zsgv{r4Y zB5x$Gk+{zVxPxv!p%cD(6b*dxX-jXqI8#Z}?A-PAFxI5}h0@-ONx%1f%6Hy}ObAM$ z{06d6fM{UuXVRjeJw&#eZO zg$pW=FRAt~3YoDgYd&v^V#JqfE2kphf3_gch_;H?5>0=fU$;4~_Kfn9U$Z+Dn;tfg z-#mp=y?v#=L>cxqS_AT#)MppkE~hLjw!l@HDntQi5BzN5orSlTSksmr_);GS=i&1D z3o?G1s91JMraMTVvaUZb$^4clZ+iq-s_>*epyhRI0h(6Kka5Xc#ekOM!B|(;yuozA zCBbG)MgNab_Bea#mrZez5)_F~ zrG1p50hrI+u)V#!0@p{sBTQrQ$ z5Ly3YM;AZiDJ)HkslaD*5IPefEn;K)p2xsA=0z5c2)koj<)Qk}S}4nUQz`LrrrJs6 zCxjk++x6;vgc0Z-u#{sg&RX*B$P=gbvmWsB6Ti`27Z8IU#_*<;+luwX*c}Sn)%kuj zVCtm!oi-DA6zvtX?J3Q*w{IgYjW+;!RJaBC!{yTw%O0JLb+(kdf$>Z0X^`N3G|Dq}`>3AkM-C z!t7lA(zl0UmGa(}cpwG;#3~<+mNcJntZ>Ya@#ODa5T9E3^q3hx?o%AzjxCydvGBgw z@t{Uq@!FN>;b&9@NCA*)-}env;{6Ykq* z0FRCxpp;_-1!V$5QT2gdN+z=d;2U$U5C2?s=e~2a77Bb4k=?=s(Pus!UTPsoHpIMQ znghNJd+^pSe+S8)J@#8Wcu$fg`)7i!6A$wUjL5SRHJyD3v$zY>r|?=}<;=V|wS7dw zT?$aoN~`VOQuEmvQ`TQe?&8a43it7-V}0#w1KyZdWJP@;EMIGLIh*UUU-{5VBw9C- z=$vL^HT%O%*7i8YmVf-JQde>|>Wa+8kNX8uNP3s@_>g0@T(RCfa68S2?_X*udLXpw zVf>UV{om3)a1?So@PD`g!q@#3A9r2)J?Tk%dFElOs#_OBr`+C>GV_VIE}joCNP3cK zcHZZ}7Nh%X49+F}b$88{bAPN~w-Nf~?wWP$)<*}N!X3WoUtj>e{59*yjcu2p*R3nw zbZ)kiaa6iLCVTo)Vwp%DikV1`U5bCE6yu*ux$3`tK}FZNdE5Px&7gr^g0BH)tH3ic z|8Zra?FLNG7!8IVjAKnc*v{o#7)S8u)a;gFQj~Q&>qQ(zeVN#cDy|_gyy{k?iA?0N z_R-DrD3ncm4R^7ByH)cG*1%@7ohYj%&zKI;JwUen&-R=|pK(i{+s?Raybs$0#P7vX zFVxq&l!vWK3o z)4?x#IXf3zfaf6H0qcl{uOxmbLL|7%fvv?fAli$Y>1iHcW-^P zo1qtk2%*TCd0Me?Cn>Kfw~w^v3Sh|RtiP~EU%J*a9G5wI3e41phlC5pRn2oUi{vM! z4x~Ni9_f7Z_tO)xW=qtVEnfPZDllReyPXSoFZq=HAkPlA>=1%1U` zBK=||j`7Tg@|bM7vA+|*V&YfjQTkj3sU6rr#*Ykxn`&r*U~Y=^B;6Lp-CTI@jI{^5iTjm z+mXv}Q<>UyyUFn~^&V{Q6n@ki&m>4M4y0ohI!xtL$S31ht||j5mLTZFslS2W!*ozN zrJOpxq4aTAUO}M`X)V3{fDioEx2%xmn?IeyvaKYy3;@mPf~!Fmh)}XErC)R^Gi4=t zt!gHTF1YuhKKc!P9pc6rWO?6Ad%3;>r3E`M8%1Q=+g4=_-OdaWnJLN|?s-XWKtLkS zyZ}mk=wa0RK(u2a^aJR&u$o_*B%yy1#K!bA>;>e-9p$;K110YO&(tr;k^S**z|r_2 zQ>$w(I%Hdoo^jG|a|uHiK#U}>12_jg)4DWy1TZA%L+O*@j<0hUzm@5#t!Dgnz|G}A z@HwWTXsUu&Y5XM>k%9W4T{zUgA}ZZJhM3xg`Ye^&Ak2wD7wwv&$EkEb?{sIvr1&tl zOdT#6KWxlZR8BX=Vws4qu@o;RxR$)Pm%U<)bn6%G9RDo+wl9X_SPNsUNID~A!8*MpSlBzR5f6W5MVTHqVV}qPbKG>U86dWX zpBFw@Om?&*zXx@HO?44TZ35k;^j+wmPXK<-%zO_W>CrD9>j3>^_UQpldeI;-48GJ_ z8CoJ3PK8{oHY@|!fM4K%n7-~|F@E!7}F4RU??SYh) zium>0l*zuS^*nB+(7X=g6J{e0!P%|B1;R+bi;1d;d`h?A!!y*a&R8TuEIqyAwhqZx zeKlA9J-WLCSroam+SITNH=cMI9iJXQVS$@>NDWx56n7PtSIXW}Oj-~p*zn0|jLe1T=D3|lz4fkQ6^UQn0KeXhpRKn|;> zO&tauIo#L~T*I`BKGyJ5(07hIR>v43crJB^c<2>ln9tvK8fMmAae|y96nUGJ>&}>q3nN z`)j2AhhR~AVEj*mb5_4Rt{`q5Uw)t)0=8-KyM?HEhuKq)%ZQ1PgfYgb>==1Y!OWDn zN}h`_)|EA%o#^eDw^tmOrHJ-B6)eHtaU9j1IZatN%Y2||uaQ+HA5 zf`*%lhg`&_{6=G-O9)wN0C&Ayc=b}Ib_Y;1ht#fyw<5< zw-y%nf`(a$t-19Jk~5+=_qn8`Tky;2LOpD*u=2Aw3YSB=o61|{6FwutE1gE;MC6`?l_BU8GeWu*Jj#*I9Y>LoDg6Q z=4;flQnq@N$S?r0ll2)vTquVtlzb}zqIefg zaxGC$c^OtxPAa=Vqm`<+AeG~ia;M%iFIUjJmQq%~i99Y8`xtEI0D|0#QI2* z&0on(U({z8hwoi-2=Z`wB%Iz6|F1ryXD+W~_&+MTx^h6PQ1*1;ZTx%+TMba(4Us5* zV;Bn5zjJaoFYt{O9_&E2A!qM_74&nAax*0&zMSi1D{m}^J&+YEPqYU~0|Y(yb6HWF zvBzb&ETnC-AHVX(bfe=PB~I`V@9AA8^~J!v>^(-0qvw$k5^<}+I*711D;CBU%WF_` zUz2Ft5wuA?`N*PNGCOahyuc3Sg2Ep{`r(pclW2%5?JbG4KuFraSc{H(5Qaw=l zS?2SN{hrjRV3vBJz5OaBl;3a>6Z)k04GZeJG2GO7mtSnNRr5M}GQU60MOwDK70q(4 zGX}-qkDPKjP}9~}qd5EqKTGficqZq_MfQH581KpApODF24USq2EwQ&KaYBez?{61M zGLhH$&EW_IZ1K3z_>SsM&+<{=#9SpS&_#BL*tu5yRUa9%2=Njs&%_gIAxjuim+5;Z{6k#A2kYH)u7p>4(=CTwuaBqh56<^?H{N>yuyk@)`g(1> z&J$!jCc4=)ip*q+<#HhktwMqezbSZjvH>eK;6;y}9!ls&4bRf%gliKhxLO&4dYp~3 zFfEoH4PO0~<{5S9$ao0TTsv$6FS$d#Cr$h1qix&2YNWJr;k2eTM;+{5gj`N>3=TU~ z&5SveV+V%VOQ6(ODLCXLKq@e+c#w^)ngq z8FDD_i_effTiTwUt&~-pvtk9sT*-(2GW&vPJ7gw~EpASePv#QzeCLi|BjZ4W1|+5@ zHuhLB`=q2St*4(H)hV99&6;A^!pl@;mUg-&3%PHKWv*>*`WpEOqGufF)j(IB2zoew z0(p2!rZqv-?SIYpqE>LW^jY}DC||6Q-!Y{%tu^&UKh|l-N*Y(5NNu&t1Q+ibB{->f zL#+;x6l*{N27CyWfIQHgd{zB7bsg_upms?`oJHTv84_=W5z6z4Q(9rwzIG4`MX10p z)mHJZj@o1LbE1CaXd&7<7$=oopay_$P~v{}SRGz`<$c~>dXYd0ox^QTv0*;v2a+QA zscx%S+zhxNt$8*|VLx^A`IMD~AaqWY#^EEmrYjAT4!lF-&N&cNzz)LBMSKGkt@Fb) z)`O2RRp5ff1%=4OeN$&0f=7vLxU>8wwbei)SV$6--%#yRsY94Gn7O-Zk80ov*bS|& zDAT>-@gv|5E}rSTzvk{73H~OY!G%CHGgmJG`vz&LUA=w65q?C7lT#T25HX)mQ&imw z0w@dTM!KvzSIU1N(~PnOl`mt?F#dGO6$gh1ZUs7DrqYfyVYekFOj?Woq3W=Ag4Acy zi$JCHYlNVvOxIKnzeo4I5|#FqY&zb%c>#6`mE=wJL^txj9dq!DKerCv^*fw$FHXFkiIztdvUD z%1b!t5M#jW;5e22GikL4dmyT+KkRp$1**;n<%+{g&filO<|fM9_n?~=ko)mli0b>! zz8cuVD-uP;R(Kk>L5)9^a6*c}p}@`sn;x^tZ`+p1c0ypBy|S+EnEp)Q(wJd~(O{S6fMzem+Xmm%M0&c@^)u z&he3S*Rd;?+bHwT4PXga2t11$zly39gmO&<#bVOSco#;c7yU=&;#b)-88P}O67o4M zkqM)D&g-(di%88qjZp0DCh!x!Da6rFlypI!BeK*Bn}{5b-8r}e$q0L?zM+~_4yIeg zxU>S@M{YVTpK2^p+e$uk@ArIM9VpK)~xXsS2w6Z;s}2~ zDtdshJ03QR5x6XhttF?ZC`7xo#-zr>r67!V9^nRRcu~T;rr}-4J|b*6WFum6U5yzI zXREr>9JpYU;J!nOw%J;F#D@PL3yd9Wgp3RUUVFs za&d2X|NKXhT$43|3|W@!m5M?h)F}i1dukCJGA!NVw$p_WjC|Q0eDyFqX5+ zLxQ7|aYtAla0|=A6a#$MwM~9v&hCS1B1c>f$W&bWdv8^HlFRaQqFpckMF0{bO!6uI zAZHL2&RK=OSl&Bf8b;A@bn1k<1ULQ%8j&SNms1j06ijaF@ceSI+zod1qT))~uw=9L zX4d?!?ni_Na=o3>qyTJ9maBt(I^o=k6Q7dJ+hX)^zCKrcyVyY5UMu}53ZHcvvY;o* z2QNuO+EY%s1o{CyjE_{HO<93GK;JgOoKujux=qS=NAy1OXyPY72QT&+aGx`ykOSYg zF_RbZk5W1FeHWMsZ6C06MSM0ZH7*0C-DH@R%!H>)zNg8jpA62WlZGSI{gP~9hp{Tg ziSp+a@qw7|TwSdmU_N@!l3{$*id8+M@(ivb=AQ1mbweNO-A981_7+{EJJ?(3x{crT zfPUdVS50k1J8agHp3n12nNKK^rpEo5;My=_#TArOio*lQ=B3!`PE+MV z@eX2lhl5*Sz>YAluVdg)bY+vOAL#QQLA#JpUdfJh zd3z=GI`yIbi7tq*r0;hXWGdwWQhbW(T5KKwaCCb;u`<_V)sEB@_a51GeCc}YmA%Od3g@7ECT z1T+B`zEBt8IU#-#)9i||AC6m@^1#KYxw1tIWqNnXO~gi^w^anjakIKqv*|0u*=x-T z94(dtkM*`mA%VmksA(YVRa73f&%pS}@aMUY#_FO5=#Rwky>AVgik{HBdJUbX3#^?_ z@Q?0{e{L@-XwVnTdP&*0R_MR1CebfpIV`*7OCGRVizrb8%wnNsvLvWr)L+$}hLm)< zPtc<};fJ5YCQVcO!t59s5+G8P5jzDzm)e3yBbd$JVt4QZMp@4=)oLZ_V{eIxIz4-# ziy-m12@VpVO;o^4khz?!t=5hS`ZIg56hYXXn>CBguzi9qRNH-|H z7IP*h{G@ZwDKvC^4@T`!Y)}tj`!&}aL3Rk7dSm9wO1Q3%#xix|)qhy(5)?^*_0#CIwP1RGh@J5p`pR+8#%w16vTuJf0ykHx+2^Bpb2RpKiuvt zKPU~6d3Z5^pY$6>O-1XdFA3MN0-ot^<9!}z4^T{}$37wM=b-Or z1bD+jKQ8_{O+72^Qm_3#I&0Lw#s8C)p8Q+;-Dyz1-l@)i?rZwWgQx#|t@|$j54XP` znT-8j%5u_n`CX0s;-FA6iYhQe{p53Q*{WX{- zy+)n7KwSms_acjHqiupa5aI@osos*>W{N^T7#I>LzDa#5uU~>`s@faXZOD9i?lO2* z%=8-JKjU`zGfSkUys-!)tM?)u)br?#koa2mb|~afH^c<7$W8N@%(c?Ns19~ zXiPYC*5wM{MeG2};MyR6&L!fd8xe(5Msy7So0SB*jMy*>5~d)YJADBd3K&IXisX&x z_Bs15gRD3+QeLfrRlJ03K>0KYQO_{OrF6EfV!9b= z>MN_9mCM?D_)SA~6V7TY7J8Qz5-8MBZJaLI4f>f*Fk+#KAw#&=WrA1C#&4#8zOe0H z#3-t0r|S92_`!)=D>tosRR!Sb>T|xNE9gsmBcIqEUOv|(V(z3_z%|Oz!Hyou(i6^} zKqg!EJ82&!r?0e-oQkNUH}uNggEfiZ0F%Hy{0a3|G{oe#ADB3e>D2xc?@R+mtlYc^ zd!H?o{A>ew1mgW5#?SDb3;iSIOin8Z#5FK*ouwg$f7Fu`^Xv=KH`5>aB{@PcnefG} z_$G6V428(x$pr-d8LFouMw=&$o(~|i>Y?O?-(BYHPf_Y?vn(B$c@9r&p%azOOUa;W zyrzvGE!8GWzTO6=oU6{}B3HgO@oMdHE$aI*KuZ4nphtS@Qp(oIcf%-l1j65g=_bGH zrfNI_JtQT{Y)W3X8dR{VH7qX406I<$8PgOdosaSo2wNc-BB5i#IL43D-P9O0|L)8j0)EAG|dM!=w|!_A?0M)~Fna z``pT1De$TlZi@$!Lvc~~l`=u>^vU0G*QNW%aJ6SKnZ%K+FbCf^>G8vDqAMd+k&CVJ z7z89!9x6cE*YtlF1~p)HBd}S@rF|pk@Bz{1f?kyR35>*}0E2AWBH`nz>FDeKYEF>< z1!1EzqBcpgob}|d%_U?@-faWkhWKmd$Y+A42_(0hBYoczTRBc>GaW|Il9<+JULDX< zS{Ir7&lQhU#Xkl3FhR5-^L$6v%FTB48cI>NA)j%YvJF8Ow8kI+jq3GPaQb;wXGYdj z-a@AR6ek;bEk5ds$AIwKB2Sa+g2JDNtQ}t)Y0j$^&MVfS-1at&9al)A2 zl!G8V1CHf*b{v=9f*%WBGLalskKyBtAS2QfD8{SJsE>8H9fT0XKUCp?S*`g#Dp1`f zJ|wz69(z7+oGS$?R9?vwcS9+L@+MzqVUk5KMKbTFmBp&$H>Ekqd*;j+f)}&y)4YZ0 zl*{w!9&>_~y_8+R0|Z~QhI|je7dk3rnf3geE$GnC4bYf=m%M4 zo8Ykt4XCxaG5*+Yq?QWvGV0zsxTRp!*(eeonE`SCW%zKWEZfm{S>-D=^GNU3XFULB zuKHIa`u2Ib;&kJ$T}yZeJCP{|QamMN9P{|OXf#Ff>=NWet8n>pk=$wC`yzcTJAJwz z%B=5ZCMmDppfqr@_8UFSNs(Nymq5Ra?GUK-VW)5g@l8dL&N%yHh5RoBZL9v$>BQl$ zu^W;jgn@ks?-KAIkv4QbL3w)MTQSjJ;zOP5+Sv+-b9ywK1+SX=)xCID1(_5NjHYQ? zz18vF1`Nt6cqOOl4KPRfnN%>0$4D8)vH2C`x0h7SCYE&J$;t-OHqi)Z?!?5K$m}N6 zb6ljxz{>-_1V2;{mD^&AY$!d56>u!uoL#yfa{MbKXhCMf9GC(xa6XFYEc6*wMr_%B zkRRKw)H0B&*5I?AQ@#MoCcwGkn<^qxk;kxeOWy-Kl9isxTNIW2T7A&f>uDorzhb_};GZz#O@G+knGDea~(T>7pM@Mh`MM3hjzdBsc+TI`eC0cvXu z>1hm%+=cJ;*#tSbI0R5sLH*|M=;B;z&y~sfDeH4nmU3gJ2~z$8fl>0YDdijI<8<5@ zDG{{119%a4cH|Y=ZaY=1Ef$#to*zU{`}CA;_EAW8-oZXt<0BhWW35k4qG6j7$}?G9IwfDwz4%b$=lSF zW!J@2U|(WYyYOMT6|;K9r&1b%Sgw~_H82e3kn0r51)wBm{{h^rmbA1Gx{zEi68lH} z&mdLu#={s-`34J>-%V!cNO+Hg+enZ(A6cvEN;b%b7L~*B*+P-jc*&~+7x4*)x)%UN zqVzs1Xg;uFVbKz0Zp#Yq7(*-=< znNZ;6C%tC3_g)s_=MK;Vk*vKKRPnu4Yx%MQ{AX|V%ThD}cdISj$QZqqQvwI44x1xB zelfLUIX{3Bf-tJtd~Sd3AFy@@zv2U8H!zj{DOKTHn`^Ir=ZTA-WvPAQo*#pL=|9Nu z`tb_|O8T9?+p#*4c`Nx8Y&Yi#q;(KQID;h`DL+Vd=~e%Yx7I!!{so6wL3T*50hB{6j>Ov;3U|&K=|Tl*bibvHT^s|UE^d~5 zj;b0B8*;LGnG<_Z>WsmgC^!SZ?89!;V(m}{Go_oYeIdqo9fVD#ODU@Nq=t6+PztO# zB_XqrguGgb^j&!%Ez|d%ez3;*6fQv*@&tE?pV+RrQ|#F?SjW4-5&xL+7*1g;x_EnE zs~znlR4>KDZ=!|`cCyskD*9t|{5!~|h2$o9puW0nEAM=a7Y1){kWFT9LLOfB>wsrM z-r%cfXmyY}jI>>~O>1}j^nyhEf6~(b5mX0fj5UMPjEpV2t*>S8b9K*tyvzF3-wWe4 z1gC3XYc!uu*EW%$yk$##QuC*9)2LzJUq8P+s}4=D3Gjp@d~AH?uk&P=p7q~<|9veZ z(0$A9{WjOzlmZe6UA#&!qKj#kMa$aVPP=U znlcuBELhZpHQWK_%2oNZc@u@TNk@XG8j=k36Q1dxKo!@n=B3Th^)0;)1%jW{D(lm9 z__?v#T=KcgdWGXW5Zi?vnHFoRzbc!MBR!^i(()dpy~rUmox`;By_#>6fd_9JdcpxE zd-xKeskYzun+{OkdmrIw+62zDI2!FUE>`)ojr9r0eEtdK&cAWP`ntfg&qwD|0Bj2@ zMx=`O+{)z++&+zG4m8=@)=w6sX$cxKy$0cwQ~1H?#+c82!NM|aw4-tdYP!WtK`Dv8 zyKvd7-X*~0B#)B@3Af7K8GBJG=)qZ}0P={}t*d3mQi17u+n7MULGtzZ%XOpE% z!%G*^Pq2}v5qL>?%roSl%=rLL;-PBWkI{CNURA4j_27}^6mQg}O253J<0jJ4Luw4` zR{;52#_%I6R@8H_m(Vj8VA;Ub4~4BD4CdqApJ&Z3-RA8 zdauu`V>nWQqSbxk4po|EDnEc7aW5saA;4i<#&y$HJC5QN6cTB(vQI~gDK0o|N(dJ1=u( z!8~3%a!9Mt%TM;+8gb3&X`Y=M$UmB_$8FzILX^C2SNQR}_G3Hvab*bT8`}Gf8FgZf zn40yvjB|~N?h@AF8i6}!T0^-Lf)SdEYMM3ls~-Q=Gd|@)^HPRr_FJc9Kcj;GGnHUW zJi^JtqxS#rjV>xOB!nPe3-3_2eSq%8~x5{XpLU0L?M^C$Gk$&zrI< zRmZB$S)!WXjvp4M^nmHW68=8Y@g|^>V2m$~yY-gotl~^)5?hVwMkkIDr6Z=+a0`yl)k-Xn+$n9p*Lhbp&(! zDXnBFOwhQm*Mv>x8nE}eGorTg_~Vhp1pXNvb!Z4OT@B(+tOZ)Nk&&L%H*&Z5!}!Q5 zPT^tXCm>|q0-_ivZYLYzv*P5sOj=uFCU<@}=Rj<&4$oXtd%L-4kdR(lvjllAbx|A# ztz>b!DLsz$Y3fz}BSg>x@fL6uRsJ8BxxNFXHb7o~sO!KhxLRB)T4o=2>~6U?V)p^| zV1&Ts_&ZJ)+jTffY(Nhcl>pG1P~LW$YM&-&Sl;0#I4eePhuxj1!{){Yi7M74 zNR*c`1_(a|^YGaDLZ(S^uF!)M_^H?DndItS2+`eKxsCH*)3g*j$AqhXh>`{Q4ccmU z$NamLdgdb)(yZ68+G)82!Ou@p^pV(#7In3a%)PitJd%EWCW8CntZHFyAXi;4`^&Do zT~&f6r?X>pLyB*bi#Dq@^DxwLj&sbc8*}u!aWmga+pa^+0hO=x?807aZi;*J!?b!C zZA>|isN0y(A3P$Ai6jjPT^BAs^U}1S_ONsF><#h%1*s#E1z_yC%d_d97Gm>+`8T8v zo|wb&w__K%*I*gUlLE_K$ZL6I@IrIIN+Sk*qg}s4lT$J$NU76}EHL#v8m`{X*(#;= zaCZw=(Tx4LW92X%$*nd&gCJ~c*f&v@1fx>_Til-#Iba{!P+%!sD$KDh!w|k^Ayx!y znEIl`2r2@)NU~EwZ0LYNNv4dvo%5f>aHsmO^bs0Q1l@>zs6`@JsCa9}vVEqr&cxpu zBJN)Z|0H}+j+dJLAl*TirY7aG8$k=5mrWo7G_MF@drO(0qij?#wb8FUPOPRPE}&;y z)c^nL9<5p3Yamb|E0{m_d;AYfy6@!u6-=pSNcuTsy|&lE)3YBhh!%^pjuY#%_5{8! z>mvHp?+LH^)S+48hvbN=>TXvZGDcSMcx z4vxQU2fAR})WzH({)B({;Bj#z^-RnVa#>=m!w8;_(&kc*=elYeT4}zSpB}wflGX|@ zY884Z3KZx9372B3vA)mhFRA{RqOHJxV=PFB@Gr5+^EPZ%s4O32vIsh>34NO0C@B{% zooC->WAwnsygn^o%E#-oyh)HA)4(tC{YLNkxU&A^z3L&UPcN@5%AojtEFjVZOXTUqP7Ugq?Qz`T?**dKvlQxLTb`(@pM91MgQ)?P7=p-@JyAT(zzuiipb7 z-R-zSc1s`DKhaEcAE+MdmxtwkBT!hd@yWc!SZ#UlLFaUeBYBgAVFHb3KoPJS5OTX} zGW|sI5*01H%@w7_fpdAqj+eHDpCk z=>p8b3l~bGB$C|Aj6pZQ&pXjyxb#(cDotd|-=2~>$4y`r)u_t`;!4A;ZI$Mxg2LDR zG!|WeEQMP&ztQc8&r^6ut3QqUy4Wn2=ApGpaZHFQ>L%zZgL1vtQ${luy$*o=@}V^G zW4EH6=IYIQTldibQ^-L<6+0fmo6R#OdOf1KB%VXw=JcNtwq`q5sQ2ZR-avR`G7NO8`A?`P+TR zL31mjC~Jc-=UR6xx}d`wb(fBPie4Fccs}XMrQxxw+t6B*JA)#(;=id&Smt~fvCU6M zbaMmK!TwicJhF;Pk%mohv_VPV)rz`m3}CnTDAau_Q`(y&A??B0waHF0V6VC|BpK2r zlEiVBa@OAE;Wd?Kq27eSK=m1ET}1Os3~}&DhqvTB^=JP-jELsP(dX1vlOI0Nbthx= z@XgD~b_~h`99w-5xTq6{z`F+ne;4>y4p7!lFfUIQ0`d*K|< zbK&0>`7qF?#z_DKJxM$+N=^fBoQbH?FB3T263D zOsb6H7CvGAfjb0G5I?Qqioa;(FhSnnAomq%Cg@{!S(51bIiy1xGhL8uKa_MYiXh{> zV*i@;*GJftd*voUt&YqAQ&$r|S_2;?@k_47H@=68TW(4lvoUk#FB*pwl#Q6-N3^T= zpu0)xe!){AJsn3i#oDe7!8RQRyAKZDSb0*D=8q)3wLCU?7j>lr0vOMsA3z-4VyNib z4txzM0J=|sIMq&3ElSzk$F-GMf-!e1QSF}uX=v+>C z0(*9iyhFL3$yR*d!M!um0`{1z*GI)YLYX`-l^;dQ273+%p~j>BLv^m~rUP^6ptFfp zOhMg-bV;cp%ypsruPV>U-^sDj#kfwzR$=93bk&dGFG!B0z6x`E{-38F-iaYW7kmlJ z3q!i(hTXlIMh@r4@r-l0N1vmY1)~?y_V|aKuDz5$Evc5o#255SeC9)SmC83$^i_E@ zDzIZXeB1@j`-f5_VSX95R2Ua*b=;0oAB zfKMqsQ*;Y(#9h7XU6!)(+>iv7Q%KYIn%qauI;CvLz$Y}g6P}Dv(&Lq-IpBvk0+dg4 z;$LyCzBlWM#U#XHr9bTkZWYUVZq*_81A4M=ak5@YqriK!ujnmv0zS4C-)}B!Pw1|fTstUBSR^zp-FYrXTgY~zk5%bcmTDKxrf;}h<~or$ z@sS5A>HuF?xFrPuuy5#@Wu;4Pq#eEj(iCaLAb^u)t@6)BuAw)L3AxK}s-p6d!yf=s z`f4x=XidqM4jxs71leHd$~s`V3Rl3Z)1WDo>n)eN{8;`P*{e4YPg>!q?z*N0om4r>m({|4nrkM~-^nt43BJcU9RA~jz5;!w3b zC!V4NUsp>Vrzn&&4Pm5d*ivkwX6QrN8#FHyy;@Zp(-MoT) zmC^qyJ5u229UxtSCQK4-Wr)s?*0$QN6Hj|KVfuKxF!-s16jD6q`(N+H%aQvyd)L+nscZ^ zAI@azt5Tt=d9r5>^9F(X$-62j6c4Cs^=cI7AkhMUl<1$IeyJsy!DWfR#HndXBa<|_ zq_eMQ)5PB5I?k3wV$>MY9E*F^s-Ms>qtsGd@3U8*m~iGg0YmYAKS0ma8)0uS=Gv9; zIb?BkVGubr6a}Jr<=OGqrwqH}0$yQwbGQ0L5$wY%!yzs*gJasG{;5!EYsJ~zJ{TCe z>?dc+M&h0S<0l&6&MS1=N{_S6R17xH)K1^H&vODUo}CkQ#U=JRHVu7~V%fdr$O?Qt zFMP3ny6MVIw2`3V53gYfdk=P1keJ3R%?Me!^P6e!GzR#+HOSGPLw0tue3a-KQ{9U) z7@2?b|5*CYs3x{>ZIy!@L_tMBL=q8|Vo%-x}fxgUIY?R2uMpvfB*@kk={P;z2BO(X07=%zh=*V_VYf47msr7s9cS?r_l9F zB)-e-;vTfTGAe;X0-!$lY3ho+*6&Rkz-hRzrr)FHv=or#7X1fS;KW}dHUmdFh;q(9 zz8~G~=N&JSVV$`5s0)?kcszxM;Gx#NKV@7ED0 z@D;@YsJ)YFt!N~8ITIrfp}#$6vA@$)0jO+ighSfM9`iTc(6&b=qp7LV-GcM8X`SK< z@=^KnNT@Z%0F*|*Q>;0Ulo)~2yuZb~fe&G^%U`kNZ5ThB0zGx7I+^#x>4%MrkoQt5 zh;)BjGtVlNHFmgGMcVhEU6#S=;LYKSHtaX(yWkcmm7Xp6^mUA2`!%KfYxtnI7Cb-c z+ozgz!SHDzj8|nP5>1YUy@0N#NS`YFJ}~b|ytP%Yh>Z`3{2(X+Xa6Za+rDo)3p_r# zeTZCN3vN{SeS<-14bvfF6G-afHU6U*>p=HNB3^Az(b<&!uIw-* z1es~MZaJ)#Sgb(3blNjFRkU1j18r=Hw6pgU5#m}4C=GLQJIOA24)EU!p#IA|w{wbv z?rz8S-9_&x>;HK+Prq~IgLXD=_^F;~-hWa!!73byZn_C()x8xT@aC-2EpZU}ReXnuk0-9g-e zUPgY==F?8)zfOs^=hK>Qt1rqSEoAthcjV_4@B;iv@5J#+Mp zmXCuO?5IpvtOpW=TAs%Fu?t^|hNeTaSlUePVX~W5<3tPd0lF!Tqfvji({9{$p~#k4 z{jX6c^`{$2aVvT(u>4?n*?pzHhN&6^P^z_sFPkjv+ZAdAkHur_s>(t}# zvge@Bd6AafqqYidP@25G&|7X3zM-O zC|~;3v)31iDQgQfC&ek^HcImV>-aqf?Y#=qHjm%NJ8~TP1&!hZ6>gn+e7@`_;>+su zk$~{x+te(+?5LkBFXlctgQFcauz6+vI>l6=6RE>GUbJO-vjalgl-Gt2H^d? z0hjgxo4I!B_c8zSB8get)ehG6_E2dxD04mcyS_5>6EWQ;UFVWbWzcfqbvo6~{toKU zm$p>l&VujhSb~)f@(Hl=ybI)pWGAY^T^gkVm7Usdr|y}vT^K3@H~RZ@u=W+yj1xZ) z%D%AZic8f?pM}?D-~k+~MB)nepwfcM>6e-?yZqXv>?4!nIr@2coV6$ zyIc*Xq6cq1RnPC2Ssbj@8e{vsOUjFi+XR`xedqK85{)K``~Z~sw|4hxOcl!`pIt4U zx}A$0z&h5yN*A1;)*jA@6FrKcg?^LH$2_-V* zfFR9DZjC1Ey(3^&xh-E>5qE-M^gp_&Eq!|ZF4SAf3)}Zq20JcqL9(*Gy>r#i&MY09 z1?8x2v=>*LpLb(VDF*cMMRfJ%sEdAh*7A=}u16Rzk(-!5)~yhII9I5ntu}M+UMnhm z=~W84C@PMBooUQ?&AFU?Gn?H-NF}um*sPm$m9i4Ur7G=)c~x7W@c8+X}Ewt$}d1{d$}|)wfQ_S)GCO7 zHp|g%YJGvx)P+HviV{c!+tC#9<$k`g6r8kMId^)b&6a;gbZFVc=>di=%2Rj+6_qvC zxB2yd>f%;M%wk5+%TNDU0hJd-oJY#}8(QC|pHAv4PB?nT(!8m6sr++($j5NUB&zP@ zSi%6%FesINVLBm78kl2A$I#ZA-Ym#995yTj+%Dg38?^^79x>-&m~+@`>UF@5T-32? z!FIIORz#y&=J3j+{hw4ASnYI-i28{0OeLhs843&_`h`t0s7=!-sJyRVdgkI*>Y-Ym&16x|C*!?7CT1b{eK)8p`H79V8GyD@VAYIyu{N))m*2NhtIJ~h(A-*8R#$4YvaoB9*w$UGf$K5H=^yeHc^()b{*C5@!*dZajxX7e zI`(0wj=JxTVy6AlK;vIaZ`Ge%VkQF-V~k}NT)!T<4%d||TE`f26v=Z`4b{dq9^lzD zKp`tVDQ7g#yfRO{HsXYz{^&J6AtkRa=3(Q@bvR67KGcz*IZK3(!`b1i&~8BtPH6#9 z-$19MJN3M9Xo@GX3{9GPf{E1yrxgs~8R*;bXwXiJX>SrZ`bp3uaFzqW=2eYICqmxO zpc3QqAh%2xA0gbsW-7a|CB-einz6I$1_fukvdnSSe2tA`XHf;7TA-+F#wj0T(!J<9VCfFhwX;(8?V)@(0nYmQLw%Z)hWK zb6d@d*4Fj4ShHh{6rPECR&2zJaE)tRnzrJVljc;<8pZ%hbb_=&^}s-xKHyQeo!`+; zdllED0dX-}Pshe)3nFm$Ta}hSPwUR2rjsUuBq?V6xj?b$H2B|4lV|#Z;N|fy zy)rAOH>Q=uKd0oe-8JhQ!Yw+^vo}gFSegN4=PO!3eNT%NXbrcu?P(aA+iCSaet|g^ z!0>*hwz)&Q)T%P+MkI-z2rdpJtzN{MLFrc2kTzYOz9_xx>hx8ATwu=zbl*>BO;WZq zpubXV(Vhk49_QUMRd&2dFwzAqdVmvyQZ0PxJy#GK(oTEy;A0D51e%_9w$?(NNz`8S zoa16mr|nh+E2ygwx97Z6Uwdme;eN8}>FdHB0(_AGZ~_Y1D>L`!APyC5)(fU5N?*zY znEG^==ojIVXJ+_K{7VWDt>+Q{@U-`lf_qS2gxej^l0FHRcuY0q`m~U4Zrf}bIAw}6 zh=zQ0yoGedXsHe~W7ql=VqEib_Ac!Z>yF%_ZZEjNb8(#clkQTzA$$P+a68G&r6=x3 z(_LJ*yOlz$aJ@q{;Z7MxH=YVlM&3(lyt~30XdD!&(|HeQYZ%@wS4-W;{|(*$%RKsL z{0Dw8DHInk3Hhn*Ps2cyx;G_A8Kpcb##z4`Ah8d7vTb zPyE6c$x-n$bTl6`o%74BYU}8S7Pk%0F`GQrO5P@lw()LW6}Fq>K+X74E<;yDOjNpv z2O#pn>bMf`*zF<*mBlIYZsuZ}kzj*FlUbPm3}eT~2)0G1fuDI-ejD@n2Rz7pd`D^d}qO0k!#*jho@=$+lssivaOKl*lUZ( zZCk>&Py8$W{pY{3FK*jKPhJ0YvgPk}Hhob^Trj9IK!oEh!=pbcE=TEj!pO;SOzycT zKM(1Fs8eg9JEi8c<@ZzNKZ-o=uux`n%yQhHIkoj~QxDDLF zydI??#Wfl6+gtX}G<_NC8c}W`C^Ud;(=E7W=q>!f2j8`)PMvG??t<>qJw5oDpY*X8 zJ#uT^Yw_^~Ed3w8{b>`-h?(p1(}sntvIUIdW-`I!r;8?j3&+Gvl=Zdjr#zl-Co5H$ zM5X<~HpT=(8++g5kEnKwaH#!yy;nGIm|F?9O+~0iN6+l9maTdQa}e`_ZqDs?>T`Q4 zpp(EoFRc8}X#=`r$8f6rd>o4ISOc%|?1XxXFMBSqgC zUeOBIB3$wBY0OiDda7>FPw#D@`Peb7r-d)=OLlN-Rsx+gAzq1lrPnnL^<|ah?@m{J zPN?mapC?b9XmaVYkV0nKpufnm9r%?sS>N%w%t#IGEb|KW+rarf8#+?s`1fY)LfY3j z8F4KytCY%)SUCsyLt=qF+_nd%CF0YaFJed)aZUQ?NFATU&?)&L{?wLrL=dA-(X8q}x&&sdU<0o~56K`gKE6%S_X~Tj9dxZhaTPRdbi7wj9kc8!_ZeuH=DBNf&X?pqV4z zcu4R?Ak^KuAN|SoZDvZ#+Jf~{18bsUo#SNgk7LU(fiG=OnJS#zw47g(Os<2M#uzHT z^6$)ezvLamxajVVg>^mcO4(>CV!bz-DNV1D?pMmtTUiZnC%-K#SIV6pJIi!)Sn}=R z=TzTzW*(KPJ}FM~;$zMVjwzT9X)~teTRT)0{pFw3SMlG&pYufW?w*TcFtEU#`t}O6 z2Ux8*ZmG$NP{2PZw4J6?$NJn0t{sMQcE!4py^89)zPd)`DLit?XZ4t^YFO%TK4@(n zdldqI2nngI-ak`5UFfU`FN`_U6z5;=-lWx*y|Ts9m`uLBueSoD1&xqh9sV?FTnVk) zi~k+-?6S~av26i$9$~FGa~!32kL5`y{s;e~qIlbZpYEv>20t3McyKX4szzfDz@JSS z2^}kIzbT>rI|;s@Q&&E5a!Y5?`$*}Jz5iVP&&MozXY%GGPr!~R8VQZb{Uv{P*lL@B z%zB?>XqioYg?#e4T>n1p{15MskvV*ocDzFFwJmLIdqxlsJ}W0lNp)rl`Th`&nw(rZ zb4RU0RCkc>uoed%m7jL-0X(R88r^-YF*1}5aUA8Y7k#TFDqe+yeuvCaZ%;C-A;u4S z>=%u|7^4Q}%AvmktK1G8YvQQWnMRd(PLSiMwdjt@bbhV;LOR4vew5iNSTZTVKt_cz z=|N~%7BAg!jUx>}CNj!r?Pi}MPV|1dT);Tas_;ZcLdlW_v=a> zs`>#QO~aoa`!i{}*cnnFX(iyf^|0-&pj%DLZNyOjYHGYR1QQQ<}h|@B}Fv z)31_SA)#-SPH?(Y8>3Ht=e~FxiZW23`%#afVWz}ern2Cr!unq2FJdBSVj|CI6Y{Sx z!}bmEGvk=em>1$+F?&Y-Cnk1(&D13sj>%|ivxEFHWZ>HfE8R7t@`&+T4u3sU*rP(` z>zBw!?*w;21D()2!wK`x6}KJXBOeutIP$INNe$#;IagI|7iv`E?k7`=lZPuo0g!1& zPC_<}2Jo?_l`Ge!L9VwItsQst{I!;2P#aQ_OKTunjXKhhN^_>(WDV`NE^7-Iv~?KO z-{{>Z*9CA6Em<}y9-$?hL--1N2_#K<|B=oLjhQzV&ekqSB0h%x!Vl317OqDnl@$43 z)Rwp$sRO6(2HLGb@jMP|b5^klbX_!HQ>8@pz`_PrGUtyqWqA|b8wax^e0qB4RJZ`Q zUmUqj4#rGfwz^T5X0>f)Hm?m@=#?VLd*^_jMQ!C+h$7SVEs3DSGHTwZBl zZx#?txjVSJGh+ic33$KxL>kq)K-igU;mNsAD&+n}jeEjdFuT8KPwx`$!aji_R?0n?g*nN$Cb%n*nxptv5&-%=+0W?6&P z4$GH&<=44{qf3XJBzfY4DRH~&D+AQKWffO|_?rarKY(NNkSxX^IP}zH?JAs1_#V=( z7G_$Y4>Umq#_->y@RBlTwRmAq)VJ9Gl49+fWeKyi$c?q}uBS`<&&PZFJcwKR5@ZA$B<})Wnj9VOKH5g+n@$;!KK$ z5wAdVHqDnRbXS4SEBmt2oy%Q?$tz^go<=X5Hpn+F6mqH6MgRdc^yu3py>2ruQRqbmZhW_EWT_ko|f;lhkYS`!q$~1d=LLu zq0n5?qPM^AZK)FOV_dv}j5m539fuX?5Xbw%&E1B=XYJ^>df-u>KXGj^rSduX>(cXV zFUUvN50*G2#y-?u))&X}JBFeEcF(hrp4U+icWNmv1?L^XttvOI zt;xKDyr!2u=O_TNa0}(1Cj%yLP3S@jm#*MntQey*{q$%(_8wrpB`!G$ohw=c)}#5T zi8^|HzddM(9j+x?iT>lL5@Ct&Y>v3Nitc7dJhz-9MZMy|taGjwnw7t-y+?ki;|u1L<|wzRXz3o|o$d>Z zv%ShKG8@(dEvEb#ull&JR) zz=w!;9^07mZfO;VxR)DT?^NINTLVp07ke7TW3A=$EhnbYT2fNeOE9M;gL%xSTWU2Q zB=F`M+lk+65_JTMF*}gq+@t8zK3VYQ6sj zM3FfSRogoZIvpZw-?nQ1y~TDUqVAs`r@Suy*Mq-=5(g0U&B$= z^S=i~_<>2|2k5x!sclNU)zZ2}am7K=Zgy~AoQB*`beHYb88u=9%n)s0r{T6@Z#ibD z;~t6gItlifX+SHLwhZ4@)VzvLErW} zKC-H&w#Je{i8@OMlkj0FBEX`&aB(S*2A(78pqti!TeD4ev66<3l2rp`+Cvtscu*+FI3Jac>-Jj=@^ zHa{HGYWYeXBnJ+u8+z*@H=NW^AF6;B+5zjtmQBV;+fi1T(JPturYdaIqb&XlVct9C z5doP5-+*IK?T8?k2HCp2gtpFOWGnE!sh)L;F0DdG8M<0y5#Y35uf;~0fM}q$SWVVE ziU}Sy8ROm#&yJcX8}@RVX~M_rEB=K)i;ClLJ7B|Zr@6yPtkddsZby{fD>(ULtS5Q@ z$kH1CG0`Rk5j<)<)`G8w3O?v-@gE8Izz60z>P}nqrN)G$Wa`T7n)qsxfvkTWG=Kj= zF1f-3L3XKvp;YP-ql$4CWVW~f|7|7DeIQJady=>ZZQ-;jG%-9~@`^fx>NMvJh{1ki zgQ>XJP}v5=(BpDn5k?=G)SC|BRBov4=l=8CspdA7u3!y>%tKqV`fK_MF()t@7z%ga zuMlHkWrDbjLt!n}k&ln}NfII+s)pfu^*jAmg>v^KkyimUY&k9A8u-_31D4H*WFtrL zor=Xx9Q9f>(s6X^&Frc3$m>7$GgeA^MwV_j`7utzpNWlukdNxCC1MgmFgC6qyK_6! zr@cvLabxOgV>9@^UFS#;z3R(pc?B z6(cVldI``%uK2`6WR?}tfSYai&y^--&d=eNwS;;S_RkXc z&gJhWOugU*Hd`7Gw~zsVqtzayY`n}PvV9szt}57H6~3S=%AHP=IYYKry5)Z&^@vjo z-xI{(0azA#MSnDq8aE333ZMt%l|u7hG7%{VeOnJ=j>~SLiIVSHP{&c*fh3bY8ZL9Y zg=dq+9V+kTB>4`f2TA9JPwRsdplf42|kQKK4#%QGY6w@({`71w&_nmkbSAUTM9-|hv;1P<4+|o9U)#d>gct0 zYODMc+pjUyB++CvKZnfQEPJQ3hVnaCM^&kAU&Fs5^+t8ukQ^GDk#yF3XUie^(Sl|A zW=tv7a^b+JcethAV~rNYYQI~{1AF-|k_j@+F$aK?<<{~L7VQPE-8MVjq`l!^hm|IjzAd=~UD zDvEz&CDW|YBL=(s+C*!8m2Df@8$Uk?k+>*@S8D#npB5O5W)Y6yEbu>}UPk=vbe+x@ z5dZ$Pj35fIofXKhopsh&%BDCh8x{FlFhVPx5HTpaQ?i?W6WHXHujc5ihZ6ae~KqbUYA}DH_oeN@mdg4g%!Z?BXmdN6};Zs(&~M z8K9J@r9CfOZq0q`!rUj~EZGusfa@e4+kIxM@q2B8OQmsi1sC?9SITq)Rs zAHnk8e$o>%XN9w7?fML45+--yK)V?$alxJZAl0n^k4TU0x6c>;7e(&_!83zNA2!;o z9i0`L+IQLh-fVG10?~!8k=PKnNWMM-LJo&s9QS;R_f#Lj0bfzQ$$Y|KLUq5%M7ApY zSJ%Ql1@k3r{0)BRHMJ8L?2Xmr5YdoPOn?@=%Hg-S34HU64hN*SpfXU*_rsR4GOwtv z{MZRJ@eoJXEl`+6!F&>b@h&a4X-_z#S`=#vWD!kH)SKfKlZXaD5URcYRb#Fs!1ql4 zY>b{@(W~j6%qgOBwqr|7=~$b-pllL;Z$ep75abAZ9ptkIdKr5mN{=;Bwz4g5GOeP6 z``tG)0~7;lEiP&u`Y62szA;5BVMXR2R>ogPs0N1`Q#V8Q`0jyDgQWkp)z&hU6(Dl1 zz2pjhwbtyzS)GZdZC$fw-mGf0O@fgF_q{*9`AfsvgGxwXOeN7j3rTk>HMgL9?je~b zVRhgyhM47Hn1%fduT$h13xck#dZ2Ceil``}RwVbVqjj@BG3dd7F@;q_YnQoULpY)Z z#2@(;5Cpq2bFx}00u%x9+&+SXV)#(6%bM`LZAPpFci)@WVlD+02U$2uo;Q|}$MDok z>v)kpcA7#+6hxrMBAi#K$=n$m+GHa#5FIjMgf2CvZVKvy_PKmlV1mF?PNiBwlR*T? z5UQnP8RgZ~7qgn^Ej4}DK|U~}Wt$<7Qf~tsoN6gwI21-)D_$t+@B7~8Agi3c6jwg` z-~>c6aik^tGbtV8GY1r67Ys#q^RB|7DmJxI1NC3U3DX7-d;{D={l#Lig<`B9?u^2h zImvAP&k3+Hq?Kg)TUo;alIxKJPc+p;h7-&Ecm4US3)y?Td}(Oa_0__XDb zhzXYxR2hUjUWCZ3Dk;Ahfj~VQ(qltUtH3>(;!w`T2wvE;+zrFj01W0?aj{QVJ<*}( zHEYZykBl-|#-@R(Ge}5)SOudvbeF@af!%sP+W?C>XGJ41jfZdpPpLY>q_(^HK*G?|s+IR*EXBdH6c}k*bsit4JUhv;nk25t?()KP z%e>jST#e{-NqB8MksLm0tVmRPD-1)YRTpSxKa$*P0pP3MhIv<+hyq-l3N^`mFik~} zqY99#K!jfD;W3TLhd3{_6?5uebpB~QWHRIi)vZpC41C$BC1Rt>C(dS$9QMy%XO~Fb zE&ANNczk?oXds3Q3=OwzG+EY>?Ur>+j4v(X&L|8jLe=`Ql5eAdKs7eTPHh$T$T60E zC4qRSQToyv&TbBm(JQEMCHiyrXZd^u{2aUM32g(;z2FueC;p3h=+l~Yy2)`FzBDJ> z$%OVvC$<@xi!_U-8l(M{zXtj&19Vm1 z^h0Qe8Z`m98T4HclVd_SZrJTHZH_hr9DD-vc?t@JK_rwLHB+sl(olW@y=1n2U-rT1 zzxQ^1lfE&V6w?T^YCyrDM?@n8b+pHPI$N}de8TSwT$k}g8q^`wK|BPeWBIn+MrF@9 ze#$rOFVa3Xpe1mpp9}p5H;@1w*Xkm=MzGA_7H%h(UvhUOiXE35TxfvFNDmm&gL|h3 zvD%ByOl97iic)NABATiD1ca{oe-kwrF>lrBVybxxwNt)k9N3~-EFA~+NN`OKB9CC< zi70JlISkMZWkkWX9qE741L>Gup?lcRD+FN$EJ(jJ1nCt(Y=OUrmKIO86Z71|fKtPA zY^?8%e8$De^_`*YLpw^7Hb;*);FMDrVFzp6aTB^fb-1^Zgxv^7{ zHq$Q8Ei6W~Y?LWJr|F&R;=FCX(P~yYpk}k4+AkO%iI*E^O{7_oiO_=gS*we;e_4vzXt`Atxa3Oo=!BT#O^Lw;O#CRjo5wtTmE>3?v%TT8^p3L|6V{)+1^w&yK`96}>(gu;~a>~@L@YMHuIRctVBM=4KNhog)4=nIxjrCbr zVFud@&Ql)p(qS16yht>Z^`s(0`Pn0m?))J?U9S3Y z^)&TNo6nEpE=s_hHTQEsz8`Cc(X}PE;VGdM6wLP^^pjN{GliT0pF)1|CpNpz z&L>j~^vP@^5B_oDYi53%r^R^hFuiaG+K>X{w$&5-T@6dqTHeB5yp)7s{J|BZUrw`u z%(M+(cX1p*_d{pV$CiY1xzKye-R&O`x)1QnM!Qf=LR7=C^4E+^*uLr2(PfnaC)=8W zbKRy3Uz7cmCc)EYjYr5^^2WmOyH(!^ew({aGs*3}$5D>M9<(3z-#v4K(>pEb2v**@ z@l3jrPiT^SEr9*iuO(L(oiXR~UV2+_iLlgXBZK-i2eKj%kXJ!@EKIxHJ;?GC%#d=4 zqB+=W!27s*n9?9(83Z#kL>3gS)hoT77R*B;bBa0IrGgrx`WurdxFfkFQMMv9rEP>3 zMJWC-d3a_u3XedvpfmhZ@Y&P319c9pzkvBwcG+p9Z?B5}sK1elM6f*j&~yBoD;^VC zgBj>AE^&?8O#<18-&;ETM2ih7&&6_3Q<2-C_W>O;zG zf)4+91mItv;BWg?Y_@YrZ*AETjBQMUMS_AI^P0QnSKVj9T7gc~@EXF1*qGWsJab(g zq6*8j-dWSET)M8NBWq8;j$sQ*YZL{WDFmuLwOo<;K{?Ner90o+&X3y>jU!oCa16oo zQ`o4Xx6;*hz37Vo#69$hkd~~+=g02$b3&HrXQ#QwbUOA77o83B# zVeTe5v#st8i;OAWq^p2w)iaQYLwcxmz4nUPk)&)yv^T2k<)YmrlWwu2ln_bML3^OF zo4ZJ*+-#UW;{pmIE>zw`Fy=7-TMHMtf|n)-jUTzyefV+k>idzzf3x0>^xS;BV>SC` ztK!M=mxtnO{{n41zV*JLAv@3{_tvmcX@-80`~2mZ=ueT-Z0|3TpMTx5j0N;>@4uNF zlNou9NRp#CG46k>rNtqNS>ncP!+U(HEX&+R(1!!yMo2kReL_8xJVW)w=N=-01L7XE zEpW>o_tUuWy#$vn?GF6DVAlZ0dt?fiZs9$prvrgQk&b#Sniud%j zh;&73hW!-@sFBKX=JxVS|h6uNGJX)LhF0%wzUx%Pnl^9w>QW( z7I4+FEA^f1kEg?-`38&#UE@-WQ;noXOViSVr-?RV{9 zOUx=@I7*`+82+HHpfK&C$b{J!xIz#mSbHuB3P#NHv@pGPDLT@clKvX5V=H47rirIb z1hlx&M7(5N%i5;(v}4jjXT9Cz-b&UPFsm~35MKE=ADzUz;MmxwDzc@zmtcCN8NWR) zI}T1i?L^){#Vfy8cI|zYP|8a|ZHQix8+>o$FxgYCedl0B2GV@mI|iq@R* z$;_)#z818}}JbpH3|o!8*p=h z?+(O=ouSkGVBdyMp@p#(Y7Oiug4606LsY#|nRCiC`W~Rtt=*EcA(*=VW3cijr6-OX zks#QF-zjVo-VOD8L|xnTmjYp=E|4!hbd8nkABrHZvRZcMf?3ShRO8Sh^v2IxYd8~} zM0|uPWL=P*1<#E5WTBPdpP{}syJ`7ZVVbUkJ2cA3~*WB$s4+w1Ze+#uwQq zI7Be6sQKADj=KY*Uw3OfM7(Y>hRJTvZ$TzHmg>#I10jWF%`LILgnamx{Oq69N6%mz zvDX*mt0#3>o{={cchcRso_4rawUvzT%S}KvAxznoY0}wMk?3cETJpj-PJR1FI%LD> zcJMRzn&(FD1P6gVGDyNZK53chj@;gLd!0fg zG2Wp#usr5PQXsenau|05#2`v{e9}<*PNSo=PlnFkWsQ#3F+ISz*h7i}u|pvwfk;<= zhQV0p;C^1j*0GAm=SyK0^oth>(de&QYKK{qr7_$qjz@`y%V*2D$U|M9m4m=oK~LH^ z1c%?0y#{PI{EXNtNyO;R!kDgUgb8#9Vg!ttCJ0LO!*FtvZv-=It`$tV46G)h2HJcq z@NQ9;pMl!WOA3X&7td{&2Uj#L1GwiB`A69P#cA+}-3IC1>ZQ{`4^jWzX$mn7RT|C5 zFmgvy5YCFnf;U>s{cY$}l5}Tu?@ugjlICOTS4y6C2QcgEh1p7?bXLC%(jQlFF$$34 z(!@LrXkl$DD8ckq&4vpqm5-wauUBX8MB9aahP$5di6{WSCjYTv2FnH|+gKh(tTnrE z-a-8cZ^!0Bsh#&ShE(U87x*^GdN}`g%tGK{5c&=tXPFKaDExVHQBjrhaWUy^8`RDS zw)Bhes@58bDHcXZ_VUz(=HLu42?p_`9%|BBgj02z1fRm`$=1j0)7b zhF77|i8BDEJ!{8A5UTn%UfDG&>*ee~V_FKV=VA^qt!5Ddte^vdZr#2U+$%6dr3elR zgg+231-tmw)BvTby5W_#wTLi2vru##bohbH(Q%i_wK3INvnXY($2*LvsgQ5q`VB;vr5E0cV z;yauQo?Mo9f7{Yk{MposeNBc+mLX__@UQR6zN(h`hv&Ma!+CB6mmt68IIjJ$oWB%< z5aqZiCM$JtLLOPY_J0h3_j4RqK#{?t(7G9qkk7wPt=rIcGy2u~@k2T7QuCdw>3{vb z#@SPKOGW8~_^AEMd9RiCcT{~im%VS|)Q9sgEU$jNGJoXeGWd1f>-&Ptr5DTd1+dAL z>sT^g^(#iL0w&AubUtHcEvEM?jpNE}#%(5Z!sEJS{l;V7;Ft%UbVd4~=}c3MIDYNZ8Cq{^Xyp;~=lX_htrM2^5H z4Thm%>g;i`HgiM^laX(WiHhpfz7Gmmky@+nb*`2#j_30F#8ykem_<~+IFOkjT=Z2u z#Kcm}#hPdks$12iP}xx?F!{{MuChFvut|}7MT=f`h2Y>4o##Eaa2{M9>oiq%9+0yV}Lvsp_eWLBQ1=lO_%n=#DWCF5P_xOTO9m{G~&6+kys z0;wgVxHswc;TUbu^+{q!`9wIWNEae%cSf2J8&~ls7(qU)m81tZ_NC?|!-l^oj9ZlQ z+3--mUzh>bm{h)2G5Nzp;kVH!L7WNY7T8G3h98?t4*$sV@WP5s(Vxm;uI&GS{x~vk z?1ixjKMfJI%7u>Eq3W%sw^tCwG`xKp|@q4xr$;`*)=@E zxgJ7wrpwoZ|7nKjb)i<|NNlZzANrEZm4r%kbJ8|-twc{Sd7^U#Y{J-TR22A$33*Q zssu^{Qyxez)A83Po3j$-B_K-LqxvHiz;Y(|z_nM8(+mNY!X(>s9BzIbZ7-!5tUAX& zwy|b+xM`pHNKxYp!P_LD_T>FiqZkk5Q|#;sc8KzqTLRMaKjO-yWKy%IckG+JNrOGD;{zRz`YDY(b`{TRB3^{if(XtW<-IQL{3AlXS2pQm%I zNx}w2-u&gIqD(lxc9K@~DZks|q3o*iRQ}y+E75xXDqjyloFn+`VCj`g)n2B3w-n#Z z+X2C(I4dLE*Jm`dKbTiIT^6N{{236*Zqf&$}+pGLjm!{Z4F?K`zGWIGyORI5M=b$NnvJ4#w+(T$PWTFf;_EW}*7U(l;pdr@{rg?dQXU6JaT6FK zeYYEQbtE)sC}>3M^g?p%T`hE^`ZccwXQk32i5ymVp=hTgmBcwub_nM6G*0ITgf0z;cdM56hdPUwTtsbFIISr_fEG0f00zx zS2I@(eoUa57i1`8$lB55AoVpT&R%a+|C+y4bXOwj4W+;uN zZHjMi%UqhwB_MFiO%u&u%*AH|u>#1TEdGL)58`fquB1916 zoRZ%lOZz`|>%t%4J?vxtvRJ+!3P}OJLz=l6ux)0Gz18aar0fMJ=Cvt}Cw13ijgcu) z^zx4+EqDO%Yac$ZyhIi$-yq0_Z4u_w-=jN{zJ2%KSjw2oTwceTgEO66PX(y%fe$Zk1?f8&kYzMoh}>E686_}RQ! zjoBq{X~G0sW0Bl;iG*!Y#eTq**y!bZv}C!6EV$E$%#MU9zj44X=^PpJ*{fZtJuP3ja>5VS0XYsV#+O)Rj2o#)o7$2#++?t6hkds>ZE zy5x-^Y@?zhbUDbMi08Ghhh^%Gf-FT2w@Pq6sz&Xxy>4kL=(5-zQ&Tnu;$zx2J`Rq0 zuLgZv$#>QlVsbdf!y*I!yK3yxzWkyF&UV4Ss!5Xl!-Bb~`6Dh@hz=H|l}1XB7K9G` z2B5|*n3f?37v7C}F6fRuWDluEJGjhBPdY_da`OJvy+?b^?m~thg5JgODZhwWz`Q`k zP2B%^-)`o3f8BCw$ko$C}7RwP5o_Zq%=d!&3H`27Vay7I3 z^!joyqVZvsBmGg-+8Cu3J;IK@uv?5dc7v>*)$$>>slNi3(qEM&K&sRmYO|F~R30I1 zQ=4#;+=NX991hK}9RnP83{bM6lbH4oayJ(?AftAwzQcJb1}nG7^pNwm!Bd&`%`nV5 zbc+hjmYBeAwTrIZ#fGT^Rd{hHG`y&qk;(`+Q75omMr;y>d0iJ(9MDo5EIG7}tj&%` z3Cpq-ZUNL-%E%2BxPo7wdSWSwqATyo;VT!H$i6mPBy}nKrk2yFp(GKzO z(ud3+AUBxYN8pl10uxNTwM@eu{Bh0oSesdEY31l=isbNp4`wgUF5?Y#2={E14Ah) zXrflL7!ityNDx~?0?IXO2g=%cic`kS6_CdR(k_A^lh0Y144v{gAC+4YBuMuEXgc?J zru+EsU!^XUtCrNIa#*F>bzK#eOHQ+OlB>G1N}{k;l1e$m*sv{0l2}nmY)k4w4kO8F zHs>X5DmLdahp}NNJD+a9`*+{}e*b(wzK_S}eR#c|FH`DgQBch{L+*qMRhQBE9rs=k7gQ~{y%jtK2TKpVd}rF$Bx35*KL;Hr-}4S8rC)c4Ou3GPR|Xm zXYNrxONkVeU(vOR7e7z-i9Y$qX0_qJ19w;0qYedUk?J97n^X<4v)ZfrN^CtBfLm^1H3(f^ApJVMN_RKjhpzxIx_Pa$t zz>T>L-?RRK%#Js0aJ?KB#IP8oizl0 z0N+NoAJW$}dntoW~4@tJ_3adVa zjhYE-93`lq8oE6AXC^7p#S{kWtmEX#PQzmB85XRU#`oYl+q>9s(l`sZXGvWy=W)B$ zDeEu?4B5e31%C-X^C9El5Yz!*j-H|8IJf7MOE+fTF~snjcS8<#k7^rYgDl_07UnX4ZWcwkIQSjh+l*h9+miSQB~0P1 z6yLSZDFTT8jB`^s2(#%A^ev={k4B?nzmidnk(}2be6C%5-LVHds1M=`d+$m&qrbU0 zPLSd&DY$UR6>B3uO|+DCL^O08^V@J+Twx9?pfzmc_w3`8g3Ex$n*sA^$@1UKzhQ(H z6*2wZVoSIOim)!R7r!ss5xCax17-v!DvSiW7Ml#>_T!rH%QQm@l@=Se} zYAjgCNvt^lgvmBVZ0!jYkekrPs+%X`6|I_Pmo9xz?Q2S`|FtOiuZ-MIU%MHOi=D(E z&m8|lAVdSDgI%ZzuqIZm)MK|8Se-0CIU&&{kaml66z+GwRNaDP)n`&IfYa5n)G8)# z+8IqcOi3CEvJ}`M;c)}xsZ&0S#V*|VcGYW_AnAh~6Tme+1j3 zy%ylmRu!8HoYPD*JU*)4>HclRvHa3Pq^~0#c*e?IF_MQ0&DIO-Zl88jdB6~PD7las zb4p`7M=mOU^N}-CRIuDB?&3n+$Q!Bof~!X+dFCHuT&__tFI+wE!AJ&WhGr;^9s}Pr z*Uf6(G3CU;zbcBS?N2M8Po*N4@h*i0%Q|BH0}h03KzNPb!ye~2b=zFllP383Q#dEU zh2$yY*kO*X?v{0B`g}#0V3>{mT?gl>Pt^j;H7`hW)pMNjlSgB?!2Tm~Uo*cgG&%1? z?{YFu04AVM3FC=^0ti9C=}0c=+b6J7fxE{`kI63zPvUMOo)ykOGk#|CuIE&e!=HBXJU82O6YSuLSzSE{tjYJv1&1r?=uI|5+4#ATQwU#hu;3^ zeC@`FH}*8^uBZ)5*Z<*Sdz-=6kZyUT!#rhnog0N7*8F*HqU= z_J&Qa*6?3oV8Y?=R669RaCh%6)&VT+->&VsE&tg}X}-vXtPXE0*2g{a?4`llRdyVX z_SdKhV^Jw8|0=`w8O3ofQy6_j<7$|$i98|m4TvFpj&Ts)&UI8TRk@r~oNM;ggy+D2 zK_{5F+U4o@18*5l!VdF_mr+0WN4)vDmDWE%p?x7In`1q0Y16riW8gh7mrQ=&OS!rI`u5HTE3R%&NZWcx`%8iexc@%zb8x#*>=fKi z+!DAR^U&oe=RWu-FfV<-$1lB{tn>fPocTlW-Fu5EPK!I9BodP2@e8cz>eAgJI`ur>F^y zxogkt_UpepXEf(F2LL;kDW2mZpmS0SIhr85s};Q#C|J?`^xb8+CM1&`J>0ZVHvd9! zki8bh{u0-yqj*Sa+nW@1?MR_SUoGNI5AIed_FxA}Cic@2TX5uc0@RFR9WAD$#gvCn zF;?tT)Gn-Kf&gkd5^_8pGzv1b8nFHvH*G7#v7E*wl~%bm;W~Yk!d(I*ZG2BI?hIJ( zJ2XH71M0Pe8cV5R*wJlLWk~{xts%)ZkGgk5TUXa~K*L9j`-hZC;Iy@#&H01)7C1qX znLlVS;l8L1JqrHM61p7=DzRaIu;Q-ptaA<#fAZ#e)qN8aF(YNtgBH+qz)&uT$s4== z#PDB+mzj0O`JT?EfLo2F_%^Ms&zFshs*n?A*EO6 zep?%sTj=8^IB8bhg(*(l8a$v@R0B#|$IfsXoCrbP7z zOaMc3Z&}gO)ec{ILo&@ylxyX>-U6k~5;k+ulo(x6~9h!Wb`nubqO=}NS7=1@vS6@O!c(W{$Dx2*d6Elj~0?U z@xCqg%W%5|-D^uILGVVV71C>TO;))%$}Z~E3dPc76pRq>#x4W6^S&9gw&-kV6F#Z{ zc=pvya2{kE+)8ZKW{i5mJ?sC>d3V!8)p5q#^e=}vsBN?upKxvUf)$ORMrHe%sYFAhIIXX|s>)g?8!KD$ECVlkWtyBNAn&;2+?M_Nz2ToMw%#IJA#> zti?|9F!r0EG>htyLWKlp}pR)bzV9HeM0n3HifB6LfdtQ)C92jS7H9G^8(cgkF+0-bK)KB=8WnpV(HoHBvJ}f* z8om@3NM`9{NUYNSMWd6r6o6*6_&&`;j;~`>p>+R$*}sv;rQ^X{G*Glq|Wp4cOZ@ zefqnfe0!HqhHi)QEWnk%ie<0aC>f?63vf7>d@eaRD|e19Mlym(){|)2P|L5R_LSPA zhe_{KOTNoK0G2t*pFf8j+>d+G8ggT#8D|5|s0qD5O32@&hcB5Um^N6?7z-1Knb4cx z8&@{Kp^S!ofs}`#N7SS@)clyy1X^5_cXqNR8)XXOI|#0QZ(yAkUxPb=R73@`Y-QMr zsKUt7;Tf2WhJ8BL3W-yHf{?MFIGZiv3ynWZdWZG71|Ps81JSrd?zAx!Rk!N?9K}s?s|?qhu@$(AFf}ZB!ic3a*&?7-o;&80^XOs zafsgmbrN2FS6>GWp*Sf%A%tz&%qExQIfBr_X9D?BncGARLh!FC8ZPmQID2w6>s$?Z zyz1OMPUgMMGqOZh%%k?UcGNbm0=z`u*X`2JMewyzN3xf_utZd z1v$WTGhd)Ts0V~Q8I@x%-;ch8Qd?Y%qswHge}_DkC(jR$)Y)>H&80VNcadu3^)DmQ z5tG9WVbgGakb%azMGPZc!)_?M3d;=ZonkmYmm+xnq9_&QDyLMx0aOw^w(TZ91pK(z zn!4G7P&GzzdE0lXy@PrnU>q#{{v(VKNMh9MLW2T)IG26Eqpd|V-2ZHAi47mc`PP3( zH}jTrKYedwSRAuwpXy3`=i*BZAH7NN0TdO?v2%neAE2f{tm9{#vH4WoYJ;N_;1IyK z*zsFTp&be*d!pK{FEXBTO+mgJH&rz`|D%HM34P3bAb`CQq-CMb#=O)Aj0hBG(0J!- zudgvb*)7JY*1d=K0qW1dhdi#>qblI&faAey!`@WY>408o_nB$jr7=1b_UoK)fw|G6f-yHLty}G)+|?iNFm@Gd*dy>W`p?7Y zi>uKgrV<;{sJ+%W0MRrq<(NQ&X|y4W@Y*pf*OnINbR{NRM{lwp7XB?T0vk+;nwUB9 zV_EbqytDk4q8!Ku4T@#=E335&0k=4+<*uK?W+7BW!LkX)eO=~;`e3b{0F}gp>MNWO zly24MmO~+P!<7p$r(7AMYn^mv(8vB9$`l0nYpw`$gV4_|Cu6z|;f58_V2?z@kTVA{ zsfQ-mi{j>b?=gz|ji4*%`Y4v+PoO*Gb4tj!?)zx0r)2EoOs#JS-p9 zZ;J|AZrsQm;<5UJhJbap%gvrq7+{*)3l29(}{^3b6d zz|KZk2D)(1>|P!%1;Y!n^n*T?9&UKd%E|JlmqjYgnux+UX~yFQ8@VUTp1b>gQIO7x zw-b5^WTu}_o;+g$J2aN-gt(0Os0{lHaw+(~cbdf<{qmf7iE0OyUnY*9x!;O@ zY9~ELNhOFf(eD%Nx-$=^8%3{W<@|+k4-d62(LNU?*S+(~8prRH_=~q{Y^5|WM#M|$ z=oqC}hpGhQQrB&kvEjF9H9nH}#i+mew#sMKr>Rr)4zDm9zDx`PFA2jrLSWJ`;`u;7m=CFcgPj4+05Sj zG?z}w7oT@3+X?2h`z%6=pd)3M!>lWT+e3f2vnEGGMoUrDp^c|=>EIKTU(2hY=Rjs` zD>?r$PUbrqo7zxn4~Hq;?%_11(pyezJSSH(Z$XbDg0SDFZ%JUq7+<_9{}T;d;Yl{1 z8dOt1N-h+P%sOt}Unuzo)R?DVy01~D}5Y`j1D^S*&{!e);MW3 zXPGA?=ii2=x%AYW_sKKw!>7dezM*&9_5W0t1oWL*8++TRtjSq@dnIhQB#1^KMgD@k zFTMW|m{BMHyO8<=5TBd1bFL@Hu?sq=3fjWs@6}`!#s5-jv-&gQDQoRn!fsfv5po1p z#p&@|hOwrO#hV{izJoRMC0{OK65V;aQ7~@J`&g+Zq zUJ9FAq4Mgp;ZfzJe8#)5G&zs`E%IsI218*Yp(hO-YRvjKE^Aw|7kRL(<%XUcayM+fgJsGvJZlKF~F`^m`sU? zuzGKIk^z`>h>YR+GB(W{G9oW(0MZ8CBk=-d%_8O>QJnGs+DEdxh}q@m&9P?$mGG$M zM}i-k4JV`Hc<53aq95+{QnpCH-GEPFb?>EC%^R1D+b=+KITB6ByTxGN7YY~y(Q|3K zG=g}op&D9&h$235>uuq`ouPDKTeV)Z#UCOAwDt~6@A$L04o40mNO^N49J{F-0S;b7 zV+^}tbM$#;Zwn)PEJdhkEOc+kcpA|d0(|lFw7s$*yMFvI9}g9oPpo? zmo@h|6SKx-FIewsw+PZ`meaB&hJkL6^o_1w-BtGhabBjsSRbS4tg6FwwaBl^gZi8F zdpphMKSB<25`TWAezo8=xyka!8~y==^;^YWY>O=; zju3W17@6}C>H`W9J|%PqPCN7dnpkqB?52WR4g71W*WLLP+7{kXd1_ZxGpOA{UXkts zy;}DD8b~nD(S7~@oSW03R_+^@4l*%c%YXfC%M#{h{n2;Ld$t{|IlOIr)7F2E{&TeE zq@`<0O2C&1(Ps4OhKKb=w^zfD?rS=|v;kqCe_4}>=o_i77eIMX^15*jVgx6Ju4-v| z7(Av9Z_Wihtv7oF@;9KGo5gAi9`FIXF{_sOfmR?}x&NNNr1YVf!Y8{}3_J$O}5oPdUFEtUJgB_PX~ zu~}1X6MNOKsG&yTtSOz;!WQ;VY@XDfOiqw)@<-)rexLLH;>^&jV5tt(aFxMxJdbC( zU*$PrpQzBQn5O|_kt^|As2-}3#OeYqNw9}qTF%KZBnODL%VT@g^qMwZs1!WE2Gn<{*( z$;3(QAphDC%a4*&l1J3R2PWZdDl??`sm7{W{V{o7x*;}A;mpYsTAeS>yqAUTT*S`2qcA{J|Piz>uKI!y7oWJ;9q04l(?OZ8jjT0<#7*_|t7_!WS-OdgtDjaQ_aQbzv0AuYz$b+ngi{_i$ zejA&9JEi&8H5`%wJH#9mY>4<)vs0K<^ZHV-)&^H(t0ed+`p4~+4SkYlk59Loqk&`9 z~A7uKqr{tG9Bd%@{8GUIWK&COBg1H>ZpsH}32gEY=KC1TEeArOlrIcQEf9+Ccs zo?NCys&?KkNMwa>!mJ4I50PEfIS6L1YAA=ne>wX7&3NR93KMKnGX3@m6Db1v%;djr zjd4u|b9yOuhi{jie>JzxOYoNxh=!wj%aREUTXlEHHrYx26P^ASX3Q8sZ!bIZpn46d zxGeM_>3VpI^&0hK+%+<9-f&_eCbq{KdiAcgedrVk4IFi}ppP2aT{EB9tj~HXn7$;~ zzCYh=;&N5!Cw-ftQg$zN-cK=P$i`>LR$w=gkTDTt`Y;IFt{C3=8Vehk_#*zf%OCAuEwWYy>c#c_W`^TYPIFocP!zKOth-lQfhCHkm8ygEQjX8q11YK8PBA3gE?;}i9aLM452iuypuIu% zw3%J8!vhDR34U8*0k1;S(PBhhzh)lN5M6~FSFJ{t`2>;^fO zAGi&231_Z;e;uP-hgt@wB^wLOHAulw8MNB74>#XphQ9g>%ynd^fV}j@Hg!0xtQa*4 z_Sx?F3W0(N_Uh<*R;+Kp72w2YOr`pd{x%3B{w@4n?08K?$YBrzG3}fZU0x<}ZteaQ zI-fBg44pFYhw9q&J+Y6RtTmK`rQJ=nEpd~yLS;=TQR|1P!iWNZ>@luO+(BuWY#@YW zLkmG>ngz{auclRc2EmX}&dKleLl97%Y+|%Non2J3rCiu=*Eh*&4_zG;| z`Z~h4)YVMob~^_iWYX4BJ0S$pGw2*U(lKVAvO@ME`m8y>2|En7r#sGH8wmnRYWfm& zuqe;QxD^_t)RhA$;StO}gp5$fZLWcMV|}%@+5wO2yo&_L9Eea|nE&0STX~%N5irOz z*6ya1!BK|1K95Gg9(1cSQR`>EMd+cCl`&U|?%)=N*>!uOkXaG?kVxO?^ji%fhG2u( z^y~2U+{EPKZPBlJ9ER!q&#C(25LkU}*cV_m!ja}1CRE5Dq!CZ>l{({Z*V4}`82)LZt zgudoT+ge9$Reytw*6}x5)>GS-9FozWV8_i_77TOAE(Yz_3}L2pFLF5$OM8MX*k9II z%!xBb{vtG5D%~q~pDpJmVB!OE6pW(|@sGPPb&t`{wdcxHBR6W06OW`Xz6&Qr!TRtr zzj`efDY?rzUj*b|)a3R|Nt?kT3&uu5+y_~GtKu>*Wwy0cA#?KHrvJOq*n3I- zV5N!O#&yYw+g4q;QJ&omHv9a`fyXCTC2ZdLM?wQ{m2ed`L9og)5C7JtKV5;DD%^?6 zfqq0FeSB`{Rlyev7KLH4QU&hWH@fq}3+IdNvn`gvev!j%hT@?}?Hb!~%$3pd4qaHS z!9n>%cJWehws`F{NYu->iI9vqLCgu@Ei-U}-L#oy_h|{n1H6V&yIUWs={4EbgK-hIi= zrqq@DBQbjug29eq71r1{qBu;Gj1E?8MXdm@URbH9Hm-i`=0|M? z)R*H%7j(hINbDip-6Y;PK1K0WWW!l095YAj?1jizI`EG4ik5QL&1l^+N{xOw@M6;ztnW(mFLBms zq*_BH6C_>orH!!TaLis)=`QiJ1zWjB`4U68N@^cEGgm$=ZL2Ajw17|O&lS*1iGXPb z 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 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 From c65fc7e7750c6bfb4d30bdf18d227ebbc01f8b22 Mon Sep 17 00:00:00 2001 From: Marc Lalancette <31040756+Moo-Marc@users.noreply.github.com> Date: Thu, 23 Jan 2025 11:33:58 -0500 Subject: [PATCH 36/47] wip coregistration add SurfaceSmooth --- toolbox/anatomy/CalcVertexNormals.m | 138 ++++++++++ toolbox/anatomy/SurfaceSmooth.m | 390 ++++++++++++++++++++++++++++ 2 files changed, 528 insertions(+) create mode 100644 toolbox/anatomy/CalcVertexNormals.m create mode 100755 toolbox/anatomy/SurfaceSmooth.m 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..89dbd1d41 --- /dev/null +++ b/toolbox/anatomy/SurfaceSmooth.m @@ -0,0 +1,390 @@ +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; + + 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. + 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 + [~, ~, FN, FdA] = CalcVertexNormals(Surf); + 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); + C = sparse([Edges(:, 1); Edges(:, 2)], [Edges(:, 2), Edges(:, 1)], true); + %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]; + Iter = 0; + NormDispl = zeros(nV, 1); + while LastMaxDispl(1) > DisplTol(1) && LastMaxDispl(2) > DisplTol(2) && ... + Iter < IterTol + Iter = Iter + 1; + [N, VdA] = CalcVertexNormals(Surf); + % Double boundary vertex areas to balance their "pull". But not very precise, depends on boundary shape. + VdA(isBoundV) = 2 * VdA(isBoundV); + VWeighted = VdA * [1, 1, 1] .* 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 normal movement, distributed + % neighborhood area, that 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); + NormalDisplCorr = (NeighborAverage - Surf.Vertices(v, :)) * N(v, :)' / (NeighdABulk/VdA(v) + 1); % / (nC(v) + 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 + [~, ~, FN, FdA] = 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 + + + % ---------------------------------------------------------------------- + % Normals calculation replaced by better external function using Voronoi areas. + % [N, VdA, FN, FdA] = CalcVertexNormals(FV,N) + +% % 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 + +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 + +% % 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 + + + + + + + From bb4771856ed99b41c2b8216f3a20657e39f5bad1 Mon Sep 17 00:00:00 2001 From: Marc Lalancette <31040756+Moo-Marc@users.noreply.github.com> Date: Fri, 24 Jan 2025 19:40:55 -0500 Subject: [PATCH 37/47] wip SurfaceSmooth --- toolbox/anatomy/SurfaceSmooth.m | 826 +++++++++++++++++++------------- 1 file changed, 490 insertions(+), 336 deletions(-) diff --git a/toolbox/anatomy/SurfaceSmooth.m b/toolbox/anatomy/SurfaceSmooth.m index 89dbd1d41..3de05b0f8 100755 --- a/toolbox/anatomy/SurfaceSmooth.m +++ b/toolbox/anatomy/SurfaceSmooth.m @@ -1,343 +1,379 @@ 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; - - 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'); + % 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 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.'); + 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 - 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.']); + IterTol = 100; end - end - if nargin < 4 || isempty(DisplTol) - DisplTol = 0.01 * VoxSize; - end - if numel(DisplTol) == 1 - % Only stop when total displacement reaches the limit. - 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 - [~, ~, FN, FdA] = CalcVertexNormals(Surf); - 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); - C = sparse([Edges(:, 1); Edges(:, 2)], [Edges(:, 2), Edges(:, 1)], true); - %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]; - Iter = 0; - NormDispl = zeros(nV, 1); - while LastMaxDispl(1) > DisplTol(1) && LastMaxDispl(2) > DisplTol(2) && ... - Iter < IterTol - Iter = Iter + 1; - [N, VdA] = CalcVertexNormals(Surf); - % Double boundary vertex areas to balance their "pull". But not very precise, depends on boundary shape. - VdA(isBoundV) = 2 * VdA(isBoundV); - VWeighted = VdA * [1, 1, 1] .* 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 normal movement, distributed - % neighborhood area, that 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}, :); + 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 - 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); - NormalDisplCorr = (NeighborAverage - Surf.Vertices(v, :)) * N(v, :)' / (NeighdABulk/VdA(v) + 1); % / (nC(v) + 1); - V(v, :) = V(v, :) + DampingFactor * NeighdABulk/VdA(v) * NormalDisplCorr * N(v, :); - V(CCellBulk{v}, :) = V(CCellBulk{v}, :) - DampingFactor * NormalDisplCorr * N(CCellBulk{v}, :); + 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 - 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 + % 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 - otherwise - error('Unrecognized Freedom parameter. Should be 0, 1 or 2.'); + isBoundV(Edges(isBoundE, :)) = true; 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, :); + 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 - % 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. + 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 - LastMaxDispl(1) = sqrt( max(sum((V - Surf.Vertices).^2, 2)) ); - LastMaxDispl(2) = max(dot(V - Surf.Vertices, N, 2)); + [FaceArea, VertexArea, FaceNormals, VertexNormals] = CalcAreas(Surf); 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 - [~, ~, FN, FdA] = 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 - - - % ---------------------------------------------------------------------- - % Normals calculation replaced by better external function using Voronoi areas. - % [N, VdA, FN, FdA] = CalcVertexNormals(FV,N) - +end + % % Calculate dA normal vectors to each vertex. % function [N, VdA, FN, FdA] = CalcVertexNormals(S) % N = zeros(nV, 3); @@ -355,15 +391,22 @@ % N = bsxfun(@rdivide, N, sqrt(N(:,1).^2 + N(:,2).^2 + N(:,3).^2)); % FN = bsxfun(@rdivide, FNdA, FdA); % 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 +% % 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) @@ -381,10 +424,121 @@ % 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, 1, 2) + circshift(BaryWeights, -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 From c91dd9652ee76e006ae9e4e711c5eb1183f6c96b Mon Sep 17 00:00:00 2001 From: Marc Lalancette <31040756+Moo-Marc@users.noreply.github.com> Date: Tue, 28 Jan 2025 17:27:26 -0500 Subject: [PATCH 38/47] wip coregistration head surface --- toolbox/anatomy/SurfaceSmooth.m | 6 +- toolbox/anatomy/tess_check.m | 6 +- toolbox/anatomy/tess_downsize.m | 17 +- toolbox/anatomy/tess_isohead.m | 268 ++++++++++++++++++++++---------- 4 files changed, 212 insertions(+), 85 deletions(-) diff --git a/toolbox/anatomy/SurfaceSmooth.m b/toolbox/anatomy/SurfaceSmooth.m index 3de05b0f8..90be3eb74 100755 --- a/toolbox/anatomy/SurfaceSmooth.m +++ b/toolbox/anatomy/SurfaceSmooth.m @@ -506,15 +506,15 @@ function ViewSurfWithNormals(Vertices, Faces, VNorm, FNorm, VArea, FArea) % -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); + 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, 1, 2) + circshift(BaryWeights, -1, 2)); + 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]); diff --git a/toolbox/anatomy/tess_check.m b/toolbox/anatomy/tess_check.m index c069580c4..a3bc191a1 100644 --- a/toolbox/anatomy/tess_check.m +++ b/toolbox/anatomy/tess_check.m @@ -151,6 +151,8 @@ 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 @@ -180,10 +182,10 @@ 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.'); + 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.'); + 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'); diff --git a/toolbox/anatomy/tess_downsize.m b/toolbox/anatomy/tess_downsize.m index ade120ccc..5d4b57df1 100644 --- a/toolbox/anatomy/tess_downsize.m +++ b/toolbox/anatomy/tess_downsize.m @@ -149,6 +149,9 @@ %% ===== LOAD FILE ===== if isTessInput TessMat = TessFile; + if ~isfield(TessMat, 'Comment') + TessMat.Comment = 'iso head'; + end else % Progress bar bst_progress('start', 'Resample surface', 'Loading file...'); @@ -158,7 +161,11 @@ end TessMat.Faces = double(TessMat.Faces); TessMat.Vertices = double(TessMat.Vertices); -TessMat.Color = double(TessMat.Color); +if isfield(TessMat, 'Color') + TessMat.Color = double(TessMat.Color); +else + TessMat.Color = []; +end dsFactor = newNbVertices / size(TessMat.Vertices, 1); %% ===== RESAMPLE ===== @@ -455,12 +462,18 @@ oMesh = surfaceMesh(TessMat.Vertices, TessMat.Faces); % Reduce number of vertices - oMesh = simplify(oMesh, TessMat.Vertices, 'TargetNumFaces', newNbVertices); + 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 diff --git a/toolbox/anatomy/tess_isohead.m b/toolbox/anatomy/tess_isohead.m index 4615727b0..0e490a650 100644 --- a/toolbox/anatomy/tess_isohead.m +++ b/toolbox/anatomy/tess_isohead.m @@ -31,8 +31,8 @@ % modified quite a bit, erode and fill factors no longer used. See my notes in OneNote % To visualize steps for debugging. -isDebugVis = true; -nDebugVisSlices = 9; +isDebugVis = false; +nDebugVisSlices = 9; %#ok %% ===== PARSE INPUTS ===== % Initialize returned variables @@ -143,7 +143,7 @@ % 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)); + [Grad, VectGrad] = NormGradient(sMri.Cube(:,:,:,1)); %#ok if isempty(bgLevel) Hist = mri_histogram(Grad, [], 'headgrad'); bgLevel = Hist.bgLevel; @@ -178,7 +178,7 @@ headmask(:,:,1) = 0; %*headmask(:,:,1); headmask(:,:,end) = 0; %*headmask(:,:,1); if isDebugVis - view_mri_slices(headmask, 'x', nDebugVisSlices); + view_mri_slices(headmask, 'x', nDebugVisSlices); %#ok<*UNRCH> end % Erode + dilate, to remove small components % if (erodeFactor > 0) @@ -203,9 +203,9 @@ % Fill neck holes (bones, etc.) where it is cut at edge of volume. bst_progress('text', 'Filling holes and removing disconnected parts...'); -if isDebugVis - figure; imagesc(headmask(:,:,2)); colormap('gray'); axis equal; -end +% if isDebugVis +% figure; imagesc(headmask(:,:,2)); colormap('gray'); axis equal; +% end % 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). @@ -234,14 +234,18 @@ end % Skip if just background (e.g. above or behind head) if ~any(any(squeeze(TempMask(2, :, :)))) - if isFlip + % Flip back and move on + if isFlip TempMask = flip(TempMask, 1); end continue; end - Slice = sum(TempMask(2:2+nSlices-1, :, :), 1); if isDebugVis - figure; imagesc(squeeze(Slice)); colormap('gray'); axis equal; + 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) @@ -251,16 +255,14 @@ 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; - end - Slice = Slice | (Fill(Slice, 2) & Fill(Slice, 3)); - if isDebugVis - figure; imagesc(squeeze(Slice)); colormap('gray'); axis equal; + figure; imagesc(squeeze(Slice)); colormap('gray'); axis equal; title(sprintf('Dim %d, Flip %d, Filled', iDim, isFlip)); end Slice = CenterSpread(Slice); if isDebugVis - figure; imagesc(squeeze(Slice)); colormap('gray'); axis equal; + figure; imagesc(squeeze(Slice)); colormap('gray'); axis equal; title(sprintf('Dim %d, Flip %d, Center spread', iDim, isFlip)); end TempMask(2, :, :) = Slice; if isFlip @@ -270,21 +272,20 @@ % Permute back headmask = permute(TempMask, Perm); end -if isDebugVis - figure; imagesc(headmask(:,:,2)); colormap('gray'); axis equal; -end +% if isDebugVis +% figure; imagesc(headmask(:,:,2)); colormap('gray'); axis equal; +% end % Fill holes -InsideMask = (Fill(headmask, 1) & Fill(headmask, 2) & Fill(headmask, 3)); -headmask = InsideMask | (Dilate(InsideMask) & headmask); +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); + 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); + view_mri_slices(headmask, 'x', nDebugVisSlices); title('Center spread'); end @@ -296,14 +297,9 @@ % Flip x-y back to our voxel coordinates. sHead.Vertices = sHead.Vertices(:, [2, 1, 3]); if isDebugVis - FigureId = db_template('FigureId'); - FigureId.Type = '3DViz'; - hFig = figure_3d('CreateFigure', FigureId); - figure_3d('PlotSurface', hFig, sHead.Faces, sHead.Vertices, [1,1,1], 0); % color, transparency required - figure_3d('ViewAxis', hFig, true); % isVisible - hFig.Visible = "on"; fprintf('mri_isohead surface\n'); - [isOk, Info] = tess_check(sHead.Vertices, sHead.Faces, true, false, false); %#ok % verbose, not open, don't show + [isOk, Info] = tess_check(sHead.Vertices, sHead.Faces, true, false, true); % verbose, not open, show + title('isosurface'); end bst_progress('inc', 10); % Downsample to a maximum number of vertices @@ -316,12 +312,12 @@ % 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 - hFig = figure_3d('CreateFigure', FigureId); - figure_3d('PlotSurface', hFig, sHead.Faces, sHead.Vertices, [1,1,1], 0); % color, transparency required - figure_3d('ViewAxis', hFig, true); % isVisible - hFig.Visible = "on"; +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'); end bst_progress('inc', 15); @@ -339,7 +335,8 @@ 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, false); %#ok % verbose, not open, don't show + [isOk, Info] = tess_check(sHead.Vertices, sHead.Faces, true, false, true); % verbose, not open, show + title('mildly smoother isosurface'); end bst_progress('inc', 20); @@ -347,9 +344,11 @@ if (length(sHead.Vertices) > nVertices) bst_progress('text', 'Downsampling surface...'); % Modified tess_downsize to accept sHead + % Method = 'iso2mesh'; % Check if Lidar Toolbox is installed (requires image processing + computer vision) isLidarToolbox = exist('surfaceMesh', 'file') == 2; if isLidarToolbox + % Still produces problems, e.g. some open edges Method = 'simplify'; else % This can produce a "bad" patch. disconnected? intersecting? @@ -360,22 +359,26 @@ %[sHead.Faces, sHead.Vertices] = reducepatch(sHead.Faces, sHead.Vertices, nVertices./length(sHead.Vertices)); if isDebugVis fprintf('reduced surface (%s)\n', Method); - [isOk, Info] = tess_check(sHead.Vertices, sHead.Faces, true, false, false); %#ok % verbose, not open, don't show + [isOk, Info] = tess_check(sHead.Vertices, sHead.Faces, true, false, true); % verbose, not open, show + title('reduced') end % Fix this patch + nVertTemp = size(sHead.Vertices, 1); [sHead.Vertices, sHead.Faces] = tess_remove_small(sHead.Vertices, sHead.Faces); - if isDebugVis + 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') end end bst_progress('inc', 15); bst_progress('text', 'Smoothing...'); -sHead.Vertices = SurfaceSmooth(sHead.Vertices, sHead.Faces, 2, [], 45, 0, false); % voxel/smoothing size, iterations, freedom (normal), verbose +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); %#ok % verbose, not open, show + [isOk, Info] = tess_check(sHead.Vertices, sHead.Faces, true, false, true); % verbose, not open, show end bst_progress('inc', 10); @@ -431,49 +434,156 @@ end %% ===== Subfunctions ===== -function mask = Fill(mask, dim) -% Modified to exclude boundaries, so we can get rid of external junk as well as -% internal holes easily. - -% Initialize two accumulators, for the two directions -acc1 = false(size(mask)); -acc2 = false(size(mask)); -n = size(mask,dim); -% Process in required direction -switch dim - case 1 - for i = 2:n - acc1(i,:,:) = acc1(i-1,:,:) | mask(i-1,:,:); - end - for i = n-1:-1:1 - acc2(i,:,:) = acc2(i+1,:,:) | mask(i+1,:,:); - end - case 2 - for i = 2:n - acc1(:,i,:) = acc1(:,i-1,:) | mask(:,i-1,:); - end - for i = n-1:-1:1 - acc2(:,i,:) = acc2(:,i+1,:) | mask(:,i+1,:); - end - case 3 - for i = 2:n - acc1(:,:,i) = acc1(:,:,i-1) | mask(:,:,i-1); - end - for i = n-1:-1:1 - acc2(:,:,i) = acc2(:,:,i+1) | mask(:,:,i+1); - end +function mask = FillConcaveVolume(mask, isClean) + if nargin < 2 || isempty(isClean) + % Default to not remove any of the original mask. + isClean = false; + end + + % % Dimensions that have thickness, not singleton + % isThk = size(mask, [1,2,3]) > 1; + % nThk = sum(isThk); + + % "First to last" fill + % 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 step next. + mask = (Fill(mask, 1, true) & Fill(mask, 2, true) & Fill(mask, 3, true)); + % This was because fill was previously not including the "first and last" voxels, probably to + % exclude single voxels, but we added it back anyway so added. + % mask = InsideMask | (Dilate(InsideMask) & mask); % filled region or original adjacent 1s. + + % "Surround" fill + % Fill a voxel if it's surrounded by 1 + % 4 adjacent voxels in a plane (not diagonals) for 3d, 2 adjacent voxels in a line for 2d. + % Repeat for filling intersections. Twice ok for 2d or 3d. + mask = Surrounded(mask, true); + mask = Surrounded(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 = Surrounded(mask, false); + mask = Surrounded(mask, false); + end end -% Combine two accumulators -mask = acc1 & acc2; + + +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 -function mask = Dilate(mask) -% Dilate by 1 voxel in 6 directions, except at volume edges -mask(2:end-1,2:end-1,2:end-1) = mask(1:end-2,2:end-1,2:end-1) | mask(3:end,2:end-1,2:end-1) | ... - mask(2:end-1,1:end-2,2:end-1) | mask(2:end-1,3:end,2:end-1) | ... - mask(2:end-1,2:end-1,1:end-2) | mask(2:end-1,2:end-1,3:end); + +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 + +% 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 = 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. @@ -512,6 +622,8 @@ nOut = sum(OutMask(:)); end if nOut == 1 + % Remove "forced" initial vertex, everything else is now gone. + OutMask(iStart(1), iStart(2), iStart(3)) = false; warning('CenterSpread failed: starting center point is not part of the mask.'); end end From 3ccf61dc3e0bb417ff7f1c0c4ae8eb90046d54dd Mon Sep 17 00:00:00 2001 From: Marc Lalancette <31040756+Moo-Marc@users.noreply.github.com> Date: Wed, 29 Jan 2025 10:07:22 -0500 Subject: [PATCH 39/47] wip coregistration head surface --- toolbox/anatomy/tess_isohead.m | 182 ++++++++++++++++++++------------- 1 file changed, 112 insertions(+), 70 deletions(-) diff --git a/toolbox/anatomy/tess_isohead.m b/toolbox/anatomy/tess_isohead.m index 0e490a650..cb1d710e6 100644 --- a/toolbox/anatomy/tess_isohead.m +++ b/toolbox/anatomy/tess_isohead.m @@ -1,4 +1,4 @@ -function [HeadFile, iSurface] = tess_isohead(iSubject, nVertices, erodeFactor, fillFactor, bgLevel, Comment, isGradient) +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) @@ -40,6 +40,16 @@ 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 @@ -289,87 +299,109 @@ end + + %% ===== CREATE SURFACE ===== % Compute isosurface bst_progress('text', 'Creating isosurface...'); -% 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]); -if isDebugVis - fprintf('mri_isohead surface\n'); - [isOk, Info] = tess_check(sHead.Vertices, sHead.Faces, true, false, true); % verbose, not open, show - title('isosurface'); -end -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)); -% bst_progress('inc', 10); -% end - -% 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'); -end -bst_progress('inc', 15); -% Clean final surface -% This is very strange, it doesn't look at face locations, only the normals. -% After isosurface, many many faces are parallel. -% bst_progress('text', 'Fill: Cleaning surface...'); -% [sHead.Vertices, sHead.Faces] = tess_clean(sHead.Vertices, sHead.Faces); - -% 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'); +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); + % 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)); + % bst_progress('inc', 10); + % end + + % 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); + + % Clean final surface + % This is very strange, it doesn't look at face locations, only the normals. + % After isosurface, many many faces are parallel. + % bst_progress('text', 'Fill: Cleaning surface...'); + % [sHead.Vertices, sHead.Faces] = tess_clean(sHead.Vertices, sHead.Faces); + + % 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 -bst_progress('inc', 20); -% Downsampling isosurface -if (length(sHead.Vertices) > nVertices) +% Downsampling surface +if (length(sHead.Vertices) > 1.5* nVertices) bst_progress('text', 'Downsampling surface...'); % Modified tess_downsize to accept sHead - % Method = 'iso2mesh'; - % Check if Lidar Toolbox is installed (requires image processing + computer vision) - isLidarToolbox = exist('surfaceMesh', 'file') == 2; - if isLidarToolbox - % Still produces problems, e.g. some open edges - Method = 'simplify'; - else - % This can produce a "bad" patch. disconnected? intersecting? - % TODO: need to fix and use tess_clean, or use different method - Method = 'reducepatch'; - end sHead = tess_downsize(sHead, nVertices, Method); - %[sHead.Faces, sHead.Vertices] = reducepatch(sHead.Faces, sHead.Vertices, nVertices./length(sHead.Vertices)); 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') + title('reduced', 'color', 'white') end % Fix this patch - 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') + 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); @@ -443,7 +475,18 @@ % % Dimensions that have thickness, not singleton % isThk = size(mask, [1,2,3]) > 1; % nThk = sum(isThk); - +% for iDim = 1:3 +% % Swap slice dimension into first position. +% switch iDim +% case 1 +% Perm = 1:3; +% case 2 +% Perm = [2, 1, 3]; +% case 3 +% Perm = [3, 2, 1]; +% end +% TempMask = permute(headmask, Perm); +% % "First to last" fill % 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 step next. @@ -465,7 +508,6 @@ % 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 = Surrounded(mask, false); - mask = Surrounded(mask, false); end end From 2f5a36b92fdbcb91bf3fb81e40ad77b27af526ca Mon Sep 17 00:00:00 2001 From: Marc Lalancette <31040756+Moo-Marc@users.noreply.github.com> Date: Wed, 29 Jan 2025 17:23:36 -0500 Subject: [PATCH 40/47] wip coregistration head surface clean --- toolbox/anatomy/tess_isohead.m | 1193 +++++++++++++++----------------- 1 file changed, 555 insertions(+), 638 deletions(-) diff --git a/toolbox/anatomy/tess_isohead.m b/toolbox/anatomy/tess_isohead.m index cb1d710e6..337332279 100644 --- a/toolbox/anatomy/tess_isohead.m +++ b/toolbox/anatomy/tess_isohead.m @@ -1,521 +1,463 @@ 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. - -% @============================================================================= -% 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 - -% Work in progress: Marc Lalancette 2022-2025 -% modified quite a bit, erode and fill factors no longer used. See my notes in OneNote - -% 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''.'); + % 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. + + % @============================================================================= + % 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 -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) + 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 - % Call tess_isohead(iSubject, nVertices, erodeFactor, fillFactor) + 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 - bgLevel = []; - Comment = []; + error('Wrong input type.'); 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 -%% ===== 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'); + %% ===== 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 -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 -% Guess background level -if isempty(bgLevel) - bgLevel = sMri.Histogram.bgLevel; -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) + % 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 = str2double(res{1}); - erodeFactor = str2double(res{2}); - fillFactor = str2double(res{3}); - bgLevel = str2double(res{4}); + % Guess background level 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 + %% ===== 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 -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(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); -if isDebugVis - view_mri_slices(headmask, 'x', nDebugVisSlices); %#ok<*UNRCH> -end -% 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); - -% Remove isolated voxels (dots or holes) from 5 out of 6 sides -% isFill = false(size(headmask)); -% isFill(2:end-1,2:end-1,2:end-1) = (headmask(1:end-2,2:end-1,2:end-1) + headmask(3:end,2:end-1,2:end-1) + ... -% headmask(2:end-1,1:end-2,2:end-1) + headmask(2:end-1,3:end,2:end-1) + ... -% headmask(2:end-1,2:end-1,1:end-2) + headmask(2:end-1,2:end-1,3:end)) >= 5 & ... -% ~headmask(2:end-1,2:end-1,2:end-1); -% headmask(isFill) = 1; -% isFill = false(size(headmask)); -% isFill(2:end-1,2:end-1,2:end-1) = (headmask(1:end-2,2:end-1,2:end-1) + headmask(3:end,2:end-1,2:end-1) + ... -% headmask(2:end-1,1:end-2,2:end-1) + headmask(2:end-1,3:end,2:end-1) + ... -% headmask(2:end-1,2:end-1,1:end-2) + headmask(2:end-1,2:end-1,3:end)) <= 1 & ... -% headmask(2:end-1,2:end-1,2:end-1); -% headmask(isFill) = 0; - -% Fill neck holes (bones, etc.) where it is cut at edge of volume. -bst_progress('text', 'Filling holes and removing disconnected parts...'); -% if isDebugVis -% figure; imagesc(headmask(:,:,2)); colormap('gray'); axis equal; -% end -% 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. - switch iDim - case 1 - Perm = 1:3; - case 2 - Perm = [2, 1, 3]; - case 3 - Perm = [3, 2, 1]; + + %% ===== 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 - 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); + 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 - % Skip if just background (e.g. above or behind head) - if ~any(any(squeeze(TempMask(2, :, :)))) - % Flip back and move on - if isFlip + 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 - 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))) + % 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 - 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 = CenterSpread(Slice); - if isDebugVis - figure; imagesc(squeeze(Slice)); colormap('gray'); axis equal; title(sprintf('Dim %d, Flip %d, Center spread', iDim, isFlip)); - end - TempMask(2, :, :) = Slice; - if isFlip - TempMask = flip(TempMask, 1); end + % Permute back dimensions to original order. + headmask = permute(TempMask, Perm); end - % Permute back - headmask = permute(TempMask, Perm); -end -% if isDebugVis -% figure; imagesc(headmask(:,:,2)); colormap('gray'); axis equal; -% 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 - - + % 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 + %% ===== 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 - [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) = []; + % 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('iso2mesh surface\n'); + fprintf('reduced surface (%s)\n', Method); [isOk, Info] = tess_check(sHead.Vertices, sHead.Faces, true, false, true); % verbose, not open, show - title('iso2mesh surface', 'color', 'white'); + title('reduced', '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); - % 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)); - % bst_progress('inc', 10); - % end - - % 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); - - % Clean final surface - % This is very strange, it doesn't look at face locations, only the normals. - % After isosurface, many many faces are parallel. - % bst_progress('text', 'Fill: Cleaning surface...'); - % [sHead.Vertices, sHead.Faces] = tess_clean(sHead.Vertices, sHead.Faces); - - % 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'); + % 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 - bst_progress('inc', 20); -end + end + bst_progress('inc', 15); -% 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); + 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('reduced surface (%s)\n', Method); + fprintf('final smoothed surface\n'); [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]); - -% % Smooth isosurface -% bst_progress('text', 'Fill: Smoothing surface...'); -% VertConn = tess_vertconn(Vertices, Faces); -% Vertices = tess_smooth(Vertices, 1, 10, VertConn, 0); -% % One final round of smoothing -% VertConn = tess_vertconn(Vertices, Faces); -% Vertices = tess_smooth(Vertices, 0.2, 3, VertConn, 0); -% -% % 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); -% 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; + 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 - 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 + % Return surface + HeadFile = sHead.Vertices; + iSurface = sHead.Faces; + end -% Close, success -if isProgress - bst_progress('stop'); -end + % Close, success + if isProgress + bst_progress('stop'); + end end %% ===== 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. + % Default to not remove any of the original mask. But called with true in this file. isClean = false; end - % % Dimensions that have thickness, not singleton - % isThk = size(mask, [1,2,3]) > 1; - % nThk = sum(isThk); -% for iDim = 1:3 -% % Swap slice dimension into first position. -% switch iDim -% case 1 -% Perm = 1:3; -% case 2 -% Perm = [2, 1, 3]; -% case 3 -% Perm = [3, 2, 1]; -% end -% TempMask = permute(headmask, Perm); -% - % "First to last" fill + % 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 step next. + % 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)); - % This was because fill was previously not including the "first and last" voxels, probably to - % exclude single voxels, but we added it back anyway so added. - % mask = InsideMask | (Dilate(InsideMask) & mask); % filled region or original adjacent 1s. - % "Surround" fill - % Fill a voxel if it's surrounded by 1 - % 4 adjacent voxels in a plane (not diagonals) for 3d, 2 adjacent voxels in a line for 2d. + % "Surround" and "sandwich" fills again + mask = KernelClean(mask, true); % Repeat for filling intersections. Twice ok for 2d or 3d. - mask = Surrounded(mask, true); - mask = Surrounded(mask, true); + 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 = Surrounded(mask, false); + mask = KernelClean(mask, false); 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. - +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 @@ -524,35 +466,121 @@ 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); + % 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 - % 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 + % 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); - % 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; + 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 + + function mask = Fill(mask, dim, isFullSingl) % Modified to exclude boundaries, so we can get rid of external junk as well as % internal holes easily. @@ -608,7 +636,7 @@ % % 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) @@ -626,168 +654,57 @@ % end -function OutMask = 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. -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; - warning('CenterSpread failed: starting center point is not part of the mask.'); -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)); + % 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 - -% Modified version to detect duplicate faces based on vertex indices, not strange alignment of face -% normals. -% TODO: DELETE just started, probably won't keep. -% function [Vertices, Faces, remove_vertices, remove_faces, Atlas] = tess_clean(Vertices, Faces, Atlas) -% % TESS_CLEAN: Check the integrity of a tesselation. -% % -% % USAGE: [Vertices, Faces, remove_vertices, Atlas] = tess_clean(Vertices, Faces, Atlas) -% % -% % DESCRIPTION: -% % Check in a tesselation if there are some identical faces and remove the bad_oriented one. -% % Moreover it removes isolated triangles and some other pathological configurations. -% % -% % INPUTS: -% % - Vertices : Mx3 double matrix -% % - Faces : Nx3 double matrix -% % OUTPUTS: -% % - Vertices : Corrected vertices structure -% % - Faces : Corrected faces structure -% -% % Authors: Julien Lefevre, 2007 -% % Francois Tadel, 2008-2014 -% % Marc Lalancette, 2025 -% -% % Parse inputs -% if (nargin < 3) || isempty(Atlas) -% Atlas = []; -% end -% % Check matrix orientation -% if (size(Vertices, 2) ~= 3) || (size(Faces, 2) ~= 3) -% error('Faces and Vertices must have 3 columns (X,Y,Z).'); -% end -% -% % Face connectivity matrix for edges -% [~, FaceConn] = tess_faceconn(Faces, 2); -% -% % Items to remove -% remove_faces=[]; -% remove_vertices=[]; -% -% % Sort face vertex indices to identify duplicates. -% FacesSrt = sort(Faces, 2); -% [~, iFace, iUniqFace] = unique(FacesSrt, 'rows'); -% % UniqFaces = Faces(iFace), Faces = UniqFaces(iUniqFace) -% % For each unique item, check if multiple copies in iUniqFace -% for iU = 1:numel(iFace) -% if sum(iUniqFace == iU) > 1 -% isFaceSuspect = iUniqFace == iU; -% -% iRemoveFace(end+1) = i -% -% TessArea = tess_area(Vertices, Faces); -% [~, FaceNormals] = tess_normals(Vertices, Faces); -% -% sort_crossprod = sortrows(abs([FaceNormals,(1:size(FaceNormals,1))'])); -% diff_sort_crossprod = diff(sort_crossprod); -% indices = find((diff_sort_crossprod(:,1) < tol) & ... -% (diff_sort_crossprod(:,2) < tol) & ... -% (diff_sort_crossprod(:,3) < tol)); -% % Indices of redundant triangles (same coordinates, two different orientations) -% indices_tri1 = sort_crossprod(indices,4); -% indices_tri2 = sort_crossprod(indices+1,4); -% -% [VertFacesConn, FaceConn] = tess_faceconn(Faces); -% -% % For each suspected face we compute the mean of normals of neighbouring faces -% scal=zeros(length(indices_tri1),2); -% -% % We remove faces whose normal is not in the same direction as their neighbouring faces -% for i=1:length(indices_tri1) -% neighbours = find(FaceConn(indices_tri1(i),:)); -% neighbours = setdiff(neighbours, indices_tri2(i)); -% % Isolated faces -% if isempty(neighbours) -% remove_faces = [remove_faces, indices_tri1(i), indices_tri2(i)]; -% remove_vertices = [remove_vertices, Faces(indices_tri1(i),:)]; -% else -% normal_mean = mean(FaceNormals(neighbours,:) .* repmat(TessArea(neighbours),1,3),1); -% norm_i = FaceNormals(indices_tri1(i),:); -% scal(i,1) = normal_mean*norm_i'/(norm(normal_mean)); -% -% if scal(i,1)>0 -% remove_faces = [remove_faces,indices_tri2(i)]; -% scal(i,2) = indices_tri2(i); -% else -% if scal(i,1)<0 -% remove_faces = [remove_faces,indices_tri1(i)]; -% scal(i,2) = indices_tri1(i); -% else -% remove_faces = [remove_faces,indices_tri1(i),indices_tri2(i)]; -% remove_vertices = [remove_vertices, Faces(indices_tri1(i),:)]; -% end -% end -% end -% end -% -% % Find all the isolated faces -% FaceConn(remove_faces, :) = 0; -% FaceConn(:, remove_faces) = 0; -% iIsolatedFaces = find(sum(FaceConn) <= 1); -% remove_faces = union(remove_faces', iIsolatedFaces); -% % Remove faces -% Faces(remove_faces, :) = []; -% -% % Find the vertices that are not used in any face -% VertConn = tess_vertconn(Vertices, Faces); -% iIsolatedVert = find(sum(VertConn) <= 1); -% remove_vertices = union(remove_vertices, iIsolatedVert); -% % Remove vertices -% [Vertices, Faces, Atlas] = tess_remove_vert(Vertices, Faces, remove_vertices, Atlas); -% -% end - From 4c54a2864babde0c24fbb863e428535f22066f93 Mon Sep 17 00:00:00 2001 From: Marc Lalancette <31040756+Moo-Marc@users.noreply.github.com> Date: Sun, 2 Feb 2025 15:49:08 -0500 Subject: [PATCH 41/47] wip coregistration --- toolbox/io/bst_save_coregistration.m | 12 +++++++++--- .../process/functions/process_adjust_coordinates.m | 6 +++++- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/toolbox/io/bst_save_coregistration.m b/toolbox/io/bst_save_coregistration.m index 7bdb27397..c95335a04 100644 --- a/toolbox/io/bst_save_coregistration.m +++ b/toolbox/io/bst_save_coregistration.m @@ -75,7 +75,10 @@ continue; end sMriJson = bst_jsondecode(MriJsonFile, false); - BstFids = {'NAS', 'LPA', 'RPA', 'AC', 'PC', 'IH'}; + % 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, including under .Header, accordingly, and it saved the transformation @@ -99,6 +102,7 @@ % 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. @@ -131,7 +135,7 @@ sMriNative = sMriScs; % transformed below for iStudy = 1:numel(sStudies) - % Is it a link to raw file? + % 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') @@ -139,12 +143,14 @@ break; end end + % Skip study if no raw file found. if ~isLinkToRaw continue; end % Find MEG _coordsystem.json Link = load(file_fullpath(sStudies(iStudy).Data(iData).FileName)); + % Skip if original MEG file not found. if ~exist(Link.F.filename, 'file') warning('Missing raw MEG file. Skipping study %s.', Link.F.filename); continue; @@ -154,7 +160,7 @@ [MegPath, MegName, MegExt] = bst_fileparts(MegPath); end MegCoordJsonFile = file_find(MegPath, '*_coordsystem.json', 1, false); % max depth 1, not just one file - + % Skip if MEG json file not found. if isempty(MegCoordJsonFile) warning('Imported MEG BIDS _coordsystem.json file not found. Skipping study %s.', Link.F.filename); continue; diff --git a/toolbox/process/functions/process_adjust_coordinates.m b/toolbox/process/functions/process_adjust_coordinates.m index 742dfd181..088ad846d 100644 --- a/toolbox/process/functions/process_adjust_coordinates.m +++ b/toolbox/process/functions/process_adjust_coordinates.m @@ -1142,7 +1142,11 @@ 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 + % Do the same with head coils to get "current" SCS to Native + % transformation; used when exporting coregistration to BIDS. + % Here we temporarily put the head coil coordinates into .SCS to + % compute SCS to Native transformation, instead of relying on previous + % transformations (which are named differently for different MEG systems). iHpiN = find(strcmpi(ChannelMat.HeadPoints.Label, 'HPI-N')); iHpiL = find(strcmpi(ChannelMat.HeadPoints.Label, 'HPI-L')); iHpiR = find(strcmpi(ChannelMat.HeadPoints.Label, 'HPI-R')); From 45f747bd7a048de8206efda3bb8fd1e3a936a7b8 Mon Sep 17 00:00:00 2001 From: Marc Lalancette <31040756+Moo-Marc@users.noreply.github.com> Date: Tue, 4 Feb 2025 14:42:16 -0500 Subject: [PATCH 42/47] wip coregistration interactive view histogram --- toolbox/gui/view_mri_histogram.m | 53 ++- toolbox/io/bst_save_coregistration.m | 447 ++++++++++-------- .../functions/process_adjust_coordinates.m | 49 +- 3 files changed, 342 insertions(+), 207 deletions(-) diff --git a/toolbox/gui/view_mri_histogram.m b/toolbox/gui/view_mri_histogram.m index daa8b4f78..a68b6bfd0 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,7 +27,7 @@ % For more information type "brainstorm license" at command prompt. % =============================================================================@ % -% Authors: Francois Tadel, 2006-2020 +% Authors: Francois Tadel, 2006-2020, Marc Lalancette 2025 %% ===== LOAD OR COMPUTE HISTOGRAM ===== % Display progress bar @@ -40,7 +42,7 @@ Histogram = mri_histogram(MRI.Cube(:,:,:,1)); % Save histogram s.Histogram = Histogram; - bst_save(MriFile, s, 'v7', 1); + bst_save(MriFile, s, 'v7', 1); % isAppend else Histogram = MRI.Histogram; end @@ -88,17 +90,58 @@ 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?', Histogram.bgLevel, bgLevel), ... + 'MRI background threshold'); + if isCancel + return; + elseif Proceed + % Save histogram + Histogram.bgLevel = bgLevel; + s.Histogram = Histogram; + bst_save(MriFile, s, 'v7', 1); % isAppend + % Close figure + delete(fig); + end + else + % Close figure + delete(fig); + end +end + +end diff --git a/toolbox/io/bst_save_coregistration.m b/toolbox/io/bst_save_coregistration.m index c95335a04..dee720a4f 100644 --- a/toolbox/io/bst_save_coregistration.m +++ b/toolbox/io/bst_save_coregistration.m @@ -1,234 +1,303 @@ 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. -% -% 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 = false; -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 + % Save MRI-MEG coregistration info in imported raw BIDS dataset, or MRI fiducials only if not BIDS. + % + % 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 -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; + if nargin < 2 || isempty(isBids) + isBids = false; 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; + 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 - % Get all linked raw data files. - sStudies = bst_get('StudyWithSubject', sSubjects.Subject(iSub).FileName); - 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, including under .Header, accordingly, 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 + bst_progress('start', 'Save co-registration', ' ', 0, nSub); - 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); + 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 - 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); + 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 - sMriNative = sMriScs; % transformed below - - 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; + % Get all linked raw data files. + sStudies = bst_get('StudyWithSubject', sSubjects.Subject(iSub).FileName); + 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 - % Skip study if no raw file found. - if ~isLinkToRaw + + % 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 - - % Find MEG _coordsystem.json - Link = load(file_fullpath(sStudies(iStudy).Data(iData).FileName)); - % Skip if original MEG file not found. - if ~exist(Link.F.filename, 'file') - warning('Missing raw MEG file. Skipping study %s.', Link.F.filename); + 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 - [MegPath, MegName, MegExt] = bst_fileparts(Link.F.filename); - if strcmpi(MegExt, '.meg4') - [MegPath, MegName, MegExt] = bst_fileparts(MegPath); + 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 - MegCoordJsonFile = file_find(MegPath, '*_coordsystem.json', 1, false); % max depth 1, not just one file - % Skip if MEG json file not found. - if isempty(MegCoordJsonFile) - warning('Imported MEG BIDS _coordsystem.json file not found. Skipping study %s.', Link.F.filename); + + 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; - ChannelMat = in_bst_channel(sStudies(iStudy).Channel.FileName); - % ChannelMat.SCS are *digitized* anatomical landmarks (if present, otherwise might be - % digitized head coils) in Brainstorm/SCS coordinates (CTF from anatomical landmarks). - % Not updated after refine with head points, so we don't rely on them but use those - % saved in sMri. - % - % We applied MRI=>SCS from sMri to 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. - ChannelMat = process_adjust_coordinates('UpdateChannelMatScs', ChannelMat); - % Convert from (possibly adjusted) SCS to Native, and m to cm. + % 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}; - sMriNative.SCS.(Fid)(:) = 100 * [ChannelMat.Native.R, ChannelMat.Native.T] * [sMriScs.SCS.(Fid)'; 1]; + % cs_convert mri is in meters + sMriScs.SCS.(Fid) = cs_convert(sMri, 'mri', 'scs', sMri.SCS.(Fid) ./ 1000); end + sMriNative = sMriScs; % transformed below - for c = 1:numel(MegCoordJsonFile) - sMegJson = bst_jsondecode(MegCoordJsonFile{c}); - if ~isfield(sMegJson, 'IntendedFor') || isempty(sMegJson.IntendedFor) - sMegJson.IntendedFor = strrep(ImportedFile, [BidsRoot filesep], 'bids::'); + % 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}; - %if isfield(sMri, 'SCS') && isfield(sMri.SCS, Fid) && ~isempty(sMri.SCS.(Fid)) && any(sMri.SCS.(Fid)) + sMriNative.SCS.(Fid)(:) = 100 * ChanNativeTransf * [sMriScs.SCS.(Fid)'; 1]; % Round to um. - sMegJson.AnatomicalLandmarkCoordinates.(Fid) = round(sMriNative.SCS.(Fid) * 10000) / 10000; + 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; - [~, isMriUpdated, isMriMatch, ChannelMat] = process_adjust_coordinates('CheckPrevAdjustments', ChannelMat, sMri); if ~isfield(sMegJson, 'FiducialsDescription') sMegJson.FiducialsDescription = ''; end - if isMriUpdated && isMriMatch - 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.'; - else - 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.'; - end if isempty(strfind(sMegJson.FiducialsDescription, AddFidDescrip)) sMegJson.FiducialsDescription = strtrim([sMegJson.FiducialsDescription, AddFidDescrip]); end - WriteJson(MegCoordJsonFile{c}, sMegJson); - OutFilesMeg{iOutSub}{c} = MegCoordJsonFile{c}; + 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 - 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 + isSuccess(iOutSub) = true; + bst_progress('inc', 1); + end % subject loop -bst_progress('stop'); + bst_progress('stop'); end diff --git a/toolbox/process/functions/process_adjust_coordinates.m b/toolbox/process/functions/process_adjust_coordinates.m index 088ad846d..c91291262 100644 --- a/toolbox/process/functions/process_adjust_coordinates.m +++ b/toolbox/process/functions/process_adjust_coordinates.m @@ -987,12 +987,13 @@ end % GeoMedian -function [AlignType, isMriUpdated, isMriMatch, ChannelMat] = CheckPrevAdjustments(ChannelMat, sMri) +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. @@ -1062,19 +1063,37 @@ % 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; - if isPrint - disp('BST> MRI fiducials previously updated, but different than current digitized fiducials.'); + % 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 @@ -1142,15 +1161,12 @@ 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 to get "current" SCS to Native - % transformation; used when exporting coregistration to BIDS. - % Here we temporarily put the head coil coordinates into .SCS to - % compute SCS to Native transformation, instead of relying on previous - % transformations (which are named differently for different MEG systems). + % 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); @@ -1160,6 +1176,12 @@ % 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.'); @@ -1182,11 +1204,11 @@ % 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 subject, with separate digitized points, will still need -% the usual "per dataset" registration adjustment to align with the same 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, the Transform is returned unaltered. +% (if both isInteractive and isConfirm are false). In that case, Transform is returned unaltered. % % INPUTS: % - ChannelFile : Channel file to align with its anatomy @@ -1194,7 +1216,8 @@ % 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. +% 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. @@ -1283,7 +1306,7 @@ % 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); +[~, isMriUpdated, ~, ~, ChannelMat] = CheckPrevAdjustments(ChannelMat, sMriOld); % Get user confirmation if isMriUpdated % Already done previously. From 685286df496c4d27ddc46bb30d08628bd6327136 Mon Sep 17 00:00:00 2001 From: Rachel Flynn - MEG Lab Date: Tue, 4 Feb 2025 14:45:00 -0500 Subject: [PATCH 43/47] wip coregistration small fixes --- toolbox/process/functions/process_adjust_coordinates.m | 2 +- toolbox/process/functions/process_import_bids.m | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/toolbox/process/functions/process_adjust_coordinates.m b/toolbox/process/functions/process_adjust_coordinates.m index 742dfd181..3aa0f16b2 100644 --- a/toolbox/process/functions/process_adjust_coordinates.m +++ b/toolbox/process/functions/process_adjust_coordinates.m @@ -998,7 +998,7 @@ % Nothing to check. return; end - if nargin < 2 || isempty(sMri) || ~isfield(sMri, 'History') + if nargin < 2 || isempty(sMri) || ~isfield(sMri, 'History') || isempty(sMri.History) iMriHist = []; else % History string is set in figure_mri SaveMri. diff --git a/toolbox/process/functions/process_import_bids.m b/toolbox/process/functions/process_import_bids.m index f4a9babb7..551ff0df2 100644 --- a/toolbox/process/functions/process_import_bids.m +++ b/toolbox/process/functions/process_import_bids.m @@ -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 From 9d4f02c70d445375c4a2a16955b55a158de1764c Mon Sep 17 00:00:00 2001 From: Marc Lalancette <31040756+Moo-Marc@users.noreply.github.com> Date: Tue, 4 Feb 2025 15:20:20 -0500 Subject: [PATCH 44/47] wip coregistration interactive view histogram --- toolbox/gui/view_mri_histogram.m | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/toolbox/gui/view_mri_histogram.m b/toolbox/gui/view_mri_histogram.m index a68b6bfd0..cd2043c4e 100644 --- a/toolbox/gui/view_mri_histogram.m +++ b/toolbox/gui/view_mri_histogram.m @@ -1,4 +1,4 @@ -function hFig = view_mri_histogram( MriFile, isInteractive ) +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, isInteractive=false); @@ -29,6 +29,10 @@ % % Authors: Francois Tadel, 2006-2020, Marc Lalancette 2025 +if nargin < 2 || isempty(isInteractive) + isInteractive = false; +end + %% ===== LOAD OR COMPUTE HISTOGRAM ===== % Display progress bar bst_progress('start', 'View MRI historgram', 'Computing histogram...'); @@ -115,7 +119,7 @@ function clickCallback(~, event) bgLevel = event.IntersectionPoint(1); % fprintf('Clicked at x = %.4f\n', x); if bgLevel ~= Histogram.bgLevel - set(hBg, xdata, [bgLevel, bgLevel]); + set(hBg, 'xdata', [bgLevel, bgLevel]); % drawnow % needed? end end @@ -124,22 +128,20 @@ function closeFigureCallback() if bgLevel ~= Histogram.bgLevel % Request save confirmation. [Proceed, isCancel] = java_dialog('confirm', sprintf(... - 'MRI background intensity threshold changed (%d > %d). Save?', Histogram.bgLevel, bgLevel), ... + 'MRI background intensity threshold changed (%d > %d). Save?', round(Histogram.bgLevel), round(bgLevel)), ... 'MRI background threshold'); if isCancel return; - elseif Proceed + end + if Proceed % Save histogram Histogram.bgLevel = bgLevel; s.Histogram = Histogram; bst_save(MriFile, s, 'v7', 1); % isAppend - % Close figure - delete(fig); end - else - % Close figure - delete(fig); end + % Close figure + delete(hFig); end end From 3513e930899f5a67e87d91ece2a7b72524239ea4 Mon Sep 17 00:00:00 2001 From: Marc Lalancette <31040756+Moo-Marc@users.noreply.github.com> Date: Tue, 4 Feb 2025 17:49:26 -0500 Subject: [PATCH 45/47] bugfix in mri histogram bglevel check --- toolbox/anatomy/mri_histogram.m | 30 +++++++++++++++--------------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/toolbox/anatomy/mri_histogram.m b/toolbox/anatomy/mri_histogram.m index 1badf8695..178422b3f 100644 --- a/toolbox/anatomy/mri_histogram.m +++ b/toolbox/anatomy/mri_histogram.m @@ -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)) @@ -223,17 +223,17 @@ 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, ie. if there is an interval of empty bins + % at the beginning of the Histogram, after the first (zero-intensity) bin, i.e. if a range + % of low intensity values were replaced by zeros in the volume. + % 2025: Modified to work as described, and avoid cases where it wrongly gave a threshold near 0. + % First non-empty bin after first bin. + iNonZero = find(Histogram.fncY(2:end) ~= 0, 1) + 1; + if ~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; @@ -242,7 +242,7 @@ % 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 exist) + % (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; From 9e4cefeca1236c731c2c1a37e002c83e707aff06 Mon Sep 17 00:00:00 2001 From: Marc Lalancette <31040756+Moo-Marc@users.noreply.github.com> Date: Tue, 4 Feb 2025 18:15:05 -0500 Subject: [PATCH 46/47] fix in mri histogram bglevel check --- toolbox/anatomy/mri_histogram.m | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/toolbox/anatomy/mri_histogram.m b/toolbox/anatomy/mri_histogram.m index 178422b3f..579008bfa 100644 --- a/toolbox/anatomy/mri_histogram.m +++ b/toolbox/anatomy/mri_histogram.m @@ -223,13 +223,15 @@ defaultWhite = round(interp1(unikCumulFncY, unikFncX, .8)); Histogram.bgLevel = defaultBg; Histogram.whiteLevel = defaultWhite; - % Detect if the background has already been removed, ie. if there is an interval of empty bins - % at the beginning of the Histogram, after the first (zero-intensity) bin, i.e. if a range - % of low intensity values were replaced by zeros in the volume. - % 2025: Modified to work as described, and avoid cases where it wrongly gave a threshold near 0. + % 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 ~isempty(iNonZero) && iNonZero > 2 + 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 From 03eba9bd5851d146aabb6ba2edb831c39215aa61 Mon Sep 17 00:00:00 2001 From: Marc Lalancette <31040756+Moo-Marc@users.noreply.github.com> Date: Tue, 11 Feb 2025 17:29:12 -0500 Subject: [PATCH 47/47] fix gradient method of thresholding MRI for head surface --- toolbox/anatomy/tess_isohead.m | 2 +- toolbox/io/bst_save_coregistration.m | 17 ++++++++++++++++- 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/toolbox/anatomy/tess_isohead.m b/toolbox/anatomy/tess_isohead.m index 337332279..df44a1fd4 100644 --- a/toolbox/anatomy/tess_isohead.m +++ b/toolbox/anatomy/tess_isohead.m @@ -113,7 +113,7 @@ return end % Guess background level - if isempty(bgLevel) + if isempty(bgLevel) && ~isGradient bgLevel = sMri.Histogram.bgLevel; end diff --git a/toolbox/io/bst_save_coregistration.m b/toolbox/io/bst_save_coregistration.m index dee720a4f..593576255 100644 --- a/toolbox/io/bst_save_coregistration.m +++ b/toolbox/io/bst_save_coregistration.m @@ -1,6 +1,8 @@ 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). @@ -14,7 +16,7 @@ % https://groups.google.com/g/bids-discussion/c/BeyUeuNGl7I if nargin < 2 || isempty(isBids) - isBids = false; + isBids = []; end sSubjects = bst_get('ProtocolSubjects'); if nargin < 1 || isempty(iSubjects) @@ -47,6 +49,19 @@ 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).