function [nbrhood,vo,fo,out2in]=cosmo_surficial_neighborhood(ds, surfs, varargin)
% neighborhood definition for surface-based searchlight
%
% [nbrhood,vo,fo,out2in]=cosmo_surficial_neighborhood(ds, surfs, ...)
%
% Inputs:
% ds fMRI-like volumetric or surface-based dataset
% surfs cell with specification of surfaces. The following
% formats are valid options:
% # Volumetric datasets:
% * Single surface (Caret, BrainVoyager)
% - {fn, [start,stop]}
% - {fn, [start,stop], niter}
% - {v,f,[start,stop], niter}
% where
% + fn is the filename of a single surface
% + start and stop are distances towards and away
% from the center of a hemisphere. For example,
% if surface coordinates are in mm (which is
% typical), start=-3 and stop=2 means selecting
% voxels that are 3 mm or closer to the surface
% on the white-matter side, up to voxels that are
% 2 mm from the surface on the pial matter side
% + niter is the number of subsampling (mesh
% decimation) iterations to use for the output
% surface.
% + v are Px3 coordinates for P nodes
% + f are Qx3 node indices for Q faces
% * Twin surface (FreeSurfer)
% - {fn1, fn2}
% - {fn1, fn2, fn_o}
% - {fn1, fn2, niter}
% - {v1,v2,f}
% - {v1,v2,f,v_o,f_o}
% - {v1,v2,f,niter}
% where
% + fn{1,2} are filenames for pial and white
% surfaces
% + fn_o is the filename of an intermediate
% (node-wise average of a pial and white
% surface) output surface, and can be of a lower
% resolution than the pial and white surfaces.
% This can be achieved by using MapIcosahedron
% to generate meshes of different densities, e.g.
% ld=64 for the pial and white surface and ld=16
% for the intermediate surface. It is required
% that for every node in the intermediate surface
% there is a corresponding node (at the same
% spatial location) on the node-wise average of
% the pial and white surface.
% + v{1,2} are Px3 coordinates for P nodes of the
% pial and white surfaces
% + f are Qx3 node indices for Q faces on the pial
% and white surfaces
% + v_o and f_o are the nodes and vertices of an
% intermediate output surface. It is required
% that the vertices in v_o are a subset of the
% pair-wise averages of vertices in v1 and v2. A
% typical use case is using standard topologies
% from AFNI's MapIcosahedron, where the v1 and v2
% are high-res with X linear divisions, v_o is
% low-res with Y linear divisions, and Y<X and X
% is an integer multiple of Y.
% + niter is the number of subsampling (mesh
% decimation) iterations to use for the output
% surface.
% # Surface-based datasets:
% - {fn}
% - {fn, niter}
% - {v, f, niter}
% select neighbors:
% 'radius', r } either within radius r (usually in mm), grow
% 'count', c } the radius to get the nearest c neighbors,
% 'area', a } grow the radius to get neighbors whose area size
% } is less than or equal area a (usually in mm^2)
% 'direct', true } or get direct neighbors only (nodes who share an
% } edge)
% These four options are mutually exclusive
% 'metric',metric distance metric along cortical surface. One of
% 'geodesic' (default), 'dijkstra' or 'euclidean'.
% 'line_def',line_def definition of lines from inner to outer surface
% See surfing_nodeidxs2coords
% 'progress', p Show progress every p centers (default: 100)
% 'vol_def', v Volume definition with fields .mat (4x4 affine
% voxel-to-world matrix) and .dim (1x3 number of
% voxels in each dimension). If omittied, voldef is
% based on ds
% 'subsample_min_ratio', r When niter is provided and positive, it
% defines the minimum ratio between surfaces
% of old and new faces. This should help in
% preventing the generation of triangles with
% angles close to 180 degrees (default: 0.2).
% 'center_ids', c Center ids in output surface of the nodes for
% which the neighborhood should be computed. Empty
% means all nodes are used (default: [])
%
% Outputs:
% nbrhood neighborhood struct with fields
% .neighbors Mx1 cell with .neighbors{k} the
% indices of features (relative to ds)
% in the neighborhood of node k
% .a.fdim.labels set to {'node_indices'}
% .a.fdim.values set to {center_ids} with center_ids
% Mx1 ids of each neighborhood
% .fa.node_indices identical to center_ids
% .fa.radius } if ds is a surface-based dataset,
% .fa.area } these fields are present and contain
% } the radius and total surface area of
% the selected nodes
% v_o NVx3 coordinates for N nodes of the output surface
% f_o NFx3 node indices for Q faces of the output surface
% out2in Node mapping from output to input surface.
%
% Example:
% % this example uses BrainVoyager Surfaces
% anat_fn='SUB02_MPRAGE_ISO_IIHC_TAL.vmr';
% surf_fn='SUB02_MPRAGE_ISO_IIHC_TAL_LH_RECOSM.srf';
%
% % read dataset and surface
% ds=cosmo_fmri_dataset(anat_fn,'mask',anat_fn);
% fprintf('Dataset has %d samples and %d features\n',size(ds.samples));
%
% [v,f]=surfing_read(surf_fn);
% fprintf('Input surface has %d nodes and %d faces\n',size(v,1),size(f,1))
%
% % define neighborhood parameters
% count=100; % 100 voxels per searchlight
% niter=10; % 10 mesh decimation algorithms
%
% % surface definition; voxels are selected that are 3 mm or closer to the
% % surface on the white-matter side, up to voxels that are 1 mm from the
% % surface on the pial matter side.
%
% % Note: for FreeSurfer surfaces one could use
% % {surf_white_fn, surf_pial_fn [,niter]}
% surfs={surf_fn,[-3 1],niter};
% [nbrhood,vo,fo]=cosmo_surficial_neighborhood(ds,surfs,'radius',radius);
% fprintf('Neighborhood has %d elements\n', numel(nbrhood.neighbors))
% fprintf('Output surface has %d nodes and %d faces\n',size(vo,1),size(fo,1))
%
% % write decimated (smaller) output surface in ASCII format
% surfing_write(['small_surf.asc'],vo,fo);
%
% % define a measure that counts the number of features in each searchlight
% % (in more practical applications the measure could be, for example,
% % cosmo_correlation_measure or cosmo_crossvalidation_measure)
% voxel_counter=@(x,opt) cosmo_structjoin('samples',size(x.samples,2));
%
% % run a searchlight
% res=cosmo_searchlight(ds,voxel_counter,'nbrhood',nbrhood);
% fprintf('Searchlights have mean %.3f +/- %.3f features\n',...
% mean(res.samples), std(res.samples));
%
%
% % store surface dataset (in AFNI/SUMA NIML format) with the number of
% % features (voxels) for each center node of the output surface
% s=struct();
% s.node_indices=res.a.fdim.values{1}(res.fa.node_indices);
% s.data=res.samples';
% surfing_write('voxcount.niml.dset',s);
%
% % store volume dataset (in NIFTI format) with the number of times each
% % voxel was selected by a searchlight
% vol=ds;
% vol.samples(:)=0;
% for k=1:numel(nbrhood.neighbors);
% nbrs=nbrhood.neighbors{k};
% vol.samples(nbrs)=vol.samples(nbrs)+1;
% end
% cosmo_map2fmri(vol,'voxcount.nii');
% cosmo_map2fmri(ds,'anat.nii');
%
% Notes
% - Higher values of niter, or using a less dense mesh for fn_o or
% (v_o, f_o) means that the output surface has fewer nodes, which will
% decrease execution time for searchlights.
% - This function requires the surfing toolbox, surfing.sourceforge.net or
% github.com/nno/surfing
%
% See also: surfing, surfing_nodeidxs2coords, cosmo_searchlight
%
% # For CoSMoMVPA's copyright information and license terms, #
% # see the COPYING file distributed with CoSMoMVPA. #
cosmo_check_external('surfing');
check_input(surfs, varargin{:});
defaults=struct();
defaults.metric='geodesic';
defaults.line_def=[10 0 1];
defaults.progress=100;
defaults.vol_def=[];
defaults.subsample_min_ratio=.2;
defaults.center_ids=[];
opt=cosmo_structjoin(defaults,varargin{:});
ds_type=get_ds_type(ds);
if strcmp(opt.metric,'geodesic') && ~isfield(opt,'direct')
cosmo_check_external('fast_marching');
end
% get surfaces
[v1,v2,f,vo,fo]=parse_surfs(surfs, ds_type);
ds_is_surface=strcmp(ds_type, 'surface');
one_surface=numel(v2)==2 || (ds_is_surface && isempty(v2));
% define intermediate high-res surface
if one_surface
vi=v1;
else
vi=(v1+v2)*.5;
end
switch numel(vo)
case 0
% no low-res surface, so use intermediate surface as source
vo=vi;
fo=f;
case 1
% subsample the surface
[vo,fo]=surfing_subsample_surface(vi,f,vo,...
opt.subsample_min_ratio, opt.progress);
end
% find mapping from low to high res surface
% if they are identical then low2high is the identity
out2in=surfing_maplow2hires(vo', vi');
% set center ids
center_ids=opt.center_ids(:)'; % ensure row vector
if isempty(opt.center_ids)
center_ids=1:size(vo,1);
else
out2in=out2in(center_ids);
end
% either surface or volumetric input dataset
ds_is_surface=cosmo_check_dataset(ds,'surface',false);
ds_is_volume=cosmo_check_dataset(ds,'fmri',false);
if sum([ds_is_surface, ds_is_volume])~=1
error('Unsupported dataset: expected surface or fmri dataset');
end
if ds_is_surface
% todo: use out2in to allow for subset of node indices as center
% (the current implementation computes neighborhoods for all
% nodes)
if ~isequal(center_ids,1:size(vo,1))
error('Unsupported center_ids option');
end
if ~isequal(out2in',1:numel(out2in))
%error('Unsupported other output surface than input surface');
end
nbrhood=surface_to_surface_neighborhood(ds,vi,f,opt);
elseif ds_is_volume
% get circle definition
circle_def=get_circle_def(opt);
% set volume definition
if isempty(opt.vol_def)
vol_def=ds.a.vol;
else
vol_def=opt.vol_def;
end
% define voxel mask based on features in dataset
ds_one=cosmo_slice(ds,1); % arbitrary sample
ds_one.samples(:)=1; % all set to one
ds_unflat=cosmo_unflatten(ds_one); % missing features have value of zero
vol_def.mask=ds_unflat~=0; % define mask
n2v=surfing_voxelselection(v1',v2',f',circle_def,vol_def,...
out2in,opt.line_def,...
opt.metric,0+opt.progress);
ncenters=numel(center_ids);
assert(ncenters==numel(n2v));
% determine unique center ids used
[unq_center_ids,unused,center_ids_mapping]=unique(center_ids);
% find mapping from features
nf_full=numel(vol_def.mask);
nf_mask=sum(vol_def.mask(:));
all2msk_indices=zeros(1,nf_full);
all2msk_indices(vol_def.mask)=1:nf_mask;
% store neighborhood information
nbrhood=struct();
nbrhood.a.fdim.labels={'node_indices'};
nbrhood.a.fdim.values={unq_center_ids};
nbrhood.fa.node_indices=center_ids_mapping(:)';
% set neighbors while considering the masked nature of the input dataset
neighbors=cell(ncenters,1);
for k=1:ncenters
msk_indices=all2msk_indices(double(n2v{k}));
assert(all(msk_indices>0));
neighbors{k}=msk_indices;
end
nbrhood.neighbors=neighbors;
else
assert(false,'this should never happen');
end
% compatibility wrapper for the surfing toolbox
nbrhood=ensure_neighbors_row_vectors(nbrhood);
origin=struct();
origin.a=ds.a;
origin.fa=ds.fa;
nbrhood.origin=origin;
cosmo_check_neighborhood(nbrhood,ds);
function nbrhood=surface_to_surface_neighborhood(ds,vertices,faces,opt)
circle_def=get_circle_def(opt);
dim_label='node_indices';
[two,fdim_index,attr_name,dim_name]=cosmo_dim_find(ds,...
dim_label,true);
fdim_nodes=ds.a.(dim_name).values{fdim_index};
fa_indices=ds.(attr_name).(dim_label);
if ~isequal(sort(fdim_nodes), unique(fdim_nodes))
error('values in .a.%s.values{%d} are not all unique',...
dim_name, fdim_index);
end
% - unq_nodes contains the node index associated with each unique
% node_indices feature in the dataset
[fa_idxs,unq_fa_indices]=cosmo_index_unique(fa_indices');
unq_nodes=fdim_nodes(unq_fa_indices);
nvertices=size(vertices,1);
too_large_index=find(unq_nodes>nvertices,1);
if any(too_large_index)
error(['surface has %d vertices, but maximum .fa.%s '...
'is %d'],...
nvertices,dim_label,fa_node_ids(too_large_index));
end
ignore_vertices_msk=true(nvertices,1);
ignore_vertices_msk(unq_nodes)=false;
vertices(ignore_vertices_msk,:)=NaN;
% set mapping from nodes to feature ids
ncenters=numel(unq_fa_indices);
node2feature_ids=cell(1,nvertices);
for k=1:ncenters
node=unq_nodes(k);
if ignore_vertices_msk(node)
feature_ids=cell(1,0);
else
feature_ids=fa_idxs{k};
end
node2feature_ids{node}=feature_ids;
end
% run node selection
[n2ns,radii]=surfing_nodeselection(vertices',faces',circle_def,...
opt.metric,opt.progress);
% set output
nbrhood=struct();
nbrhood.a.fdim.labels={'node_indices'};
nbrhood.a.fdim.values={fdim_nodes};
nbrhood.neighbors=cell(ncenters,1);
nbrhood.fa.radius=zeros(1,ncenters);
nbrhood.fa.node_indices=zeros(1,ncenters);
% add area fieldname
node2area=surfing_surfacearea(vertices,faces);
nbrhood.fa.area=zeros(1,ncenters);
for k=1:ncenters
center_node=unq_nodes(k);
nbrhood.fa.node_indices(k)=unq_fa_indices(k);
nbrhood.fa.radius(k)=radii(center_node);
nbrhood.fa.area(k)=sum(node2area(n2ns{center_node}));
if ignore_vertices_msk(center_node)
around_feature_ids=zeros(1,0);
else
around_nodes=n2ns{center_node};
around_feature_ids=cat(1,node2feature_ids{around_nodes});
end
nbrhood.neighbors{k}=around_feature_ids(:)';
end
function nbrhood=ensure_neighbors_row_vectors(nbrhood)
% compatibility wrapper for the surfing toolbox
for k=1:numel(nbrhood.neighbors)
if size(nbrhood.neighbors{k},1)~=1
nbrhood.neighbors{k}=nbrhood.neighbors{k}';
end
end
function [v1,v2,f,vo,fo]=parse_surfs(surfs, ds_type)
% helper function to get surfaces
ds_is_surface=strcmp(ds_type,'surface');
[v1,v2,f,vo,fo]=parse_surfs_arguments(ds_is_surface, surfs);
if numel(v2)~=2 && (numel(f)==2 || isempty(f))
% swap position
temp=f;
f=v2;
v2=temp;
end
check_surf_arguments(ds_is_surface,v1,v2,f,vo,fo);
function check_surf_arguments(ds_is_surface,v1,v2,f,vo,fo)
if isempty(v1) || (isempty(v2) && ~ds_is_surface) || isempty(f)
error('Not enough arguments for surfaces and topology');
end
% c2 can be vector with two elements indicating the size of the curved
% cylinder with a circle as basis; if not, it should be a surface with
% the same size as c1
one_surface=numel(v2)==2;
if ~(one_surface || ds_is_surface || isequal(size(v1),size(v2)))
error('Size mismatch between surfaces: %dx%d != %dx%d',...
size(v1,1), size(v1,2),...
size(v2,1), size(v2,2));
end
surfing_check_surface(v1,f);
if ~(one_surface || ds_is_surface)
surfing_check_surface(v2,f);
end
if isempty(fo)
if numel(vo)>1
% if not a scaler (niter) then throw an error
error('Topology missing for output surface');
end
else
surfing_check_surface(vo,fo);
end
function [v1,v2,f,vo,fo]=parse_surfs_arguments(ds_is_surface,surfs)
if ~iscell(surfs)
error('surfs argument must be a cell');
end
n=numel(surfs);
% space for output
v1=[];
v2=[];
f=[];
vo=[];
fo=[];
for k=1:n
s=surfs{k};
if ischar(s)
% filename; read the surface
[c,f_]=surfing_read(s);
if isempty(v1)
v1=c;
f=f_;
elseif isempty(v2) && ~ds_is_surface
if ~isequal(f_,f)
error('Topology mismatch between the two surfaces');
end
v2=c;
elseif isempty(vo)
vo=c;
fo=f_;
else
error('Superfluous argument at position %d', k);
end
elseif isnumeric(s)
% numeric array; coordinates or faces
if isempty(v1)
v1=s;
elseif isempty(v2)
v2=s;
elseif isempty(f)
f=s;
elseif isempty(vo)
vo=s;
elseif isempty(fo)
fo=s;
else
error('Superfluous argument at position %d', k);
end
else
error('Expected surface filename or array at position %d', k);
end
end
function ds_type=get_ds_type(ds)
supported_ds_types={'surface','fmri'};
n=numel(supported_ds_types);
for k=1:n
supported_ds_type=supported_ds_types{k};
if cosmo_check_dataset(ds,supported_ds_type,false)
ds_type=supported_ds_type;
return
end
end
% maybe it is not a dataset at all, check this possibility
cosmo_check_dataset(ds);
% it is a valid dataset but not fmri or surface;
% try to give an appropriate error message
for k=1:n
supported_ds_type=supported_ds_types{k};
if is_ds_type_like(ds, supported_ds_type)
% let it throw an error
cosmo_check_dataset(ds,supported_ds_type);
end
end
error('Unknown dataset type, supported are: %s.', ...
cosmo_strjoin(supported_ds_types,', '));
function tf=is_ds_type_like(ds, type)
% helper function that uses heuristics to find type of dataset
tf=false;
switch type
case 'surface'
if cosmo_isfield(ds,'fa.node_indices')
tf=true;
return;
end
if cosmo_isfield(ds,'a.fdim.values') && ...
isequal({'node_indices'},ds.a.fdim.values)
tf=true;
return;
end
case 'fmri'
if any(cosmo_isfield(ds,{'fa.i','fa.j','fa.k'}))
tf=true;
return;
end
if cosmo_isfield(ds,'a.fdim.values') && ...
any(cosmo_match({'i','j','k'},ds.a.fdim.values))
tf=true;
return
end
otherwise
error('unsupported type %s', type);
end
function check_input(surfs, varargin)
% give deprecation notice
if isnumeric(surfs)
error(['Second argument must be a cell with a surface '...
'definition (as of Jan 2015, the syntax for this '...
'function has changed)']);
end
function circle_def=get_circle_def(opt)
metric2circle_def_func=struct();
metric2circle_def_func.radius=@(x)x;
metric2circle_def_func.count=@(x)[0 x]; % fixed initial radius
metric2circle_def_func.area=@(x)[5 Inf x]; % default initial radius
metric2circle_def_func.direct=@get_direct_circle_def;
% options are mutually exclusive
metrics=fieldnames(metric2circle_def_func);
metric_msk=cosmo_isfield(opt,metrics);
if sum(metric_msk)~=1
error('Use one of these arguments to define neighbors: %s',...
cosmo_strjoin(metrics, ', '));
end
metric=metrics{metric_msk};
func=metric2circle_def_func.(metric);
param=opt.(metric);
if ~isscalar(param) || param<0
error('value for ''%s'' must be a scalar not less than zero',...
metric);
end
circle_def=func(opt.(metric));
function circle_def=get_direct_circle_def(radius)
% false or 0 give a zero radius, otherwise NaN
if isnan(radius) || radius
circle_def=NaN;
else
circle_def=[5 1];
end