Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
bc3487f
Add fmri_surface_data object for CIFTI/GIFTI surface & grayordinate data
torwager Jun 26, 2026
8afa4cf
Add fmri_surface_data docs, workflow how-to, and a CIFTI sample map
torwager Jul 4, 2026
f178d11
Review pass: make fmri_surface_data examples runnable and docs accurate
torwager Jul 8, 2026
6f72e90
fmri_surface_data: mask volume-only methods; redirect orthviews/monta…
torwager Jul 9, 2026
4dd32b2
fmri_surface_data: rename property intent -> imagetype; label_table n…
torwager Jul 9, 2026
1d0a050
fmri_surface_data: harmonize surface() colours with the volume visual…
torwager Jul 9, 2026
1827bb8
Integrate fmri_surface_data into the stateful fmridisplay (surface-na…
torwager Jul 9, 2026
1daf4a1
Surface viz: surface() returns a managed fmridisplay and renders unde…
torwager Jul 10, 2026
fb67b57
Tests: cover managed surface() return, hemisphere correctness, space-…
torwager Jul 10, 2026
3369c43
Docs: document surface() managed return, set_colormap workflow, space…
torwager Jul 10, 2026
b4126d5
Surface viz: make the managed surface layer fully stateful (controlle…
torwager Jul 10, 2026
7f64803
Surface viz: render subcortical grayordinates for mixed objects (surf…
torwager Jul 10, 2026
3c28fd2
Tests + docs: managed surface layer statefulness and subcortical rend…
torwager Jul 10, 2026
8de90f0
Surface viz: strip view-selectors before render_blobs; keep the contr…
torwager Jul 10, 2026
feb6ec2
Colormap: collapse a 4-element split cmaprange to full span for singl…
torwager Jul 10, 2026
a5a6ea2
load_image_set: add surface/volumetric map sets and a categorized 'list'
torwager Jul 11, 2026
79461c6
load_atlas: register Tian subcortical scale atlases and add a categor…
torwager Jul 11, 2026
db122a6
Docs + tests for the load_image_set / load_atlas registry updates
torwager Jul 11, 2026
0250cfc
Test: skip canlab_test_load_registries under GitHub Actions
torwager Jul 11, 2026
213cc4e
Test: include canlab_test_load_registries in the GitHub Actions suite
torwager Jul 11, 2026
e6f47ad
Docs: make explicit that vol2surf/surf2vol ARE the CBIG registration-…
torwager Jul 12, 2026
597a6d2
Add resample_surface: native surface-to-surface resampling for fmri_s…
torwager Jul 12, 2026
57ea273
Add onavg (OpenNeuro Average) as a resample_surface template space
torwager Jul 12, 2026
5db9bd3
Surface viz: auto-resample surface data onto a mismatched standard mesh
torwager Jul 12, 2026
bae10f9
Surface viz: render surface data onto ARBITRARY MNI isosurfaces via v…
torwager Jul 12, 2026
610030f
Surface viz: 'unique'/'solid' color modes + a single source of truth …
torwager Jul 12, 2026
6ea9987
render_blobs: ignore 'unique'/'solid' color-mode flags; controller bg…
torwager Jul 12, 2026
b660f23
fmridisplay controller: never change the current figure (montage axes…
torwager Jul 12, 2026
c2fc3da
fmridisplay controller: working 'unique (per region)' colormap dropdo…
torwager Jul 12, 2026
92f1d30
region/surface: 'unique'/'solid' colour modes + auto unique for atlases
torwager Jul 12, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 67 additions & 0 deletions CanlabCore/@fmri_surface_data/apply_mask.m
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
function obj = apply_mask(obj, mask, varargin)
% apply_mask Mask a grayordinate object by zeroing out-of-mask grayordinates.
%
% :Usage:
% ::
% obj = apply_mask(obj, mask)
% obj = apply_mask(obj) % uses obj.mask if it is set
% obj = apply_mask(obj, mask, 'invert')
%
% Surface analogue of fmri_data.apply_mask, greatly simplified per design
% decision D5b: because all objects share a standardized grayordinate space,
% masking is intrinsic -- there is no fmri_mask_image, no resampling, and no
% empty-removal. Out-of-mask grayordinate rows are simply set to 0; .dat keeps
% its full size and the geometry is unchanged.
%
% :Inputs:
% **obj:** an fmri_surface_data object.
% **mask:** (optional) one of
% - logical/numeric vector [nGrayordinates x 1] (nonzero = keep)
% - another fmri_surface_data on the same space (nonzero/non-NaN = keep)
% If omitted (or empty), the object's own .mask property is used.
%
% :Optional Inputs:
% **'invert':** keep the complement (zero the in-mask grayordinates instead).
%
% :Outputs:
% **obj:** masked object (out-of-mask rows zeroed).
%
% :See also: fmri_surface_data, compare_space

invert = any(strcmpi(varargin, 'invert'));

% Fall back to the object's .mask property when no mask is passed
if nargin < 2 || isempty(mask)
if isempty(obj.mask)
error('fmri_surface_data:apply_mask:nomask', ...
['No mask supplied and obj.mask is empty. Pass a [nGrayordinates x 1] ' ...
'logical/numeric vector or an fmri_surface_data mask, or set obj.mask.']);
end
mask = obj.mask;
end

if isa(mask, 'fmri_surface_data')
if compare_space(obj, mask) ~= 0
error('fmri_surface_data:apply_mask:space', ...
['Mask is not on the same grayordinate space as the data ' ...
'(compare_space ~= 0). Resample first.']);
end
keep = any(mask.dat ~= 0 & ~isnan(mask.dat), 2);
elseif islogical(mask) || isnumeric(mask)
keep = logical(mask(:));
if numel(keep) ~= size(obj.dat, 1)
error('fmri_surface_data:apply_mask:length', ...
'Mask vector length (%d) must equal the number of grayordinates (%d).', ...
numel(keep), size(obj.dat, 1));
end
else
error('fmri_surface_data:apply_mask:type', ...
'mask must be a logical/numeric vector or an fmri_surface_data object.');
end

if invert, keep = ~keep; end

obj.dat(~keep, :) = 0;
obj.history{end+1} = sprintf('apply_mask: kept %d / %d grayordinates (zeroed the rest)', ...
nnz(keep), numel(keep));
end
137 changes: 137 additions & 0 deletions CanlabCore/@fmri_surface_data/apply_parcellation.m
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
function [parcel_means, parcel_labels, parcel_table] = apply_parcellation(obj, parcels, varargin)
% apply_parcellation Average grayordinate data within parcels of a surface atlas.
%
% :Usage:
% ::
% parcel_means = apply_parcellation(obj, parcels)
% [parcel_means, labels, tbl] = apply_parcellation(obj, parcels, 'area')
%
% Computes the mean of each map over the grayordinates in each parcel of a
% surface/grayordinate parcellation, mirroring image_vector.apply_parcellation.
% Parcels with key 0 (and NaN) are treated as background / medial wall and are
% excluded. Operates directly on .dat (no resampling needed when on the same
% space).
%
% :Inputs:
% **obj:** an fmri_surface_data object ([nGrayordinates x nMaps]).
% **parcels:** the parcellation, one of
% - an fmri_surface_data with integer label keys (e.g. a `.dlabel`),
% on the SAME grayordinate space (compare_space == 0); its label_table
% provides parcel names.
% - an integer vector [nGrayordinates x 1] of label keys.
%
% :Optional Inputs:
% **'area':** area-weight the average using per-vertex surface area (cortex)
% instead of an unweighted mean. (Subcortical voxels use unit weight.)
%
% :Outputs:
% **parcel_means:** [nMaps x nParcels] mean value per parcel.
% **parcel_labels:** 1 x nParcels cell of parcel names (or 'parcel_<key>').
% **parcel_table:** table with columns key, label, n_grayordinates, (area).
%
% :Examples:
% ::
% atl = fmri_surface_data(which('Gordon333.32k_fs_LR_Tian_Subcortex_S2.dlabel.nii'));
% s = fmri_surface_data(which('transcriptomic_gradients.dscalar.nii'));
% pm = apply_parcellation(s, atl); % [nMaps x nParcels]
%
% :See also: fmri_surface_data, reparse_contiguous, surface_region, condf2indic

use_area = any(strcmpi(varargin, 'area'));

% ---- Resolve parcel keys + names ----
names_by_key = containers.Map('KeyType', 'double', 'ValueType', 'char');
if isa(parcels, 'fmri_surface_data')
if compare_space(obj, parcels) ~= 0
error('fmri_surface_data:apply_parcellation:space', ...
'Parcellation is not on the same grayordinate space (compare_space ~= 0).');
end
keys = round(double(parcels.dat(:, 1)));
lt = labeltable2struct(parcels.label_table); % accepts a MATLAB table or struct array
for i = 1:numel(lt)
names_by_key(lt(i).key) = lt(i).name;
end
elseif isnumeric(parcels)
keys = round(double(parcels(:)));
if numel(keys) ~= size(obj.dat, 1)
error('fmri_surface_data:apply_parcellation:length', ...
'Parcel key vector length (%d) must equal the number of grayordinates (%d).', ...
numel(keys), size(obj.dat,1));
end
else
error('fmri_surface_data:apply_parcellation:type', ...
'parcels must be an fmri_surface_data or an integer vector.');
end

% ---- Indicator matrix over positive keys (exclude 0 / NaN) ----
ukeys = unique(keys(keys > 0 & ~isnan(keys)));
nP = numel(ukeys);
if nP == 0
error('fmri_surface_data:apply_parcellation:noparcels', 'No positive parcel keys found.');
end
indic = double(keys == ukeys'); % [nGray x nP] 0/1

% ---- Per-grayordinate weights ----
if use_area
w = local_vertex_areas(obj); % [nGray x 1]
else
w = ones(size(obj.dat, 1), 1);
end

D = double(obj.dat); % [nGray x nMaps]
wsum = (w' * indic); % [1 x nP] total weight per parcel
sums = (D .* w)' * indic; % [nMaps x nP]
parcel_means = sums ./ wsum; % [nMaps x nP]

% ---- Labels + table ----
parcel_labels = cell(1, nP);
counts = sum(indic, 1); % grayordinates per parcel
for i = 1:nP
if names_by_key.isKey(ukeys(i)) && ~isempty(names_by_key(ukeys(i)))
parcel_labels{i} = names_by_key(ukeys(i));
else
parcel_labels{i} = sprintf('parcel_%d', ukeys(i));
end
end

if nargout >= 3
parcel_table = table(ukeys(:), parcel_labels(:), counts(:), wsum(:), ...
'VariableNames', {'key', 'label', 'n_grayordinates', 'total_weight'});
end
end


% -------------------------------------------------------------------------
function w = local_vertex_areas(obj)
% Per-grayordinate weight = barycentric vertex area on the cortical mesh
% (1/3 of incident face areas); subcortical voxels get unit weight.
w = ones(size(obj.dat, 1), 1);
for mi = 1:numel(obj.brain_model.models)
m = obj.brain_model.models{mi};
rows = (m.start:(m.start + m.count - 1))';
if ~strcmp(m.type, 'surf'), continue; end
[V, F] = local_hemi_VF(obj.surface_space, m.struct);
va = local_face_vertex_area(V, F); % [numvert x 1]
w(rows) = va(m.vertlist + 1);
end
end


function [V, F] = local_hemi_VF(space, structname)
% Prefer midthickness (true areas); fall back to inflated if unavailable.
try
g = load_surface_geom(space, 'midthickness');
catch
g = load_surface_geom(space, 'inflated');
end
if contains(upper(structname), 'LEFT'), V = g.vertices_lh; F = g.faces_lh;
else, V = g.vertices_rh; F = g.faces_rh; end
end


function va = local_face_vertex_area(V, F)
% Barycentric per-vertex area: each vertex gets 1/3 of each incident face's area.
v1 = V(F(:,1), :); v2 = V(F(:,2), :); v3 = V(F(:,3), :);
fa = 0.5 * sqrt(sum(cross(v2 - v1, v3 - v1, 2).^2, 2)); % [nFaces x 1]
va = accumarray(F(:), repmat(fa, 3, 1) / 3, [size(V,1) 1]);
end
74 changes: 74 additions & 0 deletions CanlabCore/@fmri_surface_data/cat.m
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
function obj = cat(obj, varargin)
% cat Concatenate fmri_surface_data objects along the image (map) dimension.
%
% :Usage:
% ::
% combined = cat(obj1, obj2, obj3, ...)
%
% Horizontally concatenates the [grayordinates x maps] data of two or more
% fmri_surface_data objects (e.g. combining subjects/contrasts into one dataset),
% along with their per-map annotations (X, Y, covariates, image_names,
% metadata_table). All objects must share the same grayordinate space
% (compare_space == 0); otherwise an error is raised. Surface analogue of
% fmri_data.cat. No replace_empty step is needed (.dat is always full, D5b).
%
% :Inputs:
% **obj, varargin:** two or more fmri_surface_data objects on the same space.
%
% :Outputs:
% **obj:** a single fmri_surface_data with all maps concatenated.
%
% :Examples:
% ::
% all_subs = cat(sub1, sub2, sub3);
% t = ttest(all_subs);
%
% :See also: horzcat, compare_space, fmri_surface_data

for i = 1:numel(varargin)
o2 = varargin{i};
if ~isa(o2, 'fmri_surface_data')
error('fmri_surface_data:cat:type', 'All inputs must be fmri_surface_data objects.');
end
if compare_space(obj, o2) ~= 0
error('fmri_surface_data:cat:space', ...
['Objects are not on the same grayordinate space (compare_space ~= 0). ' ...
'Resample to a common space before concatenating.']);
end

obj.dat = [obj.dat, o2.dat];

obj.X = local_vcat(obj.X, o2.X);
obj.Y = local_vcat(obj.Y, o2.Y);
obj.covariates = local_vcat(obj.covariates, o2.covariates);
obj.image_names = local_catcell(obj.image_names, o2.image_names);
obj.metadata_table = local_cattable(obj.metadata_table, o2.metadata_table);
if ~isempty(obj.images_per_session) || ~isempty(o2.images_per_session)
obj.images_per_session = [obj.images_per_session(:); o2.images_per_session(:)]';
end
end

obj.removed_images = false(size(obj.dat, 2), 1);
obj.removed_voxels = false(size(obj.dat, 1), 1);
obj.history{end+1} = sprintf('cat: concatenated to %d maps', size(obj.dat, 2));
end


% -------------------------------------------------------------------------
function v = local_vcat(a, b)
if isempty(a) && isempty(b), v = []; return; end
v = [a; b]; % per-map (rows = maps) fields
end

function c = local_catcell(a, b)
if isempty(a) && isempty(b), c = {}; return; end
a = reshape(a, [], 1); b = reshape(b, [], 1);
c = [a; b];
end

function t = local_cattable(a, b)
if isempty(a) && isempty(b), t = []; return; end
if isempty(a), t = b; return; end
if isempty(b), t = a; return; end
t = [a; b];
end
66 changes: 66 additions & 0 deletions CanlabCore/@fmri_surface_data/compare_space.m
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
function isdiff = compare_space(obj, obj2)
% compare_space Compare the grayordinate spaces of two fmri_surface_data objects.
%
% :Usage:
% ::
% isdiff = compare_space(obj, obj2)
%
% Surface analogue of image_vector.compare_space, preserving the same integer
% return contract so callers (cat, apply_mask) can branch on it. Compares the
% surface_space tag and the brain_model layout (per-model structure, count, and
% surface vertex count), then the in-data selection (vertlist / voxlist).
%
% :Outputs:
% **isdiff:** integer code:
% - 0 same space and same in-data grayordinates
% - 1 different spaces (tag or model layout differ)
% - 2 no brain_model for one or more objects
% - 3 same space, but different in-data grayordinates (vertlist/voxlist
% or row count differ)
%
% :See also: image_vector.compare_space, resample_space, cat, apply_mask

if isempty(obj.brain_model) || isempty(obj2.brain_model)
isdiff = 2;
return
end

% Space tag
if ~strcmp(char(obj.surface_space), char(obj2.surface_space))
isdiff = 1;
return
end

m1 = obj.brain_model.models;
m2 = obj2.brain_model.models;
if numel(m1) ~= numel(m2)
isdiff = 1;
return
end

% Model layout: structure name, in-data count, and surface vertex count
for i = 1:numel(m1)
same = strcmp(m1{i}.struct, m2{i}.struct) ...
&& isequaln(m1{i}.numvert, m2{i}.numvert) ...
&& strcmp(m1{i}.type, m2{i}.type);
if ~same
isdiff = 1;
return
end
end

% Same layout: now check the exact in-data selection
isdiff = 0;
if size(obj.dat, 1) ~= size(obj2.dat, 1)
isdiff = 3;
return
end
for i = 1:numel(m1)
if m1{i}.count ~= m2{i}.count ...
|| ~isequal(m1{i}.vertlist, m2{i}.vertlist) ...
|| ~isequal(m1{i}.voxlist, m2{i}.voxlist)
isdiff = 3;
return
end
end
end
Loading
Loading