diff --git a/toolbox/anatomy/cs_convert.m b/toolbox/anatomy/cs_convert.m index 250fa170b..706e4cb15 100644 --- a/toolbox/anatomy/cs_convert.m +++ b/toolbox/anatomy/cs_convert.m @@ -16,7 +16,7 @@ % DESCRIPTION: https://neuroimage.usc.edu/brainstorm/CoordinateSystems % - voxel : X=left>right, Y=posterior>anterior, Z=bottom>top % Coordinate of the center of the first voxel at the bottom-left-posterior of the MRI volume: (1,1,1) -% - mri : Same as 'voxel' but in millimeters instead of voxels: mriXYZ = voxelXYZ * Voxsize +% - mri : Same as 'voxel' but in meters (not mm here) instead of voxels: mriXYZ = voxelXYZ * Voxsize % - scs : Based on: Nasion, left pre-auricular point (LPA), and right pre-auricular point (RPA). % Origin: Midway on the line joining LPA and RPA % Axis X: From the origin towards the Nasion (exactly through) @@ -122,7 +122,7 @@ scs2captrak = [tCapTrak.R, tCapTrak.T; 0 0 0 1]; end -% ===== CONVERT SRC => MRI ===== +% ===== CONVERT SRC => MRI (m) ===== % Evaluate the transformation to apply switch lower(src) case 'voxel' @@ -174,7 +174,7 @@ error(['Invalid coordinate system: ' src]); end -% ===== CONVERT MRI => DEST ===== +% ===== CONVERT MRI (m) => DEST ===== % Evaluate the transformation to apply switch lower(dest) case 'voxel' diff --git a/toolbox/anatomy/mri_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 ed1c25137..30928f006 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, 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) @@ -10,12 +10,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 @@ -33,6 +33,12 @@ iSurface = []; isSave = true; % Parse inputs +if (nargin < 7) || isempty(isGradient) + isGradient = false; +end +if (nargin < 6) || isempty(bgLevel) + bgLevel = []; +end if (nargin < 5) || isempty(Comment) Comment = []; end @@ -85,7 +91,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 +104,7 @@ if isempty(bgLevel) bgLevel = sMri.Histogram.bgLevel; end -else +elseif isempty(bgLevel) && ~isGradient bgLevel = sMri.Histogram.bgLevel; end % Check parameters values @@ -112,24 +118,94 @@ % 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,:,:); -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', 15); % view_mri_slices(headmask, 'x', 20) @@ -137,40 +213,68 @@ %% ===== 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]); 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 +% 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', 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 +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', 10); + bst_progress('inc', 15); 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); + +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, '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 @@ -200,6 +304,90 @@ if isProgress bst_progress('stop'); end +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 + + +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/core/bst_colormaps.m b/toolbox/core/bst_colormaps.m index 13fb57e56..07304c9b0 100644 --- a/toolbox/core/bst_colormaps.m +++ b/toolbox/core/bst_colormaps.m @@ -376,18 +376,23 @@ function SetMaxCustom(ColormapType, DisplayUnits, newMin, newMax) case {'3DViz', 'MriViewer'} % Get surfaces defined in this figure TessInfo = getappdata(sFigure.hFigure, 'Surface'); - % Find 1st surface that match this ColormapType - iTess = find(strcmpi({TessInfo.ColormapType}, ColormapType), 1); + % Find surfaces that match this ColormapType + iSurfaces = find(strcmpi({TessInfo.ColormapType}, ColormapType)); DataFig = []; - if ~isempty(iTess) && ~isempty(TessInfo(iTess).DataSource.Type) - DataFig = TessInfo(iTess).DataMinMax; - DataType = TessInfo(iTess).DataSource.Type; - % For Data: use the modality instead - if strcmpi(DataType, 'Data') && ~isempty(ColormapInfo.Type) && ismember(ColormapInfo.Type, {'eeg', 'meg', 'nirs'}) - DataType = upper(ColormapInfo.Type); - % sLORETA: Do not use regular source scaling (pAm) - elseif strcmpi(DataType, 'Source') && ~isempty(strfind(lower(TessInfo(iTess).DataSource.FileName), 'sloreta')) - DataType = 'sLORETA'; + for i = 1:length(iSurfaces) + iTess = iSurfaces(i); + if ~isempty(TessInfo(iTess).DataSource.Type) + DataFig = [min([DataFig(:); TessInfo(iTess).DataMinMax(:)]), ... + max([DataFig(:); TessInfo(iTess).DataMinMax(:)])]; + % We'll keep the last non-empty DataType + DataType = TessInfo(iTess).DataSource.Type; + % For Data: use the modality instead + if strcmpi(DataType, 'Data') && ~isempty(ColormapInfo.Type) && ismember(ColormapInfo.Type, {'eeg', 'meg', 'nirs'}) + DataType = upper(ColormapInfo.Type); + % sLORETA: Do not use regular source scaling (pAm) + elseif strcmpi(DataType, 'Source') && ~isempty(strfind(lower(TessInfo(iTess).DataSource.FileName), 'sloreta')) + DataType = 'sLORETA'; + end end end if isempty(DataFig) @@ -1329,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 = []; @@ -1340,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/db/db_set_channel.m b/toolbox/db/db_set_channel.m index d5d0764bc..d866171e4 100644 --- a/toolbox/db/db_set_channel.m +++ b/toolbox/db/db_set_channel.m @@ -183,12 +183,10 @@ [ChannelMat, R, T, isSkip, isUserCancel, strReport, Tolerance] = channel_align_auto(OutputFile, [], 0, isConfirm, Tolerance); % User validated: keep this answer for the next round (force alignment for next call) if ~isSkip - if isUserCancel + if isUserCancel || isempty(ChannelMat) ChannelAlign = 0; - elseif ~isempty(ChannelMat) + elseif ChannelAlign < 2 ChannelAlign = 2; - else - ChannelAlign = 0; end end end diff --git a/toolbox/gui/figure_3d.m b/toolbox/gui/figure_3d.m index 176571088..190965a4c 100644 --- a/toolbox/gui/figure_3d.m +++ b/toolbox/gui/figure_3d.m @@ -3993,18 +3993,22 @@ function ViewAxis(hFig, isVisible) isVisible = isempty(findobj(hAxes, 'Tag', 'AxisXYZ')); end if isVisible + % Works but now default zoom is too tight >:( % Get dimensions of current axes + set(hAxes, 'XLimitMethod', 'tight', 'YLimitMethod', 'tight', 'ZLimitMethod', 'tight'); XLim = get(hAxes, 'XLim'); - YLim = get(hAxes, 'XLim'); - ZLim = get(hAxes, 'XLim'); - d = max(abs([XLim(:); YLim(:); ZLim(:)])); + YLim = get(hAxes, 'YLim'); + ZLim = get(hAxes, 'ZLim'); % Draw axis lines + d = XLim(2) * 1.04; line([0 d], [0 0], [0 0], 'Color', [1 0 0], 'Marker', '>', 'Parent', hAxes, 'Tag', 'AxisXYZ'); + text(d * 1.04, 0, 0, 'X', 'Color', [1 0 0], 'Parent', hAxes, 'Tag', 'AxisXYZ'); + d = YLim(2) * 1.04; line([0 0], [0 d], [0 0], 'Color', [0 1 0], 'Marker', '>', 'Parent', hAxes, 'Tag', 'AxisXYZ'); + text(0, d * 1.04, 0, 'Y', 'Color', [0 1 0], 'Parent', hAxes, 'Tag', 'AxisXYZ'); + d = ZLim(2) * 1.04; line([0 0], [0 0], [0 d], 'Color', [0 0 1], 'Marker', '>', 'Parent', hAxes, 'Tag', 'AxisXYZ'); - text(d+0.002, 0, 0, 'X', 'Color', [1 0 0], 'Parent', hAxes, 'Tag', 'AxisXYZ'); - text(0, d+0.002, 0, 'Y', 'Color', [0 1 0], 'Parent', hAxes, 'Tag', 'AxisXYZ'); - text(0, 0, d+0.002, 'Z', 'Color', [0 0 1], 'Parent', hAxes, 'Tag', 'AxisXYZ'); + text(0, 0, d * 1.04, 'Z', 'Color', [0 0 1], 'Parent', hAxes, 'Tag', 'AxisXYZ'); % Enforce camera target at (0,0,0) % camtarget(hAxes, [0,0,0]); else diff --git a/toolbox/gui/figure_mri.m b/toolbox/gui/figure_mri.m index 873264af0..6c79a5c0f 100644 --- a/toolbox/gui/figure_mri.m +++ b/toolbox/gui/figure_mri.m @@ -2557,10 +2557,22 @@ function ButtonSave_Callback(hFig, varargin) %% ===== SAVE MRI ===== +% Update MRI fiducials and adjust surfaces. +% Input can be figure handle or sMri. function [isCloseAccepted, MriFile] = SaveMri(hFig) ProtocolInfo = bst_get('ProtocolInfo'); % Get MRI - sMri = panel_surface('GetSurfaceMri', hFig); + if ishandle(hFig) + sMri = panel_surface('GetSurfaceMri', hFig); + isUser = true; + elseif isstruct(hFig) + sMri = hFig; + isUser = false; + else + bst_error('SaveMri: Unexpected input: %s.', class(hFig)); + isCloseAccepted = 0; + return; + end MriFile = sMri.FileName; MriFileFull = bst_fullfile(ProtocolInfo.SUBJECTS, MriFile); % Do not accept "Save" if user did not select all the fiducials @@ -2578,21 +2590,26 @@ function ButtonSave_Callback(hFig, varargin) warning('off', 'MATLAB:load:variableNotFound'); sMriOld = load(MriFileFull, 'SCS'); warning('on', 'MATLAB:load:variableNotFound'); - % If the fiducials were modified + % 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(sMri.SCS.NAS - sMriOld.SCS.NAS) > 1e-3) || ... - (max(sMri.SCS.LPA - sMriOld.SCS.LPA) > 1e-3) || ... - (max(sMri.SCS.RPA - sMriOld.SCS.RPA) > 1e-3)) + && ((max(abs(sMri.SCS.NAS - sMriOld.SCS.NAS)) > 1e-3) || ... + (max(abs(sMri.SCS.LPA - sMriOld.SCS.LPA)) > 1e-3) || ... + (max(abs(sMri.SCS.RPA - sMriOld.SCS.RPA)) > 1e-3)) % Nothing to do... else + % sMri.SCS.R, T and Origin are updated before calling this function. sMriOld = []; end % === HISTORY === % History: Edited the fiducials if ~isfield(sMriOld, 'SCS') || ~isequal(sMriOld.SCS, sMri.SCS) || ~isfield(sMriOld, 'NCS') || ~isequal(sMriOld.NCS, sMri.NCS) - sMri = bst_history('add', sMri, 'edit', 'User edited the fiducials'); + if isUser + sMri = bst_history('add', sMri, 'edit', 'User edited the fiducials'); + else + sMri = bst_history('add', sMri, 'edit', 'Applied digitized anatomical fiducials'); % string used to verify elsewhere + end end % ==== SAVE MRI ==== diff --git a/toolbox/gui/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 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/math/bst_meshfit.m b/toolbox/math/bst_meshfit.m index 5ad40a256..0ec577892 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. +PenalizeInside = true; +if nargin < 4 || isempty(Outliers) + Outliers = 0; +end + +% nV = size(Vertices, 1); +nF = size(Faces, 1); +nP = size(P, 1); +Outliers = ceil(Outliers * nP); + +% Edges as indices +Edges = unique(sort([Faces(:,[1,2]); Faces(:,[2,3]); Faces(:,[3,1])], 2), 'rows'); +% Edge direction "doubly normalized" so that later projection should be between 0 and 1. +EdgeDir = Vertices(Edges(:,2),:) - Vertices(Edges(:,1),:); +EdgeL = sqrt(sum(EdgeDir.^2, 2)); +EdgeDir = bsxfun(@rdivide, EdgeDir, EdgeL); +% Edges as vectors +EdgesV = zeros(nF, 3, 3); +EdgesV(:,:,1) = Vertices(Faces(:,2),:) - Vertices(Faces(:,1),:); +EdgesV(:,:,2) = Vertices(Faces(:,3),:) - Vertices(Faces(:,2),:); +EdgesV(:,:,3) = Vertices(Faces(:,1),:) - Vertices(Faces(:,3),:); +% First edge to second edge: counter clockwise = up +FaceNormals = 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.02 mm displacement. +OptimOptions = optimoptions(@fminunc, 'MaxFunctionEvaluations', 1000, 'MaxIterations', 200, ... + 'FiniteDifferenceStepSize', 1e-3, ... + 'FunctionTolerance', 1e-4, 'StepTolerance', 2e-2, 'Display', 'none'); % 'OptimalityTolerance', 1e-15, 'final-detailed' + +BestParams = fminunc(@CostFunction, InitParams, OptimOptions); +[R,T,newP] = Transform(BestParams, P); +T = T'; +distFinal = PointSurfDistance(newP); % Calculate the final error -distFinal = get_distance(Vertices, VertNorm, newP, dt); -errFinal = sum(abs(distFinal)); +errFinal = CostFunction(BestParams); % If the error is larger than at the beginning: cancel the modifications +% Should no longer occur. if (errFinal > errInit) disp('BST> The optimization failed finding a better fit'); R = []; @@ -61,88 +105,103 @@ newP = P; end -end - +% Better cost function for points fitting: higher cost for points inside the +% head > 1mm, (better distance calculation). + function [Cost, Dist] = CostFunction(Params) + [~,~,Points] = Transform(Params, P); + Dist = PointSurfDistance(Points); + if PenalizeInside + isInside = inpolyhedron(Faces, Vertices, Points, 'FaceNormals', FaceNormals, 'FlipNormals', true); + %patch('Faces',Faces,'Vertices',Vertices); hold on; light; axis equal; + %quiver3(FaceVertices(:,1,1), FaceVertices(:,2,1), FaceVertices(:,3,1), FaceNormals(:,1,1), FaceNormals(:,2,1), FaceNormals(:,3,1)); + %scatter3(Points(1,1),Points(1,2),Points(1,3)); + iSquare = isInside & Dist > 0.001; + Dist(iSquare) = Dist(iSquare).^2 *1e3; % factor for "squaring mm" + iOutside = find(~isInside); + end + Cost = sum(Dist); + for iP = 1:Outliers + if PenalizeInside + % Only remove outside points. + [MaxD, iMaxD] = max(Dist(~isInside)); + Dist(iOutside(iMaxD)) = 0; + else + [MaxD, iMaxD] = max(Dist); + Dist(iMaxD) = 0; + end + Cost = Cost - MaxD; + end + end +%% TODO Slow, look for alternatives. +% This seems similar: https://www.mathworks.com/matlabcentral/fileexchange/52882-point2trimesh-distance-between-point-and-triangulated-surface % ===== COMPUTE POINTS/MESH DISTANCE ===== -% Approximates the distance to the mesh by the projection on the norm vector of the nearest neighbor -function [dist,dt] = get_distance(Vertices, VertNorm, P, dt) - % Find the nearest neighbor - [iNearest, dist_pt, dt] = bst_nearest(Vertices, P, 1, 0, dt); - % Distance = projection of the distance between the point and its nearest - % neighbor in the surface on the vertex normal - % As the head surface is supposed to be very smooth, it should be a good approximation - % of the distance from the point to the surface. - dist = abs(sum(VertNorm(iNearest,:) .* (P - Vertices(iNearest,:)),2)); -end - -% ===== COMPUTE TRANSFORMATION ===== -% Gauss-Newton optimization algorithm -% Based on work from Qianqian Fang, 2008 -function [R,T,newP] = fit_points(Vertices, VertNorm, P, dt) - % Initial parameters: no rotation, no translation - C = zeros(6,1); - newP = P; - % Sensitivity - delta = 1e-4; - % Maximum number of iterations - maxiter = 20; - % Initialize error at the previous iteration - errPrev = Inf; - % Start Gauss-Newton iterations - for iter = 1:maxiter - % Calculate the current residual: the sum of distances to the surface - dist0 = get_distance(Vertices, VertNorm, newP, dt); - err = sum(abs(dist0)); - % If global error is going up: stop - if (err > errPrev) - break; +% For exact distance computation, we need to check all 3: vertices, edges and faces. + function Dist = PointSurfDistance(Points) + Epsilon = 1e-9; % nanometer + % Check distance to vertices + if license('test','statistics_toolbox') + DistVert = pdist2(Vertices, Points, 'euclidean', 'Smallest', 1)'; + else + DistVert = zeros(nP, 1); + for iP = 1:nP + % Find closest surface vertex. + DistVert(iP) = sqrt(min(sum(bsxfun(@minus, Points(iP, :), Vertices).^2, 2))); + end end - errPrev = err; - % fprintf('iter=%d error=%f\n', iter, err); - % Build the Jacobian (sensitivity) matrix - J = zeros(length(dist0),length(C)); - for i = 1:length(C) - dC = C; - if (C(i)) - dC(i) = C(i) * (1+delta); - else - dC(i) = C(i) + delta; + % Check distance to faces + DistFace = inf(nP, 1); + for iP = 1:nP + % Considered Möller and Trumbore 1997, Ray-Triangle Intersection (https://stackoverflow.com/questions/42740765/intersection-between-line-and-triangle-in-3d), but this is simpler still. + % Vectors from triangle vertices to point. + Pyramid = bsxfun(@minus, Points(iP, :), FaceVertices); + % Does the point project inside each face? + InFace = all(sum(Pyramid .* EdgeTriNormals, 2) > -Epsilon, 3); + if any(InFace) + DistFace(iP) = min(abs(sum(Pyramid(InFace,:,1) .* FaceNormals(InFace,:), 2))); end - % Apply this new transformation to the points - [tmpR,tmpT,tmpP] = get_transform(dC, P); - % Calculate the distance for this new transformation - dist = get_distance(Vertices, VertNorm, tmpP, dt); - % J=dL/dC - J(:,i) = (dist-dist0) / (dC(i)-C(i)); end - % Weight the matrix (normalization) - wj = sqrt(sum(J.*J)); - J = J ./ repmat(wj,length(dist0),1); - % Calculate the update: J*dC=dL - dC = (J\dist0) ./ wj'; - C = C - 0.5*dC; - % Get the updated positions with the calculated A and b - [R,T,newP] = get_transform(C, P); + % Check distance to edges + DistEdge = inf(nP, 1); + for iP = 1:nP + % Vector from first edge vertex to point. + Pyramid = bsxfun(@minus, Points(iP, :), Vertices(Edges(:, 1), :)); + Projection = sum(Pyramid .* EdgeDir, 2); + InEdge = Projection > -Epsilon & Projection < (EdgeL + Epsilon); + if any(InEdge) + DistEdge(iP) = sqrt(min(sum((Pyramid(InEdge,:) - bsxfun(@times, Projection(InEdge), EdgeDir(InEdge,:))).^2, 2))); + end + end + + Dist = min([DistVert, DistEdge, DistFace], [], 2); end -end + + +% % Approximates the distance to the mesh by the projection on the norm vector of the nearest neighbor +% function [dist,dt] = get_distance(Vertices, VertNorm, P, dt) +% % Find the nearest neighbor +% [iNearest, dist_pt, dt] = bst_nearest(Vertices, P, 1, 0, dt); +% % Distance = projection of the distance between the point and its nearest +% % neighbor in the surface on the vertex normal +% % As the head surface is supposed to be very smooth, it should be a good approximation +% % of the distance from the point to the surface. +% dist = abs(sum(VertNorm(iNearest,:) .* (P - Vertices(iNearest,:)),2)); +% end % ===== GET TRANSFORMATION ===== -function [R,T,P] = get_transform(params, P) - % Get values - mx = params(1); my = params(2); mz = params(3); % Translation parameters - x = params(4); y = params(5); z = params(6); % Rotation parameters - % Rotation - Rx = [1 0 0 ; 0 cos(x) sin(x) ; 0 -sin(x) cos(x)]; % Rotation over x - Ry = [cos(y) 0 -sin(y); 0 1 0; sin(y) 0 cos(y)]; % Rotation over y - Rz = [cos(z) sin(z) 0; -sin(z) cos(z) 0; 0 0 1]; % Rotation over z - R = Rx*Ry*Rz; - % Translation - T = [mx; my; mz]; - % Apply to points - if (nargin >= 2) - P = (R * P' + T * ones(1,size(P,1)))'; + function [R,T,Points] = Transform(Params, Points) + % Translation in mm (to use default TypicalX of 1) + T = Params(1:3)'/1e3; + % Rotation in degrees (again for expected order of magnitude of 1) + x = Params(4)*pi/180; y = Params(5)*pi/180; z = Params(6)*pi/180; % Rotation parameters + Rx = [1 0 0 ; 0 cos(x) sin(x) ; 0 -sin(x) cos(x)]; % Rotation over x + Ry = [cos(y) 0 -sin(y); 0 1 0; sin(y) 0 cos(y)]; % Rotation over y + Rz = [cos(z) sin(z) 0; -sin(z) cos(z) 0; 0 0 1]; % Rotation over z + R = Rx*Ry*Rz; + % Apply to points + Points = bsxfun(@plus, Points * R', T); end + end diff --git a/toolbox/process/functions/process_adjust_coordinates.m b/toolbox/process/functions/process_adjust_coordinates.m index bf861a247..71d7c73cb 100644 --- a/toolbox/process/functions/process_adjust_coordinates.m +++ b/toolbox/process/functions/process_adjust_coordinates.m @@ -1,9 +1,10 @@ function varargout = process_adjust_coordinates(varargin) % PROCESS_ADJUST_COORDINATES: Adjust, recompute, or remove various coordinate transformations. % -% Native coordinates are based on system fiducials (e.g. MEG head coils), -% whereas Brainstorm's SCS coordinates are based on the anatomical fiducial -% points from the .pos file. +% Native coordinates are based on system fiducials (e.g. MEG head coils), whereas Brainstorm's SCS +% coordinates are based on the anatomical fiducial points. After alignment between MRI and +% headpoints, the anatomical fiducials on the MRI side define the SCS and the ones in the channel +% files (ChannelMat.SCS) are ignored. % @============================================================================= % This function is part of the Brainstorm software: @@ -23,14 +24,13 @@ % For more information type "brainstorm license" at command prompt. % =============================================================================@ % -% Authors: Marc Lalancette, 2018-2020 +% Authors: Marc Lalancette, 2018-2022 eval(macro_method); end - -function sProcess = GetDescription() %#ok +function sProcess = GetDescription() % Description of the process sProcess.Comment = 'Adjust coordinate system'; sProcess.Description = 'https://neuroimage.usc.edu/brainstorm/Tutorials/HeadMotion#Adjust_the_reference_head_position'; @@ -42,7 +42,7 @@ sProcess.OutputTypes = {'raw', 'data'}; sProcess.nInputs = 1; sProcess.nMinFiles = 1; - % Option [to do: ignore bad segments] + % Option sProcess.options.reset.Type = 'checkbox'; sProcess.options.reset.Comment = 'Reset coordinates using original channel file (removes all adjustments: head, points, manual).'; sProcess.options.reset.Value = 0; @@ -60,10 +60,18 @@ sProcess.options.bad.Type = 'checkbox'; sProcess.options.bad.Comment = 'For adjust option, exclude bad segments.'; sProcess.options.bad.Value = 1; - sProcess.options.bad.Class = 'Adjust'; + sProcess.options.bad.Class = 'Adjust'; sProcess.options.points.Type = 'checkbox'; sProcess.options.points.Comment = 'Refine MRI coregistration using digitized head points.'; sProcess.options.points.Value = 0; + sProcess.options.points.Controller = 'Refine'; + sProcess.options.tolerance.Comment = 'Tolerance (outlier points to ignore):'; + sProcess.options.tolerance.Type = 'value'; + sProcess.options.tolerance.Value = {0, '%', 0}; + sProcess.options.tolerance.Class = 'Refine'; + sProcess.options.scs.Type = 'checkbox'; + sProcess.options.scs.Comment = 'Replace MRI nasion and ear points with digitized landmarks (cannot undo).'; + sProcess.options.scs.Value = 0; sProcess.options.remove.Type = 'checkbox'; sProcess.options.remove.Comment = 'Remove selected adjustments (if present) instead of adding them.'; sProcess.options.remove.Value = 0; @@ -103,9 +111,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,52 +139,69 @@ % ---------------------------------------------------------------- if sProcess.options.reset.Value - % The main goal of this option is to fix a bug in a previous - % version: when importing a channel file, when going to SCS - % coordinates based on digitized coils and anatomical - % fiducials, the channel orientation was wrong. We wish to fix - % this but keep as much pre-processing that was previously - % done. Thus we will re-import the channel file, and copy the - % projectors (and history) from the old one. + % The original goal of this option was to fix data affected by a previous bug while + % keeping as much pre-processing that was previously done. We re-import the channel + % file, and copy the projectors (and history) from the old one. - [ChannelMat, NewChannelFiles, Failed] = ... - ResetChannelFile(ChannelMat, NewChannelFiles, sInputs(iFile), sProcess); - if Failed + [ChannelMat, NewChannelFiles, isError] = ResetChannelFile(ChannelMat, ... + NewChannelFiles, sInputs(iFile), sProcess); + if isError continue; end % ---------------------------------------------------------------- elseif sProcess.options.remove.Value - % Because channel_align_manual does not consistently apply the - % manual transformation to all sensors or save it in both - % TransfMeg and TransfEeg, it could lead to confusion and - % errors when playing with transforms. Therefore, if we detect - % a difference between the MEG and EEG transforms when trying - % to remove one that applies to both (currently only refine - % with head points), we don't proceed and recommend resetting - % with the original channel file instead. + % Because channel_align_manual does not consistently apply the manual transformation to + % all sensors or save it in both TransfMeg and TransfEeg, it could lead to confusion and + % errors when playing with transforms. Therefore, if we detect a difference between the + % MEG and EEG transforms when trying to remove one that applies to both (currently only + % refine with head points), we don't proceed and recommend resetting with the original + % channel file instead. Which = {}; if sProcess.options.head.Value - Which{end+1} = 'AdjustedNative'; + Which{end+1} = 'AdjustedNative'; %#ok end if sProcess.options.points.Value - Which{end+1} = 'refine registration: head points'; + Which{end+1} = 'refine registration: head points'; %#ok end for TransfLabel = Which - TransfLabel = TransfLabel{1}; + TransfLabel = TransfLabel{1}; %#ok ChannelMat = RemoveTransformation(ChannelMat, TransfLabel, sInputs(iFile), sProcess); end % TransfLabel loop + + % We cannot change back the MRI fiducials, but in order to be able to update it again + % from digitized fids without warnings, edit the MRI history. + if sProcess.options.scs.Value + % Get subject in database, with subject directory + sSubject = bst_get('Subject', sInputs(iFile).SubjectFile); + MriFile = sSubject.Anatomy(sSubject.iAnatomy).FileName; + sMri = in_mri_bst(sSubject.Anatomy(sSubject.iAnatomy).FileName); + % Slightly change the string we use to verify if it was done: append " (hidden)". + iHist = find(strcmpi(sMri.History(:,3), 'Applied digitized anatomical fiducials')); + if ~isempty(iHist) + for iH = 1:numel(iHist) + sMri.History{iHist(iH),3} = [sMri.History{iHist(iH),3}, ' (hidden)']; + end + try + bst_save(file_fullpath(MriFile), sMri, 'v7'); + catch + bst_report('Error', sProcess, sInputs(iFile), ... + sprintf('Unable to save MRI file %s.', MriFile)); + continue; + end + end + end end % reset channel file or remove transformations % ---------------------------------------------------------------- if ~sProcess.options.remove.Value && sProcess.options.head.Value % Complex indexing to get all inputs for this same channel file. - [ChannelMat, Failed] = AdjustHeadPosition(ChannelMat, ... + [ChannelMat, isError] = AdjustHeadPosition(ChannelMat, ... sInputs(iUniqInputs == iUniqInputs(iFile)), sProcess); - if Failed + if isError continue; end end % adjust head position @@ -187,18 +211,40 @@ % Redundant, but makes sense to have it here also. bst_progress('text', 'Fitting head surface to points...'); - [ChannelMat, R, T, isSkip] = ... - channel_align_auto(sInputs(iFile).ChannelFile, ChannelMat, 0, 0); % No warning or confirmation - % ChannelFile needed to find subject and scalp surface, but not - % used otherwise when ChannelMat is provided. - if isSkip - bst_report('Error', sProcess, sInputs(iFile), ... - 'Error trying to refine registration using head points.'); + % If called externally without a tolerance value, set isWarning true so it asks. + if isempty(sProcess.options.tolerance.Value) + isWarning = true; + Tolerance = 0; + else + isWarning = false; + Tolerance = sProcess.options.tolerance.Value{1} / 100; + end + [ChannelMat, R, T, isSkip, isUserCancel, strReport] = channel_align_auto(sInputs(iFile).ChannelFile, ... + ChannelMat, isWarning, 0, Tolerance); % No confirmation + % ChannelFile needed to find subject and scalp surface, but not used otherwise when + % ChannelMat is provided. + if ~isempty(strReport) + bst_report('Info', sProcess, sInputs(iFile), strReport); + elseif isSkip + bst_report('Warning', sProcess, sInputs(iFile), ... + 'Refine registration using head points, failed finding a better fit.'); continue; end 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. Possibly have hidden option for no interactive warnings. + + [~, isCancel] = channel_align_scs(ChannelFile, eye(4), true, false); % interactive warnings but no confirmation + if isCancel + continue; + end + % TODO Verify + end + % ---------------------------------------------------------------- % Save channel file. bst_save(file_fullpath(sInputs(iFile).ChannelFile), ChannelMat, 'v7'); @@ -212,10 +258,9 @@ end % file loop bst_progress('stop'); - % Return the input files that were processed properly. Include those - % that were removed due to sharing a channel file, where appropriate. - % The complicated indexing picks the first input of those with the same - % channel file, i.e. the one that was marked ok. + % Return the input files that were processed properly. Include those that were removed due to + % sharing a channel file, where appropriate. The complicated indexing picks the first input of + % those with the same channel file, i.e. the one that was marked ok. OutputFiles = {sInputs(isFileOk(iUniqInputs(iUniqFiles))).FileName}; end @@ -266,21 +311,29 @@ % end -function [ChannelMat, NewChannelFiles, Failed] = ... - ResetChannelFile(ChannelMat, NewChannelFiles, sInput, sProcess) +function [ChannelMat, NewChannelFiles, isError] = ResetChannelFile(ChannelMat, NewChannelFiles, sInput, sProcess) + % Reload a channel file, but keep projectors and history. First look for original file from + % history, and if it's no longer there, user will be prompted. User selections are noted as + % pairs {old, new} in NewChannelFiles for potential reuse (e.g. same original data at multiple + % pre-processing steps). + % This function does not save the file, but only returns the updated structure. if nargin < 4 sProcess = []; end - Failed = false; + if nargin < 3 + sInput = []; + end + if nargin < 2 || isempty(NewChannelFiles) + NewChannelFiles = cell(0,2); + end + isError = false; bst_progress('text', 'Importing channel file...'); % Extract original data file from channel file history. - if any(size(ChannelMat.History) < [1, 3]) || ... - ~strcmp(ChannelMat.History{1, 2}, 'import') + if any(size(ChannelMat.History) < [1, 3]) || ~strcmp(ChannelMat.History{1, 2}, 'import') NotFound = true; ChannelFile = ''; else - ChannelFile = regexp(ChannelMat.History{1, 3}, ... - '(?<=: )(.*)(?= \()', 'match'); + ChannelFile = regexp(ChannelMat.History{1, 3}, '(?<=: )(.*)(?= \()', 'match'); if isempty(ChannelFile) NotFound = true; else @@ -307,10 +360,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 would not know which file to pick! And + % prompt is modal for Matlab, so likely can't look at command window (e.g. if Brainstorm is + % in front). So add another pop-up with the needed info. [ChanPath, ChanName, ChanExt] = fileparts(ChannelFile); MsgFig = msgbox(sprintf('Select the new location of channel file %s %s to reset %s.', ... ChanPath, [ChanName, ChanExt], sInput.ChannelFile), ... @@ -322,13 +374,11 @@ DefaultFormats.ChannelIn = FileFormat; bst_set('DefaultFormats', DefaultFormats); - [NewChannelMat, NewChannelFile] = import_channel(... - sInput.iStudy, '', FileFormat, 0, 0, 0, [], []); + [NewChannelMat, NewChannelFile] = import_channel(sInput.iStudy, '', FileFormat, 0, 0, 0, [], []); else % Import from original file. - [NewChannelMat, NewChannelFile] = import_channel(... - sInput.iStudy, ChannelFile, FileFormat, 0, 0, 0, [], []); + [NewChannelMat, NewChannelFile] = import_channel(sInput.iStudy, ChannelFile, FileFormat, 0, 0, 0, [], []); % iStudies, ChannelFile, FileFormat, ChannelReplace, ChannelAlign, isSave, isFixUnits, isApplyVox2ras) % iStudy index is needed to avoid error for noise recordings with missing SCS transform. % ChannelReplace is for replacing the file, only if isSave. @@ -337,31 +387,30 @@ % See if it worked. if isempty(NewChannelFile) - bst_report('Error', sProcess, sInput, ... - 'No file channel file selected.'); - Failed = true; + bst_report('Error', sProcess, sInput, 'No file channel file selected.'); + isError = true; return; elseif isempty(NewChannelMat) - bst_report('Error', sProcess, sInput, ... - sprintf('Unable to import channel file: %s', NewChannelFile)); - Failed = true; + bst_report('Error', sProcess, sInput, sprintf('Unable to import channel file: %s', NewChannelFile)); + isError = true; return; elseif numel(NewChannelMat.Channel) ~= numel(ChannelMat.Channel) bst_report('Error', sProcess, sInput, ... 'Original channel file has different channels than current one, aborting.'); - Failed = true; + isError = true; return; elseif NotFound && ~isempty(ChannelFile) % Save the selected new location. NewChannelFiles(end+1, :) = {ChannelFile, NewChannelFile}; end - % Copy the new old projectors and history to the new structure. + % Copy the old projectors and history to the new structure. NewChannelMat.Projector = ChannelMat.Projector; NewChannelMat.History = ChannelMat.History; ChannelMat = NewChannelMat; - % clear NewChannelMat + % Add number of channels to comment, like in db_set_channel. ChannelMat.Comment = [ChannelMat.Comment, sprintf(' (%d)', length(ChannelMat.Channel))]; + % Add history ChannelMat = bst_history('add', ChannelMat, 'import', ... ['Reset from: ' NewChannelFile ' (Format: ' FileFormat ')']); end % ResetChannelFile @@ -380,9 +429,8 @@ % Need to check for empty, otherwise applies to all channels! else iChan = []; % All channels. - % Note: NIRS doesn't have a separate set of - % transformations, but "refine" and "SCS" are applied - % to NIRS as well. + % Note: NIRS doesn't have a separate set of transformations, but "refine" and "SCS" are + % applied to NIRS as well. end while ~isempty(iUndoMeg) if isMegOnly && isempty(iChan) @@ -441,8 +489,8 @@ end % RemoveTransformation -function [ChannelMat, Failed] = AdjustHeadPosition(ChannelMat, sInputs, sProcess) - Failed = false; +function [ChannelMat, isError] = AdjustHeadPosition(ChannelMat, sInputs, sProcess) + isError = false; % Check the input is CTF. isRaw = (length(sInputs(1).FileName) > 9) && ~isempty(strfind(sInputs(1).FileName, 'data_0raw')); if isRaw @@ -453,13 +501,12 @@ if ~strcmp(DataMat.Device, 'CTF') bst_report('Error', sProcess, sInputs, ... 'Adjust head position is currently only available for CTF data.'); - Failed = true; + isError = true; return; end - % The data could be changed such that the head position could be - % readjusted (e.g. by deleting segments). This is allowed and the - % previous adjustment will be replaced. + % The data could be changed such that the head position could be readjusted (e.g. by deleting + % segments). This is allowed and the previous adjustment will be replaced. if isfield(ChannelMat, 'TransfMegLabels') && iscell(ChannelMat.TransfMegLabels) && ... ismember('AdjustedNative', ChannelMat.TransfMegLabels) bst_report('Info', sProcess, sInputs, ... @@ -477,7 +524,7 @@ [Locs, HeadSamplePeriod] = process_evt_head_motion('LoadHLU', sInputs(iIn), [], false); if isempty(Locs) % No HLU channels. Error already reported. Skip this file. - Failed = true; + isError = true; return; end % Exclude all bad segments. @@ -506,14 +553,14 @@ 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. Locs(:, ismember(iHeadSamples, iBad)) = []; end end - Locations = [Locations, Locs]; + Locations = [Locations, Locs]; %#ok end % If a collection was aborted, the channels will be filled with zeros. Remove these. @@ -523,55 +570,46 @@ MedianLoc = MedianLocation(Locations); % disp(MedianLoc); - % Also get the initial reference position. We only use it to see - % how much the adjustment moves. + % Also get the initial reference position. We only use it to estimate how much the adjustment moves. InitRefLoc = ReferenceHeadLocation(ChannelMat, sInputs); if isempty(InitRefLoc) % There was an error, already reported. Skip this file. - Failed = true; + isError = true; return; end - % Extract transformations that are applied before and after the - % head position adjustment. Any previous adjustment will be - % ignored here and replaced later. + % Extract transformations that are applied before and after the head position adjustment. Any + % previous adjustment will be ignored here and replaced later. [TransfBefore, TransfAdjust, TransfAfter, iAdjust, iDewToNat] = ... GetTransforms(ChannelMat, sInputs); if isempty(TransfBefore) % There was an error, already reported. Skip this file. - Failed = true; + isError = true; return; end % Compute transformation corresponding to coil position. [TransfMat, TransfAdjust] = LocationTransform(MedianLoc, ... TransfBefore, TransfAdjust, TransfAfter); - % This TransfMat would automatically give an identity - % transformation if the process is run multiple times, and - % TransfAdjust would not change. - - % Apply this transformation to the current head position. - % This is a correction to the 'Dewar=>Native' - % transformation so it applies to MEG channels only and not - % to EEG or head points, which start in Native. + % This TransfMat would automatically give an identity transformation if the process is run + % multiple times, and TransfAdjust would not change. + + % Apply this transformation to the current head position. This is a correction to the + % 'Dewar=>Native' transformation so it applies to MEG channels only and not to EEG or head + % points, which start in Native. iMeg = sort([good_channel(ChannelMat.Channel, [], 'MEG'), ... good_channel(ChannelMat.Channel, [], 'MEG REF')]); ChannelMat = channel_apply_transf(ChannelMat, TransfMat, iMeg, false); % Don't apply to head points. ChannelMat = ChannelMat{1}; - % After much thought, it was decided to save this - % adjustment transformation separately and at its logical - % place: between 'Dewar=>Native' and - % 'Native=>Brainstorm/CTF'. In particular, this allows us - % to use it directly when displaying head motion distance. - % This however means we must correctly move the - % transformation from the end where it was just applied to - % its logical place. This "moved" transformation is also - % computed in LocationTransform above. + % After much thought, it was decided to save this adjustment transformation separately and at + % its logical place: between 'Dewar=>Native' and 'Native=>Brainstorm/CTF'. In particular, this + % allows us to use it directly when displaying head motion distance. This however means we must + % correctly move the transformation from the end where it was just applied to its logical place. + % This "moved" transformation is also computed in LocationTransform above. if isempty(iAdjust) iAdjust = iDewToNat + 1; - % Shift transformations to make room for the new - % adjustment, and reject the last one, that we just - % applied. + % Shift transformations to make room for the new adjustment, and reject the last one, that + % we just applied. ChannelMat.TransfMegLabels(iDewToNat+2:end) = ... ChannelMat.TransfMegLabels(iDewToNat+1:end-1); % reject last one ChannelMat.TransfMeg(iDewToNat+2:end) = ChannelMat.TransfMeg(iDewToNat+1:end-1); @@ -591,7 +629,7 @@ AfterRefLoc = ReferenceHeadLocation(ChannelMat, sInputs); if isempty(AfterRefLoc) % There was an error, already reported. Skip this file. - Failed = true; + isError = true; return; end DistanceAdjusted = process_evt_head_motion('RigidDistances', AfterRefLoc, InitRefLoc); @@ -601,14 +639,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 +652,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 +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, :) = []; @@ -655,25 +690,25 @@ end % ReferenceHeadLocation -function [TransfBefore, TransfAdjust, TransfAfter, iAdjust, iDewToNat] = ... - GetTransforms(ChannelMat, sInputs) - % Extract transformations that are applied before and after the head - % position adjustment we are creating now. We keep the 'Dewar=>Native' - % transformation intact and separate from the adjustment for no deep - % reason, but it is the only remaining trace of the initial head coil +function [TransfBefore, TransfAdjust, TransfAfter, iAdjust, iDewToNat] = GetTransforms(ChannelMat, sInputs) + % Extract transformations that are applied before and after the head position adjustment we are + % creating now. We keep the 'Dewar=>Native' transformation intact and separate from the + % adjustment for no deep reason, but it is the only remaining trace of the initial head coil % positions in Brainstorm. - % The reason this function was split from LocationTransform is that it - % can be called only once outside the head sample loop in process_sss, - % whereas LocationTransform is called many times within the loop. + % The reason this function was split from LocationTransform is that it can be called only once + % outside the head sample loop in process_sss, whereas LocationTransform is called many times + % within the loop. - % When this is called from process_sss, we are possibly working on a - % second head adjustment, this time based on the instantaneous head - % position, so we need to keep the global adjustment based on the entire - % recording if it is there. + % When this is called from process_sss, we are possibly working on a second head adjustment, + % this time based on the instantaneous head position, so we need to keep the global adjustment + % based on the entire recording if it is there. - % Check order of transformations. These situations should not happen - % unless there was some manual editing. + if nargin < 2 + sInputs = []; + end + % Check order of transformations. These situations should not happen unless there was some + % manual editing. iDewToNat = find(strcmpi(ChannelMat.TransfMegLabels, 'Dewar=>Native')); iAdjust = find(strcmpi(ChannelMat.TransfMegLabels, 'AdjustedNative')); TransfBefore = []; @@ -724,11 +759,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 +769,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 +792,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 +827,19 @@ % % M = GeoMedian(X, Precision) % - % Calculate the geometric median: the point that minimizes sum of - % Euclidean distances to all points. size(X) = [n, d, ...], where n is - % the number of data points, d is the number of components for each point - % and any additional array dimension is treated as independent sets of - % data and a median is calculated for each element along those dimensions - % sequentially; size(M) = [1, d, ...]. This is an approximate iterative - % procedure that stops once the desired precision is achieved. If - % Precision is not provided, 1e-4 of the max distance from the centroid - % is used. - % - % Weiszfeld's algorithm is used, which is a subgradient algorithm; with - % (Verdi & Zhang 2001)'s modification to avoid non-optimal fixed points - % (if at any iteration the approximation of M equals a data point). - % - % - % (c) Copyright 2018 Marc Lalancette - % The Hospital for Sick Children, Toronto, Canada - % - % 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. + % Calculate the geometric median: the point that minimizes sum of Euclidean distances to all + % points. size(X) = [n, d, ...], where n is the number of data points, d is the number of + % components for each point and any additional array dimension is treated as independent sets of + % data and a median is calculated for each element along those dimensions sequentially; size(M) + % = [1, d, ...]. This is an approximate iterative procedure that stops once the desired + % precision is achieved. If Precision is not provided, 1e-4 of the max distance from the + % centroid is used. % - % This program is distributed WITHOUT ANY WARRANTY. - % See the LICENSE file, or for details. + % 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). % - % 2012-05 + % Marc Lalancette 2012-05 nDims = ndims(X); XSize = size(X); @@ -850,17 +866,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 +882,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 +896,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 +916,174 @@ end % GeoMedian +function [AlignType, isMriUpdated, isMriMatch, 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 = []; + isPrint = nargout == 0; + if any(~isfield(ChannelMat, {'History', 'HeadPoints'})) + % Nothing to check. + return; + end + if nargin < 2 || isempty(sMri) || ~isfield(sMri, 'History') + iMriHist = []; + else + % History string is set in figure_mri SaveMri. + iMriHist = find(strcmpi(sMri.History(:,3), 'Applied digitized anatomical fiducials'), 1, 'last'); + end + % Can also be reset, so check for 'import' action and ignore previous alignments. + iImport = find(strcmpi(ChannelMat.History(:,2), 'import')); + iAlign = find(strcmpi(ChannelMat.History(:,2), 'align')); + iAlign(iAlign < iImport(end)) = []; + if numel(iImport) > 1 + AlignType = 'none/reset'; + else + AlignType = 'none'; + end + while ~isempty(iAlign) + % Check which adjustment was done last. + switch lower(ChannelMat.History{iAlign(end),3}(1:5)) + case 'remov' % ['Removed transform: ' TransfLabel] + % Removed a previous step. Ignore corresponding adjustment and look again. + iAlign(end) = []; + if strncmpi(ChannelMat.History{iAlign(end),3}(20:24), 'AdjustedNative', 5) + iAlignRemoved = find(cellfun(@(c)strcmpi(c(1:5), 'added'), ChannelMat.History(iAlign,3)), 1, 'last'); + elseif strncmpi(ChannelMat.History{iAlign(end),3}(20:24), 'refine registration: head points', 5) + iAlignRemoved = find(cellfun(@(c)strcmpi(c(1:5), 'refin'), ChannelMat.History(iAlign,3)), 1, 'last'); + elseif strncmpi(ChannelMat.History{iAlign(end),3}(20:24), 'manual correction', 5) + iAlignRemoved = find(cellfun(@(c)strcmpi(c(1:5), 'align'), ChannelMat.History(iAlign,3)), 1, 'last'); + else + bst_error('Unrecognized removed transformation in history.'); + end + if isempty(iAlignRemoved) + bst_error('Missing removed transformation in history.'); + else + iAlign(iAlignRemoved) = []; + end + case 'added' % 'Added adjustment to Native coordinates based on median head position' + % This alignment is between points and functional dataset, ignore here. + iAlign(end) = []; + case 'refin' % 'Refining the registration using the head points:' + % Automatic MRI-points alignment + AlignType = 'auto'; + break; + case 'align' % 'Align channels manually:' + % Manual MRI-points alignment + AlignType = 'manual'; + break; + case 'non-l' % 'Non-linear transformation' + AlignType = 'non-linear'; + break; + otherwise + AlignType = 'unrecognized'; + break; + end + end + if isPrint + disp(['BST> Previous registration adjustment: ' AlignType]); + end + if ~isempty(iMriHist) + isMriUpdated = true; + % Compare digitized fids to MRI fids (in MRI coordinates, mm). ChannelMat.SCS fids are NOT + % kept up to date when adjusting registration (manual or auto), so get them from head points + % again. + % Get the three fiducials in the head points + ChannelMat = UpdateChannelMatScs(ChannelMat); + 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.'); + end + else + isMriMatch = true; + if isPrint + disp('BST> MRI fiducials previously updated, and match current digitized fiducials.'); + end + end + end + +end + + +function [DistHead, DistSens, Message] = CheckCurrentAdjustments(ChannelMat, ChannelMatRef) + % Display max displacement from registration adjustments, in command window. + % If second ChannelMat is provided as reference, get displacement between the two. + isPrint = nargout == 0; + if nargin < 2 || isempty(ChannelMatRef) + ChannelMatRef = []; + end + + % Update SCS from head points if present. + ChannelMat = UpdateChannelMatScs(ChannelMat); + + if ~isempty(ChannelMatRef) + ChannelMatRef = UpdateChannelMatScs(ChannelMatRef); + % For head displacement, we use the "rigid distance" from the head motion code, basically + % the max distance of any point on a simplified spherical head. + DistHead = process_evt_head_motion('RigidDistances', ... + [ChannelMat.SCS.NAS(:); ChannelMat.SCS.LPA(:); ChannelMat.SCS.RPA(:)], ... + [ChannelMatRef.SCS.NAS(:); ChannelMatRef.SCS.LPA(:); ChannelMatRef.SCS.RPA(:)]); + DistSens = max(sqrt(sum(([ChannelMat.Channel.Loc] - [ChannelMatRef.Channel.Loc]).^2))); + else + % Implicitly using actual (MRI) SCS as reference, this includes all adjustments. + DistHead = process_evt_head_motion('RigidDistances', ... + [ChannelMat.SCS.NAS(:); ChannelMat.SCS.LPA(:); ChannelMat.SCS.RPA(:)]); + % Get equivalent transform for all adjustments to "undo" on sensors for comparison. The + % adjustments we want come after 'Native=>Brainstorm/CTF' + iNatToScs = find(strcmpi(ChannelMat.TransfMegLabels, 'Native=>Brainstorm/CTF')); + if iNatToScs < numel(ChannelMat.TransfMeg) + Transf = eye(4); + for t = iNatToScs+1:numel(ChannelMat.TransfMeg) + Transf = ChannelMat.TransfMeg{t} * Transf; + end + Loc = [ChannelMat.Channel.Loc]; + % Inverse transf: subtract translation first, then rotate the "other way" (transpose). + LocRef = Transf(1:3,1:3)' * bsxfun(@minus, Loc, Transf(1:3,4)); + DistSens = max(sqrt(sum((Loc - LocRef).^2))); + else + DistSens = 0; + end + end + + Message = sprintf('BST> Max displacement for registration adjustment:\n head: %1.1f mm\n sensors: %1.1f cm\n', ... + DistHead*1000, DistSens*100); + if isPrint + fprintf(Message); + end + +end + + +function ChannelMat = UpdateChannelMatScs(ChannelMat) + if ~isfield(ChannelMat, 'HeadPoints') + return; + end + % Get the three anatomical fiducials in the head points + iNas = find(strcmpi(ChannelMat.HeadPoints.Label, 'Nasion') | strcmpi(ChannelMat.HeadPoints.Label, 'NAS')); + iLpa = find(strcmpi(ChannelMat.HeadPoints.Label, 'Left') | strcmpi(ChannelMat.HeadPoints.Label, 'LPA')); + iRpa = find(strcmpi(ChannelMat.HeadPoints.Label, 'Right') | strcmpi(ChannelMat.HeadPoints.Label, 'RPA')); + if ~isempty(iNas) && ~isempty(iLpa) && ~isempty(iRpa) + ChannelMat.SCS.NAS = mean(ChannelMat.HeadPoints.Loc(:,iNas)', 1); %#ok<*UDIM> + ChannelMat.SCS.LPA = mean(ChannelMat.HeadPoints.Loc(:,iLpa)', 1); + ChannelMat.SCS.RPA = mean(ChannelMat.HeadPoints.Loc(:,iRpa)', 1); + end + % Do the same with head coils, used when exporting coregistration to BIDS + iHpiN = find(strcmpi(ChannelMat.HeadPoints.Label, 'HPI-N')); + iHpiL = find(strcmpi(ChannelMat.HeadPoints.Label, 'HPI-L')); + iHpiR = find(strcmpi(ChannelMat.HeadPoints.Label, 'HPI-R')); + if ~isempty(iHpiN) && ~isempty(iHpiL) && ~isempty(iHpiR) + 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/process/functions/process_evt_head_motion.m b/toolbox/process/functions/process_evt_head_motion.m index 43a7d659c..83ea75748 100644 --- a/toolbox/process/functions/process_evt_head_motion.m +++ b/toolbox/process/functions/process_evt_head_motion.m @@ -467,8 +467,7 @@ if nargin < 3 || isempty(StopThreshold) StopThreshold = false; end - - if size(Locations, 1) ~= 9 || size(Reference, 1) ~= 9 + if size(Locations, 1) ~= 9 %|| size(Reference, 1) ~= 9 bst_error('Expecting 9 HLU channels in first dimension.'); end nS = size(Locations, 2); @@ -476,15 +475,24 @@ % Calculate distances. - Reference = reshape(Reference, [3, 3]); - % Reference "head origin" and inverse "orientation matrix". - [YO, YR] = RigidCoordinates(Reference); - % Sphere radius. - r = max( sqrt(sum((Reference - YO(:, [1, 1, 1])).^2, 1)) ); - if any(YR(:)) % any ignores NaN and returns false for empty. - YI = inv(YR); % Faster to calculate inverse once here than "/" in loop. + if nargin < 2 || isempty(Reference) + % Assume reference defines coordinate system. + YO = zeros(3, 1); + YI = eye(3); + % Use first location to estimate sphere radius. + r = max(sqrt(sum(bsxfun(@minus, reshape(Locations(1:9), [3,3]), ... + (Locations(4:6) + Locations(7:9))/2).^2, 1))); else - YI = YR; + Reference = reshape(Reference, [3, 3]); + % Reference "head origin" and inverse "orientation matrix". + [YO, YR] = RigidCoordinates(Reference); + % Sphere radius, estimate from reference. + r = max( sqrt(sum((Reference - YO(:, [1, 1, 1])).^2, 1)) ); + if any(YR(:)) % any ignores NaN and returns false for empty. + YI = inv(YR); % Faster to calculate inverse once here than "/" in loop. + else + YI = YR; + end end % SinHalf = zeros([nS, 1, nT]); @@ -499,21 +507,8 @@ % it is a rotation around an axis through the real origin). R = XR * YI; % %#ok - % Sine of half the rotation angle. - % SinHalf = sqrt(3 - trace(R)) / 2; - % For very small angles, this formula is not accurate compared to - % w, since diagonal elements are around 1, and eps(1) = 2.2e-16. - % This will be the order of magnitude of non-diag. elements due to - % errors. So we should get SinHalf from w. - % Rotation axis with amplitude = SinHalf (like in rotation quaternions). - w = [R(3, 2) - R(2, 3); R(1, 3) - R(3, 1); R(2, 1) - R(1, 2)] / ... - (2 * sqrt(1 + R(1, 1) + R(2, 2) + R(3, 3))); - SinHalf = sqrt(sum(w.^2)); - TNormSq = sum(T.^2); - % Maximum sphere distance for translation + rotation, as described - % above. - D(s, t) = sqrt( TNormSq + (2 * r * SinHalf)^2 + ... - 4 * r * sqrt(TNormSq * SinHalf^2 - (T' * w)^2) ); + % Maximum sphere distance for translation + rotation, as described above. + D(s, t) = RigidDistTransform(R, T, r); % CHECK should be comparable AND >= to max coil movement. % Option to interrupt when past a distance threshold. @@ -527,6 +522,28 @@ +function D = RigidDistTransform(R, T, rad) + % Maximum sphere distance for translation + rotation, as described above. + if isempty(T) && size(R,1) == 4 + T = R(1:3, 4); + R = R(1:3, 1:3); + end + % Sine of half the rotation angle. + % SinHalf = sqrt(3 - trace(R)) / 2; + % For very small angles, this formula is not accurate compared to w, since diagonal + % elements are around 1, and eps(1) = 2.2e-16. This will be the order of magnitude of + % non-diag. elements due to errors. So we should get SinHalf from w. + % Rotation axis with amplitude = SinHalf (like in rotation quaternions). + w = [R(3, 2) - R(2, 3); R(1, 3) - R(3, 1); R(2, 1) - R(1, 2)] / ... + (2 * sqrt(1 + R(1, 1) + R(2, 2) + R(3, 3))); + SinHalf = sqrt(sum(w.^2)); + TNormSq = sum(T.^2); + + D = sqrt( TNormSq + (2 * rad * SinHalf)^2 + 4 * rad * sqrt(TNormSq * SinHalf^2 - (T' * w)^2) ); +end % RigidDistTransform + + + function [O, R] = RigidCoordinates(FidsColumns) % Convert head coil locations to origin position and rotation matrix. % Works with 9x1 or 3x3 (columns) input. diff --git a/toolbox/process/functions/process_headpoints_refine.m b/toolbox/process/functions/process_headpoints_refine.m index 9cf995a6f..f6e4f6db9 100644 --- a/toolbox/process/functions/process_headpoints_refine.m +++ b/toolbox/process/functions/process_headpoints_refine.m @@ -41,7 +41,7 @@ sProcess.options.title.Comment = [... 'Refine the MEG/MRI registration using digitized head points.
' ... 'If (tolerance > 0): fit the head points, remove the digitized points the most
' ... - 'distant to the scalp surface, and fit again the the head points on the scalp.


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

']; sProcess.options.title.Type = 'label'; % Tolerance sProcess.options.tolerance.Comment = 'Tolerance (outlier points to ignore):'; @@ -61,13 +61,13 @@ % Get options tolerance = sProcess.options.tolerance.Value{1} / 100; % Get all the channel files - uniqueChan = unique({sInputs.ChannelFile}); + [uniqueChan, iUniqFiles] = unique({sInputs.ChannelFile}); % Loop on all the channel files for i = 1:length(uniqueChan) % Refine registration [ChannelMat, R, T, isSkip, isUserCancel, strReport] = channel_align_auto(uniqueChan{i}, [], 0, 0, tolerance); if ~isempty(strReport) - bst_report('Info', sProcess, sInputs, strReport); + bst_report('Info', sProcess, sInputs(iUniqFiles(i)), strReport); end end % Return all the files in input diff --git a/toolbox/sensors/channel_align_auto.m b/toolbox/sensors/channel_align_auto.m index 86671ea89..71d8fcd48 100644 --- a/toolbox/sensors/channel_align_auto.m +++ b/toolbox/sensors/channel_align_auto.m @@ -14,7 +14,7 @@ % % INPUTS: % - ChannelFile : Channel file to align on its anatomy -% - ChannelMat : If specified, do not read or write any information from/to ChannelFile +% - ChannelMat : If specified, do not read or write any information from/to ChannelFile (except to get scalp surface). % - isWarning : If 1, display warning in case of errors (default = 1) % - isConfirm : If 1, ask the user for confirmation before proceeding % - tolerance : Percentage of outliers head points, ignored in the final fit @@ -101,7 +101,7 @@ sSubject = bst_get('Subject', sStudy.BrainStormSubject); if isempty(sSubject) || isempty(sSubject.iScalp) if isWarning - bst_error('No scalp surface available for this subject', 'Align EEG sensors', 0); + bst_error('No scalp surface available for this subject', 'Automatic EEG-MEG/MRI registration', 0); else disp('BST> No scalp surface available for this subject.'); end @@ -162,19 +162,19 @@ %% ===== FIND OPTIMAL FIT ===== % Find best possible rigid transformation (rotation+translation) -[R,T,tmp,dist] = bst_meshfit(SurfaceMat.Vertices, SurfaceMat.Faces, HP); +[R,T,tmp,dist] = bst_meshfit(SurfaceMat.Vertices, SurfaceMat.Faces, HP, tolerance); % Remove outliers and fit again -if ~isempty(dist) && ~isempty(tolerance) && (tolerance > 0) - % Sort points by distance to scalp - [tmp__, iSort] = sort(dist, 1, 'descend'); - iRemove = iSort(1:nRemove); - % Remove from list of destination points - HP(iRemove,:) = []; - % Fit again - [R,T,tmp,dist] = bst_meshfit(SurfaceMat.Vertices, SurfaceMat.Faces, HP); -else - nRemove = 0; -end +% if ~isempty(dist) && ~isempty(tolerance) && (tolerance > 0) +% % Sort points by distance to scalp +% [tmp__, iSort] = sort(dist, 1, 'descend'); +% iRemove = iSort(1:nRemove); +% % Remove from list of destination points +% HP(iRemove,:) = []; +% % Fit again +% [R,T,tmp,dist] = bst_meshfit(SurfaceMat.Vertices, SurfaceMat.Faces, HP); +% else +% nRemove = 0; +% end % Current position cannot be optimized if isempty(R) bst_progress('stop'); @@ -190,24 +190,32 @@ ' | Number of outlier points removed: ' sprintf('%d (%d%%)', nRemove, round(tolerance*100)), 10 ... ' | Initial number of head points: ' num2str(size(HeadPoints.Loc,2))]; +% Create [4,4] transform matrix from digitized SCS to MRI SCS according to this fit. +DigToMriTransf = eye(4); +DigToMriTransf(1:3,1:3) = R; +DigToMriTransf(1:3,4) = T; + %% ===== ROTATE SENSORS AND HEADPOINTS ===== -for i = 1:length(ChannelMat.Channel) - % Rotate and translate location of channel - if ~isempty(ChannelMat.Channel(i).Loc) && ~all(ChannelMat.Channel(i).Loc(:) == 0) - ChannelMat.Channel(i).Loc = R * ChannelMat.Channel(i).Loc + T * ones(1,size(ChannelMat.Channel(i).Loc, 2)); +if ~isequal(DigToMriTransf, eye(4)) + for i = 1:length(ChannelMat.Channel) + % Rotate and translate location of channel + if ~isempty(ChannelMat.Channel(i).Loc) && ~all(ChannelMat.Channel(i).Loc(:) == 0) + ChannelMat.Channel(i).Loc = R * ChannelMat.Channel(i).Loc + T * ones(1,size(ChannelMat.Channel(i).Loc, 2)); + end + % Only rotate normal vector to channel + if ~isempty(ChannelMat.Channel(i).Orient) && ~all(ChannelMat.Channel(i).Orient(:) == 0) + ChannelMat.Channel(i).Orient = R * ChannelMat.Channel(i).Orient; + end end - % Only rotate normal vector to channel - if ~isempty(ChannelMat.Channel(i).Orient) && ~all(ChannelMat.Channel(i).Orient(:) == 0) - ChannelMat.Channel(i).Orient = R * ChannelMat.Channel(i).Orient; + % Rotate and translate head points + if isfield(ChannelMat, 'HeadPoints') && ~isempty(ChannelMat.HeadPoints) && ~isempty(ChannelMat.HeadPoints.Loc) + ChannelMat.HeadPoints.Loc = R * ChannelMat.HeadPoints.Loc + ... + T * ones(1, size(ChannelMat.HeadPoints.Loc, 2)); end end -% Rotate and translate head points -if isfield(ChannelMat, 'HeadPoints') && ~isempty(ChannelMat.HeadPoints) && ~isempty(ChannelMat.HeadPoints.Loc) - ChannelMat.HeadPoints.Loc = R * ChannelMat.HeadPoints.Loc + ... - T * ones(1, size(ChannelMat.HeadPoints.Loc, 2)); -end %% ===== SAVE TRANSFORMATION ===== +% We could decide to skip this if the transformation is identity. % Initialize fields if ~isfield(ChannelMat, 'TransfEeg') || ~iscell(ChannelMat.TransfEeg) ChannelMat.TransfEeg = {}; @@ -221,13 +229,9 @@ if ~isfield(ChannelMat, 'TransfEegLabels') || ~iscell(ChannelMat.TransfEegLabels) || (length(ChannelMat.TransfEeg) ~= length(ChannelMat.TransfEegLabels)) ChannelMat.TransfEegLabels = cell(size(ChannelMat.TransfEeg)); end -% Create [4,4] transform matrix -newtransf = eye(4); -newtransf(1:3,1:3) = R; -newtransf(1:3,4) = T; % Add a rotation/translation to the lists -ChannelMat.TransfMeg{end+1} = newtransf; -ChannelMat.TransfEeg{end+1} = newtransf; +ChannelMat.TransfMeg{end+1} = DigToMriTransf; +ChannelMat.TransfEeg{end+1} = DigToMriTransf; % Add the comments ChannelMat.TransfMegLabels{end+1} = 'refine registration: head points'; ChannelMat.TransfEegLabels{end+1} = 'refine registration: head points'; diff --git a/toolbox/sensors/channel_align_manual.m b/toolbox/sensors/channel_align_manual.m index 8d847b538..77f57e157 100644 --- a/toolbox/sensors/channel_align_manual.m +++ b/toolbox/sensors/channel_align_manual.m @@ -191,7 +191,7 @@ % ===== DISPLAY HEAD POINTS ===== % Display head points -figure_3d('ViewHeadPoints', hFig, 1); +figure_3d('ViewHeadPoints', hFig, 1, 1); % visible and color-coded % Get patch and vertices hHeadPointsMarkers = findobj(hFig, 'Tag', 'HeadPointsMarkers'); hHeadPointsLabels = findobj(hFig, 'Tag', 'HeadPointsLabels'); @@ -204,7 +204,9 @@ HeadPointsHpiLoc = []; if isHeadPoints % More transparency to view points inside. - panel_surface('SetSurfaceTransparency', hFig, 1, 0.5); + panel_surface('SetSurfaceTransparency', hFig, 1, 0.4); + % Hide MEG helmet + set(hHelmetPatch, 'visible', 'off'); % Get markers positions HeadPointsMarkersLoc = get(hHeadPointsMarkers, 'Vertices'); % Hide HeadPoints when looking at EEG and number of EEG channels is the same as headpoints @@ -232,7 +234,7 @@ % ===== DISPLAY MRI FIDUCIALS ===== % Get the fiducials positions defined in the MRI volume -sMri = load(file_fullpath(sSubject.Anatomy(sSubject.iAnatomy).FileName), 'SCS'); +sMri = load(file_fullpath(sSubject.Anatomy(sSubject.iAnatomy).FileName), 'SCS', 'History'); if ~isempty(sMri.SCS.NAS) && ~isempty(sMri.SCS.LPA) && ~isempty(sMri.SCS.RPA) % Convert coordinates MRI => SCS MriFidLoc = [cs_convert(sMri, 'mri', 'scs', sMri.SCS.NAS ./ 1000); ... @@ -406,6 +408,8 @@ bst_progress('stop'); end +% Check and print to command window if previously auto/manual registration, and if MRI fids updated. +process_adjust_coordinates('CheckPrevAdjustments', in_bst_channel(ChannelFile), sMri); end %% ===== MOUSE CALLBACKS ===== @@ -742,6 +746,7 @@ function AlignKeyPress_Callback(hFig, keyEvent) end % Ask if needed to update also the other modalities if isempty(isAll) + % TODO We might have < 10 but still want to update electrodes. Verify instead if there are real EEG (not just ECG EOG) if (gChanAlign.isMeg || gChanAlign.isNirs) && (length(iEeg) > 10) isAll = java_dialog('confirm', 'Do you want to apply the same transformation to the EEG electrodes ?', 'Align sensors'); elseif ~gChanAlign.isMeg && ~isempty(iMeg) @@ -851,41 +856,47 @@ function AlignKeyPress_Callback(hFig, keyEvent) function AlignClose_Callback(varargin) global gChanAlign; if gChanAlign.isChanged + isCancel = false; + % Get new positions + [ChannelMat, Transf, iChannels] = GetCurrentChannelMat(); + % Load original channel file + ChannelMatOrig = in_bst_channel(gChanAlign.ChannelFile); + % Ask user to save changes (only if called as a callback) if (nargin == 3) - SaveChanged = 1; + SaveChanges = 1; else - SaveChanged = java_dialog('confirm', ['The sensors locations changed.' 10 10 ... - 'Would you like to save changes? ' 10 10], 'Align sensors'); + [SaveChanges, isCancel] = java_dialog('confirm', ['The sensors locations changed.' 10 10 ... + 'Would you like to save changes? ' 10 10], 'Align sensors'); + end + % Don't close figure if cancelled. + if isCancel + return; + end + % Report (in command window) max head and sensor displacements from changes. + if SaveChanges || gChanAlign.isHeadPoints + process_adjust_coordinates('CheckCurrentAdjustments', ChannelMat, ChannelMatOrig); end % Save changes to channel file and close figure - if SaveChanged + if SaveChanges % Progress bar bst_progress('start', 'Align sensors', 'Updating channel file...'); % Restore standard close callback for 3DViz figures set(gChanAlign.hFig, 'CloseRequestFcn', gChanAlign.Figure3DCloseRequest_Bak); drawnow; - % Get new positions - [ChannelMat, Transf, iChannels] = GetCurrentChannelMat(); - % Load original channel file - ChannelMatOrig = in_bst_channel(gChanAlign.ChannelFile); % Save new electrodes positions in ChannelFile bst_save(gChanAlign.ChannelFile, ChannelMat, 'v7'); % Get study associated with channel file [sStudy, iStudy] = bst_get('ChannelFile', gChanAlign.ChannelFile); % Reload study file db_reload_studies(iStudy); + % Apply to other recordings with same sensor locations in the same subject + CopyToOtherFolders(ChannelMatOrig, iStudy, Transf, iChannels); bst_progress('stop'); end - else - SaveChanged = 0; end % Only close figure gChanAlign.Figure3DCloseRequest_Bak(varargin{1:2}); - % Apply to other recordings with same sensor locations in the same subject - if SaveChanged - CopyToOtherFolders(ChannelMatOrig, iStudy, Transf, iChannels); - end end diff --git a/toolbox/sensors/channel_align_scs.m b/toolbox/sensors/channel_align_scs.m new file mode 100644 index 000000000..450cc3d32 --- /dev/null +++ b/toolbox/sensors/channel_align_scs.m @@ -0,0 +1,231 @@ +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 + 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